Skip to main content

specfold_core/markdown/
body.rs

1//! Reads the parts of a Markdown body specfold cares about: `[[references]]`, heading anchors,
2//! and paths that cite spec files directly. Fenced code blocks and inline code are skipped, so
3//! examples in documentation never count as references.
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use std::sync::OnceLock;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct Link {
11    pub text: String,
12    pub line: usize,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Anchor {
17    pub name: String,
18    pub heading: String,
19    pub line: usize,
20}
21
22#[derive(Debug, Default, Clone, Serialize, Deserialize)]
23pub struct Body {
24    pub links: Vec<Link>,
25    pub anchors: Vec<Anchor>,
26    /// Paths into a spec directory written as plain text, e.g. `services/x/.spec/plan/…md`.
27    pub path_cites: Vec<Link>,
28    pub first_heading: Option<String>,
29    pub lines: usize,
30}
31
32/// `[[target]]` and `[[target|label]]`; group 1 is the target.
33pub fn wiki_re() -> &'static Regex {
34    static R: OnceLock<Regex> = OnceLock::new();
35    R.get_or_init(|| Regex::new(r"\[\[([^\[\]\n|]+)(?:\|[^\[\]\n]*)?\]\]").expect("static regex is valid"))
36}
37
38fn anchor_re() -> &'static Regex {
39    static R: OnceLock<Regex> = OnceLock::new();
40    R.get_or_init(|| Regex::new(r"\{#([A-Za-z0-9][A-Za-z0-9_.-]*)\}\s*$").expect("static regex is valid"))
41}
42
43/// C201: a line with its inline code spans taken out, CommonMark style: a run of N backticks
44/// closes only at the next run of exactly N; a run with no partner is plain text.
45pub fn strip_code_spans(line: &str) -> String {
46    let b = line.as_bytes();
47    let run_at = |i: usize| b[i..].iter().take_while(|c| **c == b'`').count();
48    let (mut out, mut i, mut kept) = (String::new(), 0, 0);
49    while i < b.len() {
50        if b[i] != b'`' {
51            i += 1;
52            continue;
53        }
54        let n = run_at(i);
55        let mut j = i + n;
56        let close = loop {
57            match b[j..].iter().position(|c| *c == b'`') {
58                None => break None,
59                Some(p) => {
60                    let at = j + p;
61                    let m = run_at(at);
62                    if m == n {
63                        break Some(at + m);
64                    }
65                    j = at + m;
66                }
67            }
68        };
69        match close {
70            Some(end) => {
71                out.push_str(&line[kept..i]);
72                kept = end;
73                i = end;
74            }
75            None => i += n,
76        }
77    }
78    out.push_str(&line[kept..]);
79    out
80}
81
82/// Builds the regex that finds plain-text paths into `<spec_dir>/` ending in `.md`.
83pub fn path_cite_re(spec_dir: &str) -> Regex {
84    let dir = regex::escape(spec_dir);
85    Regex::new(&format!(r"(?:[A-Za-z0-9_.@-]+/)*{dir}/[A-Za-z0-9_./@-]+\.md"))
86        .expect("an escaped spec dir always forms a valid regex")
87}
88
89/// `first_line` is the 1-based line number of the body's first line in the file.
90pub fn scan(body: &str, first_line: usize, path_re: &Regex) -> Body {
91    let mut out = Body::default();
92    let mut fence = crate::markdown::Fence::default();
93    for (i, raw_line) in body.lines().enumerate() {
94        let line_no = first_line + i;
95        out.lines += 1;
96        let trimmed = raw_line.trim_start();
97        if fence.step(trimmed) || fence.inside() {
98            continue;
99        }
100        let line = strip_code_spans(raw_line);
101        if let Some((_, heading_text)) = crate::markdown::atx(raw_line) {
102            let heading_clean = anchor_re().replace(heading_text, "").trim().to_string();
103            if out.first_heading.is_none() && !heading_clean.is_empty() {
104                out.first_heading = Some(heading_clean.clone());
105            }
106            if let Some(c) = anchor_re().captures(heading_text) {
107                out.anchors.push(Anchor { name: c[1].to_string(), heading: heading_clean, line: line_no });
108            }
109        }
110        for c in wiki_re().captures_iter(&line) {
111            out.links.push(Link { text: c[1].trim().to_string(), line: line_no });
112        }
113        for m in path_re.find_iter(&line) {
114            out.path_cites.push(Link { text: m.as_str().to_string(), line: line_no });
115        }
116    }
117    out
118}
119
120#[cfg(test)]
121mod tests {
122    #[test]
123    fn c201_code_spans_pair_equal_backtick_runs() {
124        use super::strip_code_spans as s;
125        assert_eq!(s("Also ``c ` d`` with [[x]] and `y`."), "Also  with [[x]] and .");
126        assert_eq!(s("`` `id` ``"), "");
127        assert_eq!(s("a ` b [[x]]"), "a ` b [[x]]", "an unpaired run is text");
128        assert_eq!(s("`a` ``b"), " ``b");
129    }
130
131    use super::*;
132
133    #[test]
134    fn finds_links_anchors_and_cites() {
135        let body = "# Checkout\n\nSee [[adr:shop/20260101000000]] and [[sot:shop/cart#CO-1|the rule]].\n\n## Totals {#CO-1}\n\n```\n[[not:a/link]]\n```\nInline `[[not:a/link]]` too.\nOld path services/shop/.spec/plan/x.plan.md here.\n";
136        let b = scan(body, 5, &path_cite_re(".spec"));
137        assert_eq!(b.links.len(), 2);
138        assert_eq!(b.links[0].line, 7);
139        assert_eq!(b.links[1].text, "sot:shop/cart#CO-1");
140        assert_eq!(b.anchors[0].name, "CO-1");
141        assert_eq!(b.anchors[0].heading, "Totals");
142        assert_eq!(b.first_heading.as_deref(), Some("Checkout"));
143        assert_eq!(b.path_cites[0].text, "services/shop/.spec/plan/x.plan.md");
144    }
145}