Skip to content

Commit 3ee0da7

Browse files
Aldominguez12claude
andcommitted
fix(lint): exclude [ from frag and alias classes too (linear scan)
Follow-up to the target-class fix (e03e322): the frag class #[^\]|]* and alias class [^\]]+ still allowed [, so the same O(n^2) backtracking survived on long ]-free runs seeded with [[a#... or [[a|... (a bare [[a#*10000 hung ~15s per pass, and lint scans each file twice). Extend the same [-exclusion to both inner classes: frag #[^\]|]* -> #[^\[\]|]* alias [^\]]+ -> [^\[\]]+ Every pathological shape is now linear (20k -> <20ms), and matching on valid Obsidian syntax is byte-for-byte unchanged (headings, block refs, aliases, note/attachment embeds, same-page fragments). Adds regression tests for the frag/alias runs alongside the existing [-run one. Addresses review follow-up on #180. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e03e322 commit 3ee0da7

2 files changed

Lines changed: 30 additions & 3 deletions

File tree

openkb/lint.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@
2424
# same-page fragment links [[#Heading]], and embeds ![[file]]. Named groups
2525
# keep the pieces separable so Obsidian fragment/embed syntax survives
2626
# validation and rewriting instead of being demoted to plain text.
27-
# `[` is excluded from the target class: Obsidian forbids it in note names,
28-
# and allowing it makes scans quadratic on long runs of unmatched `[`.
27+
# `[` is excluded from every inner class (target, frag, and alias): Obsidian
28+
# forbids it in note names, and allowing it in any class lets that class run
29+
# past a following `[[` that starts a new unclosed link, so a long `]`-free
30+
# region seeded with `[[a#`… or `[[a|`… backtracks quadratically — not just a
31+
# bare run of `[`. Bounding all three keeps the scan linear.
2932
_WIKILINK_RE = re.compile(
30-
r"(?P<embed>!)?\[\[(?P<target>[^\[\]|#]*)(?P<frag>#[^\]|]*)?(?:\|(?P<alias>[^\]]+))?\]\]"
33+
r"(?P<embed>!)?\[\[(?P<target>[^\[\]|#]*)(?P<frag>#[^\[\]|]*)?(?:\|(?P<alias>[^\[\]]+))?\]\]"
3134
)
3235

3336
# Extension-like suffix on a wikilink target: 1-8 alphanumeric chars with at

tests/test_lint.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,30 @@ def test_unmatched_bracket_run_scans_in_linear_time(self):
701701
assert out == payload
702702
assert ghosts == []
703703

704+
def test_unmatched_fragment_run_scans_in_linear_time(self):
705+
# Regression: excluding "[" from the *target* class alone was not
706+
# enough — the frag class `#[^\]|]*` still swallowed following `[[`s,
707+
# so a long `]`-free run of `[[a#`… stayed quadratic (~15s at 10k).
708+
# Excluding "[" from the frag class too keeps it linear.
709+
payload = "[[a#" * 20_000
710+
start = time.perf_counter()
711+
out, ghosts = strip_ghost_wikilinks(payload, set())
712+
elapsed = time.perf_counter() - start
713+
assert elapsed < 1.0
714+
assert out == payload
715+
assert ghosts == []
716+
717+
def test_unmatched_alias_run_scans_in_linear_time(self):
718+
# Same quadratic class via the alias group `[^\]]+` on a `[[a|`… run;
719+
# excluding "[" from the alias class bounds it.
720+
payload = "[[a|" * 20_000
721+
start = time.perf_counter()
722+
out, ghosts = strip_ghost_wikilinks(payload, set())
723+
elapsed = time.perf_counter() - start
724+
assert elapsed < 1.0
725+
assert out == payload
726+
assert ghosts == []
727+
704728
# --- find_broken_links ------------------------------------------------
705729

706730
def test_heading_link_to_existing_page_not_broken(self, tmp_path):

0 commit comments

Comments
 (0)