Skip to content
Open
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
49 changes: 36 additions & 13 deletions markdown/inlinepatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,10 @@ class LinkInlineProcessor(InlineProcessor):
RE_LINK = re.compile(r'''\(\s*(?:(<[^<>]*>)\s*(?:('[^']*'|"[^"]*")\s*)?\))?''', re.DOTALL | re.UNICODE)
RE_TITLE_CLEAN = re.compile(r'\s')

# Cache of the bracket pairing computed for the last `data` string seen by

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this shared across all instances, even across different Markdown instances?

# `getText`, as `(data, matches)`; rebuilt when the string identity changes.
_bracket_cache: tuple[str, dict[int, int]] | None = None

def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]:
""" Return an `a` [`Element`][xml.etree.ElementTree.Element] or `(None, None, None)`. """
text, index, handled = self.getText(data, m.end(0))
Expand Down Expand Up @@ -1157,19 +1161,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
cache = self._bracket_cache
if cache is None or cache[0] is not data:
cache = (data, self._pair_brackets(data))
self._bracket_cache = cache
end = cache[1].get(index, -1)
if end == -1:
# The brackets were never closed - scan the rest as before.
return data[index:], len(data), False
return data[index:end - 1], end, True

@staticmethod
def _pair_brackets(data: str) -> dict[int, int]:
"""Map each position to one past the `]` that closes a bracket opened
right before it (the first position whose bracket balance drops below
this position's), computed for all positions in one pass.
"""
balances = [0] * (len(data) + 1)
balance = 0
for i, c in enumerate(data):
if c == '[':
balance += 1
elif c == ']':
balance -= 1
balances[i + 1] = balance
matches: dict[int, int] = {}
nearest: dict[int, int] = {}
for i in range(len(data), -1, -1):
end = nearest.get(balances[i] - 1)
if end is not None:
matches[i] = end
nearest[balances[i]] = i
return matches


class ImageInlineProcessor(LinkInlineProcessor):
Expand Down
32 changes: 32 additions & 0 deletions tests/test_syntax/inline/test_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
License: BSD (see LICENSE.md for details).
"""

import time
import unittest

import markdown

from markdown.test_tools import TestCase


Expand Down Expand Up @@ -434,3 +439,30 @@ def test_ref_round_brackets(self):
"""
)
)


class TestUnclosedBracketPerformance(unittest.TestCase):
"""
Regression tests for https://github.com/Python-Markdown/markdown/issues/798.

`LinkInlineProcessor.getText` used to rescan the entire remaining document
for a matching bracket on every call, so a run of n unclosed `[` made
parsing O(n^2): before the fix, the documents below took tens of seconds
to parse. The fix pre-computes bracket matches in a single linear pass, so
parsing completes in milliseconds and the generous time bounds below only
fire if the quadratic scan ever comes back.
"""

def test_unclosed_bracket_run_scales_linearly(self):
start = time.perf_counter()
rendered = markdown.markdown('[' * 20000)
elapsed = time.perf_counter() - start
self.assertEqual(rendered, '<p>' + '[' * 20000 + '</p>')
self.assertLess(elapsed, 5.0)

def test_link_after_unclosed_bracket_run(self):
prefix = '[' * 5000
self.assertEqual(
markdown.markdown(f'{prefix}[Text](http://link.com) more text'),
f'<p>{prefix}<a href="http://link.com">Text</a> more text</p>'
)