Skip to main content

specfold_core/markdown/
counts.rs

1//! `<!-- spec:count ... -->N<!-- /spec:count -->` markers: a number in prose that names where
2//! its true value comes from, so a check can compare and a fix can rewrite it.
3
4const OPEN: &str = "<!-- spec:count";
5const CLOSE: &str = "<!-- /spec:count -->";
6
7/// One well-formed marker on a line.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Marker {
10    /// 1-based line in the scanned text.
11    pub line: usize,
12    /// The whole marker as written, for an exact replacement.
13    pub raw: String,
14    /// The words between `spec:count` and `-->`.
15    pub source: String,
16    /// What the marker currently wraps, trimmed.
17    pub value: String,
18}
19
20/// A marker that cannot be read; the message says why.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Malformed {
23    pub line: usize,
24    pub message: String,
25}
26
27/// Blanks inline code spans so markers quoted in backticks are not read; byte offsets are kept.
28fn 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
85/// Every marker in a Markdown text, skipping fenced code; malformed markers are returned apart.
86pub 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
110/// The marker with its value replaced.
111pub 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
116/// Body rows of the pipe tables in `section` of `text`: header and separator lines not counted.
117pub 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}