From 3d1613f7da4a1cf8426841f6c119347947527a5c Mon Sep 17 00:00:00 2001 From: SparkUiX Date: Fri, 25 Sep 2026 05:44:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20FIX:=20Preserve=20code=20spans?= =?UTF-8?q?=20after=20unclosed=20labels=20(#438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + markdown_it/rules_inline/backticks.py | 108 +++++++++--------- markdown_it/rules_inline/state_inline.py | 2 +- tests/test_port/fixtures/commonmark_extras.md | 28 +++++ 4 files changed, 84 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040ff138..9c9cf2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +* šŸ› Preserve code spans after unclosed link and image labels by making backtick lookahead independent of parser position, fixing [#438](https://github.com/executablebooks/markdown-it-py/issues/438) * ✨ Add `--enable-tables` to the CLI for file, standard input and interactive parsing in [#422](https://github.com/executablebooks/markdown-it-py/pull/422) * šŸ› Fix CLI interactive mode joining input lines with an extra newline, which split every line into its own paragraph and broke hard line breaks, in [#172](https://github.com/executablebooks/markdown-it-py/issues/172) * šŸ› Fix trimming and splitting with the Python whitespace set instead of the CommonMark one, which dropped U+001C–U+001F and U+0085 from paragraphs, headings, table cells and fence info strings and let distinct reference labels resolve each other, in [#418](https://github.com/executablebooks/markdown-it-py/pull/418), thanks to [@Nexory](https://github.com/Nexory) diff --git a/markdown_it/rules_inline/backticks.py b/markdown_it/rules_inline/backticks.py index 67b5b5af..f71c2bae 100644 --- a/markdown_it/rules_inline/backticks.py +++ b/markdown_it/rules_inline/backticks.py @@ -1,72 +1,72 @@ # Parse backticks -import re - from .state_inline import StateInline -regex = re.compile("^ (.+) $") + +def _build_last_runs(src: str) -> dict[int, int]: + """Map each backtick run length to its last position in the source.""" + last_runs: dict[int, int] = {} + pos = 0 + + while (start := src.find("`", pos)) != -1: + pos = start + 1 + while pos < len(src) and src[pos] == "`": + pos += 1 + last_runs[pos - start] = start + + return last_runs def backtick(state: StateInline, silent: bool) -> bool: - pos = state.pos + """Parse an inline code span or consume an unmatched backtick run.""" + start = state.pos - if state.src[pos] != "`": + if state.src[start] != "`": return False - start = pos - pos += 1 maximum = state.posMax + pos = start + 1 - # scan marker length - while pos < maximum and (state.src[pos] == "`"): + # Scan marker length. + while pos < maximum and state.src[pos] == "`": pos += 1 marker = state.src[start:pos] - openerLength = len(marker) - - if state.backticksScanned and state.backticks.get(openerLength, 0) <= start: - if not silent: - state.append_pending(marker) - state.pos += openerLength - return True - - matchStart = matchEnd = pos - - # Nothing found in the cache, scan until the end of the line (or until marker is found) - while True: - try: - matchStart = state.src.index("`", matchEnd) - except ValueError: - break - matchEnd = matchStart + 1 - - # scan marker length - while matchEnd < maximum and (state.src[matchEnd] == "`"): - matchEnd += 1 - - closerLength = matchEnd - matchStart - - if closerLength == openerLength: - # Found matching closer length. - if not silent: - token = state.push("code_inline", "code", 0) - token.markup = marker - token.content = state.src[pos:matchStart].replace("\n", " ") - if ( - token.content.startswith(" ") - and token.content.endswith(" ") - and len(token.content.strip()) > 0 - ): - token.content = token.content[1:-1] - state.pos = matchEnd - return True - - # Some different length found, put it in cache as upper limit of where closer can be found - state.backticks[closerLength] = matchStart - - # Scanned through the end, didn't find anything - state.backticksScanned = True + opener_length = len(marker) + + if not state.backticksScanned: + # Lookaheads may visit runs out of order, so build this cache from the + # whole source independently of the parser's current position. + state.backticks = _build_last_runs(state.src) + state.backticksScanned = True + + if state.backticks.get(opener_length, -1) >= pos: + match_end = pos + + while ( + match_start := state.src.find("`", match_end) + ) != -1 and match_start < maximum: + match_end = match_start + 1 + # A run crossing posMax cannot be a closer in this parse range. + while match_end < len(state.src) and state.src[match_end] == "`": + match_end += 1 + if match_end > maximum: + break + + if match_end - match_start == opener_length: + if not silent: + token = state.push("code_inline", "code", 0) + token.markup = marker + token.content = state.src[pos:match_start].replace("\n", " ") + if ( + token.content.startswith(" ") + and token.content.endswith(" ") + and token.content.strip() + ): + token.content = token.content[1:-1] + state.pos = match_end + return True if not silent: state.append_pending(marker) - state.pos += openerLength + state.pos = pos return True diff --git a/markdown_it/rules_inline/state_inline.py b/markdown_it/rules_inline/state_inline.py index 28e2f3a3..85e6af0f 100644 --- a/markdown_it/rules_inline/state_inline.py +++ b/markdown_it/rules_inline/state_inline.py @@ -76,7 +76,7 @@ def __init__( # Stack of delimiter lists for upper level tags self._prev_delimiters: list[list[Delimiter]] = [] - # backticklength => last seen position + # Backtick run length => last position, built lazily on first use self.backticks: dict[int, int] = {} self.backticksScanned = False diff --git a/tests/test_port/fixtures/commonmark_extras.md b/tests/test_port/fixtures/commonmark_extras.md index 2de75e5d..b375d07b 100644 --- a/tests/test_port/fixtures/commonmark_extras.md +++ b/tests/test_port/fixtures/commonmark_extras.md @@ -765,3 +765,31 @@ Inline HTML in image description .

a<b>c

. + +Unclosed link labels must not suppress a later code span (#438). +. +[foo `bar` baz` +. +

[foo bar baz`

+. + +Unclosed image labels must not suppress a later code span (#438). +. +![alt `code` x` +. +

![alt code x`

+. + +A longer backtick run outside a link label must not close a shorter opener. +. +[`](``) +. +

`

+. + +Code spans still take precedence over links when the closer follows the label. +. +[foo `bar](/url)` baz +. +

[foo bar](/url) baz

+.