Skip to content

Commit f7d6408

Browse files
committed
🐛 FIX: report accurate source lines for directives and text
Fix three classes of wrong source-line attribution: - Directive option warnings (unknown key, invalid value, bad YAML, comments) now point at the offending option's own source line, for both `---` and `:option:` block styles, instead of the directive's opening line. Unknown option keys are now warned once per key, at its own line. The `---` options block is now split line-based: any text trailing a closing delimiter still becomes body content (as previously), but is kept as its own line, so it and all subsequent body lines map exactly to their source lines. A new `options_to_tokens` API in `myst_parser.parsers.options` exposes the tokenizer's per-key positions, and its `State` now records the lines of `#` comments (`options_to_items` is now a thin wrapper around it). Directives run from synthesized, non-source content (HTML images and admonitions) attribute option warnings to the element's line. - Directives with body content on the opening line (no-argument directives such as `{note}`) reported every body warning one line too low; `body_offset` is now `-1` for the merged first line. - `document.current_line` was set once per directive and never reset, so docutils' `Node.setup_child` stamped the most recent directive's line (or 0) onto every subsequently-created line-less node, e.g. every `Text` node - the root cause of tools such as sphinxcontrib-spelling and gettext reporting 'line 0' or the last directive's line. The renderer now keeps `current_line` in sync as nodes are created. Also: - `block_text` passed to directives is now the full directive text (opening fence, options, body, closing fence), matching what an equivalent rST directive receives, so third-party directives that inspect it see rST-consistent content. - `include` with `:literal:` now sets the literal block's line to `start-line + 1` (docutils parity) instead of a hardcoded 1. Closes #546
1 parent e843b5f commit f7d6408

14 files changed

Lines changed: 516 additions & 67 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,20 @@ def parse_directive_text(
142142
first_line: str,
143143
content: str,
144144
*,
145+
line: int | None = None,
145146
validate_options: bool = True,
146-
) -> tuple[list[str], dict[str, Any], list[str], int]:
147+
additional_options: dict[str, str] | None = None,
148+
) -> DirectiveParsingResult:
147149
"""Parse directive text into its components.
148150
149151
:param directive_class: The directive class to parse for.
150152
:param first_line: The first line (arguments).
151153
:param content: The directive content.
154+
:param line: The 1-based source line of the directive's opening line.
152155
:param validate_options: Whether to validate options against the directive spec.
153-
:return: Tuple of (arguments, options, body_lines, body_offset).
156+
:param additional_options: Additional options to add to the directive.
157+
:return: A ``DirectiveParsingResult`` with fields
158+
``arguments``, ``options``, ``body``, ``body_offset`` and ``warnings``.
154159
:raises MarkupError: If the directive text is malformed.
155160
"""
156161
...

docs/configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,5 +211,13 @@ suppress_warnings = ["myst.header"]
211211

212212
Or use `--myst-suppress-warnings="myst.header"` for the [docutils CLI](myst-docutils).
213213

214+
:::{note}
215+
Two known limitations apply to the source lines reported by warnings:
216+
warnings for inline syntax (such as roles and links) report the first line of the enclosing paragraph,
217+
rather than the exact line of the syntax,
218+
and warnings within the rendered output of a [substitution](syntax/substitutions) may map beyond the substitution's own location,
219+
if that output spans more lines than the source.
220+
:::
221+
214222
```{myst-warnings}
215223
```

myst_parser/mdit_to_docutils/base.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,9 @@ def nested_render_text(
321321
if tokens and tokens[0].type == "front_matter":
322322
tokens.pop(0)
323323

324-
# update the line numbers
324+
# update the line numbers,
325+
# note the maps stay 0-based here;
326+
# ``_render_tokens`` applies the final 0-based to 1-based conversion
325327
for token in tokens:
326328
if token.map:
327329
token.map = [token.map[0] + lineno, token.map[1] + lineno]
@@ -374,6 +376,10 @@ def add_line_and_source_path(self, node, token: SyntaxTreeNode) -> None:
374376
"""Copy the line number and document source path to the docutils node."""
375377
with suppress(ValueError):
376378
node.line = token_line(token)
379+
# keep the document's current_line in sync, so that docutils'
380+
# ``Node.setup_child`` stamps sensible lines onto line-less
381+
# children (such as ``Text`` nodes), rather than a stale value
382+
self.document.current_line = node.line
377383
node.source = self.document["source"]
378384

379385
def add_line_and_source_path_r(
@@ -1783,12 +1789,22 @@ def render_directive(
17831789
:param arguments: The remaining text on the same line as the directive name.
17841790
"""
17851791
position = token_line(token)
1792+
# reconstruct the directive's full text, so that ``block_text`` aligns
1793+
# with what an equivalent rST directive would receive
1794+
# (best-effort: the parser strips fence indentation and normalises
1795+
# the info string, so this may not be byte-identical to the source)
1796+
content_text = token.content
1797+
if content_text and not content_text.endswith("\n"):
1798+
# e.g. for an unterminated fence at the end of the document
1799+
content_text += "\n"
1800+
block_text = f"{token.markup}{token.info}\n{content_text}{token.markup}\n"
17861801
nodes_list = self.run_directive(
17871802
name,
17881803
arguments,
17891804
token.content,
17901805
position,
17911806
additional_options=additional_options,
1807+
block_text=block_text,
17921808
)
17931809
self.current_node += nodes_list
17941810

@@ -1799,6 +1815,8 @@ def run_directive(
17991815
content: str,
18001816
position: int,
18011817
additional_options: dict[str, str] | None = None,
1818+
*,
1819+
block_text: str | None = None,
18021820
) -> list[nodes.Element]:
18031821
"""Run a directive and return the generated nodes.
18041822
@@ -1809,6 +1827,8 @@ def run_directive(
18091827
:param position: The line number of the first line
18101828
:param additional_options: Additional options to add to the directive,
18111829
above those parsed from the content.
1830+
:param block_text: The full unparsed text of the directive,
1831+
defaulting to ``content`` if not given.
18121832
18131833
"""
18141834
self.document.current_line = position
@@ -1838,7 +1858,10 @@ def run_directive(
18381858
directive_class,
18391859
first_line,
18401860
content,
1841-
line=position,
1861+
# when no block_text is given, the content is synthesized
1862+
# (e.g. from HTML attributes) and has no source lines,
1863+
# so option warnings fall back to the directive's position
1864+
line=position if block_text is not None else None,
18421865
additional_options=additional_options,
18431866
)
18441867
except MarkupError as error:
@@ -1883,7 +1906,7 @@ def run_directive(
18831906
# the line offset of the first line of the content
18841907
content_offset=parsed.body_offset,
18851908
# a string containing the entire directive
1886-
block_text="\n".join(parsed.body),
1909+
block_text=content if block_text is None else block_text,
18871910
state=state,
18881911
state_machine=state_machine,
18891912
)

myst_parser/mocking.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,8 @@ def run(self) -> list[nodes.Element]:
451451
literal_block = nodes.literal_block(
452452
file_content, source=str(path), classes=self.options.get("class", [])
453453
)
454-
literal_block.line = 1 # TODO don;t think this should be 1?
454+
# docutils sets this to the ``start-line`` option (or 0) + 1
455+
literal_block.line = (self.options.get("start-line") or 0) + 1
455456
self.add_name(literal_block)
456457
if "number-lines" in self.options:
457458
# note starting in docutils 0.22 this option is now an integer instead of a string, see: https://github.com/live-clones/docutils/commit/f39ac1413e56a330c8fea6e0d080fed0ff2b8483

myst_parser/parsers/directives.py

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636

3737
from __future__ import annotations
3838

39-
import re
4039
from collections.abc import Callable
4140
from dataclasses import dataclass
4241
from textwrap import dedent
@@ -50,7 +49,7 @@
5049

5150
from myst_parser.warnings_ import MystWarnings
5251

53-
from .options import TokenizeError, options_to_items
52+
from .options import TokenizeError, options_to_tokens
5453

5554
YAML_LOAD_ERRORS = (yaml.YAMLError, ValueError, RecursionError)
5655
"""Errors that ``yaml.safe_load`` can raise on invalid input:
@@ -76,7 +75,12 @@ class DirectiveParsingResult:
7675
body: list[str]
7776
"""The lines of body content"""
7877
body_offset: int
79-
"""The number of lines to the start of the body content."""
78+
"""The number of lines from the directive's opening line
79+
to the first line of the body content.
80+
81+
This is ``-1`` when body content starts on the opening line itself
82+
(only possible for directives that take no arguments).
83+
"""
8084
warnings: list[ParseWarnings]
8185
"""List of non-fatal errors encountered during parsing.
8286
(message, line_number)
@@ -144,7 +148,9 @@ def parse_directive_text(
144148
)
145149
)
146150
body_lines.insert(0, first_line)
147-
content_offset = 0
151+
# the merged first body line sits on the opening line itself,
152+
# one line *before* where body content normally starts
153+
content_offset -= 1
148154
arguments = []
149155
else:
150156
arguments = parse_directive_arguments(directive_class, first_line)
@@ -181,21 +187,40 @@ def _parse_directive_options(
181187
) -> _DirectiveOptions:
182188
"""Parse (and validate) the directive option section.
183189
184-
:returns: (content, options, validation_errors)
190+
:param content: All text after the directive's opening line,
191+
possibly starting with an option block
192+
:param directive_class: The directive class to validate options against
193+
:param as_yaml: Whether to parse the options block with the full YAML spec,
194+
rather than validating against the directive's option specification
195+
(used by myst-nb)
196+
:param line: The 1-based line number of the directive's opening line,
197+
or None if unknown
198+
:param additional_options: Additional options for the directive,
199+
which the options block takes priority over
185200
"""
186201
options_block: None | str = None
202+
options_position: int | None = None
203+
"""The 1-based source line of the first line of the options block."""
187204
if content.startswith("---"):
188-
line = None if line is None else line + 1
189-
content = "\n".join(content.splitlines()[1:])
190-
match = re.search(r"^-{3,}", content, re.MULTILINE)
191-
if match:
192-
options_block = content[: match.start()]
193-
content = content[match.end() + 1 :] # TODO advance line number
205+
options_position = None if line is None else line + 2
206+
content_lines = content.splitlines()[1:]
207+
for index, content_line in enumerate(content_lines):
208+
if content_line.startswith("---"):
209+
options_block = "\n".join(content_lines[:index])
210+
# any text after the closing delimiter's dashes becomes
211+
# the first line of the body content (and, being kept as
212+
# its own line, maps exactly to its source line)
213+
remainder = content_line.lstrip("-")
214+
remainder = remainder.removeprefix(" ")
215+
rest = content_lines[index + 1 :]
216+
content = "\n".join([remainder, *rest] if remainder else rest)
217+
break
194218
else:
195-
options_block = content
219+
options_block = "\n".join(content_lines)
196220
content = ""
197221
options_block = dedent(options_block)
198222
elif content.lstrip().startswith(":"):
223+
options_position = None if line is None else line + 1
199224
content_lines = content.splitlines()
200225
yaml_lines = []
201226
while content_lines:
@@ -214,12 +239,19 @@ def _parse_directive_options(
214239
yaml_errors: list[ParseWarnings] = []
215240
try:
216241
yaml_options = yaml.safe_load(options_block or "") or {}
217-
except YAML_LOAD_ERRORS:
242+
except YAML_LOAD_ERRORS as exc:
218243
yaml_options = {}
244+
yaml_error_line = options_position
245+
if (
246+
options_position is not None
247+
and isinstance(exc, yaml.MarkedYAMLError)
248+
and exc.problem_mark is not None
249+
):
250+
yaml_error_line = options_position + exc.problem_mark.line
219251
yaml_errors.append(
220252
ParseWarnings(
221253
"Invalid options format (bad YAML)",
222-
line,
254+
yaml_error_line,
223255
MystWarnings.DIRECTIVE_OPTION,
224256
)
225257
)
@@ -228,7 +260,7 @@ def _parse_directive_options(
228260
yaml_errors.append(
229261
ParseWarnings(
230262
"Invalid options format (not a dict)",
231-
line,
263+
options_position,
232264
MystWarnings.DIRECTIVE_OPTION,
233265
)
234266
)
@@ -237,32 +269,53 @@ def _parse_directive_options(
237269
validation_errors: list[ParseWarnings] = []
238270

239271
options: dict[str, str] = {}
272+
option_lines: dict[str, int] = {}
273+
"""0-based line of each key, within the options block
274+
(both block styles keep a 1:1 line correspondence with the source).
275+
"""
240276
if options_block is not None:
241277
try:
242-
_options, state = options_to_items(options_block)
243-
options = dict(_options)
278+
option_tokens, state = options_to_tokens(options_block)
244279
except TokenizeError as err:
245280
return _DirectiveOptions(
246281
content,
247282
options,
248283
[
249284
ParseWarnings(
250285
f"Invalid options format: {err.problem}",
251-
line,
286+
None
287+
if options_position is None
288+
else options_position + err.problem_mark.line,
252289
MystWarnings.DIRECTIVE_OPTION,
253290
)
254291
],
255292
has_options_block,
256293
)
294+
for key_token, value_token in option_tokens:
295+
options[key_token.value] = (
296+
value_token.value if value_token is not None else ""
297+
)
298+
option_lines[key_token.value] = key_token.start.line
257299
if state.has_comments:
258300
validation_errors.append(
259301
ParseWarnings(
260302
"Directive options has # comments, which may not be supported in future versions.",
261-
line,
303+
None
304+
if options_position is None
305+
else options_position
306+
+ (state.comment_lines[0] if state.comment_lines else 0),
262307
MystWarnings.DIRECTIVE_OPTION_COMMENTS,
263308
)
264309
)
265310

311+
def _option_line(name: str) -> int | None:
312+
"""The 1-based source line of an option key, or None if unknown
313+
(e.g. keys injected via ``additional_options`` have no source line).
314+
"""
315+
if options_position is None or name not in option_lines:
316+
return None
317+
return options_position + option_lines[name]
318+
266319
if issubclass(directive_class, TestDirective):
267320
# technically this directive spec only accepts one option ('option')
268321
# but since its for testing only we accept all options
@@ -274,14 +327,19 @@ def _parse_directive_options(
274327

275328
# check options against spec
276329
options_spec: dict[str, Callable] = directive_class.option_spec
277-
unknown_options: list[str] = []
278330
new_options: dict[str, Any] = {}
279331
value: str | None
280332
for name, value in options.items():
281333
try:
282334
converter = options_spec[name]
283335
except KeyError:
284-
unknown_options.append(name)
336+
validation_errors.append(
337+
ParseWarnings(
338+
f"Unknown option key: {name!r} (allowed: {sorted(options_spec)})",
339+
_option_line(name),
340+
MystWarnings.DIRECTIVE_OPTION,
341+
)
342+
)
285343
continue
286344
if not value:
287345
# restructured text parses empty option values as None
@@ -296,23 +354,13 @@ def _parse_directive_options(
296354
validation_errors.append(
297355
ParseWarnings(
298356
f"Invalid option value for {name!r}: {value}: {error}",
299-
line,
357+
_option_line(name),
300358
MystWarnings.DIRECTIVE_OPTION,
301359
)
302360
)
303361
else:
304362
new_options[name] = converted_value
305363

306-
if unknown_options:
307-
validation_errors.append(
308-
ParseWarnings(
309-
f"Unknown option keys: {sorted(unknown_options)} "
310-
f"(allowed: {sorted(options_spec)})",
311-
line,
312-
MystWarnings.DIRECTIVE_OPTION,
313-
)
314-
)
315-
316364
return _DirectiveOptions(content, new_options, validation_errors, has_options_block)
317365

318366

0 commit comments

Comments
 (0)