Skip to main content

nova_doc/
doctest.rs

1//! Doc tests: the Nova code blocks in the docs a library's site shows,
2//! each as a test module (spec
3//! `docs/superpowers/specs/2026-10-11-phase-3-5b-doc-tests-design.md` §4
4//! to §7).
5
6use std::path::{Component, Path};
7
8use nova_diagnostics::{FileDb, FileId, Spanned};
9use nova_lexer::Token;
10
11use crate::markdown::{self, Block, CodeBlock, DocText, Docs};
12use crate::model::{self, Failure, Impl, Package};
13
14// The test function each doc test module declares (spec §5.2), which
15// the driver collects.
16use nova_driver::DOC_TEST_FUNCTION as FUNCTION;
17
18/// Where a line of a doc test's text comes from (spec §7).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Origin {
21    /// A line the doc test adds: the package's import, or the test
22    /// function's.
23    Added,
24    /// A line of the block.
25    Code {
26        /// The offset in the file that its column 0 maps to.
27        at: u32,
28        /// For a hidden line, the column its `# ` was cut at, and how many
29        /// bytes were cut.
30        cut: Option<(u32, u32)>,
31    },
32}
33
34/// A doc test: one Nova code block of the docs a library's site shows, as
35/// a test module (spec §5).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DocTest {
38    /// `shapes::circle (src/lib.nova:41)` (spec §5.3).
39    pub name: String,
40    /// `src/lib.nova:41`: where the block starts, which an unknown word's
41    /// warning names (spec §4.2).
42    pub place: String,
43    /// The module's text (spec §5), ending in a line break.
44    pub text: String,
45    /// The block's file, by its name in the program's `FileDb`: the path
46    /// the loader read it from, as `Path::display` writes it, or
47    /// `<std/time>` for std's.
48    pub file: String,
49    /// Where the block starts in `file`: its opening fence, or an indented
50    /// block's first line, which added lines map to (spec §7).
51    pub start: u32,
52    /// Where each line of `text` comes from.
53    pub lines: Vec<Origin>,
54    /// The words after `nova` that mean nothing (spec §4.2).
55    pub unknown: Vec<String>,
56}
57
58impl DocTest {
59    /// Where byte `offset` of `text` is in `file` (spec §7). A hidden
60    /// line's column after its cut moves past its `# `, and an added line
61    /// is at `start`.
62    pub fn map(&self, offset: u32) -> u32 {
63        let bytes = self.text.as_bytes();
64        let offset = (offset as usize).min(bytes.len());
65        let line = bytes[..offset].iter().filter(|&&b| b == b'\n').count();
66        let line_start = bytes[..offset]
67            .iter()
68            .rposition(|&b| b == b'\n')
69            .map_or(0, |i| i + 1);
70        let col = (offset - line_start) as u32;
71        match self.lines.get(line) {
72            Some(Origin::Code {
73                at,
74                cut: Some((c, len)),
75            }) if col >= *c => at + col + len,
76            Some(Origin::Code { at, .. }) => at + col,
77            _ => self.start,
78        }
79    }
80}
81
82/// The doc tests of the package whose `nova.toml` is in `root` (spec
83/// §4.1), in the site's order; `None` when it has no library.
84pub fn package_doc_tests(root: &Path) -> Result<Option<Vec<DocTest>>, Failure> {
85    let Some((package, a)) = model::library_package(root)? else {
86        return Ok(None);
87    };
88    let Some(graph) = a.graph.as_ref() else {
89        return Ok(Some(Vec::new()));
90    };
91    let (dir, import) = (&graph.root().dir, graph.root().import_name.as_str());
92    let shown = |file: FileId| match a.modules.iter().find(|(f, _)| *f == file) {
93        Some((_, path)) => slashed(path.strip_prefix(dir).unwrap_or(path)),
94        None => a.db.get_name(file).unwrap_or("?").to_string(),
95    };
96    Ok(Some(collect(&package, &a.db, Some(import), &shown)))
97}
98
99/// std's doc tests (spec §6.1, `--std`), in the site's order.
100pub fn std_doc_tests() -> Result<Vec<DocTest>, Failure> {
101    let (package, a) = model::std_package()?;
102    let shown = |file: FileId| std_place(a.db.get_name(file).unwrap_or("?"));
103    Ok(collect(&package, &a.db, None, &shown))
104}
105
106/// A std module's file as a doc test's name shows it: `<std/time>` is
107/// `std/time/lib.nova` (spec §5.3, §7).
108fn std_place(name: &str) -> String {
109    match name.strip_prefix("<std/").and_then(|n| n.strip_suffix('>')) {
110        Some(m) => format!("std/{m}/lib.nova"),
111        None => name.to_string(),
112    }
113}
114
115/// `path`'s parts, joined with `/` (spec §5.3).
116fn slashed(path: &Path) -> String {
117    path.components()
118        .filter_map(|c| match c {
119            Component::Normal(s) => s.to_str(),
120            _ => None,
121        })
122        .collect::<Vec<_>>()
123        .join("/")
124}
125
126/// The doc tests of `package`'s docs. `import` is the package's import
127/// name, `None` for std; `shown` names a file as a doc test's name shows
128/// it.
129fn collect(
130    package: &Package,
131    db: &FileDb,
132    import: Option<&str>,
133    shown: &dyn Fn(FileId) -> String,
134) -> Vec<DocTest> {
135    let mut tests = Vec::new();
136    for (path, docs) in documented(package) {
137        tests.extend(doc_tests_of(&path, &docs.source, db, import, shown));
138    }
139    tests
140}
141
142/// Every documented thing's docs, by the path a doc test of it is named
143/// by (spec §5.3): the search index's paths, in the site's order. Docs no
144/// file holds are among them, and give no doc test.
145fn documented(package: &Package) -> Vec<(String, &Docs)> {
146    fn impl_docs<'p>(out: &mut Vec<(String, &'p Docs)>, path: &str, i: &'p Impl) {
147        out.push((path.to_string(), &i.docs));
148        for m in &i.methods {
149            out.push((format!("{path}::{}", m.name), &m.docs));
150        }
151    }
152    let mut out = Vec::new();
153    for m in &package.modules {
154        out.push((m.qual.clone(), &m.docs));
155        for it in &m.items {
156            let path = format!("{}::{}", m.qual, it.name);
157            out.push((path.clone(), &it.docs));
158            for member in &it.members {
159                out.push((format!("{path}::{}", member.name), &member.docs));
160            }
161            for i in &it.impls {
162                impl_docs(&mut out, &path, i);
163            }
164        }
165        for i in &m.other_impls {
166            impl_docs(&mut out, &format!("{}::{}", m.qual, i.on), i);
167        }
168    }
169    for pr in &package.primitives {
170        for i in &pr.impls {
171            impl_docs(&mut out, &format!("std::{}", pr.name), i);
172        }
173    }
174    out
175}
176
177/// The doc tests of one doc text, `path`'s: its blocks that run (spec
178/// §4.2).
179fn doc_tests_of(
180    path: &str,
181    doc: &DocText,
182    db: &FileDb,
183    import: Option<&str>,
184    shown: &dyn Fn(FileId) -> String,
185) -> Vec<DocTest> {
186    let Some(file) = doc.file else {
187        return Vec::new();
188    };
189    let Some(file_name) = db.get_name(file) else {
190        return Vec::new();
191    };
192    let mut out = Vec::new();
193    for b in markdown::code_blocks(&doc.text) {
194        let Block::Test { unknown } = &b.block else {
195            continue;
196        };
197        let Some(start) = doc.source(b.start) else {
198            continue;
199        };
200        let line = db.location(file, start).map_or(1, |(l, _)| l);
201        let place = format!("{}:{line}", shown(file));
202        let (text, lines) = module(import, run_lines(&b, doc));
203        out.push(DocTest {
204            name: format!("{path} ({place})"),
205            place,
206            text,
207            file: file_name.to_string(),
208            start,
209            lines,
210            unknown: unknown.clone(),
211        });
212    }
213    out
214}
215
216/// A block's line as it runs (spec §4.3), and where it comes from.
217struct RunLine {
218    text: String,
219    origin: Origin,
220}
221
222/// `b`'s lines as they run, each mapped through `doc` into its file: a
223/// hidden line less its `# `.
224fn run_lines(b: &CodeBlock, doc: &DocText) -> Vec<RunLine> {
225    b.lines
226        .iter()
227        .map(|l| {
228            // `text[pad..]` starts at `doc.source(l.at)`; a made-up column
229            // maps into the tab before it.
230            let at = doc.source(l.at).unwrap_or(0).saturating_sub(l.pad as u32);
231            match markdown::hidden(&l.text) {
232                Some((cut, len)) => RunLine {
233                    text: format!("{}{}", &l.text[..cut], &l.text[cut + len..]),
234                    origin: Origin::Code {
235                        at,
236                        cut: Some((cut as u32, len as u32)),
237                    },
238                },
239                None => RunLine {
240                    text: l.text.clone(),
241                    origin: Origin::Code { at, cut: None },
242                },
243            }
244        })
245        .collect()
246}
247
248/// A block's test module (spec §5), and where each of its lines comes
249/// from. `import` is the package's import name, `None` for std's. Lines
250/// keep their columns: none is indented.
251fn module(import: Option<&str>, lines: Vec<RunLine>) -> (String, Vec<Origin>) {
252    let code = lines
253        .iter()
254        .map(|l| l.text.as_str())
255        .collect::<Vec<_>>()
256        .join("\n");
257    // Comments and strings are not tokens, so `import` in either is not
258    // an import.
259    let (tokens, _) = nova_lexer::lex(&code, FileId::DUMMY);
260    let hoisted = leading_imports(&code, &tokens);
261    let main = declares_main(&tokens);
262    let mut out: Vec<(String, Origin)> = Vec::new();
263    if let Some(p) = import {
264        if !imports(&tokens, p) {
265            out.push((format!("import {p}"), Origin::Added));
266        }
267    }
268    let mut rest = lines.into_iter();
269    out.extend(rest.by_ref().take(hoisted).map(|l| (l.text, l.origin)));
270    match main {
271        Some(is_async) => {
272            out.extend(rest.map(|l| (l.text, l.origin)));
273            // What `main` returns is dropped, as a program drops it.
274            let call = if is_async {
275                "let _ = block_on(main())"
276            } else {
277                "let _ = main()"
278            };
279            out.push((format!("@test fn {FUNCTION}() {{ {call} }}"), Origin::Added));
280        }
281        None => {
282            out.push((format!("@test fn {FUNCTION}() {{"), Origin::Added));
283            out.extend(rest.map(|l| (l.text, l.origin)));
284            out.push(("}".to_string(), Origin::Added));
285        }
286    }
287    let mut text = out
288        .iter()
289        .map(|(t, _)| t.as_str())
290        .collect::<Vec<_>>()
291        .join("\n");
292    text.push('\n');
293    (text, out.into_iter().map(|(_, o)| o).collect())
294}
295
296/// The index of the token after the import at `tokens[i]`: `import`, a
297/// path, then `::{…}`, `{…}` or `as name`, and a `;`.
298fn import_end(tokens: &[Spanned<Token>], i: usize) -> usize {
299    let at = |j: usize| tokens.get(j).map(|t| &t.value);
300    let mut j = i + 1;
301    if matches!(at(j), Some(Token::Ident(_))) {
302        j += 1;
303    }
304    while at(j) == Some(&Token::ColonColon) && matches!(at(j + 1), Some(Token::Ident(_))) {
305        j += 2;
306    }
307    if at(j) == Some(&Token::ColonColon) {
308        j += 1;
309    }
310    match at(j) {
311        Some(Token::LBrace) => {
312            while !matches!(at(j), None | Some(Token::RBrace) | Some(Token::Eof)) {
313                j += 1;
314            }
315            if at(j) == Some(&Token::RBrace) {
316                j += 1;
317            }
318        }
319        Some(Token::As) => {
320            j += 1;
321            if matches!(at(j), Some(Token::Ident(_))) {
322                j += 1;
323            }
324        }
325        _ => {}
326    }
327    if at(j) == Some(&Token::Semicolon) {
328        j += 1;
329    }
330    j
331}
332
333/// How many lines at the top of `code` are its leading imports, with the
334/// blank and comment lines among them (spec §5.1). An import whose last
335/// line goes on with other code stops them.
336fn leading_imports(code: &str, tokens: &[Spanned<Token>]) -> usize {
337    let line = |offset: u32| {
338        code.as_bytes()[..(offset as usize).min(code.len())]
339            .iter()
340            .filter(|&&b| b == b'\n')
341            .count()
342    };
343    let mut lines = 0;
344    let mut i = 0;
345    while tokens.get(i).is_some_and(|t| t.value == Token::Import) {
346        let end = import_end(tokens, i);
347        let last = line(tokens[end - 1].span.end.saturating_sub(1));
348        let next = &tokens[end.min(tokens.len() - 1)];
349        if next.value != Token::Eof && line(next.span.start) <= last {
350            break;
351        }
352        lines = last + 1;
353        i = end;
354    }
355    lines
356}
357
358/// Whether `tokens` declare `fn main` at the top level, and whether it is
359/// `async` (spec §5.2).
360fn declares_main(tokens: &[Spanned<Token>]) -> Option<bool> {
361    let mut depth = 0usize;
362    for (i, t) in tokens.iter().enumerate() {
363        match &t.value {
364            Token::LBrace => depth += 1,
365            Token::RBrace => depth = depth.saturating_sub(1),
366            Token::Fn if depth == 0 => {
367                let named_main = matches!(
368                    tokens.get(i + 1).map(|t| &t.value),
369                    Some(Token::Ident(n)) if n == "main"
370                );
371                if named_main {
372                    return Some(i > 0 && tokens[i - 1].value == Token::Async);
373                }
374            }
375            _ => {}
376        }
377    }
378    None
379}
380
381/// Whether `tokens` import the package `name` itself (spec §5.1):
382/// `import name`, `import name::{…}` or `import name as …`.
383fn imports(tokens: &[Spanned<Token>], name: &str) -> bool {
384    tokens
385        .windows(2)
386        .any(|w| w[0].value == Token::Import && matches!(&w[1].value, Token::Ident(n) if n == name))
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::signature::FileText;
393    use std::path::PathBuf;
394
395    /// A fresh, empty directory with a fixed name, so each run replaces the
396    /// last.
397    fn fresh(name: &str) -> PathBuf {
398        let dir = std::env::temp_dir().join(format!("nova-doc-doctest-{name}"));
399        let _ = std::fs::remove_dir_all(&dir);
400        std::fs::create_dir_all(&dir).unwrap();
401        dir
402    }
403
404    fn write(dir: &Path, files: &[(&str, &str)]) {
405        for (path, text) in files {
406            let path = dir.join(path);
407            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
408            std::fs::write(path, text).unwrap();
409        }
410    }
411
412    fn manifest(name: &str) -> String {
413        format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2026\"\n")
414    }
415
416    /// [`package_doc_tests`], its diagnostics rendered when it fails.
417    fn doc_tests(root: &Path) -> Option<Vec<DocTest>> {
418        match package_doc_tests(root) {
419            Ok(tests) => tests,
420            Err(Failure::Diagnostics { db, diagnostics }) => panic!(
421                "{}",
422                nova_diagnostics::render::render_to_string(&db, &diagnostics)
423            ),
424            Err(Failure::Other(why)) => panic!("{why}"),
425        }
426    }
427
428    /// Lines that run, line `i` from source offset `100 * i`.
429    fn lines(texts: &[&str]) -> Vec<RunLine> {
430        texts
431            .iter()
432            .enumerate()
433            .map(|(i, t)| RunLine {
434                text: t.to_string(),
435                origin: Origin::Code {
436                    at: 100 * i as u32,
437                    cut: None,
438                },
439            })
440            .collect()
441    }
442
443    /// The `///` lines of `src`, read as `file`.
444    fn doc_lines(src: &str, file: FileId) -> Vec<Spanned<String>> {
445        nova_lexer::lex(src, file)
446            .0
447            .into_iter()
448            .filter_map(|t| match t.value {
449                Token::DocComment(s) => Some(Spanned::new(s, t.span)),
450                _ => None,
451            })
452            .collect()
453    }
454
455    #[test]
456    fn a_block_becomes_a_module() {
457        // Spec §5.1 and §5.2. Mutant 3: the package import is not doubled.
458        let code = |at: u32| Origin::Code { at, cut: None };
459        let (text, origins) = module(
460            Some("shapes"),
461            lines(&[
462                "// Measure.",
463                "import shapes::{circle}",
464                "",
465                "let c = circle(1.0)",
466            ]),
467        );
468        assert_eq!(
469            text,
470            "// Measure.\nimport shapes::{circle}\n@test fn nova_doctest() {\n\n\
471             let c = circle(1.0)\n}\n"
472        );
473        assert_eq!(
474            origins,
475            [
476                code(0),
477                code(100),
478                Origin::Added,
479                code(200),
480                code(300),
481                Origin::Added
482            ]
483        );
484        for (import, block, expected) in [
485            (
486                Some("shapes"),
487                &["let c = circle(1.0)"][..],
488                "import shapes\n@test fn nova_doctest() {\nlet c = circle(1.0)\n}\n",
489            ),
490            (
491                Some("shapes"),
492                &["import shapes", "let c = circle(1.0)"][..],
493                "import shapes\n@test fn nova_doctest() {\nlet c = circle(1.0)\n}\n",
494            ),
495            (
496                Some("shapes"),
497                &["let s = \"import shapes\" // import shapes"][..],
498                "import shapes\n@test fn nova_doctest() {\n\
499                 let s = \"import shapes\" // import shapes\n}\n",
500            ),
501            (
502                Some("shapes"),
503                &["fn main() {", "    let a = 1", "}"][..],
504                "import shapes\nfn main() {\n    let a = 1\n}\n\
505                 @test fn nova_doctest() { let _ = main() }\n",
506            ),
507            (
508                Some("shapes"),
509                &["async fn main() {", "}"][..],
510                "import shapes\nasync fn main() {\n}\n\
511                 @test fn nova_doctest() { let _ = block_on(main()) }\n",
512            ),
513            (
514                Some("shapes"),
515                &["fn helper() { fn main() {} }", "helper()"][..],
516                "import shapes\n@test fn nova_doctest() {\nfn helper() { fn main() {} }\n\
517                 helper()\n}\n",
518            ),
519            (
520                None,
521                &["import a::{", "  b,", "}", "f()"][..],
522                "import a::{\n  b,\n}\n@test fn nova_doctest() {\nf()\n}\n",
523            ),
524            (
525                None,
526                &["import x; let a = 1", "let b = 2"][..],
527                "@test fn nova_doctest() {\nimport x; let a = 1\nlet b = 2\n}\n",
528            ),
529        ] {
530            assert_eq!(module(import, lines(block)).0, expected, "{block:?}");
531        }
532        let (_, origins) = module(Some("shapes"), lines(&["fn main() {", "}"]));
533        assert_eq!(origins, [Origin::Added, code(0), code(100), Origin::Added]);
534    }
535
536    /// Serves `text` at every path.
537    struct Memory(String);
538
539    impl nova_driver::Sources for Memory {
540        fn read(&self, _: &Path) -> std::io::Result<String> {
541            Ok(self.0.clone())
542        }
543    }
544
545    #[test]
546    fn a_main_that_returns_a_value_runs_as_a_program_would() {
547        // Spec §5.2: the test calls `main` and drops what it returns, so a
548        // `main` that returns a value checks, as it does in a program.
549        for (block, call) in [
550            ("fn main() -> Int { 1 }", "let _ = main()"),
551            ("async fn main() -> Int { 1 }", "let _ = block_on(main())"),
552            ("fn main() {}", "let _ = main()"),
553        ] {
554            let (text, _) = module(None, lines(&[block]));
555            let program = nova_driver::Program::doc_tests(
556                None,
557                vec![PathBuf::from("<doc tests>").join("doc0.nova")],
558            );
559            let options = nova_driver::Options {
560                tests: true,
561                ..nova_driver::Options::default()
562            };
563            let a = nova_driver::analyze_program(program, &Memory(text.clone()), &options)
564                .expect("the module reads");
565            let errors: Vec<&str> = a
566                .diagnostics
567                .iter()
568                .filter(|d| d.severity == nova_diagnostics::Severity::Error)
569                .map(|d| d.message.as_str())
570                .collect();
571            assert_eq!(errors, Vec::<&str>::new(), "{block}");
572            assert_eq!(
573                text,
574                format!("{block}\n@test fn nova_doctest() {{ {call} }}\n")
575            );
576        }
577    }
578
579    #[test]
580    fn the_line_map_points_into_the_doc_comment() {
581        // Spec §7: a body line; a hidden line, less its `# `; and an added
582        // line, at the opening fence or an indented block's first line.
583        // Plain, CRLF, in a list, in a quote, and indented by a tab.
584        for (src, line) in [
585            (
586                "/// A circle.\n///\n/// ```\n/// # let r = 1.0\n/// let c = circle(r)\n/// ```\n\
587                 pub fn circle(r: Float) -> Float { r }\n",
588                3,
589            ),
590            (
591                "/// A circle.\r\n///\r\n/// ```\r\n/// # let r = 1.0\r\n/// let c = circle(r)\r\n\
592                 /// ```\r\npub fn circle(r: Float) -> Float { r }\r\n",
593                3,
594            ),
595            (
596                "    /// - In a list:\n    ///\n    ///   ```\n    ///   # let r = 1.0\n    \
597                 ///   let c = circle(r)\n    ///   ```\n    pub fn circle(r: Float) -> Float { r }\n",
598                3,
599            ),
600            (
601                "///> ```\n///> # let r = 1.0\n///> let c = circle(r)\n///> ```\nfn f() {}\n",
602                1,
603            ),
604            ("///\tlet r = 1.0\n///\tlet c = circle(r)\nfn f() {}\n", 1),
605        ] {
606            let mut db = FileDb::new();
607            let file = db.add("src/lib.nova", src);
608            let doc = markdown::join(&doc_lines(src, file));
609            let tests = doc_tests_of("shapes::circle", &doc, &db, Some("shapes"), &|_| {
610                "src/lib.nova".to_string()
611            });
612            assert_eq!(tests.len(), 1, "{src:?}");
613            let t = &tests[0];
614            assert_eq!(t.name, format!("shapes::circle (src/lib.nova:{line})"));
615            assert_eq!(t.place, format!("src/lib.nova:{line}"));
616            assert_eq!(t.file, "src/lib.nova");
617            // Mutant 2: a hidden line runs less its `# `.
618            assert_eq!(
619                t.text,
620                "import shapes\n@test fn nova_doctest() {\nlet r = 1.0\nlet c = circle(r)\n}\n",
621                "{src:?}"
622            );
623            let at = |s: &str| src.find(s).unwrap() as u32;
624            let made = |s: &str| t.text.find(s).unwrap() as u32;
625            assert_eq!(t.map(made("c = circle")), at("c = circle"), "{src:?}");
626            assert_eq!(t.map(made("let r")), at("let r"), "{src:?}");
627            assert_eq!(t.map(made("1.0")), at("1.0"), "{src:?}");
628            let first = src.find("```").or_else(|| src.find("let r")).unwrap() as u32;
629            assert_eq!(t.start, first, "{src:?}");
630            // The added import, and the added closing brace.
631            assert_eq!(t.map(0), first);
632            assert_eq!(t.map(t.text.len() as u32 - 2), first);
633        }
634    }
635
636    #[test]
637    fn a_std_doc_test_imports_nothing_and_names_its_modules_file() {
638        let src = "//! Time.\n//!\n//! ```\n//! let d = 1\n//! ```\npub fn now() -> Int { 1 }\n";
639        let mut db = FileDb::new();
640        let file = db.add("<std/time>", src);
641        let doc = FileText::new(src, file).module_docs();
642        let tests = doc_tests_of("std::time", &doc, &db, None, &|f| {
643            std_place(db.get_name(f).unwrap_or("?"))
644        });
645        assert_eq!(tests.len(), 1);
646        let t = &tests[0];
647        assert_eq!(t.name, "std::time (std/time/lib.nova:3)");
648        assert_eq!(t.file, "<std/time>");
649        assert_eq!(t.text, "@test fn nova_doctest() {\nlet d = 1\n}\n");
650        assert_eq!(
651            t.map(t.text.find("d = 1").unwrap() as u32),
652            src.find("d = 1").unwrap() as u32
653        );
654        let Ok(tests) = std_doc_tests() else {
655            panic!("std's doc tests are not read")
656        };
657        for t in &tests {
658            assert!(t.name.starts_with("std::"), "{}", t.name);
659            assert!(t.place.starts_with("std/"), "{}", t.place);
660            assert!(t.file.starts_with("<std/"), "{}", t.file);
661        }
662    }
663
664    const SHAPES_LIB: &str = "//! Shapes.\n//!\n//! ```\n//! let a = circle(1.0)\n//! ```\n\n\
665        import units\n\n\
666        /// A circle's area.\n///\n/// ```nova,should_panic\n/// let a = circle(2.0)\n/// ```\n\
667        ///\n/// ```text\n/// not a test\n/// ```\n\
668        pub fn circle(r: Float) -> Float { 3.0 * r * r }\n\n\
669        /// A rectangle.\npub record Rect {\n    /// Its width.\n    ///\n    /// ```\n    \
670        /// let r = Rect { width: 1.0 }\n    /// ```\n    pub width: Float,\n}\n\n\
671        /// Rect's own methods.\n///\n///     let r = Rect { width: 2.0 }\n\
672        impl Rect {\n    /// Its width, twice.\n    ///\n    /// ```ignore\n    \
673        /// let w = Rect { width: 1.0 }.twice()\n    /// ```\n    \
674        pub fn twice(self) -> Float { self.width * 2.0 }\n}\n\n\
675        /// Not exported.\n///\n/// ```\n/// let s = secret()\n/// ```\n\
676        fn secret() -> Int { 1 }\n";
677
678    const SHAPES_UNITS: &str = "//! Lengths.\n//!\n//! ```\n//! let l = Length { metres: 1.0 }\n\
679        //! ```\n\n/// A length.\npub record Length {\n    pub metres: Float,\n}\n\n\
680        impl Length {\n    /// The sum of two lengths.\n    ///\n    /// ```\n    \
681        /// let l = Length { metres: 1.0 }.plus(Length { metres: 2.0 })\n    /// ```\n    \
682        pub fn plus(self, other: Length) -> Length {\n        \
683        Length { metres: self.metres + other.metres }\n    }\n}\n";
684
685    #[test]
686    fn doc_tests_are_named_by_the_sites_paths() {
687        // Spec §5.3: the package, an item, a member, an impl's own docs, a
688        // module, and a method by the site's path.
689        let dir = fresh("names");
690        write(
691            &dir,
692            &[
693                ("nova.toml", &manifest("shapes")),
694                ("src/lib.nova", SHAPES_LIB),
695                ("src/units.nova", SHAPES_UNITS),
696            ],
697        );
698        let tests = doc_tests(&dir).expect("a library");
699        let names: Vec<&str> = tests.iter().map(|t| t.name.as_str()).collect();
700        assert_eq!(
701            names,
702            [
703                "shapes (src/lib.nova:3)",
704                "shapes::circle (src/lib.nova:11)",
705                "shapes::Rect::width (src/lib.nova:24)",
706                "shapes::Rect (src/lib.nova:32)",
707                "shapes::units (src/units.nova:3)",
708                "shapes::units::Length::plus (src/units.nova:15)",
709            ]
710        );
711        assert_eq!(tests[1].unknown, ["should_panic"]);
712        assert!(tests[0].unknown.is_empty());
713        assert_eq!(
714            tests[1].text,
715            "import shapes\n@test fn nova_doctest() {\nlet a = circle(2.0)\n}\n"
716        );
717        // The names the loader gives the files, which the doc test
718        // program's database shares.
719        let lib = dir.join("src").join("lib.nova").display().to_string();
720        let units = dir.join("src").join("units.nova").display().to_string();
721        assert_eq!(tests[0].file, lib);
722        assert_eq!(tests[5].file, units);
723        assert_eq!(tests[0].start, SHAPES_LIB.find("```").unwrap() as u32);
724    }
725
726    #[test]
727    fn only_a_librarys_documented_items_give_doc_tests() {
728        // Spec §4.1. Mutant 5: a program's blocks are not tests.
729        let dir = fresh("program");
730        write(
731            &dir,
732            &[
733                ("nova.toml", &manifest("app")),
734                (
735                    "src/main.nova",
736                    "/// Runs.\n///\n/// ```\n/// main()\n/// ```\nfn main() {}\n",
737                ),
738            ],
739        );
740        assert_eq!(doc_tests(&dir), None);
741        // A private item's block, a `text` block and an `ignore`d one.
742        let dir = fresh("quiet");
743        write(
744            &dir,
745            &[
746                ("nova.toml", &manifest("quiet")),
747                (
748                    "src/lib.nova",
749                    "/// ```\n/// let a = secret()\n/// ```\nfn secret() -> Int { 1 }\n\n\
750                     /// ```text\n/// shown()\n/// ```\npub fn shown() -> Int { secret() }\n\n\
751                     /// ```ignore\n/// skipped()\n/// ```\npub fn skipped() -> Int { 2 }\n",
752                ),
753            ],
754        );
755        assert_eq!(doc_tests(&dir), Some(Vec::new()));
756    }
757}