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
36 changes: 33 additions & 3 deletions graphify/extractors/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

_MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)>?')

_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]')
_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]*)(?:#([^\]|]*))?(?:\|[^\]]*)?\]\]')

_MD_LINKABLE_EXTS = {".md", ".mdx", ".qmd", ".markdown", ".rst", ".txt"}

Expand Down Expand Up @@ -261,6 +261,10 @@ def extract_markdown(path: Path) -> dict:
from the resolved target path with the same recipe as the target file's
own node, so the edge merges into that node (no ghost node). External
URLs, in-page anchors, images and non-document targets are skipped.
Anchored wikilinks follow inline-link semantics: ``[[Page#Heading]]``
(with or without a ``|alias``) resolves to ``Page``'s node, while a
same-page anchor ``[[#Heading]]`` resolves to that heading's node in the
same file when the heading exists, and to nothing otherwise (#3333).

Fenced code blocks (``` ... ```) are skipped during parsing so their
contents don't get treated as headings, but no node is emitted for
Expand Down Expand Up @@ -319,7 +323,20 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
# same sibling many times yields one edge, not N (keeps weights meaningful).
linked_targets: set[str] = set()

def add_link(raw: str, line: int, wikilink: bool = False) -> None:
# Same-page anchors ([[#Heading]], #3333) resolve after the scan, against
# the heading map below: a link may precede the heading it targets, and
# a title with no heading node resolves to nothing rather than dangling.
heading_titles: dict[str, str] = {}
pending_anchors: list[tuple[str, int]] = []

def add_link(raw: str, line: int, wikilink: bool = False,
fragment: "str | None" = None) -> None:
if wikilink and not raw.strip():
# Same-page anchor ([[#Heading]]): resolve after the scan (#3333).
fragment = (fragment or "").strip()
if fragment:
pending_anchors.append((fragment, line))
return
resolved = _resolve_markdown_link(raw, source_dir, wikilink=wikilink)
if resolved is None:
return
Expand Down Expand Up @@ -371,7 +388,7 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None:
for m in _MD_INLINE_LINK_RE.finditer(line_text):
add_link(m.group(1), line_num)
for m in _MD_WIKILINK_RE.finditer(line_text):
add_link(m.group(1), line_num, wikilink=True)
add_link(m.group(1), line_num, wikilink=True, fragment=m.group(2))
ref_def = _MD_REF_DEF_RE.match(line_text)
if ref_def:
add_link(ref_def.group(1), line_num)
Expand All @@ -392,6 +409,9 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None:
# Avoid duplicate heading IDs by appending line number
if h_nid in seen_ids:
h_nid = _make_id(stem, title, str(line_num))
# First occurrence wins, so a same-page anchor to a duplicated
# title resolves deterministically (#3333).
heading_titles.setdefault(title, h_nid)
add_node(h_nid, title, line_num)

# Pop headings at same or deeper level
Expand All @@ -405,4 +425,14 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None:
heading_stack.append((level, h_nid))
continue

# Resolve deferred same-page anchors against the completed heading map
# (#3333): first occurrence wins, a missing title stays dropped, and the
# add_link self-reference/dedup guards apply to heading targets too.
for title, line in pending_anchors:
target = heading_titles.get(title)
if target is None or target == file_nid or target in linked_targets:
continue
linked_targets.add(target)
add_edge(file_nid, target, "references", line)

return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
100 changes: 100 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -3015,6 +3015,106 @@ def test_markdown_wikilink_fallback_unicode_normalization(tmp_path):
for e in refs), f"NFD wikilink missed the NFC file: {refs}"


# ── Anchored wikilinks (#3333) ───────────────────────────────────────────────


def test_markdown_wikilink_regex_captures_page_and_fragment():
"""The wikilink regex matches same-page anchors and captures page + heading."""
from graphify.extractors.markdown import _MD_WIKILINK_RE
m = _MD_WIKILINK_RE.search("[[Other Page#SomeHeading|alias]]")
assert m, "anchored cross-page wikilink did not match"
assert m.group(1) == "Other Page"
assert m.group(2) == "SomeHeading"
m = _MD_WIKILINK_RE.search("[[#SomeHeading]]")
assert m, "same-page anchor [[#Heading]] did not match"
assert m.group(1) == ""
assert m.group(2) == "SomeHeading"


def test_markdown_wikilink_same_page_anchor_resolves():
"""[[#Heading]] resolves to that heading's node in the same file (#3333)."""
r = _md_extract("# Note\n\n## Setup\n\nSteps here.\n\nSee [[#Setup]].\n")
file_id = next(n["id"] for n in r["nodes"] if n.get("node_kind") == "page")
refs = [e for e in r["edges"] if e["relation"] == "references"]
assert refs, "same-page anchor produced no reference edge"
heading_id = next(n["id"] for n in r["nodes"]
if n.get("node_kind") == "heading" and n["label"] == "Setup")
assert any(e["source"] == file_id and e["target"] == heading_id
for e in refs), f"anchor did not resolve to the heading: {refs}"


def test_markdown_wikilink_same_page_anchor_forward_reference():
"""[[#Heading]] placed before the heading still resolves — anchors are
resolved after the scan, so a link may precede its target (#3333)."""
r = _md_extract("# Note\n\nSee [[#Setup]] below.\n\n## Setup\n\nSteps.\n")
file_id = next(n["id"] for n in r["nodes"] if n.get("node_kind") == "page")
refs = [e for e in r["edges"] if e["relation"] == "references"]
heading_id = next(n["id"] for n in r["nodes"]
if n.get("node_kind") == "heading" and n["label"] == "Setup")
assert any(e["source"] == file_id and e["target"] == heading_id
for e in refs), f"forward anchor did not resolve: {refs}"


def test_markdown_wikilink_anchored_cross_page(tmp_path):
"""[[Page#Heading|alias]] resolves to the page's node — the same edge as
[[Page]] (anchor stripped before resolution, mirroring inline links) and
deduped with it (#3333)."""
from graphify.extractors.base import _make_id
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "Other Page.md").write_text("# Other Page\n\n## SomeHeading\n\nBody.\n")
src = pkg / "index.md"
src.write_text(
"# Index\n\nSee [[Other Page#SomeHeading|alias]] and [[Other Page]].\n")
r = extract_markdown(src)
refs = [e for e in r["edges"] if e["relation"] == "references"]
page_nid = _make_id(str(pkg / "Other Page.md"))
assert refs, "anchored cross-page link produced no reference edge"
assert len(refs) == 1, f"anchored and plain wikilinks were not deduped: {refs}"
targets = {e["target"] for e in refs}
assert targets == {page_nid}, (
f"expected exactly the page node, no heading-granular or ghost target: {refs}")


def test_markdown_wikilink_same_page_anchor_missing_heading():
"""[[#Missing]] with no such heading produces no edge — never a dangling
endpoint (#3333, R2)."""
r = _md_extract("# Note\n\n## Setup\n\nSee [[#Missing]].\n")
refs = [e for e in r["edges"] if e["relation"] == "references"]
assert refs == [], f"anchor to a missing heading produced edges: {refs}"


def test_markdown_wikilink_degenerate_forms():
"""[[]], [[#]] and [[|alias]] produce no edges and no exception (#3333, R5)."""
r = _md_extract("# Note\n\n## Setup\n\nA [[#]] B [[]] C [[|alias]] D.\n")
refs = [e for e in r["edges"] if e["relation"] == "references"]
assert refs == [], f"degenerate wikilinks produced edges: {refs}"


def test_markdown_wikilink_duplicate_heading_resolves_first():
"""A same-page anchor to a duplicated heading resolves to the first
occurrence's node (the un-disambiguated id), deterministically (#3333, R6)."""
r = _md_extract("# Note\n\n## Dup\n\nfirst\n\n## Dup\n\nsecond\n\nLink: [[#Dup]].\n")
refs = [e for e in r["edges"] if e["relation"] == "references"]
assert len(refs) == 1, f"expected one reference edge, got {refs}"
dups = sorted(
(n for n in r["nodes"]
if n.get("node_kind") == "heading" and n["label"] == "Dup"),
key=lambda n: n["source_location"],
)
assert refs[0]["target"] == dups[0]["id"], (
f"anchor must resolve to the first occurrence: {refs}")


def test_markdown_wikilink_embeds_and_fenced_skipped():
"""Embeds ![[...]] stay unmatched and fenced code is skipped (#3333, R4)."""
r = _md_extract(
"# Note\n\n![[logo.png]] embed stays unmatched.\n\n"
"```\n[[inside-fence]] is not a link.\n```\n")
refs = [e for e in r["edges"] if e["relation"] == "references"]
assert refs == [], f"embeds/fenced wikilinks produced edges: {refs}"


# ── Groovy ───────────────────────────────────────────────────────────────────


Expand Down