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> {
38 self.tags.iter().find_map(|t| t.strip_prefix("feature="))
39 }
40 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 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 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 pub fn covers_tag(&self) -> Option<&str> {
88 self.tags.iter().find_map(|t| t.strip_prefix("covers="))
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct Bind {
95 pub item: String,
96 pub line: usize,
97}
98
99#[derive(Debug, Clone)]
101pub struct ClaimMarker {
102 pub expr: String,
103 pub line: usize,
104}
105
106#[derive(Debug, Default)]
109pub struct Doc {
110 pub blocks: Vec<CodeBlock>,
112 pub binds: Vec<Bind>,
114 pub claims: Vec<ClaimMarker>,
116}
117
118pub 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; 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 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 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
206const OUTPUT_INFO: &str = "text soothfast-output";
208
209pub fn splice_output(
213 text: &str,
214 nth_capture: usize,
215 output: &str,
216) -> Result<Option<String>, String> {
217 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 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 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 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 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 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 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 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}