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 -->`, or several whitespace-separated
93/// targets in one marker.
94#[derive(Debug, Clone)]
95pub struct Bind {
96    pub item: String,
97    pub line: usize,
98}
99
100impl Bind {
101    /// The marker's targets: Rust items or `path#Symbol` file targets.
102    pub fn targets(&self) -> impl Iterator<Item = &str> {
103        self.item.split_whitespace()
104    }
105}
106
107/// `<!-- soothfast:claim item.backend.metric < value -->`
108#[derive(Debug, Clone)]
109pub struct ClaimMarker {
110    pub expr: String,
111    pub line: usize,
112}
113
114/// Everything [`scan`] extracts from one markdown file: fenced code blocks
115/// plus `soothfast:bind` / `soothfast:claim` markers, in document order.
116#[derive(Debug, Default)]
117pub struct Doc {
118    /// Fenced code blocks, with language and tags.
119    pub blocks: Vec<CodeBlock>,
120    /// `<!-- soothfast:bind ... -->` markers.
121    pub binds: Vec<Bind>,
122    /// `<!-- soothfast:claim ... -->` markers.
123    pub claims: Vec<ClaimMarker>,
124}
125
126/// Scan one markdown text. Closing markers (`<!-- /soothfast:... -->`) are
127/// structural for readers and ignored here. Errs on an unclosed fence:
128/// dropping it would silently swallow the block and every later marker.
129pub fn scan(text: &str) -> Result<Doc, String> {
130    let mut doc = Doc::default();
131    let mut fence: Option<(usize, String, Vec<String>, String, usize)> = None; // (ticks, lang, tags, code, line)
132
133    for (i, line) in text.lines().enumerate() {
134        let lineno = i + 1;
135        let trimmed = line.trim_start();
136
137        if let Some((ticks, lang, tags, code, open_line)) = fence.take() {
138            let closes = trimmed.chars().take_while(|&c| c == '`').count() >= ticks
139                && trimmed.trim_end().chars().all(|c| c == '`');
140            if closes {
141                doc.blocks.push(CodeBlock {
142                    index: doc.blocks.len(),
143                    lang,
144                    tags,
145                    code,
146                    line: open_line,
147                    close_line: lineno,
148                    ticks,
149                });
150            } else {
151                let mut code = code;
152                code.push_str(line);
153                code.push('\n');
154                fence = Some((ticks, lang, tags, code, open_line));
155            }
156            continue;
157        }
158
159        let ticks = trimmed.chars().take_while(|&c| c == '`').count();
160        if ticks >= 3 {
161            let info = trimmed[ticks..].trim();
162            let mut tokens = info.split_whitespace().map(str::to_string);
163            let lang = tokens.next().unwrap_or_default();
164            fence = Some((ticks, lang, tokens.collect(), String::new(), lineno));
165            continue;
166        }
167
168        // A marker the scanner declines to read is a claim nothing gates, so
169        // every failure below is an error rather than a `continue`.
170        if let Some(rest) = trimmed.trim_end().strip_prefix("<!-- soothfast:") {
171            let body = rest
172                .strip_suffix("-->")
173                .map(str::trim)
174                .ok_or_else(|| format!("unterminated soothfast marker at line {lineno}"))?;
175            if let Some(item) = body.strip_prefix("bind ") {
176                doc.binds.push(Bind {
177                    item: item.trim().to_string(),
178                    line: lineno,
179                });
180            } else if let Some(expr) = body.strip_prefix("claim ") {
181                doc.claims.push(ClaimMarker {
182                    expr: expr.trim().to_string(),
183                    line: lineno,
184                });
185            } else {
186                return Err(format!(
187                    "unknown soothfast marker at line {lineno}: expected `bind <item>` or `claim <expr>`"
188                ));
189            }
190        }
191    }
192    if let Some((_, _, _, _, open_line)) = fence {
193        return Err(format!("unclosed code fence opened at line {open_line}"));
194    }
195    for block in &doc.blocks {
196        if block.mock_tag().is_some() && block.feature_tag().is_none() {
197            return Err(format!(
198                "mock=NAME requires an accompanying feature=NAME tag (block opened at line {})",
199                block.line
200            ));
201        }
202        // A tag that names no feature would silently generate an ungated
203        // block, which is exactly what the tag was written to prevent.
204        if block.feature_tag().is_some() && block.feature_list().is_empty() {
205            return Err(format!(
206                "feature= tag names no feature (block opened at line {})",
207                block.line
208            ));
209        }
210    }
211    Ok(doc)
212}
213
214/// Info string of the generated output fence (fence length varies).
215const OUTPUT_INFO: &str = "text soothfast-output";
216
217/// Replace (or insert) the `text soothfast-output` fence that follows the
218/// `nth` capture-output block (0-based among capture blocks). Ok(None) when
219/// the capture block wasn't found; Err on malformed markdown.
220pub fn splice_output(
221    text: &str,
222    nth_capture: usize,
223    output: &str,
224) -> Result<Option<String>, String> {
225    // Consuming scan() keeps capture indices in lockstep with gen-tests;
226    // a second ad-hoc fence parser here desynced them in the past.
227    let doc = scan(text)?;
228    let Some(block) = doc
229        .blocks
230        .iter()
231        .filter(|b| b.is_capture())
232        .nth(nth_capture)
233    else {
234        return Ok(None);
235    };
236    let lines: Vec<&str> = text.lines().collect();
237    Ok(Some(splice_after(&lines, block.close_line - 1, output)))
238}
239
240fn splice_after(lines: &[&str], close_idx: usize, output: &str) -> String {
241    // The fence must outrun any backtick run in the output, or the block
242    // would close early and every re-splice would duplicate its tail.
243    let max_run = output
244        .lines()
245        .map(|l| l.trim_start().chars().take_while(|&c| c == '`').count())
246        .max()
247        .unwrap_or(0);
248    let fence = "`".repeat((max_run + 1).max(3));
249
250    let mut out: Vec<String> = lines[..=close_idx].iter().map(|s| s.to_string()).collect();
251
252    // Skip an existing output fence (allowing one blank line between),
253    // honoring its exact opening length so removal is exact.
254    let mut rest_start = close_idx + 1;
255    let mut peek = rest_start;
256    if peek < lines.len() && lines[peek].trim().is_empty() {
257        peek += 1;
258    }
259    if peek < lines.len() {
260        let t = lines[peek].trim_start();
261        let open_ticks = t.chars().take_while(|&c| c == '`').count();
262        if open_ticks >= 3 && t[open_ticks..].trim() == OUTPUT_INFO {
263            let mut k = peek + 1;
264            while k < lines.len() {
265                let c = lines[k].trim_start();
266                if c.chars().take_while(|&ch| ch == '`').count() >= open_ticks
267                    && c.trim_end().chars().all(|ch| ch == '`')
268                {
269                    break;
270                }
271                k += 1;
272            }
273            rest_start = k + 1;
274        }
275    }
276
277    out.push(String::new());
278    out.push(format!("{fence}{OUTPUT_INFO}"));
279    for l in output.trim_end().lines() {
280        out.push(l.to_string());
281    }
282    out.push(fence);
283    for l in &lines[rest_start.min(lines.len())..] {
284        out.push(l.to_string());
285    }
286    let mut s = out.join("\n");
287    s.push('\n');
288    s
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    const MD: &str = "\
296# T
297
298<!-- soothfast:bind demo::sorted -->
299prose
300<!-- /soothfast:bind -->
301
302```rust
303let x = 1;
304```
305
306<!-- soothfast:claim demo::f.perfcnt.instructions < 100 -->
307
308```rust no_run
309loop {}
310```
311
312```rust capture-output
313println!(\"hi\");
314```
315
316```text
317not rust
318```
319";
320
321    #[test]
322    fn scans_blocks_binds_claims() {
323        let doc = scan(MD).unwrap();
324        assert_eq!(doc.blocks.len(), 4);
325        assert_eq!(doc.binds.len(), 1);
326        assert_eq!(doc.binds[0].item, "demo::sorted");
327        assert_eq!(doc.claims.len(), 1);
328        assert!(doc.blocks[0].is_testable_rust());
329        assert!(doc.blocks[1].has_tag("no_run"));
330        assert!(doc.blocks[2].is_capture());
331        assert_eq!(doc.blocks[3].lang, "text");
332    }
333
334    #[test]
335    fn trailing_whitespace_still_reads_the_marker() {
336        let doc = scan("<!-- soothfast:claim demo::f.alloc == 0 -->  \n").unwrap();
337        assert_eq!(doc.claims.len(), 1);
338        assert_eq!(doc.claims[0].expr, "demo::f.alloc == 0");
339    }
340
341    #[test]
342    fn scan_errors_on_a_marker_it_cannot_read() {
343        assert!(scan("<!-- soothfast:claim demo::f.alloc == 0\n").is_err());
344        assert!(scan("<!-- soothfast:clam demo::f.alloc == 0 -->\n").is_err());
345    }
346
347    #[test]
348    fn splice_inserts_then_replaces() {
349        let once = splice_output(MD, 0, "hi").unwrap().unwrap();
350        assert!(once.contains("```text soothfast-output\nhi\n```"));
351        // Re-splicing with new output replaces, not duplicates.
352        let twice = splice_output(&once, 0, "bye").unwrap().unwrap();
353        assert_eq!(twice.matches("soothfast-output").count(), 1);
354        assert!(twice.contains("bye"));
355        assert!(!twice.contains("\nhi\n"));
356    }
357
358    #[test]
359    fn scan_errors_on_unclosed_fence() {
360        let err = scan("# T\n\n```rust\nlet x = 1;\n").unwrap_err();
361        assert!(err.contains("line 3"), "{err}");
362        // Markers after the open fence must not be silently consumed.
363        let err = scan("```text\n<!-- soothfast:bind demo::f -->\n").unwrap_err();
364        assert!(err.contains("unclosed"), "{err}");
365    }
366
367    #[test]
368    fn splice_errors_on_unclosed_fence_instead_of_panicking() {
369        let md = "```rust capture-output\nprintln!(\"hi\");\n";
370        let err = splice_output(md, 0, "hi").unwrap_err();
371        assert!(err.contains("unclosed"), "{err}");
372    }
373
374    #[test]
375    fn mock_tag_parses_name_and_optional_arg() {
376        let md =
377            "```rust capture-output feature=test-utils mock=fmp_profile(AAPL)\nlet x = 1;\n```\n";
378        let doc = scan(md).unwrap();
379        assert_eq!(doc.blocks[0].mock_tag(), Some(("fmp_profile", "AAPL")));
380
381        let md_no_arg =
382            "```rust capture-output feature=test-utils mock=fmp_profile\nlet x = 1;\n```\n";
383        let doc_no_arg = scan(md_no_arg).unwrap();
384        assert_eq!(doc_no_arg.blocks[0].mock_tag(), Some(("fmp_profile", "")));
385    }
386
387    #[test]
388    fn scan_errors_when_mock_tag_lacks_feature_tag() {
389        let md = "```rust capture-output mock=fmp_profile\nlet x = 1;\n```\n";
390        let err = scan(md).unwrap_err();
391        assert!(err.contains("feature=NAME"), "{err}");
392    }
393
394    #[test]
395    fn feature_list_splits_on_commas() {
396        let doc = scan("```rust feature=risk,polygon\nlet x = 1;\n```\n").unwrap();
397        assert_eq!(doc.blocks[0].feature_list(), ["risk", "polygon"]);
398        let doc = scan("```rust feature=risk\nlet x = 1;\n```\n").unwrap();
399        assert_eq!(doc.blocks[0].feature_list(), ["risk"]);
400        assert!(
401            scan("```rust\nlet x = 1;\n```\n").unwrap().blocks[0]
402                .feature_list()
403                .is_empty()
404        );
405    }
406
407    #[test]
408    fn a_bind_splits_into_whitespace_separated_targets() {
409        let doc = scan(
410            "<!-- soothfast:bind corvid_mime::parse_headers  client/Headers.kt#Headers.parse -->\n",
411        )
412        .unwrap();
413        let targets: Vec<&str> = doc.binds[0].targets().collect();
414        assert_eq!(
415            targets,
416            [
417                "corvid_mime::parse_headers",
418                "client/Headers.kt#Headers.parse"
419            ]
420        );
421    }
422
423    #[test]
424    fn scan_errors_when_feature_tag_names_no_feature() {
425        // Silently generating an ungated block would defeat the tag.
426        let err = scan("```rust feature=\nlet x = 1;\n```\n").unwrap_err();
427        assert!(err.contains("names no feature"), "{err}");
428        let err = scan("```rust feature=,,\nlet x = 1;\n```\n").unwrap_err();
429        assert!(err.contains("names no feature"), "{err}");
430    }
431
432    #[test]
433    fn scan_keeps_a_file_symbol_bind_target_whole() {
434        let doc =
435            scan("<!-- soothfast:bind web/my-app/util.test.tsx#Foo.Companion.bar -->\n").unwrap();
436        assert_eq!(
437            doc.binds[0].item,
438            "web/my-app/util.test.tsx#Foo.Companion.bar"
439        );
440    }
441
442    #[test]
443    fn splice_missing_capture_block_is_none() {
444        assert!(splice_output("prose only\n", 0, "hi").unwrap().is_none());
445    }
446
447    #[test]
448    fn splice_is_idempotent_when_output_contains_backtick_fences() {
449        let md = "```rust capture-output\nprintln!(\"```\");\n```\n\ntail prose\n";
450        let out = "before\n```\nafter";
451        let once = splice_output(md, 0, out).unwrap().unwrap();
452        // Output containing ``` forces a longer surrounding fence.
453        assert!(once.contains("````text soothfast-output\nbefore\n```\nafter\n````"));
454        let twice = splice_output(&once, 0, out).unwrap().unwrap();
455        assert_eq!(once, twice, "second splice must be a no-op");
456        // Replacing with new output removes the old block exactly once.
457        let replaced = splice_output(&once, 0, "plain").unwrap().unwrap();
458        assert_eq!(replaced.matches("soothfast-output").count(), 1);
459        assert_eq!(replaced.matches("tail prose").count(), 1);
460        assert!(!replaced.contains("before"));
461    }
462}