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
36 changes: 32 additions & 4 deletions markdown_it/rules_inline/html_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!--", pos):
# `<!-->` / `<!--->`, 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("<![CDATA[", pos):
if state.html_terminator_last("]]>") < pos + 9: # `<![CDATA[]]>`
return False
elif ch == "?":
if state.html_terminator_last("?>") < pos + 2: # `<??>`
return False
elif ch == "!":
if state.html_terminator_last(">") < pos + 3: # `<!a>`
return False
elif state.html_terminator_last(">") < pos + 2: # `<a>`, `</a>`
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
Expand Down
21 changes: 21 additions & 0 deletions markdown_it/rules_inline/state_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ def __init__(
# inside <a> 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__}"
Expand Down
74 changes: 74 additions & 0 deletions tests/test_html_inline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for the `html_inline` rule."""

import importlib

import pytest

from markdown_it import MarkdownIt


@pytest.mark.parametrize(
"src",
[
"<a>", # open tag
"<a/>", # self-closing open tag
"</a>", # close tag
"<!-->", # short comment
"<!--->", # short comment
"<!---->", # shortest full comment
"<??>", # empty processing instruction
"<!a>", # declaration
"<![CDATA[]]>", # 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", ["<![CDATA[", "<!--", "<?", "<!a", "<a", "</a"])
def test_unterminated_openers_skip_the_regex(opener, monkeypatch):
"""A run of openers that are never terminated must not run the tag regex.

Every alternative of ``HTML_TAG_RE`` ends in a specific terminator, and its
lazy sub-patterns rescan to the end of the input on each failed attempt,
which made such runs quadratic. ``html_inline`` now rejects an opener in
constant time when the terminator it needs cannot occur, so the regex is
never invoked here; before the fix it ran once per opener.
"""
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(args[1] if len(args) > 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 == "<p>x" + (opener.replace("<", "&lt;")) * 1_000 + "</p>\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 <!-- c --> <?pi?> <![CDATA[d]]> <!D> <a>b</a>"
html = MarkdownIt("commonmark").render(src)
assert calls, "expected the regex to run for terminated constructs"
assert html == "<p>" + src + "</p>\n"
Loading