From c40f649f7bb3be56e15b52b2daa4af2558485777 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Thu, 4 Jun 2026 18:59:54 +0200 Subject: [PATCH] fix: brackets in indented code blocks recognized as unresolved link references (#700) Signed-off-by: squidfunk --- .../tests/unit/collectors/test_references.py | 33 +++++++++-- .../zensical/collectors/references/cursor.py | 55 +++++++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/python/tests/unit/collectors/test_references.py b/python/tests/unit/collectors/test_references.py index a8e3b97..03c2471 100644 --- a/python/tests/unit/collectors/test_references.py +++ b/python/tests/unit/collectors/test_references.py @@ -455,7 +455,6 @@ class TestLinkReferences: pytest.param(b"text [TOC]", id="inline"), pytest.param(b"[TOC] text", id="inline-prefix"), pytest.param(b"[TOC]\ntext", id="paragraph"), - pytest.param(b" [TOC]", id="code-block"), ], ) def test_link_ref_toc_text(self, md: bytes) -> None: @@ -737,10 +736,7 @@ class TestLinkDefinitions: def test_no_link_def_indent(self) -> None: md = b" [id]: href" refs = collect(md) - assert len(refs) == 1 - - link_refs = link_refs_only(refs) - assert len(link_refs) == 1 + assert len(refs) == 0 def test_no_link_def_prefix(self) -> None: md = b"text [id]: href" @@ -1741,6 +1737,33 @@ class TestSnippets: # --------------------------------------------------------------------------- +class TestIndentedCodeBlocks: + """Tests for indented code blocks.""" + + def test_indented_code_is_ignored(self) -> None: + md = b"\n [Start][]\n" + refs = collect(md) + assert len(refs) == 0 + + def test_indented_code_with_blank_line_continuation_is_ignored(self) -> None: + md = b"\n [Start][]\n\n [End][]\n" + refs = collect(md) + assert len(refs) == 0 + + def test_indented_code_does_not_hide_following_link(self) -> None: + md = b"\n [Start][]\n\n[after](href)\n" + refs = collect(md) + assert len(refs) == 1 + + links = links_only(refs) + assert len(links) == 1 + assert text(md, links[0].text) == b"after" + assert text(md, links[0].href) == b"href" + + +# --------------------------------------------------------------------------- + + class TestInlineCode: """Tests for inline code.""" diff --git a/python/zensical/collectors/references/cursor.py b/python/zensical/collectors/references/cursor.py index b49797c..9f67a64 100644 --- a/python/zensical/collectors/references/cursor.py +++ b/python/zensical/collectors/references/cursor.py @@ -169,6 +169,13 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: while cursor.pos < cursor.end: char = cursor.data[cursor.pos] + # Top-level indented code block. + if cursor.at_line_start(): + end = _scan_indented_code(cursor) + if end is not None: + cursor.advance(end - cursor.pos) + continue + # Snippet directives and section markers: --8<-- ... if cursor.at_line_start(): end = _scan_snippet(cursor) @@ -394,6 +401,37 @@ def _scan(cursor: Cursor) -> Iterator[Reference]: # --------------------------------------------------------------------------- +def _scan_indented_code(cursor: Cursor) -> int | None: + """Scan for a top-level indented code block.""" + line = _find_line_start(cursor, cursor.pos) + if not _is_previous_line_blank(cursor, line): + return None + + pos = line + indent, end = _measure_indent(cursor, pos) + if indent < 4: + return None + if end >= cursor.end or cursor.data[end] in (_CR, _NL): + return None + + # Consume the whole chunk: indented lines plus blank continuation lines. + pos = _skip_line(cursor, end) + while pos < cursor.end: + if _is_blank_line(cursor, pos): + pos = _skip_line(cursor, pos) + continue + + indent, end = _measure_indent(cursor, pos) + if indent < 4: + break + pos = _skip_line(cursor, end) + + return pos + + +# --------------------------------------------------------------------------- + + def _scan_snippet(cursor: Cursor) -> int | None: """Scan for a pymdownx.snippets directive or section marker line.""" pos = _skip_whitespace(cursor, cursor.pos) @@ -1579,6 +1617,23 @@ def _skip_whitespace(cursor: Cursor, pos: int) -> int: return pos +def _measure_indent(cursor: Cursor, pos: int) -> tuple[int, int]: + """Return visual indentation width and first non-whitespace position.""" + indent = 0 + while pos < cursor.end: + char = cursor.data[pos] + if char == _SPACE: + indent += 1 + pos += 1 + continue + if char == _TAB: + indent += 4 - (indent % 4) + pos += 1 + continue + break + return indent, pos + + def _skip_whitespace_newline(cursor: Cursor, pos: int) -> int: """Skip trailing horizontal whitespace and at most one newline.""" pos = _skip_whitespace(cursor, pos)