use std::collections::BTreeMap;
use comrak::html::collect_text;
use comrak::nodes::{AstNode, NodeValue};
use comrak::{parse_document, Arena};
use crate::directivepass;
use crate::markdown::comrak_options;
use crate::wikilink::{flat_source, parse_wikilink};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WikilinkRef {
pub target: String,
pub label: Option<String>,
pub anchor: Option<String>,
pub line: usize,
pub col: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MdLinkRef {
pub url: String,
pub is_image: bool,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeadingRef {
pub depth: u8,
pub text: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FenceRef {
pub lang: String,
pub body: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectiveRef {
pub name: String,
pub attrs: BTreeMap<String, String>,
pub line: usize,
pub has_body: bool,
pub src: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DocRefs {
pub wikilinks: Vec<WikilinkRef>,
pub links: Vec<MdLinkRef>,
pub headings: Vec<HeadingRef>,
pub fences: Vec<FenceRef>,
pub directives: Vec<DirectiveRef>,
}
pub fn extract_refs(body_md: &str) -> DocRefs {
let mut refs = DocRefs::default();
collect(body_md, 0, &mut refs);
refs
}
impl DocRefs {
pub fn offset_lines(&mut self, lines: usize) {
if lines == 0 {
return;
}
for w in &mut self.wikilinks {
w.line += lines;
}
for l in &mut self.links {
l.line += lines;
}
for h in &mut self.headings {
h.line += lines;
}
for f in &mut self.fences {
f.line += lines;
}
for d in &mut self.directives {
d.line += lines;
}
}
}
fn collect(body_md: &str, line_offset: usize, refs: &mut DocRefs) {
let (rewritten, instances) = directivepass::extract(body_md);
for inst in &instances {
refs.directives.push(DirectiveRef {
name: inst.name.clone(),
attrs: inst.attrs.clone(),
line: line_offset + inst.line,
has_body: inst.is_block,
src: inst.attrs.get("src").cloned(),
});
}
let padded = repad_sentinels(&rewritten, &instances);
let arena = Arena::new();
let options = comrak_options();
let root = parse_document(&arena, &padded, &options);
scan_ast(root, line_offset, refs);
for inst in &instances {
if inst.is_block && !inst.inner_md.is_empty() {
collect(&inst.inner_md, line_offset + inst.line, refs);
}
}
}
fn repad_sentinels(rewritten: &str, instances: &[directivepass::DirectiveInstance]) -> String {
let mut out = rewritten.to_string();
for (idx, inst) in instances.iter().enumerate() {
if !inst.is_block {
continue;
}
let inner_lines = if inst.inner_md.is_empty() {
0
} else {
inst.inner_md.split('\n').count()
};
let s = directivepass::sentinel(idx);
out = out.replacen(&s, &format!("{s}{}", "\n".repeat(inner_lines + 1)), 1);
}
out
}
fn scan_ast<'a>(root: &'a AstNode<'a>, line_offset: usize, refs: &mut DocRefs) {
for node in root.descendants() {
let data = node.data.borrow();
let line = line_offset + data.sourcepos.start.line;
match &data.value {
NodeValue::Heading(h) => {
refs.headings.push(HeadingRef {
depth: h.level,
text: collect_text(node).trim().to_string(),
line,
});
}
NodeValue::CodeBlock(cb) if cb.fenced => {
refs.fences.push(FenceRef {
lang: cb
.info
.split_whitespace()
.next()
.unwrap_or_default()
.to_string(),
body: cb.literal.clone(),
line,
});
}
NodeValue::Link(l) => {
refs.links.push(MdLinkRef {
url: l.url.clone(),
is_image: false,
line,
});
}
NodeValue::Image(l) => {
refs.links.push(MdLinkRef {
url: l.url.clone(),
is_image: true,
line,
});
}
_ => {}
}
}
scan_wikilinks(root, line_offset, refs);
}
fn scan_wikilinks<'a>(root: &'a AstNode<'a>, line_offset: usize, refs: &mut DocRefs) {
for parent in root.descendants().filter(|n| n.first_child().is_some()) {
let children: Vec<&'a AstNode<'a>> = parent.children().collect();
let mut i = 0;
while i < children.len() {
if flat_source(children[i]).is_none() {
i += 1;
continue;
}
let start = i;
let mut combined = String::new();
while i < children.len() {
match flat_source(children[i]) {
Some(s) => {
combined.push_str(&s);
i += 1;
}
None => break,
}
}
if !combined.contains("[[") {
continue;
}
let sp = children[start].data.borrow().sourcepos;
let line = line_offset + sp.start.line;
let base_col = sp.start.column;
let mut rest = combined.as_str();
let mut consumed_chars = 0usize;
while let Some(open) = rest.find("[[") {
let Some(close_rel) = rest[open + 2..].find("]]") else {
break; };
let close = open + 2 + close_rel;
let inner = &rest[open + 2..close];
let (target_full, label) = parse_wikilink(inner);
let (target, anchor) = match target_full.split_once('#') {
Some((t, a)) => (t.trim().to_string(), Some(a.trim().to_string())),
None => (target_full, None),
};
refs.wikilinks.push(WikilinkRef {
target,
label,
anchor,
line,
col: base_col + consumed_chars + rest[..open].chars().count(),
});
consumed_chars += rest[..close + 2].chars().count();
rest = &rest[close + 2..];
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wikilinks_with_anchor_label_and_broken_target_are_reported() {
let md = "See [[guide/intro#setup|Setup]] and [[nope]] here.\n";
let refs = extract_refs(md);
assert_eq!(refs.wikilinks.len(), 2);
let w = &refs.wikilinks[0];
assert_eq!(w.target, "guide/intro");
assert_eq!(w.anchor.as_deref(), Some("setup"));
assert_eq!(w.label.as_deref(), Some("Setup"));
assert_eq!(w.line, 1);
assert_eq!(w.col, 5);
let w = &refs.wikilinks[1];
assert_eq!(w.target, "nope"); assert!(w.anchor.is_none());
assert!(w.label.is_none());
assert_eq!(w.line, 1);
}
#[test]
fn wikilink_inside_code_span_or_fence_is_not_reported() {
let md = "inline `[[not-a-link]]` here\n\n```\n[[also-not]]\n```\n\n[[real]]\n";
let refs = extract_refs(md);
assert_eq!(refs.wikilinks.len(), 1);
assert_eq!(refs.wikilinks[0].target, "real");
assert_eq!(refs.wikilinks[0].line, 7);
}
#[test]
fn wikilink_line_is_exact_across_multiline_paragraph() {
let md = "first line\nsecond [[here]]\n";
let refs = extract_refs(md);
assert_eq!(refs.wikilinks.len(), 1);
assert_eq!(refs.wikilinks[0].line, 2);
}
#[test]
fn md_links_and_images_are_reported_with_url_as_written() {
let md = "[text](https://example.com/a) and\n\n";
let refs = extract_refs(md);
assert_eq!(refs.links.len(), 2);
assert_eq!(refs.links[0].url, "https://example.com/a");
assert!(!refs.links[0].is_image);
assert_eq!(refs.links[0].line, 1);
assert_eq!(refs.links[1].url, "../img/pic.png");
assert!(refs.links[1].is_image);
assert_eq!(refs.links[1].line, 2);
}
#[test]
fn headings_all_levels_with_lines() {
let md = "# A\n## B\n### C\n#### D\n##### E\n###### F\n";
let refs = extract_refs(md);
assert_eq!(refs.headings.len(), 6);
for (i, h) in refs.headings.iter().enumerate() {
assert_eq!(h.depth as usize, i + 1);
assert_eq!(h.line, i + 1);
}
assert_eq!(refs.headings[2].text, "C");
}
#[test]
fn fences_carry_lang_body_and_line() {
let md = "intro\n\n```rust ignore\nfn main() {}\n```\n\n```\nplain\n```\n";
let refs = extract_refs(md);
assert_eq!(refs.fences.len(), 2);
assert_eq!(refs.fences[0].lang, "rust"); assert_eq!(refs.fences[0].body, "fn main() {}\n");
assert_eq!(refs.fences[0].line, 3);
assert_eq!(refs.fences[1].lang, "");
assert_eq!(refs.fences[1].line, 7);
}
#[test]
fn directive_lines_reported_including_after_fence_with_colons() {
let md = "```text\n:::\n```\n\n:::callout{type=note}\nbody\n:::\n\n:note[x]{}\n";
let refs = extract_refs(md);
assert_eq!(refs.directives.len(), 2);
assert_eq!(refs.directives[0].name, "callout");
assert_eq!(refs.directives[0].line, 5);
assert!(refs.directives[0].has_body);
assert_eq!(refs.directives[1].name, "note");
assert_eq!(refs.directives[1].line, 9);
assert!(!refs.directives[1].has_body);
assert_eq!(refs.fences.len(), 1);
assert_eq!(refs.fences[0].line, 1);
}
#[test]
fn content_after_block_directive_keeps_original_lines() {
let md = "# T\n\n:::callout{}\nbody\n:::\n\n## After\n";
let refs = extract_refs(md);
let after = refs.headings.iter().find(|h| h.text == "After").unwrap();
assert_eq!(after.line, 7);
}
#[test]
fn recursion_into_directive_body_offsets_lines_by_directive_line() {
let md = "intro\n\n:::callout{type=note}\n[[inner-link]]\n\n## Inner Heading\n:::\n";
let refs = extract_refs(md);
assert_eq!(refs.directives.len(), 1);
assert_eq!(refs.directives[0].line, 3);
assert_eq!(refs.wikilinks.len(), 1);
assert_eq!(refs.wikilinks[0].target, "inner-link");
assert_eq!(refs.wikilinks[0].line, 4);
let inner = refs
.headings
.iter()
.find(|h| h.text == "Inner Heading")
.unwrap();
assert_eq!(inner.line, 6);
}
#[test]
fn nested_directives_are_reported_from_recursion() {
let md = ":::callout{type=note}\n:::callout{type=warning}\ninner\n:::\n:::\n";
let refs = extract_refs(md);
assert_eq!(refs.directives.len(), 2);
assert_eq!(refs.directives[0].line, 1);
assert_eq!(refs.directives[1].line, 2);
assert_eq!(refs.directives[1].attrs.get("type").unwrap(), "warning");
}
#[test]
fn include_src_is_surfaced() {
let md = ":include{src=partials/setup.md}\n";
let refs = extract_refs(md);
assert_eq!(refs.directives.len(), 1);
assert_eq!(refs.directives[0].name, "include");
assert_eq!(refs.directives[0].src.as_deref(), Some("partials/setup.md"));
assert!(!refs.directives[0].has_body);
}
#[test]
fn empty_body_yields_empty_refs() {
assert_eq!(extract_refs(""), DocRefs::default());
}
#[test]
fn offset_lines_shifts_every_ref_kind() {
let md = "# H\n\n[[w]] [l](x.md)\n\n```mermaid\npie\n```\n\n:include{src=_p.md}\n";
let mut refs = extract_refs(md);
refs.offset_lines(4);
assert_eq!(refs.headings[0].line, 1 + 4);
assert_eq!(refs.wikilinks[0].line, 3 + 4);
assert_eq!(refs.links[0].line, 3 + 4);
assert_eq!(refs.fences[0].line, 5 + 4);
assert_eq!(refs.directives[0].line, 9 + 4);
let mut refs2 = extract_refs(md);
let snapshot = refs2.clone();
refs2.offset_lines(0);
assert_eq!(refs2, snapshot);
}
}