diff --git a/markdown_it/rules_inline/html_inline.py b/markdown_it/rules_inline/html_inline.py index 5eed14a8..2b7c064a 100644 --- a/markdown_it/rules_inline/html_inline.py +++ b/markdown_it/rules_inline/html_inline.py @@ -17,22 +17,50 @@ def html_inline(state: StateInline, silent: bool) -> bool: return False # Check start + src = state.src maximum = state.posMax - if state.src[pos] != "<" or pos + 2 >= maximum: + if src[pos] != "<" or pos + 2 >= maximum: return False # Quick fail on second char - ch = state.src[pos + 1] + ch = src[pos + 1] if ch not in ("!", "?", "/") and not isLetter(ord(ch)): # /* / */ return False - match = HTML_TAG_RE.match(state.src, pos) + # Every alternative of the tag regex ends with a specific terminator, at a + # known minimum offset from `pos`. If that terminator does not occur + # anywhere at or after this offset, no match is possible; bail out before + # running the regex, whose lazy sub-patterns would scan to the end of the + # input. Without this, a run of unterminated openers is quadratic. + if src.startswith("` / ``, else `` (`` is the shortest) + if state.html_terminator_last(">") < pos + 4: + return False + if ( + not src.startswith("", pos) + and not src.startswith("", pos) + and state.html_terminator_last("-->") < pos + 4 + ): + return False + elif src.startswith("") < pos + 9: # `` + return False + elif ch == "?": + if state.html_terminator_last("?>") < pos + 2: # `?>` + return False + elif ch == "!": + if state.html_terminator_last(">") < pos + 3: # `` + return False + elif state.html_terminator_last(">") < pos + 2: # ``, `` + return False + + match = HTML_TAG_RE.match(src, pos) if not match: return False if not silent: token = state.push("html_inline", "", 0) - token.content = state.src[pos : pos + len(match.group(0))] + token.content = src[pos : pos + len(match.group(0))] if isLinkOpen(token.content): state.linkLevel += 1 diff --git a/markdown_it/rules_inline/state_inline.py b/markdown_it/rules_inline/state_inline.py index 8f0ca1e8..28e2f3a3 100644 --- a/markdown_it/rules_inline/state_inline.py +++ b/markdown_it/rules_inline/state_inline.py @@ -84,6 +84,27 @@ def __init__( # inside and markdown links self.linkLevel = 0 + # Lazy cache of `terminator -> last index in src`, see + # `html_terminator_last`. + self._html_terminators: dict[str, int] | None = None + + def html_terminator_last(self, term: str) -> int: + """Index of the last occurrence of `term` in `self.src`, or -1. + + The result is cached per terminator, for the life of the state. + It is used by the `html_inline` rule to reject, in constant time, a + position at which the terminator required to close an HTML construct + cannot possibly occur -- without running the tag regex, whose lazy + sub-patterns would otherwise rescan to the end of the input on every + such (failing) attempt. + """ + if self._html_terminators is None: + self._html_terminators = {} + elif (last := self._html_terminators.get(term)) is not None: + return last + last = self._html_terminators[term] = self.src.rfind(term) + return last + def __repr__(self) -> str: return ( f"{self.__class__.__name__}" diff --git a/tests/test_html_inline.py b/tests/test_html_inline.py new file mode 100644 index 00000000..4f118797 --- /dev/null +++ b/tests/test_html_inline.py @@ -0,0 +1,74 @@ +"""Tests for the `html_inline` rule.""" + +import importlib + +import pytest + +from markdown_it import MarkdownIt + + +@pytest.mark.parametrize( + "src", + [ + "", # open tag + "", # self-closing open tag + "", # close tag + "", # short comment + "", # short comment + "", # shortest full comment + "?>", # empty processing instruction + "", # declaration + "", # empty CDATA section + ], +) +def test_shortest_constructs(src): + """The shortest legal form of each construct is passed through as raw html.""" + assert MarkdownIt("commonmark").renderInline(src) == src + + +@pytest.mark.parametrize("opener", [" 1 else None) + return real.match(*args, **kwargs) + + monkeypatch.setattr(module, "HTML_TAG_RE", Counting()) + src = "x" + opener * 1_000 # leading "x" keeps it out of `html_block` + html = MarkdownIt("commonmark").render(src) + assert calls == [] + # the openers all render as escaped text + assert html == "
x" + (opener.replace("<", "<")) * 1_000 + "
\n" + + +def test_terminated_constructs_still_reach_the_regex(monkeypatch): + """The quick-reject must be exactly that: a terminated construct is still + handed to the regex and parsed as before.""" + module = importlib.import_module("markdown_it.rules_inline.html_inline") + + real = module.HTML_TAG_RE + calls = [] + + class Counting: + def match(self, *args, **kwargs): + calls.append(1) + return real.match(*args, **kwargs) + + monkeypatch.setattr(module, "HTML_TAG_RE", Counting()) + src = "x b" + html = MarkdownIt("commonmark").render(src) + assert calls, "expected the regex to run for terminated constructs" + assert html == "" + src + "
\n"