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