use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use anyhow::{anyhow, bail, Context, Result};
use clap::Args;
use nova_diagnostics::{render, Diagnostic, FileDb, FileId, Severity, Span};
use nova_doc::{DocTest, Failure};
use nova_driver::{DiskSources, Program, Sources};
use crate::project::{self, Mode};
#[derive(Args)]
pub struct TestCmd {
filter: Option<String>,
#[arg(long)]
doc: bool,
#[arg(long, requires = "doc")]
std: bool,
}
enum Outcome {
Passed { stdout: String },
Panicked { line: String, stdout: String },
Trapped {
code: i32,
stdout: String,
stderr: String,
},
}
const PANIC_MARKER: &str = "nova: panic:";
fn classify(output: &Output) -> Outcome {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if output.status.success() {
return Outcome::Passed { stdout };
}
let stderr = String::from_utf8_lossy(&output.stderr);
match stderr.lines().find(|line| line.contains(PANIC_MARKER)) {
Some(line) => {
let line = line.trim().to_string();
Outcome::Panicked { line, stdout }
}
None => Outcome::Trapped {
code: output.status.code().unwrap_or(-1),
stdout,
stderr: stderr.into_owned(),
},
}
}
fn print_captured(label: &str, output: &str) {
if output.is_empty() {
return;
}
println!(" ---- {label} ----");
for line in output.lines() {
println!(" {line}");
}
}
fn format_exit_code(code: i32) -> String {
format!("code {code} (0x{:08X})", code as u32)
}
pub fn run(cmd: TestCmd) -> Result<()> {
let filter = cmd.filter.as_deref();
if cmd.std {
return run_std_doc_tests(filter);
}
let mode = project::mode(None)?;
if cmd.doc {
return run_doc_tests(&mode, filter);
}
project::sync(&mode)?;
let program = mode.program(nova_driver::Roots::Test);
let (exe, tests) = nova_driver::build_test_program(program)?;
let compiled: Vec<String> = tests.iter().map(|t| t.name.clone()).collect();
let names = inventory(&exe, &compiled)?;
let should_panic: Vec<bool> = tests.iter().map(|t| t.should_panic).collect();
let library = match &mode {
Mode::Project { root, .. } if has_library(root) => Some(root.as_path()),
_ => None,
};
let docs = match library {
Some(root) => library_doc_tests(root)?,
None => Vec::new(),
};
let doc_names: Vec<String> = docs.iter().map(|d| d.name.clone()).collect();
let selected = select(&names, filter);
let doc_selected = select(&doc_names, filter);
let mut available = vec![(names.len(), "test")];
if !docs.is_empty() {
available.push((docs.len(), "doc test"));
}
ensure_matched(filter, selected.len() + doc_selected.len(), &available)?;
let tally = run_tests(&exe, "test", &names, &should_panic, &selected)?;
let mut sections = vec![("test", tally)];
if let (Some(root), false) = (library, docs.is_empty()) {
let tally = run_doc_section(Some(root), &docs, &doc_selected, true)?;
sections.push(("doc test", tally));
}
finish(§ions)
}
fn run_doc_tests(mode: &Mode, filter: Option<&str>) -> Result<()> {
let Mode::Project { root, .. } = mode else {
bail!(
"nova test --doc tests a package's library; run it inside one, or use \
`nova test --doc --std`"
);
};
if !has_library(root) {
bail!("nova test --doc tests a library's docs, and this package has no src/lib.nova");
}
project::sync(mode)?;
doc_tests_alone(Some(root), &library_doc_tests(root)?, filter)
}
fn run_std_doc_tests(filter: Option<&str>) -> Result<()> {
let docs = nova_doc::std_doc_tests().map_err(failure)?;
doc_tests_alone(None, &docs, filter)
}
fn doc_tests_alone(package: Option<&Path>, docs: &[DocTest], filter: Option<&str>) -> Result<()> {
let names: Vec<String> = docs.iter().map(|d| d.name.clone()).collect();
let selected = select(&names, filter);
ensure_matched(filter, selected.len(), &[(docs.len(), "doc test")])?;
let tally = run_doc_section(package, docs, &selected, false)?;
finish(&[("doc test", tally)])
}
fn has_library(root: &Path) -> bool {
root.join("src").join("lib.nova").is_file()
}
fn library_doc_tests(root: &Path) -> Result<Vec<DocTest>> {
Ok(nova_doc::package_doc_tests(root)
.map_err(failure)?
.unwrap_or_default())
}
fn failure(f: Failure) -> anyhow::Error {
match f {
Failure::Diagnostics { db, diagnostics } => {
render::emit_all(&db, &diagnostics);
let errors = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count();
anyhow!(
"could not compile due to {errors} previous error{}",
if errors == 1 { "" } else { "s" }
)
}
Failure::Other(why) => anyhow!(why),
}
}
const DOC_TESTS: &str = "<doc tests>";
const STD_DOC_TESTS: &str = "<std doc tests>";
fn run_doc_section(
package: Option<&Path>,
docs: &[DocTest],
selected: &[usize],
after_units: bool,
) -> Result<Tally> {
for &i in selected {
warn_unknown_words(&docs[i]);
}
let (exe, should_panic) = if selected.is_empty() {
(PathBuf::new(), Vec::new())
} else {
build_doc_tests(package, docs, after_units)?
};
if after_units {
println!();
}
let names: Vec<String> = docs.iter().map(|d| d.name.clone()).collect();
run_tests(&exe, "doc test", &names, &should_panic, selected)
}
fn build_doc_tests(
package: Option<&Path>,
docs: &[DocTest],
after_units: bool,
) -> Result<(PathBuf, Vec<bool>)> {
let dir = match package {
Some(root) => root.join(DOC_TESTS),
None => PathBuf::from(STD_DOC_TESTS),
};
let modules: Vec<(PathBuf, String)> = docs
.iter()
.enumerate()
.map(|(i, d)| (dir.join(format!("doc{i}.nova")), d.text.clone()))
.collect();
let program = Program::doc_tests(package, modules.iter().map(|(p, _)| p.clone()).collect());
let by_name: HashMap<String, usize> = modules
.iter()
.enumerate()
.map(|(i, (path, _))| (path.display().to_string(), i))
.collect();
let map =
|db: &mut FileDb, d: &mut Diagnostic| to_the_docs(db, d, &by_name, docs) || !after_units;
let (exe, tests) =
nova_driver::build_test_program_with(program, &Overlay(&modules), Some(&map))?;
anyhow::ensure!(
tests.len() == docs.len(),
"the doc tests' binary holds {} tests for {} doc tests; this is a compiler bug",
tests.len(),
docs.len()
);
let compiled: Vec<String> = tests.iter().map(|t| t.name.clone()).collect();
inventory(&exe, &compiled)?;
Ok((exe, tests.iter().map(|t| t.should_panic).collect()))
}
struct Overlay<'a>(&'a [(PathBuf, String)]);
impl Sources for Overlay<'_> {
fn read(&self, path: &Path) -> std::io::Result<String> {
match self.0.iter().find(|(p, _)| p == path) {
Some((_, text)) => Ok(text.clone()),
None => DiskSources.read(path),
}
}
}
fn to_the_docs(
db: &mut FileDb,
d: &mut Diagnostic,
by_name: &HashMap<String, usize>,
docs: &[DocTest],
) -> bool {
let mut in_docs = false;
for label in &mut d.labels {
let Some(i) = db
.get_name(label.span.file)
.and_then(|name| by_name.get(name))
.copied()
else {
continue;
};
in_docs = true;
let test = &docs[i];
let Some(file) = doc_file(db, test) else {
continue;
};
let text = db.get_source(file).unwrap_or_default();
let start = on_a_char(text, test.map(label.span.start));
let end = on_a_char(text, test.map(label.span.end)).max(start);
label.span = Span::new(start, end, file);
}
if in_docs {
d.fixes.clear();
}
in_docs
}
fn doc_file(db: &mut FileDb, test: &DocTest) -> Option<FileId> {
let Some(text) = nova_driver::std_source(&test.file) else {
return db.id_of(&test.file);
};
let (name, _) = test.place.rsplit_once(':')?;
Some(db.id_of(name).unwrap_or_else(|| db.add(name, text)))
}
fn on_a_char(text: &str, offset: u32) -> u32 {
let mut at = (offset as usize).min(text.len());
while !text.is_char_boundary(at) {
at -= 1;
}
at as u32
}
fn warn_unknown_words(test: &DocTest) {
if test.unknown.is_empty() {
return;
}
let words: Vec<String> = test.unknown.iter().map(|w| format!("`{w}`")).collect();
eprintln!(
"warning: {}: unknown word{} {} in a code block's info string; the block runs as a \
plain test",
test.place,
if words.len() == 1 { "" } else { "s" },
words.join(", ")
);
}
fn inventory(exe: &Path, compiled: &[String]) -> Result<Vec<String>> {
let inventory = Command::new(exe)
.env_remove("NOVA_TEST_INDEX")
.output()
.with_context(|| format!("failed to run {}", exe.display()))?;
let status = inventory.status;
let stdout = String::from_utf8_lossy(&inventory.stdout).into_owned();
let stderr = String::from_utf8_lossy(&inventory.stderr).into_owned();
let mut lines = stdout.lines();
let count: usize = lines
.next()
.unwrap_or_default()
.trim()
.parse()
.with_context(|| {
format!(
"{}'s inventory did not start with a test count ({status}): stdout {stdout:?}, stderr {stderr:?}",
exe.display()
)
})?;
let names: Vec<String> = lines.take(count).map(str::to_string).collect();
anyhow::ensure!(
names.len() == count,
"{} reported {count} test{} but printed only {} name{} ({status}; stderr {stderr:?})",
exe.display(),
if count == 1 { "" } else { "s" },
names.len(),
if names.len() == 1 { "" } else { "s" }
);
anyhow::ensure!(
names.len() == compiled.len(),
"the compiled binary's inventory ({} tests) disagrees with the compiler's own count \
({}); this is a compiler bug (inventory process {status})",
names.len(),
compiled.len()
);
anyhow::ensure!(
(0..count).all(|i| names[i] == compiled[i]),
"the compiled binary's inventory names disagree with the compiler's own test names at \
the same positions; this is a compiler bug (inventory process {status})"
);
Ok(names)
}
fn select(names: &[String], filter: Option<&str>) -> Vec<usize> {
(0..names.len())
.filter(|&i| match filter {
Some(f) => names[i].contains(f),
None => true,
})
.collect()
}
fn ensure_matched(filter: Option<&str>, matched: usize, available: &[(usize, &str)]) -> Result<()> {
let Some(f) = filter else {
return Ok(());
};
if matched > 0 {
return Ok(());
}
let available: Vec<String> = available
.iter()
.map(|&(n, kind)| format!("{n} {kind}{}", if n == 1 { "" } else { "s" }))
.collect();
bail!(
"no test name contains the filter `{f}` ({} available)",
available.join(" and ")
)
}
struct Tally {
failed: usize,
trapped: usize,
}
fn run_tests(
exe: &Path,
kind: &str,
names: &[String],
should_panic: &[bool],
selected: &[usize],
) -> Result<Tally> {
println!(
"running {} {kind}{}",
selected.len(),
if selected.len() == 1 { "" } else { "s" }
);
let mut passed = 0usize;
let mut failed = 0usize;
let mut trapped = 0usize;
for &i in selected {
let name = &names[i];
let result = Command::new(exe)
.env("NOVA_TEST_INDEX", i.to_string())
.output()
.with_context(|| format!("failed to run {} for test `{name}`", exe.display()))?;
match (classify(&result), should_panic[i]) {
(Outcome::Passed { .. }, false) => {
passed += 1;
println!("test {name} ... ok");
}
(Outcome::Passed { stdout }, true) => {
failed += 1;
println!("test {name} ... FAILED (expected a panic, but the test passed)");
print_captured("stdout", &stdout);
}
(Outcome::Panicked { .. }, true) => {
passed += 1;
println!("test {name} ... ok");
}
(Outcome::Panicked { line, stdout }, false) => {
failed += 1;
println!("test {name} ... FAILED");
println!(" {line}");
print_captured("stdout", &stdout);
}
(
Outcome::Trapped {
code,
stdout,
stderr,
},
_,
) => {
trapped += 1;
println!("test {name} ... TRAPPED (exit {})", format_exit_code(code));
print_captured("stdout", &stdout);
print_captured("stderr", &stderr);
}
}
}
let total = selected.len();
let all_ok = failed == 0 && trapped == 0;
println!();
println!(
"test result: {}. {passed} passed; {failed} failed; {trapped} trapped; {total} total",
if all_ok { "ok" } else { "FAILED" }
);
Ok(Tally { failed, trapped })
}
fn finish(sections: &[(&str, Tally)]) -> Result<()> {
if sections
.iter()
.all(|(_, t)| t.failed == 0 && t.trapped == 0)
{
return Ok(());
}
let counts: Vec<String> = sections
.iter()
.map(|(kind, t)| {
format!(
"{} {kind}{} failed, {} trapped",
t.failed,
if t.failed == 1 { "" } else { "s" },
t.trapped
)
})
.collect();
bail!("{}", counts.join("; "))
}
#[cfg(test)]
mod tests {
use super::*;
use nova_doc::Origin;
#[test]
fn a_std_doc_tests_diagnostic_points_at_its_modules_file() {
let time = nova_driver::std_source("<std/time>").expect("std/time");
let third_line = time.match_indices('\n').nth(1).expect("three lines").0 + 1;
let test = DocTest {
name: "std::time (std/time/lib.nova:2)".to_string(),
place: "std/time/lib.nova:2".to_string(),
text: "@test fn nova_doctest() {\nlet d = nope\n}\n".to_string(),
file: "<std/time>".to_string(),
start: 0,
lines: vec![
Origin::Added,
Origin::Code {
at: third_line as u32,
cut: None,
},
Origin::Added,
],
unknown: Vec::new(),
};
let docs = [test];
let mut db = FileDb::new();
let module = db.add("<std doc tests>/doc0.nova", docs[0].text.clone());
let by_name = HashMap::from([("<std doc tests>/doc0.nova".to_string(), 0)]);
let nope = docs[0].text.find("nope").unwrap() as u32;
let mut files = Vec::new();
for _ in 0..2 {
let mut d = Diagnostic::error("E0001", "cannot find `nope` in this scope")
.with_primary_label(Span::new(nope, nope + 4, module), "here")
.with_fix(nova_diagnostics::Fix::new("a fix", Vec::new()));
assert!(to_the_docs(&mut db, &mut d, &by_name, &docs));
let span = d.labels[0].span;
assert_eq!(db.get_name(span.file), Some("std/time/lib.nova"));
assert_eq!(db.get_source(span.file), Some(time));
assert_eq!(db.location(span.file, span.start), Some((3, 9)));
assert_eq!(span.end - span.start, 4);
assert!(d.fixes.is_empty());
files.push(span.file);
}
assert_eq!(
files[0], files[1],
"the second found the file the first added"
);
let lib = db.add("src/lib.nova", "pub fn f() {}\n");
let mut d = Diagnostic::warning("W0000", "a warning")
.with_primary_label(Span::new(0, 3, lib), "here");
assert!(!to_the_docs(&mut db, &mut d, &by_name, &docs));
assert_eq!(d.labels[0].span, Span::new(0, 3, lib));
}
#[test]
fn an_exit_code_is_rendered_in_decimal_and_as_an_unsigned_hex_ntstatus() {
assert_eq!(
format_exit_code(-1073741819),
"code -1073741819 (0xC0000005)"
);
assert_ne!(format_exit_code(-1073741819), format_exit_code(-1073741795));
assert_eq!(
format_exit_code(-1073741795),
"code -1073741795 (0xC000001D)"
);
assert_eq!(format_exit_code(1), "code 1 (0x00000001)");
}
}