Skip to content

Commit c2228a2

Browse files
committed
perf: improve performance for math and formatting plugins
1 parent e001d51 commit c2228a2

4 files changed

Lines changed: 49 additions & 0 deletions

File tree

‎src/mistune/core.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def __init__(self, env: MutableMapping[str, Any]):
134134
self.no_link_before: int = 0 # high-water mark for failed balanced link candidates
135135
self.link_brackets: Dict[int, Tuple[str, Dict[int, int]]] = {}
136136
self.link_ranges: Dict[int, Tuple[str, List[int], List[int]]] = {}
137+
self.formatting_no_end: Dict[Tuple[int, str], Tuple[str, int]] = {}
137138

138139
def prepend_token(self, token: Dict[str, Any]) -> None:
139140
"""Insert token before the last token."""
@@ -151,6 +152,7 @@ def copy(self) -> "InlineState":
151152
state.in_link = self.in_link
152153
state.link_brackets = self.link_brackets
153154
state.link_ranges = self.link_ranges
155+
state.formatting_no_end = self.formatting_no_end
154156
return state
155157

156158

‎src/mistune/plugins/formatting.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ def _parse_to_end(
6161
marker: str,
6262
) -> Optional[int]:
6363
pos = m.end()
64+
cache_key = (id(state.src), marker)
65+
cache = state.formatting_no_end.get(cache_key)
66+
if cache is not None and cache[0] is state.src and pos <= cache[1]:
67+
return None
68+
6469
end_pos = _find_end_marker(state.src, pos, marker)
6570
if end_pos is None:
71+
state.formatting_no_end[cache_key] = (state.src, len(state.src))
6672
return None
6773
text = state.src[pos : end_pos - 2]
6874
new_state = state.copy()

‎tests/test_security_formatting.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,25 @@ def test_repeated_formatting_pairs_return_quickly(self):
3434
elapsed = time.monotonic() - start
3535

3636
self.assertLess(elapsed, deadline)
37+
38+
def test_repeated_unclosed_formatting_markers_are_near_linear(self):
39+
cases = [
40+
("strikethrough", "~~a "),
41+
("mark", "==a "),
42+
("insert", "^^a "),
43+
]
44+
for plugin, unit in cases:
45+
with self.subTest(plugin=plugin):
46+
md = create_markdown(plugins=[plugin])
47+
48+
def render(size):
49+
start = time.process_time()
50+
md(unit * size + "\n")
51+
return time.process_time() - start
52+
53+
render(200)
54+
small = render(1000)
55+
large = render(2000)
56+
57+
self.assertLess(large, 1.0 if IS_PYPY else 0.5)
58+
self.assertLess(large, small * 3)

‎tests/test_security_math.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import platform
2+
import time
13
from unittest import TestCase
24

35
from mistune import create_markdown
46

7+
IS_PYPY = platform.python_implementation() == "PyPy"
8+
59

610
class TestMathSecurity(TestCase):
711
def test_math_plugin_escapes_math_content(self):
@@ -14,3 +18,18 @@ def test_math_plugin_escapes_math_content(self):
1418
self.assertIn("&lt;script&gt;alert(1)&lt;/script&gt;", block_html)
1519
self.assertNotIn("<script>", inline_html)
1620
self.assertNotIn("<script>", block_html)
21+
22+
def test_repeated_inline_math_openers_are_near_linear(self):
23+
md = create_markdown(plugins=["math"])
24+
25+
def render(size):
26+
start = time.process_time()
27+
md("$a " * size + "\n")
28+
return time.process_time() - start
29+
30+
render(200)
31+
small = render(1000)
32+
large = render(2000)
33+
34+
self.assertLess(large, 1.0 if IS_PYPY else 0.5)
35+
self.assertLess(large, small * 3)

0 commit comments

Comments
 (0)