15
15
16
16
from __future__ import annotations
17
17
18
+ import ast
18
19
import re
19
- from typing import Callable , List , Optional , cast
20
+ import sys
21
+ from typing import Any , Callable , List , Optional , cast
20
22
21
23
from griffe import Docstring , Object
22
24
from mkdocstrings import get_logger
@@ -303,14 +305,12 @@ def _error(self, msg: str, just_warn: bool = False) -> None:
303
305
# We include the file:// prefix because it helps IDEs such as PyCharm
304
306
# recognize that this is a navigable location it can highlight.
305
307
prefix = f"file://{ parent .filepath } :"
306
- line = doc .lineno
307
- if line is not None : # pragma: no branch
308
- # Add line offset to match in docstring. This can still be
309
- # short if the doc string has leading newlines.
310
- line += doc .value .count ("\n " , 0 , self ._cur_offset )
308
+ line , col = doc_value_offset_to_location (doc , self ._cur_offset )
309
+ if line >= 0 :
311
310
prefix += f"{ line } :"
312
- # It would be nice to add the column as well, but we cannot determine
313
- # that without knowing how much the doc string was unindented.
311
+ if col >= 0 :
312
+ prefix += f"{ col } :"
313
+
314
314
prefix += " \n "
315
315
316
316
logger .warning (prefix + msg )
@@ -334,3 +334,68 @@ def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str]
334
334
for member in obj .members .values ():
335
335
if isinstance (member , Object ): # pragma: no branch
336
336
substitute_relative_crossrefs (member , checkref = checkref )
337
+
338
+ def doc_value_offset_to_location (doc : Docstring , offset : int ) -> tuple [int ,int ]:
339
+ """
340
+ Converts offset into doc.value to line and column in source file.
341
+
342
+ Returns:
343
+ line and column or else (-1,-1) if it cannot be computed
344
+ """
345
+ linenum = - 1
346
+ colnum = - 2
347
+
348
+ if doc .lineno is not None :
349
+ linenum = doc .lineno # start of the docstring source
350
+ # line offset with respect to start of cleaned up docstring
351
+ lineoffset = clean_lineoffset = doc .value .count ("\n " , 0 , offset )
352
+
353
+ # look at original doc source, if available
354
+ try :
355
+ source = doc .source
356
+ # compute docstring without cleaning up spaces and indentation
357
+ rawvalue = str (safe_eval (source ))
358
+
359
+ # adjust line offset by number of lines removed from front of docstring
360
+ lineoffset += leading_space (rawvalue ).count ("\n " )
361
+
362
+ if lineoffset == 0 and (m := re .match (r"(\s*['\"]{1,3}\s*)\S" , source )):
363
+ # is on the same line as opening quote
364
+ colnum = offset + len (m .group (1 ))
365
+ else :
366
+ # indentation of first non-empty line in raw and cleaned up strings
367
+ raw_line = rawvalue .splitlines ()[lineoffset ]
368
+ clean_line = doc .value .splitlines ()[clean_lineoffset ]
369
+ raw_indent = len (leading_space (raw_line ))
370
+ clean_indent = len (leading_space (clean_line ))
371
+ try :
372
+ linestart = doc .value .rindex ("\n " , 0 , offset ) + 1
373
+ except ValueError : # pragma: no cover
374
+ linestart = 0 # paranoid check, should not really happen
375
+ colnum = offset - linestart + raw_indent - clean_indent
376
+
377
+ except Exception :
378
+ # Don't expect to get here, but just in case, it is better to
379
+ # not fix up the line/column than to die.
380
+ pass
381
+
382
+ linenum += lineoffset
383
+
384
+ return linenum , colnum + 1
385
+
386
+
387
+ def leading_space (s : str ) -> str :
388
+ """Returns whitespace at the front of string."""
389
+ if m := re .match (r"\s*" , s ):
390
+ return m [0 ]
391
+ return "" # pragma: no cover
392
+
393
+ if sys .version_info < (3 , 10 ) or True :
394
+ # TODO: remove when 3.9 support is dropped
395
+ # In 3.9, literal_eval cannot handle comments in input
396
+ def safe_eval (s : str ) -> Any :
397
+ """Safely evaluate a string expression."""
398
+ return eval (s ) #eval(s, dict(__builtins__={}), {})
399
+ else :
400
+ save_eval = ast .literal_eval
401
+
0 commit comments