1#[derive(Debug, Clone)]
7pub struct CodeBlock {
8 pub index: usize,
10 pub lang: String,
12 pub tags: Vec<String>,
14 pub code: String,
15 pub line: usize,
17 pub close_line: usize,
19 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 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 pub fn feature_tag(&self) -> Option<&str> {
37 self.tags.iter().find_map(|t| t.strip_prefix("feature="))
38 }
39 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 pub fn covers_tag(&self) -> Option<&str> {
58 self.tags.iter().find_map(|t| t.strip_prefix("covers="))
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct Bind {
65 pub item: String,
66 pub line: usize,
67}
68
69#[derive(Debug, Clone)]
71pub struct ClaimMarker {
72 pub expr: String,
73 pub line: usize,
74}
75
76#[derive(Debug, Default)]
79pub struct Doc {
80 pub blocks: Vec<CodeBlock>,
82 pub binds: Vec<Bind>,
84 pub claims: Vec<ClaimMarker>,
86}
87
88pub 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; 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 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
168const OUTPUT_INFO: &str = "text soothfast-output";
170
171pub fn splice_output(
175 text: &str,
176 nth_capture: usize,
177 output: &str,
178) -> Result<Option<String>, String> {
179 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 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 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 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 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 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 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}