specfold_core/markdown/
counts.rs1const OPEN: &str = "<!-- spec:count";
5const CLOSE: &str = "<!-- /spec:count -->";
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Marker {
10 pub line: usize,
12 pub raw: String,
14 pub source: String,
16 pub value: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Malformed {
23 pub line: usize,
24 pub message: String,
25}
26
27fn mask_code(line: &str) -> String {
29 let mut out = String::with_capacity(line.len());
30 let mut inside = false;
31 for c in line.chars() {
32 if c == '`' {
33 inside = !inside;
34 out.push(c);
35 } else {
36 if inside {
37 out.extend(std::iter::repeat_n(' ', c.len_utf8()));
38 } else {
39 out.push(c);
40 }
41 }
42 }
43 out
44}
45
46fn scan_line(n: usize, line: &str, ok: &mut Vec<Marker>, bad: &mut Vec<Malformed>) {
47 let masked = mask_code(line);
48 let mut rest = 0;
49 while rest < masked.len() {
50 let hay = &masked[rest..];
51 let (Some(o), c) = (hay.find(OPEN), hay.find(CLOSE)) else {
52 if hay.contains(CLOSE) {
53 bad.push(Malformed {
54 line: n,
55 message: "closing <!-- /spec:count --> without an opening marker".into(),
56 });
57 }
58 return;
59 };
60 if c.is_some_and(|c| c < o) {
61 bad.push(Malformed { line: n, message: "closing <!-- /spec:count --> without an opening marker".into() });
62 }
63 let start = rest + o;
64 let Some(head_end) = masked[start..].find("-->").map(|i| start + i) else {
65 bad.push(Malformed { line: n, message: "spec:count marker is not closed with -->".into() });
66 return;
67 };
68 let source = masked[start + OPEN.len()..head_end].trim().to_string();
69 let body_start = head_end + 3;
70 let Some(end) = masked[body_start..].find(CLOSE).map(|i| body_start + i) else {
71 bad.push(Malformed { line: n, message: "spec:count has no <!-- /spec:count --> on the same line".into() });
72 return;
73 };
74 let value = &masked[body_start..end];
75 if value.contains(OPEN) {
76 bad.push(Malformed { line: n, message: "spec:count markers cannot be nested".into() });
77 return;
78 }
79 let stop = end + CLOSE.len();
80 ok.push(Marker { line: n, raw: line[start..stop].to_string(), source, value: value.trim().to_string() });
81 rest = stop;
82 }
83}
84
85pub fn scan(text: &str) -> (Vec<Marker>, Vec<Malformed>) {
87 let (mut ok, mut bad) = (vec![], vec![]);
88 let mut fence: Option<String> = None;
89 for (i, line) in text.lines().enumerate() {
90 let t = line.trim_start();
91 let ch = t.chars().next().unwrap_or(' ');
92 if ch == '`' || ch == '~' {
93 let run: String = t.chars().take_while(|c| *c == ch).collect();
94 if run.len() >= 3 {
95 match &fence {
96 Some(f) if run.starts_with(f.as_str()) && t[run.len()..].trim().is_empty() => fence = None,
97 None => fence = Some(run),
98 _ => {}
99 }
100 continue;
101 }
102 }
103 if fence.is_none() {
104 scan_line(i + 1, line, &mut ok, &mut bad);
105 }
106 }
107 (ok, bad)
108}
109
110pub fn rewrite(m: &Marker, n: usize) -> String {
112 let head_end = m.raw.find("-->").map(|i| i + 3).unwrap_or(0);
113 format!("{}{n}{CLOSE}", &m.raw[..head_end])
114}
115
116pub fn table_rows(text: &str, section: &str) -> Option<usize> {
118 let (lo, hi) = super::sections::span(text, section)?;
119 let lines: Vec<&str> = text.lines().collect();
120 let mut rows = 0;
121 let mut in_table = false;
122 for l in &lines[lo..hi] {
123 let Some(cells) = super::tables::cells(l) else {
124 in_table = false;
125 continue;
126 };
127 let sep = cells.iter().all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':' | ' ')));
128 if !in_table {
129 in_table = true;
130 } else if !sep {
131 rows += 1;
132 }
133 }
134 Some(rows)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn reads_markers_and_skips_code() {
143 let t = "a <!-- spec:count find type=adr -->3<!-- /spec:count --> b\n```\n<!-- spec:count x -->1<!-- /spec:count -->\n```\n`<!-- spec:count x -->1<!-- /spec:count -->`\n";
144 let (ok, bad) = scan(t);
145 assert_eq!(ok.len(), 1);
146 assert_eq!(ok[0].source, "find type=adr");
147 assert_eq!(ok[0].value, "3");
148 assert_eq!(rewrite(&ok[0], 5), "<!-- spec:count find type=adr -->5<!-- /spec:count -->");
149 assert!(bad.is_empty());
150 }
151
152 #[test]
153 fn reports_malformed() {
154 for t in [
155 "<!-- spec:count find -->3",
156 "3<!-- /spec:count -->",
157 "<!-- spec:count find --><!-- spec:count find -->1<!-- /spec:count -->",
158 ] {
159 assert_eq!(scan(t).1.len(), 1, "{t}");
160 }
161 }
162
163 #[test]
164 fn counts_table_rows() {
165 let t = "# A\n## Items\n| k | v |\n|---|---|\n| a | 1 |\n| b | 2 |\n## B\n| x |\n|---|\n| y |\n";
166 assert_eq!(table_rows(t, "Items"), Some(2));
167 assert_eq!(table_rows(t, "Nope"), None);
168 }
169}