From 51001c8cd12fdcea9e04da27fb37b1f06cf1d042 Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Sat, 31 Oct 2026 00:58:21 +0530 Subject: [PATCH] Fix code spans after unmatched backticks and avoid rescanning `find_code_spans` walked the rest of the text for every opening run of backticks without an exact closing run. The failure path also returned `start + max_ticks` although `start` was already past the opening ticks, which could split the next run and lose its code span, and the fallback to a shorter closing run was abandoned when a longer run appeared later. Record the runs of a text once, by length, and use the largest length up to the opening which still has a later run. This is the pairing of the regex replaced in #1618, and an opening now costs no more than its own ticks. Runs are held as distances from the end of the text as that does not change when earlier spans are replaced with placeholders. --- docs/changelog.md | 2 ++ markdown/inlinepatterns.py | 48 +++++++++++---------------- tests/test_syntax/inline/test_code.py | 33 ++++++++++++++++++ 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index f3335f1e5..c635e3a33 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed +* Fix code spans after a run of backticks with no closing run of the same length + in `BacktickInlineProcessor`, and no longer rescan the text for each such run. * Fix quadratic-time parsing of many consecutive reference definitions, and of many consecutive footnote definitions in the `footnotes` extension. * Support automatic links with URL schemes other than HTTP and FTP, such as diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 6f42bbf06..13a345aa1 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -412,6 +412,11 @@ def __init__(self, pattern: str): self.ESCAPED_BSLASH = '{}{}{}'.format(util.STX, ord('\\'), util.ETX) self.tag = 'code' """ The tag of the rendered element. """ + self.runs: dict[int, list[int]] = {} + """ + The runs of backticks in the current text by length, last run first. Each is held as the distance from + its end to the end of the text, which does not change as earlier spans are replaced with placeholders. + """ def find_code_spans(self, start: int, text: str) -> tuple[int, int | None]: """Find code spans.""" @@ -424,36 +429,17 @@ def find_code_spans(self, start: int, text: str) -> tuple[int, int | None]: max_ticks += 1 start += 1 - longest_span = 0 - end = 0 - - # Find an ending span of backticks that matches our opening - i = start - while i < last: - span_length = 0 - while i < last and text[i] == '`': - span_length += 1 - i += 1 - if not span_length: - i += 1 - continue - - # Did we find the end? - if max_ticks == span_length: - return start, i - span_length - - # Track the longest span of backticks we find as a fallback. - if span_length > longest_span: - longest_span = span_length - end = i - - # Since we didn't find an exact matching start and end, - # adjust start to match the largest end we could calculate. - if 0 < longest_span < max_ticks: - return start - (max_ticks - longest_span), end - longest_span + # Use as many of the starting ticks as a later run of the same length can close. A run + # behind this point can't close a later span either, so it is dropped once passed. + for ticks in range(max_ticks, 0, -1): + runs = self.runs.get(ticks, []) + while runs and runs[-1] >= last - start: + runs.pop() + if runs: + return start - (max_ticks - ticks), last - runs[-1] - ticks # We could not find a suitable pairing - return start + max_ticks, None + return start, None def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str | None, int | None, int | None]: """ @@ -464,6 +450,12 @@ def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str If the match contains `group(1)` then return the text of `group(1)` as backslash escaped. """ + if not m.pos: + # The search of a new text always starts at the beginning. Find its runs once. + self.runs = {} + for run in reversed(list(re.finditer('`+', data))): + self.runs.setdefault(run.end() - run.start(), []).append(len(data) - run.end()) + if m.group(1): return m.group(1).replace('\\\\', self.ESCAPED_BSLASH), m.start(0), m.end(0) diff --git a/tests/test_syntax/inline/test_code.py b/tests/test_syntax/inline/test_code.py index cf54de722..0106cf956 100644 --- a/tests/test_syntax/inline/test_code.py +++ b/tests/test_syntax/inline/test_code.py @@ -94,3 +94,36 @@ def test_smaller_end(self): 'a ``` b `` c', '

a ` b c

' ) + + def test_unmatched_opening_before_span(self): + + self.assertMarkdownRenders( + '``a```b```c', + '

``abc

' + ) + + def test_smaller_end_before_and_after_bigger_end(self): + + self.assertMarkdownRenders( + '``a```b`', + '

`a```b

' + ) + + self.assertMarkdownRenders( + '``a`b```', + '

`ab```

' + ) + + def test_end_in_earlier_text_is_ignored(self): + + self.assertMarkdownRenders( + '`a`\n\n`bcd', + '

a

\n

`bcd

' + ) + + def test_many_unmatched_openings(self): + """Test that each unmatched opening does not rescan the rest of the text.""" + self.assertMarkdownRenders( + '\\``' * 20000, + '

' + '``' * 20000 + '

' + )