1use std::collections::BTreeMap;
24
25use comrak::html::collect_text;
26use comrak::nodes::{AstNode, NodeValue};
27use comrak::{parse_document, Arena};
28
29use crate::directivepass;
30use crate::markdown::comrak_options;
31use crate::wikilink::{flat_source, parse_wikilink};
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct WikilinkRef {
36 pub target: String,
38 pub label: Option<String>,
40 pub anchor: Option<String>,
42 pub line: usize,
44 pub col: usize,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct MdLinkRef {
51 pub url: String,
52 pub is_image: bool,
53 pub line: usize,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct HeadingRef {
59 pub depth: u8,
60 pub text: String,
61 pub line: usize,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct FenceRef {
67 pub lang: String,
69 pub body: String,
70 pub line: usize,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct DirectiveRef {
76 pub name: String,
77 pub attrs: BTreeMap<String, String>,
78 pub line: usize,
80 pub has_body: bool,
82 pub src: Option<String>,
84}
85
86#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct DocRefs {
89 pub wikilinks: Vec<WikilinkRef>,
90 pub links: Vec<MdLinkRef>,
91 pub headings: Vec<HeadingRef>,
92 pub fences: Vec<FenceRef>,
93 pub directives: Vec<DirectiveRef>,
94}
95
96pub fn extract_refs(body_md: &str) -> DocRefs {
101 let mut refs = DocRefs::default();
102 collect(body_md, 0, &mut refs);
103 refs
104}
105
106impl DocRefs {
107 pub fn offset_lines(&mut self, lines: usize) {
111 if lines == 0 {
112 return;
113 }
114 for w in &mut self.wikilinks {
115 w.line += lines;
116 }
117 for l in &mut self.links {
118 l.line += lines;
119 }
120 for h in &mut self.headings {
121 h.line += lines;
122 }
123 for f in &mut self.fences {
124 f.line += lines;
125 }
126 for d in &mut self.directives {
127 d.line += lines;
128 }
129 }
130}
131
132fn collect(body_md: &str, line_offset: usize, refs: &mut DocRefs) {
136 let (rewritten, instances) = directivepass::extract(body_md);
137
138 for inst in &instances {
139 refs.directives.push(DirectiveRef {
140 name: inst.name.clone(),
141 attrs: inst.attrs.clone(),
142 line: line_offset + inst.line,
143 has_body: inst.is_block,
144 src: inst.attrs.get("src").cloned(),
145 });
146 }
147
148 let padded = repad_sentinels(&rewritten, &instances);
152
153 let arena = Arena::new();
154 let options = comrak_options();
155 let root = parse_document(&arena, &padded, &options);
156 scan_ast(root, line_offset, refs);
157
158 for inst in &instances {
160 if inst.is_block && !inst.inner_md.is_empty() {
161 collect(&inst.inner_md, line_offset + inst.line, refs);
162 }
163 }
164}
165
166fn repad_sentinels(rewritten: &str, instances: &[directivepass::DirectiveInstance]) -> String {
171 let mut out = rewritten.to_string();
172 for (idx, inst) in instances.iter().enumerate() {
173 if !inst.is_block {
174 continue;
175 }
176 let inner_lines = if inst.inner_md.is_empty() {
177 0
178 } else {
179 inst.inner_md.split('\n').count()
180 };
181 let s = directivepass::sentinel(idx);
182 out = out.replacen(&s, &format!("{s}{}", "\n".repeat(inner_lines + 1)), 1);
183 }
184 out
185}
186
187fn scan_ast<'a>(root: &'a AstNode<'a>, line_offset: usize, refs: &mut DocRefs) {
189 for node in root.descendants() {
190 let data = node.data.borrow();
191 let line = line_offset + data.sourcepos.start.line;
192 match &data.value {
193 NodeValue::Heading(h) => {
194 refs.headings.push(HeadingRef {
195 depth: h.level,
196 text: collect_text(node).trim().to_string(),
197 line,
198 });
199 }
200 NodeValue::CodeBlock(cb) if cb.fenced => {
201 refs.fences.push(FenceRef {
202 lang: cb
203 .info
204 .split_whitespace()
205 .next()
206 .unwrap_or_default()
207 .to_string(),
208 body: cb.literal.clone(),
209 line,
210 });
211 }
212 NodeValue::Link(l) => {
213 refs.links.push(MdLinkRef {
214 url: l.url.clone(),
215 is_image: false,
216 line,
217 });
218 }
219 NodeValue::Image(l) => {
220 refs.links.push(MdLinkRef {
221 url: l.url.clone(),
222 is_image: true,
223 line,
224 });
225 }
226 _ => {}
227 }
228 }
229 scan_wikilinks(root, line_offset, refs);
230}
231
232fn scan_wikilinks<'a>(root: &'a AstNode<'a>, line_offset: usize, refs: &mut DocRefs) {
238 for parent in root.descendants().filter(|n| n.first_child().is_some()) {
239 let children: Vec<&'a AstNode<'a>> = parent.children().collect();
240 let mut i = 0;
241 while i < children.len() {
242 if flat_source(children[i]).is_none() {
243 i += 1;
244 continue;
245 }
246 let start = i;
247 let mut combined = String::new();
248 while i < children.len() {
249 match flat_source(children[i]) {
250 Some(s) => {
251 combined.push_str(&s);
252 i += 1;
253 }
254 None => break,
255 }
256 }
257 if !combined.contains("[[") {
258 continue;
259 }
260
261 let sp = children[start].data.borrow().sourcepos;
262 let line = line_offset + sp.start.line;
263 let base_col = sp.start.column;
264
265 let mut rest = combined.as_str();
266 let mut consumed_chars = 0usize;
267 while let Some(open) = rest.find("[[") {
268 let Some(close_rel) = rest[open + 2..].find("]]") else {
269 break; };
271 let close = open + 2 + close_rel;
272 let inner = &rest[open + 2..close];
273
274 let (target_full, label) = parse_wikilink(inner);
275 let (target, anchor) = match target_full.split_once('#') {
278 Some((t, a)) => (t.trim().to_string(), Some(a.trim().to_string())),
279 None => (target_full, None),
280 };
281 refs.wikilinks.push(WikilinkRef {
282 target,
283 label,
284 anchor,
285 line,
286 col: base_col + consumed_chars + rest[..open].chars().count(),
287 });
288
289 consumed_chars += rest[..close + 2].chars().count();
290 rest = &rest[close + 2..];
291 }
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn wikilinks_with_anchor_label_and_broken_target_are_reported() {
302 let md = "See [[guide/intro#setup|Setup]] and [[nope]] here.\n";
303 let refs = extract_refs(md);
304 assert_eq!(refs.wikilinks.len(), 2);
305
306 let w = &refs.wikilinks[0];
307 assert_eq!(w.target, "guide/intro");
308 assert_eq!(w.anchor.as_deref(), Some("setup"));
309 assert_eq!(w.label.as_deref(), Some("Setup"));
310 assert_eq!(w.line, 1);
311 assert_eq!(w.col, 5); let w = &refs.wikilinks[1];
314 assert_eq!(w.target, "nope"); assert!(w.anchor.is_none());
316 assert!(w.label.is_none());
317 assert_eq!(w.line, 1);
318 }
319
320 #[test]
321 fn wikilink_inside_code_span_or_fence_is_not_reported() {
322 let md = "inline `[[not-a-link]]` here\n\n```\n[[also-not]]\n```\n\n[[real]]\n";
323 let refs = extract_refs(md);
324 assert_eq!(refs.wikilinks.len(), 1);
325 assert_eq!(refs.wikilinks[0].target, "real");
326 assert_eq!(refs.wikilinks[0].line, 7);
327 }
328
329 #[test]
330 fn wikilink_line_is_exact_across_multiline_paragraph() {
331 let md = "first line\nsecond [[here]]\n";
332 let refs = extract_refs(md);
333 assert_eq!(refs.wikilinks.len(), 1);
334 assert_eq!(refs.wikilinks[0].line, 2);
335 }
336
337 #[test]
338 fn md_links_and_images_are_reported_with_url_as_written() {
339 let md = "[text](https://example.com/a) and\n\n";
340 let refs = extract_refs(md);
341 assert_eq!(refs.links.len(), 2);
342 assert_eq!(refs.links[0].url, "https://example.com/a");
343 assert!(!refs.links[0].is_image);
344 assert_eq!(refs.links[0].line, 1);
345 assert_eq!(refs.links[1].url, "../img/pic.png");
346 assert!(refs.links[1].is_image);
347 assert_eq!(refs.links[1].line, 2);
348 }
349
350 #[test]
351 fn headings_all_levels_with_lines() {
352 let md = "# A\n## B\n### C\n#### D\n##### E\n###### F\n";
353 let refs = extract_refs(md);
354 assert_eq!(refs.headings.len(), 6);
355 for (i, h) in refs.headings.iter().enumerate() {
356 assert_eq!(h.depth as usize, i + 1);
357 assert_eq!(h.line, i + 1);
358 }
359 assert_eq!(refs.headings[2].text, "C");
360 }
361
362 #[test]
363 fn fences_carry_lang_body_and_line() {
364 let md = "intro\n\n```rust ignore\nfn main() {}\n```\n\n```\nplain\n```\n";
365 let refs = extract_refs(md);
366 assert_eq!(refs.fences.len(), 2);
367 assert_eq!(refs.fences[0].lang, "rust"); assert_eq!(refs.fences[0].body, "fn main() {}\n");
369 assert_eq!(refs.fences[0].line, 3);
370 assert_eq!(refs.fences[1].lang, "");
371 assert_eq!(refs.fences[1].line, 7);
372 }
373
374 #[test]
375 fn directive_lines_reported_including_after_fence_with_colons() {
376 let md = "```text\n:::\n```\n\n:::callout{type=note}\nbody\n:::\n\n:note[x]{}\n";
377 let refs = extract_refs(md);
378 assert_eq!(refs.directives.len(), 2);
379 assert_eq!(refs.directives[0].name, "callout");
380 assert_eq!(refs.directives[0].line, 5);
381 assert!(refs.directives[0].has_body);
382 assert_eq!(refs.directives[1].name, "note");
383 assert_eq!(refs.directives[1].line, 9);
384 assert!(!refs.directives[1].has_body);
385 assert_eq!(refs.fences.len(), 1);
387 assert_eq!(refs.fences[0].line, 1);
388 }
389
390 #[test]
391 fn content_after_block_directive_keeps_original_lines() {
392 let md = "# T\n\n:::callout{}\nbody\n:::\n\n## After\n";
393 let refs = extract_refs(md);
394 let after = refs.headings.iter().find(|h| h.text == "After").unwrap();
395 assert_eq!(after.line, 7);
396 }
397
398 #[test]
399 fn recursion_into_directive_body_offsets_lines_by_directive_line() {
400 let md = "intro\n\n:::callout{type=note}\n[[inner-link]]\n\n## Inner Heading\n:::\n";
401 let refs = extract_refs(md);
402 assert_eq!(refs.directives.len(), 1);
403 assert_eq!(refs.directives[0].line, 3);
404 assert_eq!(refs.wikilinks.len(), 1);
406 assert_eq!(refs.wikilinks[0].target, "inner-link");
407 assert_eq!(refs.wikilinks[0].line, 4);
408 let inner = refs
409 .headings
410 .iter()
411 .find(|h| h.text == "Inner Heading")
412 .unwrap();
413 assert_eq!(inner.line, 6);
414 }
415
416 #[test]
417 fn nested_directives_are_reported_from_recursion() {
418 let md = ":::callout{type=note}\n:::callout{type=warning}\ninner\n:::\n:::\n";
419 let refs = extract_refs(md);
420 assert_eq!(refs.directives.len(), 2);
421 assert_eq!(refs.directives[0].line, 1);
422 assert_eq!(refs.directives[1].line, 2);
423 assert_eq!(refs.directives[1].attrs.get("type").unwrap(), "warning");
424 }
425
426 #[test]
427 fn include_src_is_surfaced() {
428 let md = ":include{src=partials/setup.md}\n";
429 let refs = extract_refs(md);
430 assert_eq!(refs.directives.len(), 1);
431 assert_eq!(refs.directives[0].name, "include");
432 assert_eq!(refs.directives[0].src.as_deref(), Some("partials/setup.md"));
433 assert!(!refs.directives[0].has_body);
434 }
435
436 #[test]
437 fn empty_body_yields_empty_refs() {
438 assert_eq!(extract_refs(""), DocRefs::default());
439 }
440
441 #[test]
442 fn offset_lines_shifts_every_ref_kind() {
443 let md = "# H\n\n[[w]] [l](x.md)\n\n```mermaid\npie\n```\n\n:include{src=_p.md}\n";
444 let mut refs = extract_refs(md);
445 refs.offset_lines(4);
446 assert_eq!(refs.headings[0].line, 1 + 4);
447 assert_eq!(refs.wikilinks[0].line, 3 + 4);
448 assert_eq!(refs.links[0].line, 3 + 4);
449 assert_eq!(refs.fences[0].line, 5 + 4);
450 assert_eq!(refs.directives[0].line, 9 + 4);
451
452 let mut refs2 = extract_refs(md);
454 let snapshot = refs2.clone();
455 refs2.offset_lines(0);
456 assert_eq!(refs2, snapshot);
457 }
458}