From 2c6b8002688925f9edf55ff60ac4df711ea0f2c7 Mon Sep 17 00:00:00 2001 From: Naaz nuria Date: Mon, 19 Oct 2026 23:05:33 +0530 Subject: [PATCH] Fix quadratic time for unclosed brackets in link text `LinkInlineProcessor.getText` scanned forward from each `[` to find its matching `]`. The inline treeprocessor calls it for every `[` in the text, so when the brackets never close each call scanned to the end of the text and conversion became quadratic in the number of brackets. Find the closing bracket for every index in a single pass and cache the result for the current text. Matches and returned text are unchanged. --- docs/changelog.md | 2 ++ markdown/inlinepatterns.py | 45 ++++++++++++++++++-------- tests/test_syntax/inline/test_links.py | 13 ++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b7a50f0c..54b3c45c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed +* Fix quadratic rendering time for many unclosed square brackets in links + and images. * Sanitize attribute names in the `fenced_code` and `legacy_attrs` extensions so an attribute name cannot break out of its tag and inject markup (#1663). * For backwards compatibility in some languages, revert the enforcement of CommonMark diff --git a/markdown/inlinepatterns.py b/markdown/inlinepatterns.py index 785585d0..9f4f2fa3 100644 --- a/markdown/inlinepatterns.py +++ b/markdown/inlinepatterns.py @@ -1157,19 +1157,38 @@ def getText(self, data: str, index: int) -> tuple[str, int, bool]: resolving nested square brackets. """ - bracket_count = 1 - text = [] - for pos in range(index, len(data)): - c = data[pos] - if c == ']': - bracket_count -= 1 - elif c == '[': - bracket_count += 1 - index += 1 - if bracket_count == 0: - break - text.append(c) - return ''.join(text), index, bracket_count == 0 + # Scanning forward from every `[` is quadratic when the brackets do not + # close, so find the closing bracket for every index of `data` at once. + if getattr(self, '_bracket_data', None) is not data: + self._bracket_data = data + self._bracket_ends = self._findBracketEnds(data) + end = self._bracket_ends[index] + if end is None: + return data[index:], len(data), False + return data[index:end - 1], end, True + + @staticmethod + def _findBracketEnds(data: str) -> list[int | None]: + """ + For each index of `data`, return the index just past the `]` which closes an + opening bracket before that index, or `None` if the bracket never closes. + + """ + ends: list[int | None] = [None] * (len(data) + 1) + depth = 0 + # `stack` holds the indexes still waiting for a closing bracket. The bracket + # depth before each one is strictly increasing from bottom to top. + stack: list[tuple[int, int]] = [] + for pos in range(len(data) + 1): + while stack and depth < stack[-1][1]: + ends[stack.pop()[0]] = pos + stack.append((pos, depth)) + if pos < len(data): + if data[pos] == '[': + depth += 1 + elif data[pos] == ']': + depth -= 1 + return ends class ImageInlineProcessor(LinkInlineProcessor): diff --git a/tests/test_syntax/inline/test_links.py b/tests/test_syntax/inline/test_links.py index e57bd995..48464da6 100644 --- a/tests/test_syntax/inline/test_links.py +++ b/tests/test_syntax/inline/test_links.py @@ -164,6 +164,19 @@ def test_angles_and_nonsense_url(self): '

test nonsense.

' ) + def test_unclosed_square_brackets(self): + self.assertMarkdownRenders( + '[[[Text](http://link.com) [more [text', + '

[[Text [more [text

' + ) + + def test_many_unclosed_square_brackets(self): + # Each unclosed bracket used to rescan the rest of the text. + self.assertMarkdownRenders( + '[[' * 10000 + '[Text](http://link.com)', + '

' + '[[' * 10000 + 'Text

' + ) + class TestReferenceLinks(TestCase):