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)]
95pub struct Bind {
96 pub item: String,
97 pub line: usize,
98}
99
100impl Bind {
101 pub fn targets(&self) -> impl Iterator<Item = &str> {
103 self.item.split_whitespace()
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct ClaimMarker {
110 pub expr: String,
111 pub line: usize,
112}
113
114#[derive(Debug, Default)]
117pub struct Doc {
118 pub blocks: Vec<CodeBlock>,
120 pub binds: Vec<Bind>,
122 pub claims: Vec<ClaimMarker>,
124}
125
126pub 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; 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 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 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
214const OUTPUT_INFO: &str = "text soothfast-output";
216
217pub fn splice_output(
221 text: &str,
222 nth_capture: usize,
223 output: &str,
224) -> Result<Option<String>, String> {
225 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 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 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 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 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 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 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 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}