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 @@ -8,6 +8,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: when duplicate nodes merge, the richer (more complete) node is now kept as the survivor and the losers' non-empty fields are folded in, instead of a shorter-id passing mention winning and dropping content (#3372, thanks @abhay-codes07).
- Fix: a C# generic call site with explicit type arguments — `Get<int>(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get<int>` as the callee and failing to match (#3406, thanks @abhay-codes07).
- Fix: `this.X = function` / `this.X = () => …` members are now captured in every enclosing-function form (function expressions, arrows, IIFEs, callbacks), not just function declarations (#3408, thanks @abhay-codes07).
- Fix: an Obsidian same-page heading anchor — `[[#Heading]]`, or the long-hand `[[ThisPage#Heading]]` — now resolves to that heading's node, attributed to the section the link sits in; the wikilink regex could not match the form at all and discarded the `#Heading` fragment of every anchored link (#3333, thanks @tourko).

## 0.9.56 (2026-09-07)

Expand Down
52 changes: 50 additions & 2 deletions graphify/extractors/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@

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

_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]')
# group(1) page name — EMPTY for a same-page anchor `[[#Heading]]`, which the
# older `[^\]|#]+` could not match at all because it demanded a character before
# the `#`; group(2) the heading fragment, which the older non-capturing
# `(?:[#|][^\]]*)?` swallowed together with the alias. Both halves are needed to
# resolve `[[Page#Heading]]` to the heading rather than only to the page (#3333).
_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]*)(?:#([^\]|]*))?(?:\|[^\]]*)?\]\]')


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

Expand Down Expand Up @@ -348,6 +354,19 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None:
pass
add_edge(file_nid, tgt_nid, "references", line, target_file=target_file)

def is_same_page(page: str) -> bool:
"""Does *page* name this very file? An empty page name always does."""
if not page.strip():
return True
resolved = _resolve_markdown_link(page, source_dir, wikilink=True)
return resolved is not None and _make_id(str(resolved)) == file_nid

# Same-page heading anchors, resolved after the walk: the heading a
# `[[#Setup]]` points at may be declared further down the file, so its node
# does not exist yet at the line the link is read on. Each entry is
# (heading text, line, id of the section the link was written in).
pending_anchors: list[tuple[str, int, str]] = []

# Track heading stack for nesting: [(level, nid), ...]
heading_stack: list[tuple[int, str]] = []
in_code_block = False
Expand All @@ -371,7 +390,21 @@ 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)
page, anchor = m.group(1) or "", (m.group(2) or "").strip()
if not page.strip() and not anchor:
continue # `[[]]` / `[[|alias]]`: names nothing
# A heading anchor into this same file — `[[#Setup]]`, or the
# long-hand `[[ThisPage#Setup]]` — resolves to the heading's own
# node, not to the page. Deferred: the heading may be below this
# line. Attributed to the section the link sits in, matching how
# heading nesting picks its parent (#3333).
if anchor and is_same_page(page):
pending_anchors.append((
anchor, line_num,
heading_stack[-1][1] if heading_stack else file_nid,
))
continue
add_link(page, line_num, wikilink=True)
ref_def = _MD_REF_DEF_RE.match(line_text)
if ref_def:
add_link(ref_def.group(1), line_num)
Expand Down Expand Up @@ -405,4 +438,19 @@ def add_link(raw: str, line: int, wikilink: bool = False) -> None:
heading_stack.append((level, h_nid))
continue

# Every heading node is known now, so same-page anchors can be resolved.
# Existence-gated, like the document links above: an anchor naming no
# heading in this file stays edge-free rather than fabricating a target.
# A duplicate heading title keeps the plain id on its FIRST occurrence,
# which is also the one Obsidian's own anchor resolution picks.
seen_anchor_pairs: set[tuple[str, str]] = set()
for anchor, line, src_nid in pending_anchors:
h_nid = _make_id(stem, anchor)
if h_nid not in seen_ids or h_nid == src_nid:
continue
if (src_nid, h_nid) in seen_anchor_pairs:
continue
seen_anchor_pairs.add((src_nid, h_nid))
add_edge(src_nid, h_nid, "references", line)

return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
192 changes: 192 additions & 0 deletions tests/test_md_wikilink_anchors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Regression tests for wikilink heading anchors (#3333).

`_MD_WIKILINK_RE` was `\\[\\[([^\\]|#]+)(?:[#|][^\\]]*)?\\]\\]`, which had two
defects:

1. `[[#Heading]]` did not match at all — the page-name group demanded at least
one character before the `#`, so a same-page anchor never entered the link
list and the whole `[[#Heading]]` convention was silently invisible.
2. In `[[Page#Heading|alias]]` the non-capturing group swallowed everything from
the `#` onward, so the fragment was discarded and the link could only ever
resolve to `Page`.

The extractor already emits a node per heading, so a same-page anchor has a real
target: the fix resolves it to that heading's node, attributed to the section the
link was written in (the same parent rule heading nesting uses). Resolution is
existence-gated — an anchor naming no heading in the file stays edge-free.
"""
from __future__ import annotations

from pathlib import Path

from graphify.extractors.base import _file_stem, _make_id
from graphify.extractors.markdown import _MD_WIKILINK_RE, extract_markdown


def _write(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path


def _refs(result: dict) -> set[tuple[str, str]]:
return {
(edge["source"], edge["target"])
for edge in result["edges"]
if edge["relation"] == "references"
}


def _page(path: Path) -> str:
"""Id of *path*'s own page node, as `extract_markdown` builds it."""
return _make_id(str(path))


def _heading(path: Path, title: str) -> str:
"""Id of a heading node inside *path*."""
return _make_id(_file_stem(path), title)


# --- the regex itself -------------------------------------------------------

def test_same_page_anchor_matches():
m = _MD_WIKILINK_RE.search("[[#SomeHeading]]")
assert m is not None
assert m.group(1) == ""
assert m.group(2) == "SomeHeading"


def test_cross_page_anchor_keeps_both_halves():
m = _MD_WIKILINK_RE.search("[[Other Page#SomeHeading|alias]]")
assert m is not None
assert m.group(1) == "Other Page"
assert m.group(2) == "SomeHeading"


def test_plain_and_aliased_wikilinks_are_unchanged():
assert _MD_WIKILINK_RE.search("[[Other Page]]").group(1) == "Other Page"
assert _MD_WIKILINK_RE.search("[[Other Page|alias]]").group(1) == "Other Page"
assert _MD_WIKILINK_RE.search("![[embedded.png]]") is None


# --- resolution -------------------------------------------------------------

def test_same_page_anchor_links_to_the_heading_node(tmp_path: Path):
doc = _write(
tmp_path / "notes.md",
"# Notes\n\nSee [[#Setup]].\n\n## Setup\n\nInstall it.\n",
)

result = extract_markdown(doc)

assert (_heading(doc, "Notes"), _heading(doc, "Setup")) in _refs(result)


def test_the_anchor_is_attributed_to_its_enclosing_section(tmp_path: Path):
doc = _write(
tmp_path / "notes.md",
"# Notes\n\n## Overview\n\nSee [[#Setup]].\n\n## Setup\n\nInstall it.\n",
)

result = extract_markdown(doc)

assert (_heading(doc, "Overview"), _heading(doc, "Setup")) in _refs(result)
assert (_heading(doc, "Notes"), _heading(doc, "Setup")) not in _refs(result)


def test_an_anchor_above_its_target_resolves(tmp_path: Path):
"""The heading is declared below the link, so resolution must be deferred."""
doc = _write(
tmp_path / "notes.md",
"# Notes\n\n[[#Later]]\n\n## Later\n",
)

result = extract_markdown(doc)

assert (_heading(doc, "Notes"), _heading(doc, "Later")) in _refs(result)


def test_the_longhand_same_page_form_resolves_too(tmp_path: Path):
"""`[[notes#Usage]]` inside notes.md is the same link as `[[#Usage]]`, and
used to be dropped entirely by the self-reference guard."""
doc = _write(
tmp_path / "notes.md",
"# Notes\n\nSee [[notes#Usage]].\n\n## Usage\n\nRun it.\n",
)

result = extract_markdown(doc)

assert (_heading(doc, "Notes"), _heading(doc, "Usage")) in _refs(result)


def test_an_anchor_naming_no_heading_is_not_fabricated(tmp_path: Path):
doc = _write(tmp_path / "notes.md", "# Notes\n\nSee [[#Nowhere]].\n")

result = extract_markdown(doc)

assert _refs(result) == set()


def test_an_anchor_to_its_own_section_is_not_a_self_loop(tmp_path: Path):
doc = _write(tmp_path / "notes.md", "# Notes\n\nSee [[#Notes]].\n")

result = extract_markdown(doc)

assert _refs(result) == set()


def test_repeated_anchors_in_one_section_yield_one_edge(tmp_path: Path):
doc = _write(
tmp_path / "notes.md",
"# Notes\n\n[[#Setup]] and again [[#Setup]].\n\n## Setup\n",
)

result = extract_markdown(doc)

assert len([
edge for edge in result["edges"]
if edge["relation"] == "references"
]) == 1


def test_a_duplicate_heading_title_resolves_to_the_first(tmp_path: Path):
"""The second `## Setup` gets a line-suffixed id; Obsidian's own anchor
resolution picks the first, and so does this."""
doc = _write(
tmp_path / "notes.md",
"# Notes\n\n[[#Setup]]\n\n## Setup\n\n## Setup\n",
)

result = extract_markdown(doc)

assert (_heading(doc, "Notes"), _heading(doc, "Setup")) in _refs(result)


def test_a_cross_page_anchored_link_still_reaches_the_page(tmp_path: Path):
"""Unchanged behavior: a fragment into *another* file resolves to the page,
since a heading id there cannot be verified from inside this extractor."""
doc = _write(tmp_path / "notes.md", "# Notes\n\nSee [[other#Details|d]].\n")
other = _write(tmp_path / "other.md", "# Other\n\n## Details\n")

result = extract_markdown(doc)

assert (_page(doc), _page(other)) in _refs(result)


def test_an_empty_wikilink_names_nothing(tmp_path: Path):
doc = _write(tmp_path / "notes.md", "# Notes\n\n[[]] and [[|alias]]\n")

result = extract_markdown(doc)

assert _refs(result) == set()


def test_an_anchor_inside_a_fenced_block_is_ignored(tmp_path: Path):
doc = _write(
tmp_path / "notes.md",
"# Notes\n\n```\n[[#Setup]]\n```\n\n## Setup\n",
)

result = extract_markdown(doc)

assert _refs(result) == set()