1515
1616from __future__ import annotations
1717
18+ import ast
1819import re
19- from typing import Callable , List , Optional , cast
20+ import sys
21+ from typing import Any , Callable , List , Optional , cast
2022
2123from griffe import Docstring , Object
2224from mkdocstrings import get_logger
@@ -303,14 +305,12 @@ def _error(self, msg: str, just_warn: bool = False) -> None:
303305 # We include the file:// prefix because it helps IDEs such as PyCharm
304306 # recognize that this is a navigable location it can highlight.
305307 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 :
311310 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+
314314 prefix += " \n "
315315
316316 logger .warning (prefix + msg )
@@ -334,3 +334,68 @@ def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str]
334334 for member in obj .members .values ():
335335 if isinstance (member , Object ): # pragma: no branch
336336 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