Skip to content
Merged
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
27 changes: 23 additions & 4 deletions mdit_py_plugins/section_ref/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@
# ASCII digits only (``[0-9]``, never ``\d`` which also matches unicode digits).
_SECTION_REF_PATTERN = re.compile(r"ยง([0-9]+(?:\.[0-9]+)*)")

# Longest dotted part (in written digits, leading zeros included) that is still
# read as a section number. Nine digits keeps every number below 10**9, so it
# fits a signed 32-bit integer for downstream consumers, and keeps ``int()`` far
# below Python's integer string conversion limit (4300 digits by default, and
# never configurable below 640), which would otherwise raise ``ValueError`` and
# abort the whole render. No real document has a billion sections.
_MAX_PART_DIGITS = 9


def section_ref_plugin(md: MarkdownIt) -> None:
"""Parse section-sign references such as ``ยง1``, ``ยง1.1`` and ``ยง2.3.4``.
Expand All @@ -38,9 +46,14 @@ def section_ref_plugin(md: MarkdownIt) -> None:
A trailing dot is not consumed, so ``see ยง1.`` captures ``ยง1``. A ``ยง``
directly followed by an ASCII letter (e.g. ``ยง1a``) is *not* a reference,
but a following non-ASCII letter does not block a match, so ``่งยง3็ซ ``
still captures ``ยง3``. Following CommonMark backslash-escape behaviour,
``\\ยง1`` stays literal text, while ``\\\\ยง1`` renders a literal backslash
followed by a live reference.
still captures ``ยง3``. A reference with any level longer than nine
digits (e.g. ``ยง1.1234567890``) is not a reference either: the whole
text stays literal, rather than a shorter prefix being captured.
Leading zeros count towards the nine digits, so ``ยง0000000001`` stays
literal too.
Following CommonMark backslash-escape behaviour, ``\\ยง1`` stays literal
text, while ``\\\\ยง1`` renders a literal backslash followed by a live
reference.

Each reference becomes a ``section_ref`` token, with the matched text
(e.g. ``"ยง1.1"``) as its ``content``, ``markup`` set to ``ยง`` and
Expand Down Expand Up @@ -76,11 +89,17 @@ def _section_ref_rule(state: StateInline, silent: bool) -> bool:
if end < state.posMax and state.src[end].isascii() and state.src[end].isalpha():
return False

# reject an over-long level outright, so hostile input can never raise in
# ``int()``; the whole text stays literal (no prefix match splits a number)
parts = match.group(1).split(".")
if any(len(part) > _MAX_PART_DIGITS for part in parts):
return False

if not silent:
token = state.push("section_ref", "", 0)
token.content = match.group(0)
token.markup = "ยง"
token.meta = {"numbers": [int(part) for part in match.group(1).split(".")]}
token.meta = {"numbers": [int(part) for part in parts]}

state.pos = end
return True
Expand Down
28 changes: 28 additions & 0 deletions tests/fixtures/section_ref.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,34 @@ Leading zeros preserved in content (meta numbers are normalized):
<p><span class="section-ref">ยง01.02</span></p>
.

Level at the nine-digit bound (captured):
.
ยง123456789 and ยง1.999999999
.
<p><span class="section-ref">ยง123456789</span> and <span class="section-ref">ยง1.999999999</span></p>
.

Level one past the nine-digit bound (not captured):
.
ยง1234567890 and ยง1.1234567890
.
<p>ยง1234567890 and ยง1.1234567890</p>
.

Over-long middle level (not captured, no partial prefix):
.
ยง1.1234567890.2 then ยง3
.
<p>ยง1.1234567890.2 then <span class="section-ref">ยง3</span></p>
.

Leading zeros count towards the bound (not captured):
.
ยง0000000001
.
<p>ยง0000000001</p>
.

In a heading:
.
## About ยง2
Expand Down
69 changes: 69 additions & 0 deletions tests/test_section_ref.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
import time
from types import SimpleNamespace

from markdown_it import MarkdownIt
Expand Down Expand Up @@ -126,3 +127,71 @@ def test_all(line, title, input, expected):
text = md.render(input)
print(text)
assert text.rstrip() == expected.rstrip()


def test_huge_digit_run_renders_as_text():
"""``ยง`` + 5000 digits must not raise (``int()`` refuses > 4300 digits).

The over-long level makes the rule decline, so the text renders literally.
"""
md = MarkdownIt().use(section_ref_plugin)
digits = "1" * 5000
assert md.render(f"see ยง{digits}") == f"<p>see ยง{digits}</p>\n"
tokens = md.parseInline(f"see ยง{digits}")[0].children
assert tokens is not None
assert [t.type for t in tokens] == ["text"]


@pytest.mark.parametrize(
"src,numbers",
[
("ยง999999999", [999999999]),
("ยง1.999999999.2", [1, 999999999, 2]),
("ยง000000001", [1]),
],
)
def test_numbers_at_bound(src, numbers):
"""A level of exactly nine digits is still a reference, with its int value."""
md = MarkdownIt().use(section_ref_plugin)
tokens = md.parseInline(src)[0].children
assert tokens is not None
assert [t.type for t in tokens] == ["section_ref"]
assert tokens[0].content == src
assert tokens[0].meta == {"numbers": numbers}


@pytest.mark.parametrize(
"src",
["ยง1234567890", "ยง1.1234567890", "ยง1234567890.1", "ยง1.2.1234567890.3"],
)
def test_level_past_bound_declines(src):
"""Any level past nine digits makes the whole text literal (no prefix match)."""
md = MarkdownIt().use(section_ref_plugin)
tokens = md.parseInline(src)[0].children
assert tokens is not None
assert [t.type for t in tokens] == ["text"]
assert tokens[0].content == src


@pytest.mark.parametrize(
"src",
[
"ยง" + "1" * 200_000,
"ยง" + "1." * 100_000 + "1" * 10,
("ยง" + "1" * 5000 + " ") * 40,
],
ids=["one-run", "dotted-run", "many-runs"],
)
def test_long_digit_runs_stay_fast(src):
"""Long digit runs are scanned in linear time, with no backtracking blow-up.

A scan that does Python-level work per character, quadratic in the input
(up to 200k characters), or a backtracking regex would take far longer than
the budget. The budget leaves room for slow CI runners and coverage, so a
quadratic scan built from string slices or copies can still pass it.
"""
md = MarkdownIt().use(section_ref_plugin)
start = time.perf_counter()
md.render(src)
elapsed = time.perf_counter() - start
assert elapsed < 3.0, f"render took {elapsed:.3f}s for {len(src)} characters"
Loading