Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions myst_parser/config/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,16 +585,28 @@ 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:
warning(MystWarnings.MD_TOPMATTER, str(exc))
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

Expand Down
13 changes: 11 additions & 2 deletions myst_parser/mocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand All @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
96 changes: 96 additions & 0 deletions tests/test_docutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<!-- start -->\n\n{unknownrole}`z`\n")
assert (
role_warning_location(
f"```{{include}} {inc3}\n:start-after: <!-- start -->\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.

Expand Down
2 changes: 1 addition & 1 deletion tests/test_renderers/fixtures/mock_include_errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
.
Loading