diff --git a/mdit_py_plugins/section_ref/index.py b/mdit_py_plugins/section_ref/index.py index 5069123..857a7ac 100644 --- a/mdit_py_plugins/section_ref/index.py +++ b/mdit_py_plugins/section_ref/index.py @@ -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``. @@ -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 @@ -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 diff --git a/tests/fixtures/section_ref.md b/tests/fixtures/section_ref.md index f870d2a..2a1973f 100644 --- a/tests/fixtures/section_ref.md +++ b/tests/fixtures/section_ref.md @@ -149,6 +149,34 @@ Leading zeros preserved in content (meta numbers are normalized):

§01.02

. +Level at the nine-digit bound (captured): +. +§123456789 and §1.999999999 +. +

§123456789 and §1.999999999

+. + +Level one past the nine-digit bound (not captured): +. +§1234567890 and §1.1234567890 +. +

§1234567890 and §1.1234567890

+. + +Over-long middle level (not captured, no partial prefix): +. +§1.1234567890.2 then §3 +. +

§1.1234567890.2 then §3

+. + +Leading zeros count towards the bound (not captured): +. +§0000000001 +. +

§0000000001

+. + In a heading: . ## About §2 diff --git a/tests/test_section_ref.py b/tests/test_section_ref.py index 7b4d804..d83355b 100644 --- a/tests/test_section_ref.py +++ b/tests/test_section_ref.py @@ -1,4 +1,5 @@ from pathlib import Path +import time from types import SimpleNamespace from markdown_it import MarkdownIt @@ -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"

see §{digits}

\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"