Skip to main content

docgen_core/
extract.rs

1//! Read-only reference extraction for linting. Walks a document body and
2//! reports every wikilink, markdown link/image, heading, fenced code block and
3//! directive with (approximate, see below) 1-based source positions — without
4//! touching the render/build path.
5//!
6//! Positions: comrak populates `sourcepos` on AST nodes during parsing, so
7//! lines come straight from the AST. Two approximations apply:
8//!
9//! - Wikilink `col` is the start column of the folded Text/HtmlInline run that
10//!   contains the `[[…]]`, advanced by the *character* offset of the link
11//!   within that run. Markdown escapes or entity references consumed by the
12//!   parser can make this drift a little within the line; the line is exact.
13//! - Lines inside a block directive's body are the directive's opening line
14//!   plus the 1-based line within `inner_md` — exact for the common case, but
15//!   nested rewrites deeper down inherit the same additive scheme, so treat
16//!   inner positions as best-effort.
17//!
18//! Directive extraction runs first (via [`directivepass::extract`]), so
19//! everything the render pipeline would treat as directive content is reported
20//! under its directive, and sentinel-substituted lines are re-padded so the
21//! content *after* a block directive keeps its original line numbers.
22
23use 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/// One `[[target#anchor|label]]` wikilink occurrence.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct WikilinkRef {
36    /// Target as written, trimmed, without any `#anchor` part.
37    pub target: String,
38    /// Label after the first `|`, if present.
39    pub label: Option<String>,
40    /// Anchor after the first `#` in the target part, if present.
41    pub anchor: Option<String>,
42    /// 1-based source line (exact).
43    pub line: usize,
44    /// 1-based source column (approximate — see module docs).
45    pub col: usize,
46}
47
48/// One markdown link or image, url as written.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct MdLinkRef {
51    pub url: String,
52    pub is_image: bool,
53    pub line: usize,
54}
55
56/// One heading, any level h1–h6.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct HeadingRef {
59    pub depth: u8,
60    pub text: String,
61    pub line: usize,
62}
63
64/// One fenced code block (indented code blocks are not reported).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct FenceRef {
67    /// First word of the fence info string; empty if none.
68    pub lang: String,
69    pub body: String,
70    pub line: usize,
71}
72
73/// One directive occurrence (block or leaf).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct DirectiveRef {
76    pub name: String,
77    pub attrs: BTreeMap<String, String>,
78    /// 1-based line of the opening delimiter.
79    pub line: usize,
80    /// True for the block (`:::name … :::`) form.
81    pub has_body: bool,
82    /// The `src` attribute, when present (e.g. `:include{src=…}`).
83    pub src: Option<String>,
84}
85
86/// Every reference found in one document body.
87#[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
96/// Extract all references from a markdown body (frontmatter already stripped).
97/// Read-only: shares parsing helpers with the render path but never mutates
98/// render behavior. Directive bodies are recursed into; their references are
99/// reported with lines offset by the directive's opening line.
100pub 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    /// Shift every positioned reference down by `lines`. Used when the body the
108    /// refs were extracted from is preceded by stripped content in the on-disk
109    /// file (a frontmatter block), so reported lines match the raw file.
110    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
132/// Recursive worker: `line_offset` is added to every 1-based line found in
133/// `body_md` (0 at the top level; a directive's opening line when recursing
134/// into its `inner_md`, since inner line 1 is the doc line right after it).
135fn 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    // A block directive spanning N source lines was replaced by a single
149    // sentinel line; re-pad with N-1 blank lines so everything after it keeps
150    // its original line numbers.
151    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    // Recurse into block-directive bodies (their own directives, links, …).
159    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
166/// Restore the line count of the sentinel-substituted body: each *block*
167/// directive consumed `inner lines + 2` source lines (opener + inner + closer)
168/// but occupies one sentinel line, so append `inner lines + 1` blank lines.
169/// Leaf sentinels are inline and don't change the line count.
170fn 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
187/// One pass over the parsed AST: headings, links/images, fences, wikilinks.
188fn 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
232/// Read-only mirror of `wikilink::transform_wikilinks`' run folding: walk
233/// maximal runs of foldable (Text/HtmlInline) siblings and report every
234/// complete `[[…]]` found in the combined text. Text nodes never contain code
235/// spans/fences (comrak parses those as Code/CodeBlock), so wikilinks inside
236/// code are naturally not reported.
237fn 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; // unterminated `[[` — same as the render pass
270                };
271                let close = open + 2 + close_rel;
272                let inner = &rest[open + 2..close];
273
274                let (target_full, label) = parse_wikilink(inner);
275                // Split the anchor out, matching the render pass: the build
276                // resolves the page part and appends the anchor as a fragment.
277                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); // "See " is 4 chars; `[[` opens at column 5
312
313        let w = &refs.wikilinks[1];
314        assert_eq!(w.target, "nope"); // extraction doesn't resolve; linter does
315        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![alt](../img/pic.png)\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"); // first word of the info string
368        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        // The fence itself is still a fence ref, and its `:::` is not a directive.
386        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        // inner_md line 1 → doc line 4; inner line 3 → doc line 6.
405        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        // Offset 0 is a no-op.
453        let mut refs2 = extract_refs(md);
454        let snapshot = refs2.clone();
455        refs2.offset_lines(0);
456        assert_eq!(refs2, snapshot);
457    }
458}