Skip to main content

bynk_driver/
doc_examples.rs

1//! Doc-comment examples (#2461): a fenced `bynk` block in a doc comment is
2//! checked by `bynk test`, so an example cannot silently fall out of step with
3//! the code it documents. One marked `bynk test` also runs.
4//!
5//! Each example of statements becomes a `case` in a synthetic `suite` targeting
6//! the unit that declares the doc comment, so it sees the unit's scope and the
7//! ordinary test semantics. An example of declarations becomes a synthetic
8//! sibling `commons` that `uses` the declaring unit. A context cannot be
9//! `uses`d, so its declaration fence is checked in a synthetic copy of the
10//! context instead: the fence's items are added to the copy, and one that
11//! redeclares a name the context already has replaces that declaration, so a
12//! doc showing `type Order = …` on `type Order` is no collision. Only the
13//! diagnostics inside the fence count. Each such fence is checked in its own
14//! project check (a *layer* holds the n-th such fence of every context), as
15//! two fences cannot share one copy of a context. The synthetic files are
16//! built in memory and added to the project's source overlay. A first project *check* reports every
17//! diagnostic at the fence in the real file. A project that has `bynk test`
18//! examples is then compiled again with those cases added, so they are emitted
19//! and run like any other case.
20//!
21//! **Line-preserving.** A synthetic file has exactly as many lines as the real
22//! file, and every line of an example's body sits on the line it has in the
23//! real file, at the same column. The generated `suite`/`case` header is on the
24//! opening fence's line. So a diagnostic or a failing `expect` names the right
25//! line and column in the real file, and only the file name needs mapping back.
26//!
27//! **Fence info strings.** The first word is `bynk`; further words are
28//! separated by spaces or commas (`bynk ignore` and `bynk,ignore` are the same
29//! fence, matching the Book's own examples). `ignore`, `fragment` and `fail`
30//! opt a fence out; `test` makes it a case that is run; any other fence (`ts`,
31//! `text`, …) is not Bynk and is not looked at.
32
33use 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
42/// The tail of a synthetic suite's name: `<stem>.doc-examples.bynk` beside the
43/// real `<stem>.bynk`.
44const SYNTHETIC_SUFFIX: &str = ".doc-examples.bynk";
45
46/// The middle of the name of a synthetic sibling unit checking a fence of
47/// declarations: `<stem>_doc_example_<n>.bynk` beside `<stem>.bynk`.
48const ITEMS_INFIX: &str = "_doc_example_";
49
50/// How a fence's info string classifies it.
51#[derive(Debug, PartialEq, Eq)]
52enum Fence {
53    /// Not a Bynk fence (`ts`, `text`, no language, …): not looked at.
54    Foreign,
55    /// A Bynk fence that opts out of checking (`ignore`, `fragment`, `fail`).
56    Skipped,
57    /// A Bynk fence to type-check.
58    Checked,
59    /// A Bynk fence to type-check and run (`bynk test`).
60    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
83/// One line of a doc block and the byte offset in the real source where it starts.
84struct DocLine<'a> {
85    text: &'a str,
86    start: usize,
87}
88
89/// The lines of every `---` doc block. A one-line doc (`--- text`) cannot hold
90/// a fence, which needs at least two lines, so only blocks are looked at.
91fn 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
117/// A fenced example to check.
118struct Example<'a> {
119    /// Marked `bynk test`: also run.
120    run: bool,
121    /// The 0-based line of the opening fence in the real source.
122    open: usize,
123    body: Vec<DocLine<'a>>,
124}
125
126impl Example<'_> {
127    /// The 0-based line of the closing fence.
128    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
141/// A fence's opener: how many backticks, the info string after them.
142fn 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        // The open fence: its backtick count, how it classifies, its line and
153        // its body so far.
154        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                // Only a bare run of at least as many backticks closes a
163                // fence (CommonMark), so a fence shown inside a longer one
164                // stays part of its body.
165                (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        // A fence the doc ends without closing runs to the end of the doc, as
179        // Markdown renders it, so a lost closer doesn't quietly stop its
180        // example being checked.
181        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/// What a fence's body is, found by parsing it.
193#[derive(Debug, PartialEq, Eq)]
194enum Body {
195    /// Statements and expressions: the body of a `case`.
196    Statements,
197    /// Top-level declarations (`type`, `fn`, `capability`, …).
198    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
209/// Statements first (the common case: an `expect`, a `let`), then
210/// declarations. A body that is neither stays `Statements`, so the checker's
211/// own parse error is reported at the fence.
212fn 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
225/// The byte offset at which each line of `text` starts.
226fn 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
232/// One synthetic file's map back to the real file it came from: both have the
233/// same lines, so an offset maps by its line and column.
234struct Table {
235    /// The synthetic file's source-map key.
236    synth_key: PathBuf,
237    synth_starts: Vec<usize>,
238    real_starts: Vec<usize>,
239    real_len: usize,
240    /// Set for a synthetic copy of a context, whose fence is appended to the
241    /// end rather than sitting on its own lines.
242    copy: Option<CopyMap>,
243}
244
245/// Where a context copy's appended fence body sits, and where it came from.
246struct CopyMap {
247    /// The synthetic byte offset at which the appended text starts.
248    from: usize,
249    /// The fence's `uses` hoisted into the header: where each sits in the
250    /// copy, and its line in the body.
251    hoisted: Vec<(Range<usize>, usize)>,
252    /// The first appended body line, in the copy.
253    synth_line: usize,
254    /// The body's first line, in the real file.
255    real_line: usize,
256    /// The number of body lines.
257    len: usize,
258}
259
260impl Table {
261    /// The real line `offset` of the synthetic file falls on, as a column and
262    /// the real line's byte range.
263    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            // Past the body is generated text: the closing fence line.
270            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        // Generated text (the `suite`/`case` header) runs past the end of the
285        // fence line it sits on: that is the whole fence line.
286        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
295/// The synthetic suite for `examples`, line for line with the real file.
296fn 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
319/// The synthetic sibling `commons` for one example of declarations.
320fn 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
330/// The names a fence of declarations declares.
331fn 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
346/// The leading `uses` lines of a declaration fence: a `uses` after a
347/// declaration is a parse error, so the copy declares them in the context's
348/// header instead. Each is the line's index in the body and its `uses` text.
349fn 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
362/// A copy of a context made to check a declaration fence.
363struct Copy {
364    text: String,
365    /// The byte offset at which the appended fence body starts.
366    from: usize,
367    /// The `uses` the fence brought to the header: where each sits in the copy,
368    /// and its line in the fence body.
369    hoisted: Vec<(Range<usize>, usize)>,
370}
371
372/// A copy of the context `source` with the declarations in `blank` and the
373/// `close` brace (of a brace-form context) blanked out, the fence's `uses`
374/// added to the header after `header_end`, and the rest of the fence `ex`
375/// appended. Blanking keeps every line, so the real file's lines are the copy's;
376/// the body is the copy's last lines.
377fn 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
425/// The real file name a synthetic file's name stands for.
426fn 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/// The synthetic files for a project's doc examples, and the means to report
436/// their diagnostics at the fences.
437#[derive(Default)]
438pub struct DocExamples {
439    /// Every file a check adds.
440    check: Vec<(PathBuf, String)>,
441    /// The suites of the examples marked `bynk test`: what a run adds.
442    run: Vec<(PathBuf, String)>,
443    tables: Vec<Table>,
444    /// The copies of contexts that check their declaration fences, by layer.
445    layers: Vec<Vec<(PathBuf, String)>>,
446    layer_tables: Vec<Vec<Table>>,
447    /// Checked examples.
448    count: usize,
449}
450
451impl DocExamples {
452    /// Scan every unit in `sources` for checked doc examples.
453    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                    // A file the project really has is never overwritten.
515                    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        // A real file already named like the suite keeps its name, and the
531        // cases are left unchecked rather than replace it.
532        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    /// Whether any file besides the context copies is added to a check.
551    pub fn has_base(&self) -> bool {
552        !self.check.is_empty()
553    }
554
555    /// The number of extra project checks the context copies need.
556    pub fn layer_count(&self) -> usize {
557        self.layers.len()
558    }
559
560    /// `sources` with the context copies of `layer` in place of the contexts.
561    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    /// The errors a context copy's fence caused, in the real file. Only those
572    /// inside the fence count: one the replaced declaration caused elsewhere
573    /// is not the example's to answer for.
574    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                    // An error in a `uses` moved to the header is the fence
589                    // line's.
590                    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    /// Add the copy of `ctx` that checks the declaration fence `ex`, in the
598    /// layer after the `n` fences of this context already added. False when the
599    /// context cannot be copied.
600    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        // A `uses` the context already has is left out of the header.
621        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    /// The number of examples that will be checked.
664    pub fn example_count(&self) -> usize {
665        self.count
666    }
667
668    /// Whether any example is marked `bynk test`.
669    pub fn has_runnable(&self) -> bool {
670        !self.run.is_empty()
671    }
672
673    /// `sources` plus every synthetic file, for the check.
674    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    /// `sources` plus the suites of the examples marked `bynk test`.
681    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    /// For each runnable suite, the synthetic file name and the real file name
688    /// to show in its place in the run's output (`calc.doc-examples.bynk` ->
689    /// `calc.bynk`). The line and column are already the real file's.
690    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    /// The errors the examples caused, in the real file. One a synthetic file
702    /// produced is mapped to its fence. One attributed to a real file, such as a
703    /// duplicate declaration reported at the original, is kept as it is with a
704    /// note that the examples brought it about: the project compiled clean
705    /// without them, so it is theirs.
706    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    /// `ae` moved onto the whole of line `real_line` of the real file.
734    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        // A suggestion's edits are offsets into the synthetic text.
771        ae.error.suggestions.clear();
772        ae.source_path = Some(real);
773        ae
774    }
775}
776
777/// Whether `c` can be part of a file name, so a name that continues past it is a
778/// different file.
779fn is_name_char(c: char) -> bool {
780    c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
781}
782
783/// The byte offsets in `text` at which `name` occurs as a whole file name: not
784/// the tail of a longer one (`src/xcalc.doc-examples.bynk` is not
785/// `calc.doc-examples.bynk`) and not the head of one.
786fn 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
794/// Whether `path` is one of the synthetic files in `aliases`, by its whole name.
795pub 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
801/// Show the real file's name in place of a synthetic one in `text`. Only a whole
802/// file name is replaced, never part of a longer one or of other text.
803pub 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        // Each body line is on its real line, column for column.
872        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        // The generated header maps onto the opening fence line.
888        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        // Neither: stays statements, so the checker reports the parse error.
904        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        // The documented `type Order` is blanked, the fence appended, and the
939        // lines of the real file kept.
940        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        // `m.c` is already used; `a.b` moves to the header and the body keeps
959        // its lines, with the `uses` lines blank.
960        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        // The real closing brace is blanked: the last one is the generated one.
986        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        // A ````text fence showing a ```bynk example is one foreign fence.
1006        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        // A taken sibling name moves the next one along.
1031        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        // A longer name that merely ends the same way is another file.
1049        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}