1use std::collections::HashMap;
34use std::ops::Range;
35use std::path::{Path, PathBuf};
36
37use bynk_emit::project::AttributedError;
38use bynk_syntax::ast::{CommonsForm, Context, SourceUnit};
39use bynk_syntax::lexer::{TokenKind, doc_block_body_range, tokenize};
40use bynk_syntax::parser::parse_unit_with_recovery;
41
42const SYNTHETIC_SUFFIX: &str = ".doc-examples.bynk";
45
46const ITEMS_INFIX: &str = "_doc_example_";
49
50#[derive(Debug, PartialEq, Eq)]
52enum Fence {
53 Foreign,
55 Skipped,
57 Checked,
59 Run,
61}
62
63fn classify(info: &str) -> Fence {
64 let mut words = info
65 .split(|c: char| c == ',' || c.is_whitespace())
66 .filter(|w| !w.is_empty());
67 if words.next() != Some("bynk") {
68 return Fence::Foreign;
69 }
70 let words: Vec<&str> = words.collect();
71 if words
72 .iter()
73 .any(|w| matches!(*w, "ignore" | "fragment" | "fail") || w.starts_with("fail="))
74 {
75 Fence::Skipped
76 } else if words.contains(&"test") {
77 Fence::Run
78 } else {
79 Fence::Checked
80 }
81}
82
83struct DocLine<'a> {
85 text: &'a str,
86 start: usize,
87}
88
89fn doc_blocks(source: &str) -> Vec<Vec<DocLine<'_>>> {
92 let Ok(tokens) = tokenize(source) else {
93 return Vec::new();
94 };
95 let mut blocks = Vec::new();
96 for t in &tokens {
97 if t.kind != TokenKind::DocBlock {
98 continue;
99 }
100 let Some(range) = doc_block_body_range(source, t.span) else {
101 continue;
102 };
103 let mut lines = Vec::new();
104 let mut at = range.start;
105 for raw in source[range.clone()].split('\n') {
106 lines.push(DocLine {
107 text: raw.trim_end_matches('\r'),
108 start: at,
109 });
110 at += raw.len() + 1;
111 }
112 blocks.push(lines);
113 }
114 blocks
115}
116
117struct Example<'a> {
119 run: bool,
121 open: usize,
123 body: Vec<DocLine<'a>>,
124}
125
126impl Example<'_> {
127 fn close(&self) -> usize {
129 self.open + 1 + self.body.len()
130 }
131
132 fn text(&self) -> String {
133 self.body
134 .iter()
135 .map(|l| l.text)
136 .collect::<Vec<_>>()
137 .join("\n")
138 }
139}
140
141fn fence_marker(line: &str) -> Option<(usize, &str)> {
143 let trimmed = line.trim_start();
144 let ticks = trimmed.bytes().take_while(|&b| b == b'`').count();
145 (ticks >= 3).then(|| (ticks, &trimmed[ticks..]))
146}
147
148fn examples(source: &str) -> Vec<Example<'_>> {
149 let line_of = |offset: usize| source[..offset].matches('\n').count();
150 let mut out = Vec::new();
151 for block in doc_blocks(source) {
152 let mut open: Option<(usize, Fence, usize, Vec<DocLine<'_>>)> = None;
155 for line in block {
156 match (fence_marker(line.text), open.as_mut()) {
157 (None, Some((_, _, _, body))) => body.push(line),
158 (None, None) => {}
159 (Some((ticks, info)), None) => {
160 open = Some((ticks, classify(info), line_of(line.start), Vec::new()));
161 }
162 (Some((ticks, info)), Some((n, ..))) if ticks >= *n && info.trim().is_empty() => {
166 let (_, kind, open_line, body) = open.take().expect("matched Some");
167 if matches!(kind, Fence::Checked | Fence::Run) {
168 out.push(Example {
169 run: kind == Fence::Run,
170 open: open_line,
171 body,
172 });
173 }
174 }
175 (Some(_), Some((_, _, _, body))) => body.push(line),
176 }
177 }
178 if let Some((_, kind @ (Fence::Checked | Fence::Run), open_line, body)) = open {
182 out.push(Example {
183 run: kind == Fence::Run,
184 open: open_line,
185 body,
186 });
187 }
188 }
189 out
190}
191
192#[derive(Debug, PartialEq, Eq)]
194enum Body {
195 Statements,
197 Items,
199}
200
201fn parses_clean(text: &str, wanted: impl Fn(&SourceUnit) -> bool) -> bool {
202 let Ok(tokens) = tokenize(text) else {
203 return false;
204 };
205 let (unit, errors) = parse_unit_with_recovery(&tokens, text);
206 errors.is_empty() && unit.as_ref().is_some_and(wanted)
207}
208
209fn classify_body(target: &str, body: &str) -> Body {
213 let case = format!("suite {target} {{\n case \"x\" {{\n{body}\n }}\n}}\n");
214 if parses_clean(&case, |u| matches!(u, SourceUnit::Suite(_))) {
215 return Body::Statements;
216 }
217 let items = format!("commons {target}{ITEMS_INFIX}0\n\n{body}\n");
218 if parses_clean(&items, |u| matches!(u, SourceUnit::Commons(_))) {
219 Body::Items
220 } else {
221 Body::Statements
222 }
223}
224
225fn line_starts(text: &str) -> Vec<usize> {
227 std::iter::once(0)
228 .chain(text.match_indices('\n').map(|(i, _)| i + 1))
229 .collect()
230}
231
232struct Table {
235 synth_key: PathBuf,
237 synth_starts: Vec<usize>,
238 real_starts: Vec<usize>,
239 real_len: usize,
240 copy: Option<CopyMap>,
243}
244
245struct CopyMap {
247 from: usize,
249 hoisted: Vec<(Range<usize>, usize)>,
252 synth_line: usize,
254 real_line: usize,
256 len: usize,
258}
259
260impl Table {
261 fn locate(&self, offset: usize) -> (usize, Range<usize>) {
264 let line = self
265 .synth_starts
266 .partition_point(|&s| s <= offset)
267 .saturating_sub(1);
268 let real_line = match &self.copy {
269 Some(c) if line >= c.synth_line => c.real_line + (line - c.synth_line).min(c.len),
271 _ => line,
272 }
273 .min(self.real_starts.len() - 1);
274 let start = self.real_starts[real_line];
275 let end = self
276 .real_starts
277 .get(real_line + 1)
278 .map_or(self.real_len, |next| next - 1);
279 (offset - self.synth_starts[line], start..end)
280 }
281
282 fn map_range(&self, start: usize, end: usize) -> (usize, usize) {
283 let (col, line) = self.locate(start);
284 if line.start + col > line.end {
287 return (line.start, line.end);
288 }
289 let from = line.start + col;
290 let (end_col, end_line) = self.locate(end.max(start));
291 (from, (end_line.start + end_col).min(end_line.end).max(from))
292 }
293}
294
295fn suite_text(
297 target: &str,
298 file: &str,
299 n_lines: usize,
300 examples: &[&Example<'_>],
301) -> Option<String> {
302 let mut lines = vec![String::new(); n_lines];
303 for (i, ex) in examples.iter().enumerate() {
304 let head = if i == 0 {
305 format!("suite {target} {{ ")
306 } else {
307 String::new()
308 };
309 lines[ex.open] = format!("{head}case \"doc example {file}:{}\" {{", ex.open + 1);
310 for (k, l) in ex.body.iter().enumerate() {
311 lines[ex.open + 1 + k] = l.text.to_string();
312 }
313 let tail = if i + 1 == examples.len() { "} }" } else { "}" };
314 lines[ex.close()] = tail.to_string();
315 }
316 (!examples.is_empty()).then(|| lines.join("\n"))
317}
318
319fn items_text(target: &str, n: usize, n_lines: usize, ex: &Example<'_>) -> String {
321 let mut lines = vec![String::new(); n_lines];
322 lines[0] = format!("commons {target}{ITEMS_INFIX}{n}");
323 lines[ex.open] = format!("uses {target}");
324 for (k, l) in ex.body.iter().enumerate() {
325 lines[ex.open + 1 + k] = l.text.to_string();
326 }
327 lines.join("\n")
328}
329
330fn fence_item_names(target: &str, body: &str) -> Vec<String> {
332 let text = format!("commons {target}{ITEMS_INFIX}0\n\n{body}\n");
333 let Ok(tokens) = tokenize(&text) else {
334 return Vec::new();
335 };
336 match parse_unit_with_recovery(&tokens, &text).0 {
337 Some(SourceUnit::Commons(c)) => c
338 .items
339 .iter()
340 .filter_map(|i| i.name().map(|n| n.name.clone()))
341 .collect(),
342 _ => Vec::new(),
343 }
344}
345
346fn fence_uses(ex: &Example<'_>) -> Vec<(usize, String)> {
350 let mut out = Vec::new();
351 for (k, l) in ex.body.iter().enumerate() {
352 let t = l.text.trim();
353 if t.starts_with("uses ") {
354 out.push((k, t.to_string()));
355 } else if !(t.is_empty() || t.starts_with("--")) {
356 break;
357 }
358 }
359 out
360}
361
362struct Copy {
364 text: String,
365 from: usize,
367 hoisted: Vec<(Range<usize>, usize)>,
370}
371
372fn context_copy(
378 source: &str,
379 blank: &[Range<usize>],
380 close: Option<usize>,
381 header_end: usize,
382 uses: &[(usize, String)],
383 moved: &[usize],
384 ex: &Example<'_>,
385) -> Copy {
386 let mut bytes = source.as_bytes().to_vec();
387 for range in blank {
388 for b in &mut bytes[range.clone()] {
389 if !matches!(*b, b'\n' | b'\r') {
390 *b = b' ';
391 }
392 }
393 }
394 if let Some(at) = close {
395 bytes[at] = b' ';
396 }
397 let mut text = String::from_utf8(bytes).expect("blanking keeps whole characters");
398 let mut hoisted = Vec::new();
399 let mut at = header_end;
400 for (k, line) in uses {
401 let piece = format!(" {line}");
402 text.insert_str(at, &piece);
403 hoisted.push((at + 1..at + piece.len(), *k));
404 at += piece.len();
405 }
406 text.push('\n');
407 let from = text.len();
408 let body: Vec<&str> = ex
409 .body
410 .iter()
411 .enumerate()
412 .map(|(k, l)| if moved.contains(&k) { "" } else { l.text })
413 .collect();
414 text.push_str(&body.join("\n"));
415 if close.is_some() {
416 text.push_str("\n}");
417 }
418 Copy {
419 text,
420 from,
421 hoisted,
422 }
423}
424
425fn real_name_of(synthetic: &str) -> Option<String> {
427 if let Some(stem) = synthetic.strip_suffix(SYNTHETIC_SUFFIX) {
428 return Some(format!("{stem}.bynk"));
429 }
430 let stem = synthetic.strip_suffix(".bynk")?;
431 let (real, n) = stem.rsplit_once(ITEMS_INFIX)?;
432 (!n.is_empty() && n.chars().all(|c| c.is_ascii_digit())).then(|| format!("{real}.bynk"))
433}
434
435#[derive(Default)]
438pub struct DocExamples {
439 check: Vec<(PathBuf, String)>,
441 run: Vec<(PathBuf, String)>,
443 tables: Vec<Table>,
444 layers: Vec<Vec<(PathBuf, String)>>,
446 layer_tables: Vec<Vec<Table>>,
447 count: usize,
449}
450
451impl DocExamples {
452 pub fn collect(sources: &HashMap<PathBuf, String>) -> Self {
454 let mut keys: Vec<&PathBuf> = sources.keys().collect();
455 keys.sort();
456 let mut found = DocExamples::default();
457 for key in keys {
458 let source = &sources[key];
459 let Ok(tokens) = tokenize(source) else {
460 continue;
461 };
462 let (unit, errors) = parse_unit_with_recovery(&tokens, source);
463 let (target, context) = match unit {
464 Some(SourceUnit::Commons(c)) if errors.is_empty() => (c.name.joined(), None),
465 Some(SourceUnit::Context(c)) if errors.is_empty() => (c.name.joined(), Some(c)),
466 _ => continue,
467 };
468 let examples = examples(source);
469 if examples.is_empty() {
470 continue;
471 }
472 found.add_unit(sources, key, &target, context.as_ref(), &examples);
473 }
474 found
475 }
476
477 fn add_unit(
478 &mut self,
479 sources: &HashMap<PathBuf, String>,
480 key: &Path,
481 target: &str,
482 context: Option<&Context>,
483 examples: &[Example<'_>],
484 ) {
485 let source = &sources[key];
486 let file = key.file_name().unwrap_or_default().to_string_lossy();
487 let stem = key.file_stem().unwrap_or_default().to_string_lossy();
488 let real_starts = line_starts(source);
489 let n_lines = real_starts.len();
490 let table = |synth_key: &PathBuf, text: &str| Table {
491 synth_key: synth_key.clone(),
492 synth_starts: line_starts(text),
493 real_starts: real_starts.clone(),
494 real_len: source.len(),
495 copy: None,
496 };
497 let mut cases: Vec<&Example<'_>> = Vec::new();
498 let mut items_n = 0;
499 let mut copies = 0;
500 for ex in examples {
501 match classify_body(target, &ex.text()) {
502 Body::Statements => {
503 cases.push(ex);
504 self.count += 1;
505 }
506 Body::Items if context.is_some() => {
507 let ctx = context.expect("matched Some");
508 if self.add_copy(key, source, ctx, target, ex, copies) {
509 copies += 1;
510 self.count += 1;
511 }
512 }
513 Body::Items => {
514 let synth_key = loop {
516 items_n += 1;
517 let k = key.with_file_name(format!("{stem}{ITEMS_INFIX}{items_n}.bynk"));
518 if !sources.contains_key(&k) {
519 break k;
520 }
521 };
522 self.count += 1;
523 let text = items_text(target, items_n, n_lines, ex);
524 self.tables.push(table(&synth_key, &text));
525 self.check.push((synth_key, text));
526 }
527 }
528 }
529 let synth_key = key.with_file_name(format!("{stem}{SYNTHETIC_SUFFIX}"));
530 if sources.contains_key(&synth_key) {
533 self.count -= cases.len();
534 return;
535 }
536 if let Some(text) = suite_text(target, &file, n_lines, &cases) {
537 self.tables.push(table(&synth_key, &text));
538 self.check.push((synth_key.clone(), text));
539 }
540 let runnable: Vec<&Example<'_>> = cases.iter().copied().filter(|e| e.run).collect();
541 if let Some(text) = suite_text(target, &file, n_lines, &runnable) {
542 self.run.push((synth_key, text));
543 }
544 }
545
546 pub fn is_empty(&self) -> bool {
547 self.check.is_empty() && self.layers.is_empty()
548 }
549
550 pub fn has_base(&self) -> bool {
552 !self.check.is_empty()
553 }
554
555 pub fn layer_count(&self) -> usize {
557 self.layers.len()
558 }
559
560 pub fn layer_overlay(
562 &self,
563 layer: usize,
564 sources: &HashMap<PathBuf, String>,
565 ) -> HashMap<PathBuf, String> {
566 let mut out = sources.clone();
567 out.extend(self.layers[layer].iter().cloned());
568 out
569 }
570
571 pub fn remap_layer(&self, layer: usize, errors: Vec<AttributedError>) -> Vec<AttributedError> {
575 errors
576 .into_iter()
577 .filter(|ae| {
578 bynk_syntax::Severity::for_error(&ae.error) == bynk_syntax::Severity::Error
579 })
580 .filter_map(|ae| {
581 let path = ae.source_path.clone()?;
582 let table = self.layer_tables[layer]
583 .iter()
584 .find(|t| t.synth_key.ends_with(&path))?;
585 let copy = table.copy.as_ref()?;
586 let at = ae.error.span.start;
587 if let Some((_, k)) = copy.hoisted.iter().find(|(r, _)| r.contains(&at)) {
588 return Some(Self::at_fence_line(ae, table, copy.real_line + k, path));
591 }
592 (at >= copy.from).then(|| Self::remap_one(ae, table, path))
593 })
594 .collect()
595 }
596
597 fn add_copy(
601 &mut self,
602 key: &Path,
603 source: &str,
604 ctx: &Context,
605 target: &str,
606 ex: &Example<'_>,
607 n: usize,
608 ) -> bool {
609 let close = match ctx.form {
610 CommonsForm::Fragment => None,
611 CommonsForm::Brace => {
612 let at = source[..ctx.span.end].rfind('}');
613 debug_assert!(at.is_some(), "a brace-form context ends in a brace");
614 match at {
615 Some(at) => Some(at),
616 None => return false,
617 }
618 }
619 };
620 let have: Vec<String> = ctx.uses.iter().map(|u| u.target.joined()).collect();
622 let all_uses = fence_uses(ex);
623 let moved: Vec<usize> = all_uses.iter().map(|(k, _)| *k).collect();
624 let uses: Vec<(usize, String)> = all_uses
625 .into_iter()
626 .filter(|(_, line)| !have.iter().any(|h| line.trim_end() == format!("uses {h}")))
627 .collect();
628 let names = fence_item_names(target, &ex.text());
629 let blank: Vec<Range<usize>> = ctx
630 .items
631 .iter()
632 .filter(|i| i.name().is_some_and(|n| names.contains(&n.name)))
633 .map(|i| i.span().start..i.span().end)
634 .collect();
635 let Copy {
636 text,
637 from,
638 hoisted,
639 } = context_copy(source, &blank, close, ctx.name.span.end, &uses, &moved, ex);
640 let real_starts = line_starts(source);
641 let table = Table {
642 synth_key: key.to_path_buf(),
643 synth_starts: line_starts(&text),
644 copy: Some(CopyMap {
645 from,
646 hoisted,
647 synth_line: real_starts.len(),
648 real_line: ex.open + 1,
649 len: ex.body.len(),
650 }),
651 real_starts,
652 real_len: source.len(),
653 };
654 while self.layers.len() <= n {
655 self.layers.push(Vec::new());
656 self.layer_tables.push(Vec::new());
657 }
658 self.layers[n].push((key.to_path_buf(), text));
659 self.layer_tables[n].push(table);
660 true
661 }
662
663 pub fn example_count(&self) -> usize {
665 self.count
666 }
667
668 pub fn has_runnable(&self) -> bool {
670 !self.run.is_empty()
671 }
672
673 pub fn check_overlay(&self, sources: &HashMap<PathBuf, String>) -> HashMap<PathBuf, String> {
675 let mut out = sources.clone();
676 out.extend(self.check.iter().cloned());
677 out
678 }
679
680 pub fn run_overlay(&self, sources: &HashMap<PathBuf, String>) -> HashMap<PathBuf, String> {
682 let mut out = sources.clone();
683 out.extend(self.run.iter().cloned());
684 out
685 }
686
687 pub fn run_aliases(&self) -> Vec<(String, String)> {
691 self.run
692 .iter()
693 .filter_map(|(key, _)| {
694 let name = key.file_name()?.to_string_lossy().into_owned();
695 let real = real_name_of(&name)?;
696 Some((name, real))
697 })
698 .collect()
699 }
700
701 pub fn remap(&self, errors: Vec<AttributedError>) -> Vec<AttributedError> {
707 errors
708 .into_iter()
709 .filter(|ae| {
710 bynk_syntax::Severity::for_error(&ae.error) == bynk_syntax::Severity::Error
711 })
712 .map(|ae| match self.table_for(&ae) {
713 Some((table, real)) => Self::remap_one(ae, table, real),
714 None => {
715 let mut ae = ae;
716 ae.error.notes.push(
717 "this appeared when the doc-comment examples were checked".to_string(),
718 );
719 ae
720 }
721 })
722 .collect()
723 }
724
725 fn table_for(&self, ae: &AttributedError) -> Option<(&Table, PathBuf)> {
726 let path = ae.source_path.as_deref()?;
727 let name = path.file_name()?.to_string_lossy().into_owned();
728 let real = path.with_file_name(real_name_of(&name)?);
729 let table = self.tables.iter().find(|t| t.synth_key.ends_with(path))?;
730 Some((table, real))
731 }
732
733 fn at_fence_line(
735 mut ae: AttributedError,
736 table: &Table,
737 real_line: usize,
738 real: PathBuf,
739 ) -> AttributedError {
740 let start = table.real_starts[real_line];
741 let end = table
742 .real_starts
743 .get(real_line + 1)
744 .map_or(table.real_len, |next| next - 1);
745 ae.error.span = bynk_syntax::span::Span {
746 start,
747 end,
748 ..ae.error.span
749 };
750 ae.error.labels.clear();
751 ae.error.suggestions.clear();
752 ae.source_path = Some(real);
753 ae
754 }
755
756 fn remap_one(mut ae: AttributedError, table: &Table, real: PathBuf) -> AttributedError {
757 let map = |span: bynk_syntax::span::Span| {
758 let (start, end) = table.map_range(span.start, span.end);
759 bynk_syntax::span::Span { start, end, ..span }
760 };
761 ae.error.span = map(ae.error.span);
762 for (i, (span, _)) in ae.error.labels.iter_mut().enumerate() {
763 if matches!(
764 ae.label_files.get(i),
765 None | Some(bynk_emit::project::LabelFile::Primary)
766 ) {
767 *span = map(*span);
768 }
769 }
770 ae.error.suggestions.clear();
772 ae.source_path = Some(real);
773 ae
774 }
775}
776
777fn is_name_char(c: char) -> bool {
780 c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
781}
782
783fn name_occurrences<'t>(text: &'t str, name: &'t str) -> impl Iterator<Item = usize> + 't {
787 text.match_indices(name).map(|(i, _)| i).filter(move |&i| {
788 let before = text[..i].chars().next_back();
789 let after = text[i + name.len()..].chars().next();
790 !before.is_some_and(is_name_char) && !after.is_some_and(is_name_char)
791 })
792}
793
794pub fn is_synthetic_path(path: &str, aliases: &[(String, String)]) -> bool {
796 aliases.iter().any(|(synthetic, _)| {
797 path.ends_with(synthetic.as_str()) && name_occurrences(path, synthetic).count() > 0
798 })
799}
800
801pub fn apply_aliases<'a>(text: &'a str, aliases: &[(String, String)]) -> std::borrow::Cow<'a, str> {
804 let mut out = std::borrow::Cow::Borrowed(text);
805 for (synthetic, real) in aliases {
806 let at: Vec<usize> = name_occurrences(&out, synthetic).collect();
807 if at.is_empty() {
808 continue;
809 }
810 let mut replaced = String::with_capacity(out.len());
811 let mut last = 0;
812 for i in at {
813 replaced.push_str(&out[last..i]);
814 replaced.push_str(real);
815 last = i + synthetic.len();
816 }
817 replaced.push_str(&out[last..]);
818 out = std::borrow::Cow::Owned(replaced);
819 }
820 out
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 #[test]
828 fn classifies_info_strings() {
829 assert_eq!(classify("bynk"), Fence::Checked);
830 assert_eq!(classify(" bynk "), Fence::Checked);
831 assert_eq!(classify("bynk test"), Fence::Run);
832 assert_eq!(classify("bynk,test"), Fence::Run);
833 assert_eq!(classify("bynk run"), Fence::Checked);
834 assert_eq!(classify("bynk ignore"), Fence::Skipped);
835 assert_eq!(classify("bynk,ignore"), Fence::Skipped);
836 assert_eq!(classify("bynk test ignore"), Fence::Skipped);
837 assert_eq!(classify("bynk,fragment"), Fence::Skipped);
838 assert_eq!(classify("bynk,fail=bynk.types.mismatch"), Fence::Skipped);
839 assert_eq!(classify("ts"), Fence::Foreign);
840 assert_eq!(classify(""), Fence::Foreign);
841 assert_eq!(classify("bynkc"), Fence::Foreign);
842 }
843
844 fn sources(text: &str) -> HashMap<PathBuf, String> {
845 HashMap::from([(PathBuf::from("src/m.bynk"), text.to_string())])
846 }
847
848 #[test]
849 fn finds_doc_block_examples() {
850 let src = "---\nSums.\n\n```bynk\nexpect add(1, 2) == 3\n```\n\n```bynk ignore\nnope\n```\n---\ncommons m\n\n---\n```bynk\nexpect true\n```\n---\nfn add(a: Int, b: Int) -> Int { a + b }\n";
851 let found = DocExamples::collect(&sources(src));
852 assert_eq!(found.example_count(), 2);
853 let (_, text) = &found.check[0];
854 assert!(text.starts_with("\n\n\nsuite m { case "), "{text}");
855 assert!(text.contains("expect add(1, 2) == 3\n"), "{text}");
856 assert!(text.contains("expect true\n"), "{text}");
857 assert!(!text.contains("nope"), "{text}");
858 assert!(!found.has_runnable());
859 }
860
861 #[test]
862 fn a_synthetic_file_keeps_every_line_of_the_real_one() {
863 let src =
864 "---\nProse.\n\n```bynk test\n let x = 1\n\texpect x == 1\n```\n---\ncommons m\n";
865 let found = DocExamples::collect(&sources(src));
866 assert!(found.has_runnable());
867 let (_, text) = &found.run[0];
868 let want: Vec<&str> = src.split('\n').collect();
869 let got: Vec<&str> = text.split('\n').collect();
870 assert_eq!(got.len(), want.len(), "{text}");
871 assert_eq!(got[4], want[4]);
873 assert_eq!(got[5], want[5]);
874 assert!(got[3].starts_with("suite m { case \"doc example m.bynk:4\" {"));
875 assert_eq!(got[6], "} }");
876 }
877
878 #[test]
879 fn maps_a_body_offset_back_to_the_fence() {
880 let src = "---\n```bynk\nexpect 1 == 1\n```\n---\ncommons m\n";
881 let found = DocExamples::collect(&sources(src));
882 let (_, text) = &found.check[0];
883 let synth = text.find("expect").unwrap();
884 let table = &found.tables[0];
885 let (start, end) = table.map_range(synth + 7, synth + 8);
886 assert_eq!(&src[start..end], "1");
887 let (hs, he) = table.map_range(text.find("case").unwrap(), text.find("case").unwrap() + 4);
889 assert!(src[hs..he].starts_with("```"), "{:?}", &src[hs..he]);
890 }
891
892 #[test]
893 fn classifies_bodies_by_parsing_them() {
894 assert_eq!(classify_body("m", "expect 1 == 1"), Body::Statements);
895 assert_eq!(
896 classify_body("m", "let x = 1\nexpect x == 1"),
897 Body::Statements
898 );
899 assert_eq!(
900 classify_body("m", "type P = { a: Int }\n\nfn f(p: P) -> Int { p.a }"),
901 Body::Items
902 );
903 assert_eq!(classify_body("m", "this is nonsense ("), Body::Statements);
905 }
906
907 #[test]
908 fn a_declaration_fence_is_a_sibling_commons_that_uses_a_commons() {
909 let src = "---\n```bynk\nfn f() -> Int { 1 }\n```\n---\ncommons m\n";
910 let found = DocExamples::collect(&sources(src));
911 assert_eq!(found.check.len(), 1);
912 let (key, text) = &found.check[0];
913 assert!(key.ends_with("m_doc_example_1.bynk"), "{key:?}");
914 assert!(text.starts_with("commons m_doc_example_1\n"), "{text}");
915 assert!(text.contains("\nuses m\n"), "{text}");
916 assert_eq!(
917 real_name_of("m_doc_example_1.bynk").as_deref(),
918 Some("m.bynk")
919 );
920 assert_eq!(
921 real_name_of("m.doc-examples.bynk").as_deref(),
922 Some("m.bynk")
923 );
924 assert_eq!(real_name_of("m.bynk"), None);
925 assert_eq!(real_name_of("m_doc_example_x.bynk"), None);
926 }
927
928 #[test]
929 fn a_declaration_fence_in_a_context_is_checked_in_a_copy() {
930 let src = "---\n```bynk\ntype Order = { c: Int }\nfn f(o: Order) -> Int { 1 }\n```\n---\ncontext m\n\ntype Order = { c: Int }\n";
931 let found = DocExamples::collect(&sources(src));
932 assert!(!found.is_empty());
933 assert!(!found.has_base());
934 assert_eq!(found.layer_count(), 1);
935 assert_eq!(found.example_count(), 1);
936 let (key, text) = &found.layers[0][0];
937 assert!(key.ends_with("m.bynk"), "{key:?}");
938 assert!(text.starts_with(&src[..src.find("type Order = { c: Int }\n").unwrap()]));
941 let code = &text[text.find("context m").unwrap()..];
942 assert_eq!(code.matches("type Order").count(), 1, "{code}");
943 assert!(
944 text.ends_with("\ntype Order = { c: Int }\nfn f(o: Order) -> Int { 1 }"),
945 "{text}"
946 );
947 let table = &found.layer_tables[0][0];
948 let at = text.rfind("fn f").unwrap();
949 let (start, _) = table.map_range(at, at + 2);
950 assert_eq!(&src[start..start + 2], "fn");
951 }
952
953 #[test]
954 fn a_fence_uses_moves_to_the_context_header() {
955 let src = "---\n```bynk\nuses a.b\nuses m.c\n\nfn f() -> Int { 1 }\n```\n---\ncontext m uses m.c\n\ntype T = { c: Int }\n";
956 let found = DocExamples::collect(&sources(src));
957 let (_, text) = &found.layers[0][0];
958 let code = &text[text.find("context m").unwrap()..];
961 assert!(code.starts_with("context m uses a.b uses m.c\n"), "{code}");
962 assert!(text.ends_with("\n\n\n\nfn f() -> Int { 1 }"), "{text}");
963 assert_eq!(text.matches("uses a.b").count(), 2, "{text}");
964 let copy = found.layer_tables[0][0].copy.as_ref().unwrap();
965 assert_eq!(copy.hoisted.len(), 1);
966 assert_eq!(&text[copy.hoisted[0].0.clone()], "uses a.b");
967 assert_eq!(copy.hoisted[0].1, 0);
968 }
969
970 #[test]
971 fn two_declaration_fences_in_one_context_take_a_layer_each() {
972 let src = "---\n```bynk\ntype A = { c: Int }\n```\n\n```bynk\ntype A = { d: Int }\n```\n---\ncontext m\n";
973 let found = DocExamples::collect(&sources(src));
974 assert_eq!(found.layer_count(), 2);
975 assert_eq!(found.example_count(), 2);
976 }
977
978 #[test]
979 fn a_brace_form_context_closes_after_the_appended_fence() {
980 let src =
981 "---\n```bynk\nfn f() -> Int { 1 }\n```\n---\ncontext m {\n fn g() -> Int { 2 }\n}\n";
982 let found = DocExamples::collect(&sources(src));
983 let (_, text) = &found.layers[0][0];
984 assert!(text.ends_with("fn f() -> Int { 1 }\n}"), "{text}");
985 let code = &text[text.find("context m").unwrap()..];
987 assert_eq!(code.matches('}').count(), 3, "{code}");
988 }
989
990 #[test]
991 fn aliases_show_the_real_file_name() {
992 let aliases = vec![("m.doc-examples.bynk".to_string(), "m.bynk".to_string())];
993 assert_eq!(
994 apply_aliases("at src/m.doc-examples.bynk:5:3", &aliases),
995 "at src/m.bynk:5:3"
996 );
997 assert!(matches!(
998 apply_aliases("nothing here", &aliases),
999 std::borrow::Cow::Borrowed(_)
1000 ));
1001 }
1002
1003 #[test]
1004 fn a_longer_fence_keeps_a_shorter_one_shown_inside_it() {
1005 let src = "---\n````text\n```bynk\nnope nope\n```\n````\n---\ncommons m\n";
1007 assert!(DocExamples::collect(&sources(src)).is_empty());
1008 }
1009
1010 #[test]
1011 fn an_unterminated_fence_is_checked_to_the_end_of_the_doc() {
1012 let src = "---\n```bynk\nexpect 1 == 1\n---\ncommons m\n";
1013 let found = DocExamples::collect(&sources(src));
1014 assert_eq!(found.example_count(), 1);
1015 let (_, text) = &found.check[0];
1016 assert!(text.contains("expect 1 == 1\n"), "{text}");
1017 }
1018
1019 #[test]
1020 fn a_real_file_named_like_a_synthetic_one_is_never_replaced() {
1021 let src = "---\n```bynk\nexpect 1 == 1\n```\n---\ncommons m\n";
1022 let mut all = sources(src);
1023 all.insert(PathBuf::from("src/m.doc-examples.bynk"), "x".to_string());
1024 let found = DocExamples::collect(&all);
1025 assert!(
1026 found.is_empty(),
1027 "the suite is left unchecked, not written over"
1028 );
1029 assert_eq!(found.example_count(), 0);
1030 let items = "---\n```bynk\nfn f() -> Int { 1 }\n```\n---\ncommons m\n";
1032 let mut all = sources(items);
1033 all.insert(PathBuf::from("src/m_doc_example_1.bynk"), "x".to_string());
1034 let found = DocExamples::collect(&all);
1035 assert!(found.check[0].0.ends_with("m_doc_example_2.bynk"));
1036 }
1037
1038 #[test]
1039 fn aliases_only_touch_a_whole_file_name() {
1040 let aliases = vec![(
1041 "calc.doc-examples.bynk".to_string(),
1042 "calc.bynk".to_string(),
1043 )];
1044 assert_eq!(
1045 apply_aliases("at src/demo/calc.doc-examples.bynk:5:3", &aliases),
1046 "at src/demo/calc.bynk:5:3"
1047 );
1048 assert_eq!(
1050 apply_aliases("src/xcalc.doc-examples.bynk", &aliases),
1051 "src/xcalc.doc-examples.bynk"
1052 );
1053 assert_eq!(
1054 apply_aliases("calc.doc-examples.bynk.map", &aliases),
1055 "calc.doc-examples.bynk.map"
1056 );
1057 assert!(is_synthetic_path(
1058 "src/demo/calc.doc-examples.bynk",
1059 &aliases
1060 ));
1061 assert!(is_synthetic_path("calc.doc-examples.bynk", &aliases));
1062 assert!(!is_synthetic_path("src/xcalc.doc-examples.bynk", &aliases));
1063 }
1064}