diff --git a/myst_parser/parsers/directives.py b/myst_parser/parsers/directives.py index 61c4d524..fee78c79 100644 --- a/myst_parser/parsers/directives.py +++ b/myst_parser/parsers/directives.py @@ -129,7 +129,10 @@ def parse_directive_text( has_options_block = result.has_options options = result.options body_lines = result.content.splitlines() - content_offset = len(content.splitlines()) - len(body_lines) + # Use the option-block line count directly rather than + # ``len(content.splitlines()) - len(body_lines)``: re-splitting the re-joined body drops a + # trailing blank line, which would otherwise inflate the offset when the body ends in blanks. + content_offset = result.option_line_count else: parse_warnings = [] has_options_block = False @@ -176,6 +179,12 @@ class _DirectiveOptions: options: dict[str, Any] warnings: list[ParseWarnings] has_options: bool + option_line_count: int + """Number of source lines the option block occupies (0 if there is none). + + Derived before the body is joined back into a string, so it is not affected + by trailing blank lines being lost in the ``"\\n".join(...)`` round-trip. + """ def _parse_directive_options( @@ -201,6 +210,9 @@ def _parse_directive_options( options_block: None | str = None options_position: int | None = None """The 1-based source line of the first line of the options block.""" + option_line_count = 0 + """Number of source lines the option block occupies, counted before the body + is re-joined (so a body ending in blank lines does not throw the count off).""" if content.startswith("---"): options_position = None if line is None else line + 2 content_lines = content.splitlines()[1:] @@ -214,9 +226,14 @@ def _parse_directive_options( remainder = remainder.removeprefix(" ") rest = content_lines[index + 1 :] content = "\n".join([remainder, *rest] if remainder else rest) + # opening ``---`` + the option lines (``index``), then the closing ``---``: normally + # the body starts on the next line (``+ 2``), but when the closer carries trailing + # text that text is the first body line, sitting on the closing line itself (``+ 1``). + option_line_count = index + 1 if remainder else index + 2 break else: options_block = "\n".join(content_lines) + option_line_count = len(content.splitlines()) content = "" options_block = dedent(options_block) elif content.lstrip().startswith(":"): @@ -231,6 +248,7 @@ def _parse_directive_options( break yaml_lines.append(content_lines.pop(0).lstrip()[1:]) options_block = "\n".join(yaml_lines) + option_line_count = len(yaml_lines) content = "\n".join(content_lines) has_options_block = options_block is not None @@ -264,7 +282,9 @@ def _parse_directive_options( MystWarnings.DIRECTIVE_OPTION, ) ) - return _DirectiveOptions(content, yaml_options, yaml_errors, has_options_block) + return _DirectiveOptions( + content, yaml_options, yaml_errors, has_options_block, option_line_count + ) validation_errors: list[ParseWarnings] = [] @@ -290,6 +310,7 @@ def _parse_directive_options( ) ], has_options_block, + option_line_count, ) for key_token, value_token in option_tokens: options[key_token.value] = ( @@ -319,7 +340,9 @@ def _option_line(name: str) -> int | None: if issubclass(directive_class, TestDirective): # technically this directive spec only accepts one option ('option') # but since its for testing only we accept all options - return _DirectiveOptions(content, options, [], has_options_block) + return _DirectiveOptions( + content, options, [], has_options_block, option_line_count + ) if additional_options: # The options block takes priority over additional options @@ -361,7 +384,9 @@ def _option_line(name: str) -> int | None: else: new_options[name] = converted_value - return _DirectiveOptions(content, new_options, validation_errors, has_options_block) + return _DirectiveOptions( + content, new_options, validation_errors, has_options_block, option_line_count + ) def parse_directive_arguments( diff --git a/tests/test_renderers/test_parse_directives.py b/tests/test_renderers/test_parse_directives.py index 79b1bc9f..1c03c017 100644 --- a/tests/test_renderers/test_parse_directives.py +++ b/tests/test_renderers/test_parse_directives.py @@ -164,6 +164,19 @@ def test_option_warning_lines_colon_block(): assert result.body_offset == 3 +def test_body_offset_not_inflated_by_trailing_blank_lines(): + """A body ending in blank lines must not inflate ``body_offset`` (#1177).""" + # colon-style option block + assert parse_directive_text(Note, "", ":class: tip\nbody").body_offset == 1 + assert parse_directive_text(Note, "", ":class: tip\nbody\n\n\n").body_offset == 1 + # yaml-style option block + assert parse_directive_text(Note, "", "---\nclass: tip\n---\nbody").body_offset == 3 + assert ( + parse_directive_text(Note, "", "---\nclass: tip\n---\nbody\n\n\n").body_offset + == 3 + ) + + def test_option_warning_lines_none(): """Without a known directive line, option warnings have no line number.""" result = parse_directive_text(