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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

* 🐛 Preserve code spans after unclosed link and image labels by making backtick lookahead independent of parser position, fixing [#438](https://github.com/executablebooks/markdown-it-py/issues/438)
* ✨ Add `--enable-tables` to the CLI for file, standard input and interactive parsing in [#422](https://github.com/executablebooks/markdown-it-py/pull/422)
* 🐛 Fix CLI interactive mode joining input lines with an extra newline, which split every line into its own paragraph and broke hard line breaks, in [#172](https://github.com/executablebooks/markdown-it-py/issues/172)
* 🐛 Fix trimming and splitting with the Python whitespace set instead of the CommonMark one, which dropped U+001C–U+001F and U+0085 from paragraphs, headings, table cells and fence info strings and let distinct reference labels resolve each other, in [#418](https://github.com/executablebooks/markdown-it-py/pull/418), thanks to [@Nexory](https://github.com/Nexory)
Expand Down
108 changes: 54 additions & 54 deletions markdown_it/rules_inline/backticks.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,72 @@
# Parse backticks
import re

from .state_inline import StateInline

regex = re.compile("^ (.+) $")

def _build_last_runs(src: str) -> dict[int, int]:
"""Map each backtick run length to its last position in the source."""
last_runs: dict[int, int] = {}
pos = 0

while (start := src.find("`", pos)) != -1:
pos = start + 1
while pos < len(src) and src[pos] == "`":
pos += 1
last_runs[pos - start] = start

return last_runs


def backtick(state: StateInline, silent: bool) -> bool:
pos = state.pos
"""Parse an inline code span or consume an unmatched backtick run."""
start = state.pos

if state.src[pos] != "`":
if state.src[start] != "`":
return False

start = pos
pos += 1
maximum = state.posMax
pos = start + 1

# scan marker length
while pos < maximum and (state.src[pos] == "`"):
# Scan marker length.
while pos < maximum and state.src[pos] == "`":
pos += 1

marker = state.src[start:pos]
openerLength = len(marker)

if state.backticksScanned and state.backticks.get(openerLength, 0) <= start:
if not silent:
state.append_pending(marker)
state.pos += openerLength
return True

matchStart = matchEnd = pos

# Nothing found in the cache, scan until the end of the line (or until marker is found)
while True:
try:
matchStart = state.src.index("`", matchEnd)
except ValueError:
break
matchEnd = matchStart + 1

# scan marker length
while matchEnd < maximum and (state.src[matchEnd] == "`"):
matchEnd += 1

closerLength = matchEnd - matchStart

if closerLength == openerLength:
# Found matching closer length.
if not silent:
token = state.push("code_inline", "code", 0)
token.markup = marker
token.content = state.src[pos:matchStart].replace("\n", " ")
if (
token.content.startswith(" ")
and token.content.endswith(" ")
and len(token.content.strip()) > 0
):
token.content = token.content[1:-1]
state.pos = matchEnd
return True

# Some different length found, put it in cache as upper limit of where closer can be found
state.backticks[closerLength] = matchStart

# Scanned through the end, didn't find anything
state.backticksScanned = True
opener_length = len(marker)

if not state.backticksScanned:
# Lookaheads may visit runs out of order, so build this cache from the
# whole source independently of the parser's current position.
state.backticks = _build_last_runs(state.src)
state.backticksScanned = True

if state.backticks.get(opener_length, -1) >= pos:
match_end = pos

while (
match_start := state.src.find("`", match_end)
) != -1 and match_start < maximum:
match_end = match_start + 1
# A run crossing posMax cannot be a closer in this parse range.
while match_end < len(state.src) and state.src[match_end] == "`":
match_end += 1
if match_end > maximum:
break

if match_end - match_start == opener_length:
if not silent:
token = state.push("code_inline", "code", 0)
token.markup = marker
token.content = state.src[pos:match_start].replace("\n", " ")
if (
token.content.startswith(" ")
and token.content.endswith(" ")
and token.content.strip()
):
token.content = token.content[1:-1]
state.pos = match_end
return True

if not silent:
state.append_pending(marker)
state.pos += openerLength
state.pos = pos
return True
2 changes: 1 addition & 1 deletion markdown_it/rules_inline/state_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def __init__(
# Stack of delimiter lists for upper level tags
self._prev_delimiters: list[list[Delimiter]] = []

# backticklength => last seen position
# Backtick run length => last position, built lazily on first use
self.backticks: dict[int, int] = {}
self.backticksScanned = False

Expand Down
28 changes: 28 additions & 0 deletions tests/test_port/fixtures/commonmark_extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,3 +765,31 @@ Inline HTML in image description
.
<p><img src="/url" alt="a&lt;b&gt;c" /></p>
.

Unclosed link labels must not suppress a later code span (#438).
.
[foo `bar` baz`
.
<p>[foo <code>bar</code> baz`</p>
.

Unclosed image labels must not suppress a later code span (#438).
.
![alt `code` x`
.
<p>![alt <code>code</code> x`</p>
.

A longer backtick run outside a link label must not close a shorter opener.
.
[`](``)
.
<p><a href="%60%60">`</a></p>
Comment on lines +783 to +787
.

Code spans still take precedence over links when the closer follows the label.
.
[foo `bar](/url)` baz
.
<p>[foo <code>bar](/url)</code> baz</p>
.