diff --git a/myst_parser/config/main.py b/myst_parser/config/main.py index bd19dc7a..a915a3da 100644 --- a/myst_parser/config/main.py +++ b/myst_parser/config/main.py @@ -585,6 +585,14 @@ def merge_file_level( old_value, field = fields[name] + if field.metadata.get("global_only"): + warning( + MystWarnings.MD_TOPMATTER, + f"'{name}' is only allowed at the global level, ignoring", + ) + continue + + before = getattr(new, name) try: validate_field(new, field, value) except Exception as exc: @@ -592,9 +600,13 @@ def merge_file_level( continue if field.metadata.get("merge_topmatter"): - value = {**old_value, **value} - - setattr(new, name, value) + setattr(new, name, {**old_value, **value}) + elif getattr(new, name) is before: + # a converting validator (e.g. list -> set) assigns the converted + # value itself; do not clobber that with the raw value + # (note for validator authors: converters must assign a *new* + # object, never normalise the existing value in place) + setattr(new, name, value) return new diff --git a/myst_parser/mocking.py b/myst_parser/mocking.py index b65888e7..09119ba4 100644 --- a/myst_parser/mocking.py +++ b/myst_parser/mocking.py @@ -429,6 +429,10 @@ def run(self) -> list[nodes.Element]: # get required section of text startline = self.options.get("start-line", None) endline = self.options.get("end-line", None) + if startline is not None and startline < 0: + # resolve to the actual 0-based line, for source-line reporting + # (slicing semantics are unchanged by this) + startline = max(0, len(file_content.splitlines()) + startline) file_content = "\n".join(file_content.splitlines()[startline:endline]) startline = startline or 0 for split_on_type in ["start-after", "end-before"]: @@ -442,7 +446,9 @@ def run(self) -> list[nodes.Element]: f'Directive "{self.name}"; option "{split_on_type}": text not found "{split_on}".', ) if split_on_type == "start-after": - startline += split_index + len(split_on) + # advance by the number of *lines* removed, so that + # source-line reporting stays aligned + startline += file_content.count("\n", 0, split_index + len(split_on)) file_content = file_content[split_index + len(split_on) :] else: file_content = file_content[:split_index] @@ -514,9 +520,12 @@ def run(self) -> list[nodes.Element]: source_dir, path.parent, ) + # ``nested_render_text`` expects the 0-based source line of the + # first line of the text (the final 0-based to 1-based conversion + # happens in ``_render_tokens``), which is exactly ``startline`` self.renderer.nested_render_text( file_content, - startline + 1, + startline, heading_offset=self.options.get("heading-offset", 0), ) finally: diff --git a/tests/test_docutils.py b/tests/test_docutils.py index 890badbc..ace905ff 100644 --- a/tests/test_docutils.py +++ b/tests/test_docutils.py @@ -3,6 +3,7 @@ import io import sys from dataclasses import dataclass, field, fields +from pathlib import Path from textwrap import dedent from typing import Literal @@ -573,6 +574,101 @@ def test_include_literal_line(tmp_path): assert literal_block.line == 1 +def test_include_source_line_attribution(tmp_path): + """Warnings inside an ``{include}``\\ d file report the exact source line. + + Regression: every line was attributed one too low (``startline + 1`` + double-counted with the renderer's 0-based to 1-based conversion), and + ``start-after`` advanced the line offset by *characters*. + """ + + def role_warning_location(main_source: str) -> str: + stream = io.StringIO() + publish_doctree( + main_source, + parser=Parser(), + source_path=str(tmp_path / "main.md"), + settings_overrides={"warning_stream": stream}, + ) + warnings_ = [ + line for line in stream.getvalue().splitlines() if "unknownrole" in line + ] + assert len(warnings_) == 1, stream.getvalue() + # `path:line:` prefix of the docutils warning + path, line, _ = warnings_[0].split(":", 2) + return f"{Path(path).name}:{line}" + + inc = tmp_path / "inc.md" + inc.write_text("line one\n\n{unknownrole}`x`\n") + assert role_warning_location(f"```{{include}} {inc}\n```\n") == "inc.md:3" + + inc2 = tmp_path / "inc2.md" + inc2.write_text("skip1\nskip2\npara\n\n{unknownrole}`y`\n") + assert ( + role_warning_location(f"```{{include}} {inc2}\n:start-line: 2\n```\n") + == "inc2.md:5" + ) + + inc3 = tmp_path / "inc3.md" + inc3.write_text("intro\n\n\n{unknownrole}`z`\n") + assert ( + role_warning_location( + f"```{{include}} {inc3}\n:start-after: \n```\n" + ) + == "inc3.md:4" + ) + + # nested includes: the innermost file's own lines + b = tmp_path / "b.md" + b.write_text("first\n\n{unknownrole}`b`\n") + a = tmp_path / "a.md" + a.write_text(f"a para\n\n```{{include}} {b}\n```\n") + assert role_warning_location(f"```{{include}} {a}\n```\n") == "b.md:3" + + +def test_topmatter_global_only_and_validated_values(): + """Topmatter: global-only fields warn and are ignored; converting + validators' values survive the merge. + + Regression: ``merge_file_level`` re-assigned the raw topmatter value over + the value a converting validator had set, so e.g. a + ``heading_slug_func`` preset name crashed at each heading + (``'str' object is not callable``). + """ + from myst_parser.config.main import MdParserConfig, merge_file_level + + warnings_: list[str] = [] + config = merge_file_level( + MdParserConfig(), + { + "myst": { + "heading_slug_func": "github", # global-only + "enable_extensions": ["colon_fence"], # converting validator + "title_to_header": True, # plain local field + } + }, + lambda _, msg: warnings_.append(msg), + ) + assert warnings_ == [ + "'heading_slug_func' is only allowed at the global level, ignoring" + ] + assert config.heading_slug_func is None + assert config.enable_extensions == {"colon_fence"} + assert isinstance(config.enable_extensions, set) + assert config.title_to_header is True + + # end-to-end: no per-heading crash, a single topmatter warning + stream = io.StringIO() + publish_doctree( + source="---\nmyst:\n heading_slug_func: github\n---\n\n# Hello World\n", + parser=Parser(), + settings_overrides={"warning_stream": stream}, + ) + output = stream.getvalue() + assert "is not callable" not in output + assert "[myst.topmatter]" in output + + def test_text_node_line_stamping(): """Text nodes carry their enclosing block's line, not a stale one. diff --git a/tests/test_renderers/fixtures/mock_include_errors.md b/tests/test_renderers/fixtures/mock_include_errors.md index 6e443cb6..a8d7171e 100644 --- a/tests/test_renderers/fixtures/mock_include_errors.md +++ b/tests/test_renderers/fixtures/mock_include_errors.md @@ -19,5 +19,5 @@ Error in include file: ```{include} bad.md ``` . -tmpdir/bad.md:2: (WARNING/2) Unknown interpreted text role "a". [myst.role_unknown] +tmpdir/bad.md:1: (WARNING/2) Unknown interpreted text role "a". [myst.role_unknown] .