Skip to main content

soothfast_docs/
markdown.rs

1//! Line-based markdown scanning: fenced code blocks with info tags, and
2//! `<!-- soothfast:... -->` markers. Deliberately not a markdown AST — we need
3//! fences and comment markers only (dependency policy: no pulldown-cmark).
4
5/// One fenced code block.
6#[derive(Debug, Clone)]
7pub struct CodeBlock {
8    /// 0-based ordinal among ALL fenced blocks in the file.
9    pub index: usize,
10    /// First token of the info string ("rust", "text", "console", ...).
11    pub lang: String,
12    /// Remaining info-string tokens ("no_run", "ignore", "capture-output", ...).
13    pub tags: Vec<String>,
14    pub code: String,
15    /// 1-based line of the opening fence.
16    pub line: usize,
17    /// 1-based line of the closing fence.
18    pub close_line: usize,
19    /// Backtick count of the opening fence.
20    pub ticks: usize,
21}
22
23impl CodeBlock {
24    pub fn has_tag(&self, tag: &str) -> bool {
25        self.tags.iter().any(|t| t == tag)
26    }
27    /// A rust block that should become a generated test.
28    pub fn is_testable_rust(&self) -> bool {
29        self.lang == "rust" && !self.has_tag("ignore") && !self.has_tag("capture-output")
30    }
31    pub fn is_capture(&self) -> bool {
32        self.lang == "rust" && self.has_tag("capture-output")
33    }
34    /// Value of a `feature=NAME` tag: the cargo feature the generated test is
35    /// gated behind (for blocks exercising feature-gated API).
36    pub fn feature_tag(&self) -> Option<&str> {
37        self.tags.iter().find_map(|t| t.strip_prefix("feature="))
38    }
39    /// `(name, arg)` from a `mock=NAME` or `mock=NAME(ARG)` tag — the name of
40    /// a `#[soothfast::mock_seam]` fn this block calls via
41    /// `soothfast::mock::activate(name, arg)`, and its arg (`""` when
42    /// omitted). Requires an accompanying `feature=` tag; enforced in `scan`.
43    pub fn mock_tag(&self) -> Option<(&str, &str)> {
44        let raw = self.tags.iter().find_map(|t| t.strip_prefix("mock="))?;
45        match raw.strip_suffix(')').and_then(|s| s.split_once('(')) {
46            Some((name, arg)) => Some((name, arg)),
47            None => Some((raw, "")),
48        }
49    }
50    /// Raw value of a `covers=path::to::item` tag — one item, or several
51    /// comma-separated with **no spaces** (`covers=a::b,a::c`; a space
52    /// would split into a separate fence tag) when a single example
53    /// demonstrates more than one measured/bound item. A claim or bind for
54    /// any listed item renders attached to this block instead of as a
55    /// standalone chip. Callers that need the individual items should split
56    /// the value on `,` and trim.
57    pub fn covers_tag(&self) -> Option<&str> {
58        self.tags.iter().find_map(|t| t.strip_prefix("covers="))
59    }
60}
61
62/// `<!-- soothfast:bind path::to::item -->`
63#[derive(Debug, Clone)]
64pub struct Bind {
65    pub item: String,
66    pub line: usize,
67}
68
69/// `<!-- soothfast:claim item.backend.metric < value -->`
70#[derive(Debug, Clone)]
71pub struct ClaimMarker {
72    pub expr: String,
73    pub line: usize,
74}
75
76/// Everything [`scan`] extracts from one markdown file: fenced code blocks
77/// plus `soothfast:bind` / `soothfast:claim` markers, in document order.
78#[derive(Debug, Default)]
79pub struct Doc {
80    /// Fenced code blocks, with language and tags.
81    pub blocks: Vec<CodeBlock>,
82    /// `<!-- soothfast:bind ... -->` markers.
83    pub binds: Vec<Bind>,
84    /// `<!-- soothfast:claim ... -->` markers.
85    pub claims: Vec<ClaimMarker>,
86}
87
88/// Scan one markdown text. Closing markers (`<!-- /soothfast:... -->`) are
89/// structural for readers and ignored here. Errs on an unclosed fence:
90/// dropping it would silently swallow the block and every later marker.
91pub fn scan(text: &str) -> Result<Doc, String> {
92    let mut doc = Doc::default();
93    let mut fence: Option<(usize, String, Vec<String>, String, usize)> = None; // (ticks, lang, tags, code, line)
94
95    for (i, line) in text.lines().enumerate() {
96        let lineno = i + 1;
97        let trimmed = line.trim_start();
98
99        if let Some((ticks, lang, tags, code, open_line)) = fence.take() {
100            let closes = trimmed.chars().take_while(|&c| c == '`').count() >= ticks
101                && trimmed.trim_end().chars().all(|c| c == '`');
102            if closes {
103                doc.blocks.push(CodeBlock {
104                    index: doc.blocks.len(),
105                    lang,
106                    tags,
107                    code,
108                    line: open_line,
109                    close_line: lineno,
110                    ticks,
111                });
112            } else {
113                let mut code = code;
114                code.push_str(line);
115                code.push('\n');
116                fence = Some((ticks, lang, tags, code, open_line));
117            }
118            continue;
119        }
120
121        let ticks = trimmed.chars().take_while(|&c| c == '`').count();
122        if ticks >= 3 {
123            let info = trimmed[ticks..].trim();
124            let mut tokens = info.split_whitespace().map(str::to_string);
125            let lang = tokens.next().unwrap_or_default();
126            fence = Some((ticks, lang, tokens.collect(), String::new(), lineno));
127            continue;
128        }
129
130        // A marker the scanner declines to read is a claim nothing gates, so
131        // every failure below is an error rather than a `continue`.
132        if let Some(rest) = trimmed.trim_end().strip_prefix("<!-- soothfast:") {
133            let body = rest
134                .strip_suffix("-->")
135                .map(str::trim)
136                .ok_or_else(|| format!("unterminated soothfast marker at line {lineno}"))?;
137            if let Some(item) = body.strip_prefix("bind ") {
138                doc.binds.push(Bind {
139                    item: item.trim().to_string(),
140                    line: lineno,
141                });
142            } else if let Some(expr) = body.strip_prefix("claim ") {
143                doc.claims.push(ClaimMarker {
144                    expr: expr.trim().to_string(),
145                    line: lineno,
146                });
147            } else {
148                return Err(format!(
149                    "unknown soothfast marker at line {lineno}: expected `bind <item>` or `claim <expr>`"
150                ));
151            }
152        }
153    }
154    if let Some((_, _, _, _, open_line)) = fence {
155        return Err(format!("unclosed code fence opened at line {open_line}"));
156    }
157    for block in &doc.blocks {
158        if block.mock_tag().is_some() && block.feature_tag().is_none() {
159            return Err(format!(
160                "mock=NAME requires an accompanying feature=NAME tag (block opened at line {})",
161                block.line
162            ));
163        }
164    }
165    Ok(doc)
166}
167
168/// Info string of the generated output fence (fence length varies).
169const OUTPUT_INFO: &str = "text soothfast-output";
170
171/// Replace (or insert) the `text soothfast-output` fence that follows the
172/// `nth` capture-output block (0-based among capture blocks). Ok(None) when
173/// the capture block wasn't found; Err on malformed markdown.
174pub fn splice_output(
175    text: &str,
176    nth_capture: usize,
177    output: &str,
178) -> Result<Option<String>, String> {
179    // Consuming scan() keeps capture indices in lockstep with gen-tests;
180    // a second ad-hoc fence parser here desynced them in the past.
181    let doc = scan(text)?;
182    let Some(block) = doc
183        .blocks
184        .iter()
185        .filter(|b| b.is_capture())
186        .nth(nth_capture)
187    else {
188        return Ok(None);
189    };
190    let lines: Vec<&str> = text.lines().collect();
191    Ok(Some(splice_after(&lines, block.close_line - 1, output)))
192}
193
194fn splice_after(lines: &[&str], close_idx: usize, output: &str) -> String {
195    // The fence must outrun any backtick run in the output, or the block
196    // would close early and every re-splice would duplicate its tail.
197    let max_run = output
198        .lines()
199        .map(|l| l.trim_start().chars().take_while(|&c| c == '`').count())
200        .max()
201        .unwrap_or(0);
202    let fence = "`".repeat((max_run + 1).max(3));
203
204    let mut out: Vec<String> = lines[..=close_idx].iter().map(|s| s.to_string()).collect();
205
206    // Skip an existing output fence (allowing one blank line between),
207    // honoring its exact opening length so removal is exact.
208    let mut rest_start = close_idx + 1;
209    let mut peek = rest_start;
210    if peek < lines.len() && lines[peek].trim().is_empty() {
211        peek += 1;
212    }
213    if peek < lines.len() {
214        let t = lines[peek].trim_start();
215        let open_ticks = t.chars().take_while(|&c| c == '`').count();
216        if open_ticks >= 3 && t[open_ticks..].trim() == OUTPUT_INFO {
217            let mut k = peek + 1;
218            while k < lines.len() {
219                let c = lines[k].trim_start();
220                if c.chars().take_while(|&ch| ch == '`').count() >= open_ticks
221                    && c.trim_end().chars().all(|ch| ch == '`')
222                {
223                    break;
224                }
225                k += 1;
226            }
227            rest_start = k + 1;
228        }
229    }
230
231    out.push(String::new());
232    out.push(format!("{fence}{OUTPUT_INFO}"));
233    for l in output.trim_end().lines() {
234        out.push(l.to_string());
235    }
236    out.push(fence);
237    for l in &lines[rest_start.min(lines.len())..] {
238        out.push(l.to_string());
239    }
240    let mut s = out.join("\n");
241    s.push('\n');
242    s
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    const MD: &str = "\
250# T
251
252<!-- soothfast:bind demo::sorted -->
253prose
254<!-- /soothfast:bind -->
255
256```rust
257let x = 1;
258```
259
260<!-- soothfast:claim demo::f.perfcnt.instructions < 100 -->
261
262```rust no_run
263loop {}
264```
265
266```rust capture-output
267println!(\"hi\");
268```
269
270```text
271not rust
272```
273";
274
275    #[test]
276    fn scans_blocks_binds_claims() {
277        let doc = scan(MD).unwrap();
278        assert_eq!(doc.blocks.len(), 4);
279        assert_eq!(doc.binds.len(), 1);
280        assert_eq!(doc.binds[0].item, "demo::sorted");
281        assert_eq!(doc.claims.len(), 1);
282        assert!(doc.blocks[0].is_testable_rust());
283        assert!(doc.blocks[1].has_tag("no_run"));
284        assert!(doc.blocks[2].is_capture());
285        assert_eq!(doc.blocks[3].lang, "text");
286    }
287
288    #[test]
289    fn trailing_whitespace_still_reads_the_marker() {
290        let doc = scan("<!-- soothfast:claim demo::f.alloc == 0 -->  \n").unwrap();
291        assert_eq!(doc.claims.len(), 1);
292        assert_eq!(doc.claims[0].expr, "demo::f.alloc == 0");
293    }
294
295    #[test]
296    fn scan_errors_on_a_marker_it_cannot_read() {
297        assert!(scan("<!-- soothfast:claim demo::f.alloc == 0\n").is_err());
298        assert!(scan("<!-- soothfast:clam demo::f.alloc == 0 -->\n").is_err());
299    }
300
301    #[test]
302    fn splice_inserts_then_replaces() {
303        let once = splice_output(MD, 0, "hi").unwrap().unwrap();
304        assert!(once.contains("```text soothfast-output\nhi\n```"));
305        // Re-splicing with new output replaces, not duplicates.
306        let twice = splice_output(&once, 0, "bye").unwrap().unwrap();
307        assert_eq!(twice.matches("soothfast-output").count(), 1);
308        assert!(twice.contains("bye"));
309        assert!(!twice.contains("\nhi\n"));
310    }
311
312    #[test]
313    fn scan_errors_on_unclosed_fence() {
314        let err = scan("# T\n\n```rust\nlet x = 1;\n").unwrap_err();
315        assert!(err.contains("line 3"), "{err}");
316        // Markers after the open fence must not be silently consumed.
317        let err = scan("```text\n<!-- soothfast:bind demo::f -->\n").unwrap_err();
318        assert!(err.contains("unclosed"), "{err}");
319    }
320
321    #[test]
322    fn splice_errors_on_unclosed_fence_instead_of_panicking() {
323        let md = "```rust capture-output\nprintln!(\"hi\");\n";
324        let err = splice_output(md, 0, "hi").unwrap_err();
325        assert!(err.contains("unclosed"), "{err}");
326    }
327
328    #[test]
329    fn mock_tag_parses_name_and_optional_arg() {
330        let md =
331            "```rust capture-output feature=test-utils mock=fmp_profile(AAPL)\nlet x = 1;\n```\n";
332        let doc = scan(md).unwrap();
333        assert_eq!(doc.blocks[0].mock_tag(), Some(("fmp_profile", "AAPL")));
334
335        let md_no_arg =
336            "```rust capture-output feature=test-utils mock=fmp_profile\nlet x = 1;\n```\n";
337        let doc_no_arg = scan(md_no_arg).unwrap();
338        assert_eq!(doc_no_arg.blocks[0].mock_tag(), Some(("fmp_profile", "")));
339    }
340
341    #[test]
342    fn scan_errors_when_mock_tag_lacks_feature_tag() {
343        let md = "```rust capture-output mock=fmp_profile\nlet x = 1;\n```\n";
344        let err = scan(md).unwrap_err();
345        assert!(err.contains("feature=NAME"), "{err}");
346    }
347
348    #[test]
349    fn splice_missing_capture_block_is_none() {
350        assert!(splice_output("prose only\n", 0, "hi").unwrap().is_none());
351    }
352
353    #[test]
354    fn splice_is_idempotent_when_output_contains_backtick_fences() {
355        let md = "```rust capture-output\nprintln!(\"```\");\n```\n\ntail prose\n";
356        let out = "before\n```\nafter";
357        let once = splice_output(md, 0, out).unwrap().unwrap();
358        // Output containing ``` forces a longer surrounding fence.
359        assert!(once.contains("````text soothfast-output\nbefore\n```\nafter\n````"));
360        let twice = splice_output(&once, 0, out).unwrap().unwrap();
361        assert_eq!(once, twice, "second splice must be a no-op");
362        // Replacing with new output removes the old block exactly once.
363        let replaced = splice_output(&once, 0, "plain").unwrap().unwrap();
364        assert_eq!(replaced.matches("soothfast-output").count(), 1);
365        assert_eq!(replaced.matches("tail prose").count(), 1);
366        assert!(!replaced.contains("before"));
367    }
368}