Skip to content

Commit e001d51

Browse files
committed
perf: improve link label parsing performance
1 parent cca5ee6 commit e001d51

3 files changed

Lines changed: 109 additions & 66 deletions

File tree

src/mistune/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def __init__(self, env: MutableMapping[str, Any]):
133133
self.no_close_bracket_before: int = 0 # high-water mark for DoS mitigation
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]]] = {}
136+
self.link_ranges: Dict[int, Tuple[str, List[int], List[int]]] = {}
136137

137138
def prepend_token(self, token: Dict[str, Any]) -> None:
138139
"""Insert token before the last token."""
@@ -149,6 +150,7 @@ def copy(self) -> "InlineState":
149150
state.image_depth = self.image_depth
150151
state.in_link = self.in_link
151152
state.link_brackets = self.link_brackets
153+
state.link_ranges = self.link_ranges
152154
return state
153155

154156

src/mistune/inline_parser.py

Lines changed: 91 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import re
4+
from bisect import bisect_left
45
from dataclasses import dataclass
56
from typing import (
67
Any,
@@ -142,26 +143,32 @@ def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]:
142143
return pos
143144

144145
text = None
146+
text_start = pos
147+
text_end = pos
145148
label, end_pos = parse_link_label(state.src, pos)
146149
if label is None:
147150
if pos <= state.no_close_bracket_before:
148151
state.append_token({"type": "text", "raw": marker})
149152
return pos
150-
text, end_pos = _parse_link_text(state, pos)
151-
if text is None:
152-
if end_pos > state.no_close_bracket_before:
153-
state.no_close_bracket_before = end_pos
153+
close_pos = _find_closing_bracket(state, pos)
154+
if close_pos is None:
155+
if len(state.src) > state.no_close_bracket_before:
156+
state.no_close_bracket_before = len(state.src)
154157
return None
158+
text_start = pos
159+
text_end = close_pos
160+
end_pos = close_pos + 1
155161

156162
assert end_pos is not None
157163

158-
if text is None:
164+
if label is not None:
159165
text = label
166+
text_start = pos
167+
text_end = end_pos - 1
160168

161-
assert text is not None
162169
body_end_pos = end_pos
163170

164-
has_nested_link = not is_image and self._contains_nested_link(text, state)
171+
has_nested_link = not is_image and _label_contains_link(state, text_start, text_end)
165172
if has_nested_link:
166173
return None
167174
if end_pos >= len(state.src) and label is None:
@@ -179,6 +186,8 @@ def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]:
179186
# standard link [text](<url> "title")
180187
attrs, pos2 = parse_link(state.src, end_pos + 1)
181188
if pos2:
189+
if text is None:
190+
text = state.src[text_start:text_end]
182191
token = self.__parse_link_token(is_image, text, attrs, state)
183192
state.append_token(token)
184193
return pos2
@@ -192,9 +201,13 @@ def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]:
192201
label = label2
193202

194203
if label is None:
195-
_mark_no_link_before(state, body_end_pos)
196-
return None
197-
204+
ref_links = state.env.get("ref_links")
205+
if not ref_links:
206+
_mark_no_link_before(state, body_end_pos)
207+
return None
208+
if text is None:
209+
text = state.src[text_start:text_end]
210+
label = text
198211
ref_links = state.env.get("ref_links")
199212
if not ref_links:
200213
_mark_no_link_before(state, body_end_pos)
@@ -203,6 +216,8 @@ def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]:
203216
key = unikey(label)
204217
env = ref_links.get(key)
205218
if env:
219+
if text is None:
220+
text = state.src[text_start:text_end]
206221
attrs = {"url": env["url"], "title": env.get("title")}
207222
token = self.__parse_link_token(is_image, text, attrs, state)
208223
token["ref"] = key
@@ -212,29 +227,6 @@ def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]:
212227
_mark_no_link_before(state, body_end_pos)
213228
return None
214229

215-
def _contains_nested_link(self, text: str, state: InlineState) -> bool:
216-
if "[" not in text:
217-
return False
218-
if "](" not in text and "][" not in text and not state.env.get("ref_links"):
219-
return False
220-
221-
sc = self.compile_sc(["link"])
222-
nested_state = state.copy()
223-
nested_state.src = text
224-
pos = 0
225-
while pos < len(text):
226-
m = sc.search(text, pos)
227-
if not m:
228-
return False
229-
230-
marker = m.group(0)
231-
if marker == "[" and (m.start() == 0 or text[m.start() - 1] != "!"):
232-
if _is_link_like(text, m.end(), nested_state):
233-
return True
234-
pos = m.start() + 1
235-
236-
return False
237-
238230
def __parse_link_token(
239231
self,
240232
is_image: bool,
@@ -849,56 +841,89 @@ def _merge_text_tokens(tokens: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
849841
return result
850842

851843

852-
def _parse_link_text(state: InlineState, pos: int) -> Tuple[Optional[str], int]:
853-
close_pos = _find_closing_bracket(state, pos)
854-
if close_pos is None:
855-
return None, len(state.src)
856-
return state.src[pos:close_pos], close_pos + 1
857-
858-
859844
def _mark_no_link_before(state: InlineState, end_pos: int) -> None:
860845
if end_pos > state.no_link_before:
861846
state.no_link_before = end_pos
862847

863848

864849
def _find_closing_bracket(state: InlineState, pos: int) -> Optional[int]:
850+
return _get_closing_bracket_map(state).get(pos)
851+
852+
853+
def _label_contains_link(state: InlineState, start: int, end: int) -> bool:
854+
if start >= end:
855+
return False
856+
857+
starts, suffix_min_ends = _get_link_range_index(state)
858+
index = bisect_left(starts, start)
859+
return index < len(starts) and starts[index] < end and suffix_min_ends[index] <= end
860+
861+
862+
def _get_link_range_index(state: InlineState) -> Tuple[List[int], List[int]]:
863+
cache = state.link_ranges.get(id(state.src))
864+
if cache is not None and cache[0] is state.src:
865+
return cache[1], cache[2]
866+
867+
pairs = _get_closing_bracket_map(state)
868+
ranges: List[Tuple[int, int]] = []
869+
for label_start, close_pos in pairs.items():
870+
opener = label_start - 1
871+
if opener > 0 and state.src[opener - 1] == "!":
872+
continue
873+
link_end = _find_link_range_end(state.src, label_start, close_pos, state)
874+
if link_end is not None:
875+
ranges.append((opener, link_end))
876+
877+
ranges.sort()
878+
starts = [start for start, _end in ranges]
879+
suffix_min_ends = [0] * len(ranges)
880+
min_end = len(state.src) + 1
881+
for index in range(len(ranges) - 1, -1, -1):
882+
end = ranges[index][1]
883+
if end < min_end:
884+
min_end = end
885+
suffix_min_ends[index] = min_end
886+
887+
state.link_ranges[id(state.src)] = (state.src, starts, suffix_min_ends)
888+
return starts, suffix_min_ends
889+
890+
891+
def _get_closing_bracket_map(state: InlineState) -> Dict[int, int]:
865892
cache = state.link_brackets.get(id(state.src))
866893
if cache is not None and cache[0] is state.src:
867-
return cache[1].get(pos)
894+
return cache[1]
868895

869896
pairs = _build_closing_bracket_map(state.src)
870897
state.link_brackets[id(state.src)] = (state.src, pairs)
871-
return pairs.get(pos)
872-
898+
return pairs
873899

874-
def _is_link_like(src: str, pos: int, state: InlineState) -> bool:
875-
label, end_pos = parse_link_label(src, pos)
876-
if label is None:
877-
label, end_pos = _parse_link_text(state, pos)
878-
if label is None:
879-
return False
880900

881-
assert label is not None
882-
assert end_pos is not None
901+
def _find_link_range_end(src: str, label_start: int, close_pos: int, state: InlineState) -> Optional[int]:
902+
end_pos = close_pos + 1
903+
if end_pos < len(src):
904+
marker = src[end_pos]
905+
if marker == "(":
906+
_attrs, new_pos = parse_link(src, end_pos + 1)
907+
return new_pos
883908

884-
if end_pos >= len(src):
885-
ref_links = state.env.get("ref_links")
886-
return bool(ref_links and unikey(label) in ref_links)
887-
888-
marker = src[end_pos]
889-
if marker == "(":
890-
_attrs, new_pos = parse_link(src, end_pos + 1)
891-
return bool(new_pos)
909+
if marker == "[":
910+
label, new_pos = parse_link_label(src, end_pos + 1)
911+
if not new_pos:
912+
return None
913+
if label:
914+
ref_label = label
915+
else:
916+
ref_label = src[label_start:close_pos]
892917

893-
if marker == "[":
894-
label2, new_pos = parse_link_label(src, end_pos + 1)
895-
if not new_pos:
896-
return False
897-
if label2:
898-
label = label2
918+
ref_links = state.env.get("ref_links")
919+
if ref_links and unikey(ref_label) in ref_links:
920+
return new_pos
921+
return None
899922

900923
ref_links = state.env.get("ref_links")
901-
return bool(ref_links and unikey(label) in ref_links)
924+
if ref_links and unikey(src[label_start:close_pos]) in ref_links:
925+
return end_pos
926+
return None
902927

903928

904929
def _build_closing_bracket_map(src: str) -> Dict[int, int]:

tests/test_security_inline.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,19 @@ def test_deep_link_input_does_not_recurse_forever(self):
113113
elapsed = time.monotonic() - start
114114

115115
self.assertLess(elapsed, DEADLINE)
116+
117+
def test_repeated_link_suffixes_are_near_linear(self):
118+
md = create_markdown()
119+
120+
def render(size):
121+
text = "[" * size + "a" + "](/u)" * size + "\n"
122+
start = time.process_time()
123+
md(text)
124+
return time.process_time() - start
125+
126+
render(200)
127+
small = render(1000)
128+
large = render(2000)
129+
130+
self.assertLess(large, DEADLINE)
131+
self.assertLess(large, small * 3)

0 commit comments

Comments
 (0)