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
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ See the [Contributing Guide](contributing.md) for details.

### Fixed

* Fix code spans after a run of backticks with no closing run of the same length
in `BacktickInlineProcessor`, and no longer rescan the text for each such run.
* Fix quadratic-time parsing of many consecutive reference definitions, and of
many consecutive footnote definitions in the `footnotes` extension.
* Support automatic links with URL schemes other than HTTP and FTP, such as
Expand Down
48 changes: 20 additions & 28 deletions markdown/inlinepatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,11 @@ def __init__(self, pattern: str):
self.ESCAPED_BSLASH = '{}{}{}'.format(util.STX, ord('\\'), util.ETX)
self.tag = 'code'
""" The tag of the rendered element. """
self.runs: dict[int, list[int]] = {}
"""
The runs of backticks in the current text by length, last run first. Each is held as the distance from
its end to the end of the text, which does not change as earlier spans are replaced with placeholders.
"""

def find_code_spans(self, start: int, text: str) -> tuple[int, int | None]:
"""Find code spans."""
Expand All @@ -424,36 +429,17 @@ def find_code_spans(self, start: int, text: str) -> tuple[int, int | None]:
max_ticks += 1
start += 1

longest_span = 0
end = 0

# Find an ending span of backticks that matches our opening
i = start
while i < last:
span_length = 0
while i < last and text[i] == '`':
span_length += 1
i += 1
if not span_length:
i += 1
continue

# Did we find the end?
if max_ticks == span_length:
return start, i - span_length

# Track the longest span of backticks we find as a fallback.
if span_length > longest_span:
longest_span = span_length
end = i

# Since we didn't find an exact matching start and end,
# adjust start to match the largest end we could calculate.
if 0 < longest_span < max_ticks:
return start - (max_ticks - longest_span), end - longest_span
# Use as many of the starting ticks as a later run of the same length can close. A run
# behind this point can't close a later span either, so it is dropped once passed.
for ticks in range(max_ticks, 0, -1):
runs = self.runs.get(ticks, [])
while runs and runs[-1] >= last - start:
runs.pop()
if runs:
return start - (max_ticks - ticks), last - runs[-1] - ticks

# We could not find a suitable pairing
return start + max_ticks, None
return start, None

def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str | None, int | None, int | None]:
"""
Expand All @@ -464,6 +450,12 @@ def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str
If the match contains `group(1)` then return the text of `group(1)` as backslash escaped.

"""
if not m.pos:
# The search of a new text always starts at the beginning. Find its runs once.
self.runs = {}
for run in reversed(list(re.finditer('`+', data))):
self.runs.setdefault(run.end() - run.start(), []).append(len(data) - run.end())

if m.group(1):
return m.group(1).replace('\\\\', self.ESCAPED_BSLASH), m.start(0), m.end(0)

Expand Down
33 changes: 33 additions & 0 deletions tests/test_syntax/inline/test_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,36 @@ def test_smaller_end(self):
'a ``` b `` c',
'<p>a <code>` b</code> c</p>'
)

def test_unmatched_opening_before_span(self):

self.assertMarkdownRenders(
'``a```b```c',
'<p>``a<code>b</code>c</p>'
)

def test_smaller_end_before_and_after_bigger_end(self):

self.assertMarkdownRenders(
'``a```b`',
'<p><code>`a```b</code></p>'
)

self.assertMarkdownRenders(
'``a`b```',
'<p><code>`a</code>b```</p>'
)

def test_end_in_earlier_text_is_ignored(self):

self.assertMarkdownRenders(
'`a`\n\n`bcd',
'<p><code>a</code></p>\n<p>`bcd</p>'
)

def test_many_unmatched_openings(self):
"""Test that each unmatched opening does not rescan the rest of the text."""
self.assertMarkdownRenders(
'\\``' * 20000,
'<p>' + '``' * 20000 + '</p>'
)
Loading