specfold_core/markdown/
body.rs1use 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 pub path_cites: Vec<Link>,
28 pub first_heading: Option<String>,
29 pub lines: usize,
30}
31
32fn wiki_re() -> &'static Regex {
33 static R: OnceLock<Regex> = OnceLock::new();
34 R.get_or_init(|| Regex::new(r"\[\[([^\[\]\n|]+)(?:\|[^\[\]\n]*)?\]\]").unwrap())
35}
36
37fn anchor_re() -> &'static Regex {
38 static R: OnceLock<Regex> = OnceLock::new();
39 R.get_or_init(|| Regex::new(r"\{#([A-Za-z0-9][A-Za-z0-9_.-]*)\}\s*$").unwrap())
40}
41
42fn inline_code_re() -> &'static Regex {
43 static R: OnceLock<Regex> = OnceLock::new();
44 R.get_or_init(|| Regex::new(r"`+[^`]*`+").unwrap())
45}
46
47pub fn path_cite_re(spec_dir: &str) -> Regex {
49 let dir = regex::escape(spec_dir);
50 Regex::new(&format!(r"(?:[A-Za-z0-9_.@-]+/)*{dir}/[A-Za-z0-9_./@-]+\.md")).unwrap()
51}
52
53pub fn scan(body: &str, first_line: usize, path_re: &Regex) -> Body {
55 let mut out = Body::default();
56 let mut fence: Option<String> = None;
57 for (i, raw_line) in body.lines().enumerate() {
58 let line_no = first_line + i;
59 out.lines += 1;
60 let trimmed = raw_line.trim_start();
61 if let Some(f) = &fence {
62 if trimmed.starts_with(f.as_str())
63 && trimmed.trim_start_matches(f.chars().next().unwrap()).trim().is_empty()
64 {
65 fence = None;
66 }
67 continue;
68 }
69 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
70 let ch = trimmed.chars().next().unwrap();
71 let n = trimmed.chars().take_while(|c| *c == ch).count();
72 fence = Some(std::iter::repeat_n(ch, n).collect());
73 continue;
74 }
75 let line = inline_code_re().replace_all(raw_line, "");
76 if let Some(rest) = trimmed.strip_prefix('#') {
77 let level_rest = rest.trim_start_matches('#');
78 if level_rest.starts_with(' ') || level_rest.is_empty() {
79 let heading_text = level_rest.trim();
80 let heading_clean = anchor_re().replace(heading_text, "").trim().to_string();
81 if out.first_heading.is_none() && !heading_clean.is_empty() {
82 out.first_heading = Some(heading_clean.clone());
83 }
84 if let Some(c) = anchor_re().captures(heading_text) {
85 out.anchors.push(Anchor { name: c[1].to_string(), heading: heading_clean, line: line_no });
86 }
87 }
88 }
89 for c in wiki_re().captures_iter(&line) {
90 out.links.push(Link { text: c[1].trim().to_string(), line: line_no });
91 }
92 for m in path_re.find_iter(&line) {
93 out.path_cites.push(Link { text: m.as_str().to_string(), line: line_no });
94 }
95 }
96 out
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 #[test]
104 fn finds_links_anchors_and_cites() {
105 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 apps/shop/.spec/plan/x.plan.md here.\n";
106 let b = scan(body, 5, &path_cite_re(".spec"));
107 assert_eq!(b.links.len(), 2);
108 assert_eq!(b.links[0].line, 7);
109 assert_eq!(b.links[1].text, "sot:shop/cart#CO-1");
110 assert_eq!(b.anchors[0].name, "CO-1");
111 assert_eq!(b.anchors[0].heading, "Totals");
112 assert_eq!(b.first_heading.as_deref(), Some("Checkout"));
113 assert_eq!(b.path_cites[0].text, "apps/shop/.spec/plan/x.plan.md");
114 }
115}