diff --git a/Cargo.lock b/Cargo.lock index 7b637252..7b1ab224 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -526,6 +526,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "terminal_size", "tokio", "tower-lsp", ] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ae9e85de..5ad2c070 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -32,6 +32,7 @@ lsp = { package = "lisette-lsp", version = "0.4.3", path = "../lsp" } tokio = { version = "1", features = ["rt-multi-thread", "io-std"] } tower-lsp = "0.20" fs2 = "0.4" +terminal_size = "0.4" owo-colors.workspace = true rustc-hash.workspace = true serde.workspace = true diff --git a/crates/cli/src/handlers/add.rs b/crates/cli/src/handlers/add.rs index 927d699a..cb8236ef 100644 --- a/crates/cli/src/handlers/add.rs +++ b/crates/cli/src/handlers/add.rs @@ -451,7 +451,7 @@ fn setup_project( return Err(1); } - print_preview_notice(); + print_preview_notice("Third-party Go dependencies", true); let project_target_dir = project_root.join("target"); if project_target_dir.is_file() { diff --git a/crates/cli/src/handlers/build.rs b/crates/cli/src/handlers/build.rs index 800d28c0..eec629e2 100644 --- a/crates/cli/src/handlers/build.rs +++ b/crates/cli/src/handlers/build.rs @@ -251,20 +251,6 @@ pub(super) fn build_locked(prep: &BuildPrep, options: BuildOptions) -> BuildOutc let project_name = go_module_name.rsplit('/').next().unwrap_or(go_module_name); - if !quiet { - eprintln!(); - if crate::output::use_color() { - use owo_colors::OwoColorize; - eprintln!( - " · Compiling {} v{}", - project_name.bright_magenta(), - version - ); - } else { - eprintln!(" · Compiling `{}` v{}", project_name, version); - } - } - let compile_config = CompileConfig { target_phase: CompilePhase::Emit, go_module: go_module_name.to_string(), @@ -419,11 +405,25 @@ pub(super) fn build_locked(prep: &BuildPrep, options: BuildOptions) -> BuildOutc go_cli::write_emit_manifest(&prep.target_dir, &emit.new_manifest); if !quiet { - eprintln!( - " ✓ {} {}", - label, - crate::output::format_elapsed(start.elapsed()) - ); + eprintln!(); + if crate::output::use_color() { + use owo_colors::OwoColorize; + eprintln!( + " ✓ {} {} v{} {}", + label, + project_name.bright_magenta(), + version, + crate::output::format_elapsed(start.elapsed()) + ); + } else { + eprintln!( + " ✓ {} `{}` v{} {}", + label, + project_name, + version, + crate::output::format_elapsed(start.elapsed()) + ); + } } BuildOutcome { diff --git a/crates/cli/src/handlers/test/mod.rs b/crates/cli/src/handlers/test/mod.rs index f958eae8..1c589b40 100644 --- a/crates/cli/src/handlers/test/mod.rs +++ b/crates/cli/src/handlers/test/mod.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::Duration; use crate::cli_error; use crate::go_cli; @@ -12,7 +12,7 @@ mod report; use report::{build_report_filtered, exit_code, matching_tests, render}; pub fn test(path: Option, go_flags: Vec, filter: Option) -> i32 { - crate::output::print_test_unfinished_notice(); + crate::output::print_preview_notice("Test runner", false); with_locked_project(path, |prep| { let outcome = build_locked( prep, @@ -61,7 +61,6 @@ pub fn test(path: Option, go_flags: Vec, filter: Option) } }; - let started = Instant::now(); let run = match go_cli::run_tests( &build_dir, stdlib::Target::host(), @@ -83,7 +82,12 @@ pub fn test(path: Option, go_flags: Vec, filter: Option) ); eprint!( "{}", - render(&report, &outcome.sources, use_color(), started.elapsed()) + render( + &report, + &outcome.sources, + use_color(), + Duration::from_secs_f64(report.test_elapsed), + ) ); let build_error = report.build_output.trim(); diff --git a/crates/cli/src/handlers/test/report.rs b/crates/cli/src/handlers/test/report.rs index 76988d39..06dec045 100644 --- a/crates/cli/src/handlers/test/report.rs +++ b/crates/cli/src/handlers/test/report.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use syntax::ast::Span; use crate::go_cli::GoTestEvent; -use crate::output::format_elapsed; +use crate::output::{format_backticks, format_elapsed}; use diagnostics::LisetteDiagnostic; use lisette::pipeline::{Sources, TestIndex}; @@ -16,6 +16,10 @@ type FailChunks = HashMap<(String, String), (usize, Vec<(usize, String)>)>; const FAIL_ATTR_KEY: &str = "lisette-fail"; +const DESC_MAX_WIDTH: usize = 100; +const DESC_MIN_WIDTH: usize = 20; +const DESC_MAX_LINES: usize = 3; + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Status { Passed, @@ -36,10 +40,6 @@ struct FailEnvelope { struct Operand { label: String, value: String, - #[serde(default)] - lo: u32, - #[serde(default)] - hi: u32, } #[derive(Deserialize, Clone)] @@ -63,6 +63,7 @@ pub struct TestRow { pub output: String, pub failure: Option, pub children: Vec, + pub span: Span, } pub struct Report { @@ -71,6 +72,8 @@ pub struct Report { package_output: HashMap, failed_packages: HashSet, build_failed_packages: HashSet, + go_module: String, + pub test_elapsed: f64, } fn name_or_title_contains(fn_name: &str, title: Option<&str>, pattern: &str) -> bool { @@ -119,6 +122,7 @@ pub fn build_report_filtered( let mut failed_packages: HashSet = HashSet::new(); let mut build_failed_packages: HashSet = HashSet::new(); let mut fail_chunks: FailChunks = HashMap::new(); + let mut test_elapsed: f64 = 0.0; for event in events { if event.action == "attr" @@ -156,8 +160,12 @@ pub fn build_report_filtered( .push_str(text); } } + "pass" => { + test_elapsed = test_elapsed.max(event.elapsed.unwrap_or(0.0)); + } "fail" => { failed_packages.insert(event.package.clone()); + test_elapsed = test_elapsed.max(event.elapsed.unwrap_or(0.0)); } _ => {} } @@ -220,6 +228,7 @@ pub fn build_report_filtered( output: outputs.get(&key).cloned().unwrap_or_default(), failure: failures.get(&key).cloned(), children, + span: test.span, }); } Report { @@ -228,6 +237,8 @@ pub fn build_report_filtered( package_output, failed_packages, build_failed_packages, + go_module: go_module.to_string(), + test_elapsed, } } @@ -284,20 +295,19 @@ fn collect_children( failures: &HashMap<(String, String), FailureRecord>, ) -> Vec { let prefix = format!("{parent}/"); - let mut direct: Vec<&String> = terminal + let mut segments: Vec<&str> = terminal .keys() .chain(started.iter()) - .filter(|(pkg, name)| { - pkg == package && name.starts_with(&prefix) && !name[prefix.len()..].contains('/') - }) - .map(|(_, name)| name) + .filter(|(pkg, name)| pkg == package && name.starts_with(&prefix)) + .map(|(_, name)| name[prefix.len()..].split('/').next().unwrap_or("")) .collect(); - direct.sort(); - direct.dedup(); + segments.sort_unstable(); + segments.dedup(); - direct + segments .into_iter() - .map(|full| { + .map(|segment| { + let full = format!("{parent}/{segment}"); let key = (package.to_string(), full.clone()); let (status, elapsed) = match terminal.get(&key).copied() { Some(found) => found, @@ -306,13 +316,14 @@ fn collect_children( }; TestRow { package: package.to_string(), - name: full[prefix.len()..].to_string(), + name: segment.to_string(), description: None, status, elapsed, output: outputs.get(&key).cloned().unwrap_or_default(), failure: failures.get(&key).cloned(), - children: collect_children(package, full, terminal, started, outputs, failures), + children: collect_children(package, &full, terminal, started, outputs, failures), + span: Span::new(0, 0, 0), } }) .collect() @@ -325,6 +336,19 @@ fn package_of_import_path(import_path: &str) -> &str { .map_or(import_path, |(pkg, _)| pkg) } +fn package_display<'a>(package: &'a str, go_module: &str) -> &'a str { + if package == go_module { + package.rsplit('/').next().unwrap_or(package) + } else if let Some(rel) = package + .strip_prefix(go_module) + .and_then(|rest| rest.strip_prefix('/')) + { + rel + } else { + package + } +} + pub fn render(report: &Report, sources: &Sources, color: bool, total: Duration) -> String { let mut out = String::from("\n"); @@ -338,10 +362,24 @@ pub fn render(report: &Report, sources: &Sources, color: bool, total: Duration) by_package.entry(&row.package).or_default().push(row); } - for (package, mut group) in by_package { - group.sort_by(|a, b| a.name.cmp(&b.name)); - out.push_str(&format!(" {package}\n")); - render_rows(&mut out, &group, " ", sources, color); + let term_width = terminal_width(); + for (index, (package, mut group)) in by_package.into_iter().enumerate() { + if index > 0 { + out.push('\n'); + } + group.sort_by(|a, b| { + let file_a = sources.get(&a.span.file_id).map(|s| s.filename.as_str()); + let file_b = sources.get(&b.span.file_id).map(|s| s.filename.as_str()); + (file_a, a.span.byte_offset).cmp(&(file_b, b.span.byte_offset)) + }); + let header = package_display(package, &report.go_module); + let header = if color { + header.bright_magenta().to_string() + } else { + header.to_string() + }; + out.push_str(&format!(" {header}\n")); + render_rows(&mut out, &group, " ", color, term_width); // Crash before any test ran (init/`TestMain` panic): cause is package-level only. if !report.build_failed_packages.contains(package) @@ -360,12 +398,15 @@ pub fn render(report: &Report, sources: &Sources, color: bool, total: Duration) } } + render_failures(&mut out, &report.rows, &report.go_module, sources, color); + out.push('\n'); - out.push_str(&summary(&report.rows, total)); + out.push_str(&summary(&report.rows, total, color)); out } -fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, sources: &Sources, color: bool) { +fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, color: bool, term_width: usize) { + let any_described = rows.iter().any(|r| r.description.is_some()); for (i, row) in rows.iter().enumerate() { let last = i + 1 == rows.len(); let branch = if last { "└── " } else { "├── " }; @@ -376,70 +417,169 @@ fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, sources: &Sour _ => String::new(), }; if row.children.is_empty() { + let glyph = if row.status == Status::NotRun { + dim("⊘", color) + } else { + mark(row.status, color) + }; let suffix = match row.status { - Status::NotRun => " (not run)", - Status::Aborted => " (aborted)", - _ => "", + Status::Aborted => dim(" (aborted)", color), + _ => String::new(), }; out.push_str(&format!( - "{prefix}{branch}{} {}{suffix}{timing}\n", - mark(row.status, color), - row.name + "{prefix}{branch}{glyph} {}{suffix}{timing}\n", + format_backticks(&row.name, color) )); } else { - out.push_str(&format!("{prefix}{branch}{}{timing}\n", row.name)); + out.push_str(&format!( + "{prefix}{branch}{}{timing}\n", + format_backticks(&row.name, color) + )); } let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " }); if let Some(description) = &row.description { - for line in description.lines() { - out.push_str(&dim(&format!("{child_prefix} {line}"), color)); - out.push('\n'); + let line = description.split_whitespace().collect::>().join(" "); + if !line.is_empty() { + let gutter = if row.children.is_empty() { + " " + } else { + "│ " + }; + let indent = child_prefix.chars().count() + gutter.chars().count(); + let width = term_width + .saturating_sub(indent) + .clamp(DESC_MIN_WIDTH, DESC_MAX_WIDTH); + for wrapped in wrap_description(&line, width, DESC_MAX_LINES) { + out.push_str(&format!( + "{child_prefix}{gutter}{}\n", + format_description(&wrapped, color) + )); + } } } - if let Some(block) = row + let children: Vec<&TestRow> = row.children.iter().collect(); + render_rows(out, &children, &child_prefix, color, term_width); + + if any_described && !last { + out.push_str(&format!("{prefix}│\n")); + } + } +} + +fn render_failures( + out: &mut String, + rows: &[TestRow], + go_module: &str, + sources: &Sources, + color: bool, +) { + // The flat Failures section loses the tree's package grouping, so prefix the package when the run + // spans more than one, to disambiguate same-named tests across packages. + let multi_package = rows + .iter() + .map(|r| &r.package) + .collect::>() + .len() + > 1; + let mut blocks: Vec<(String, Option, String)> = Vec::new(); + for row in rows { + let prefix = if multi_package { + package_display(&row.package, go_module).to_string() + } else { + String::new() + }; + collect_failures(row, &prefix, sources, color, &mut blocks); + } + if blocks.is_empty() { + return; + } + + out.push('\n'); + let heading = if color { + "Failures".bold().to_string() + } else { + "Failures".to_string() + }; + out.push_str(&format!(" {heading}\n")); + for (path, kind, body) in blocks { + out.push('\n'); + let glyph = mark(Status::Failed, color); + let name = format_backticks(&path, color); + match kind { + Some(kind) => out.push_str(&format!(" {glyph} {name} · {kind}\n")), + None => out.push_str(&format!(" {glyph} {name}\n")), + } + for line in body.lines() { + out.push_str(&format!(" {line}\n")); + } + } +} + +fn collect_failures( + row: &TestRow, + prefix: &str, + sources: &Sources, + color: bool, + blocks: &mut Vec<(String, Option, String)>, +) { + let path = if prefix.is_empty() { + row.name.clone() + } else { + format!("{prefix} › {}", row.name) + }; + + if matches!(row.status, Status::Failed | Status::Aborted) { + if let Some((kind, body)) = row .failure .as_ref() .and_then(|record| render_failure(record, sources, color)) { - for line in block.lines() { - out.push_str(&format!("{child_prefix}{line}\n")); - } - } else if matches!(row.status, Status::Failed | Status::Aborted) { - for line in row.output.lines() { - let line = line.trim_end(); - if line.is_empty() { - continue; - } - out.push_str(&dim(&format!("{child_prefix} {line}"), color)); - out.push('\n'); + blocks.push((path.clone(), Some(kind), body)); + } else if !has_failing_descendant(row) { + let text = row + .output + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .map(|line| dim(line, color)) + .collect::>() + .join("\n"); + if !text.is_empty() { + blocks.push((path.clone(), None, text)); } } + } - let children: Vec<&TestRow> = row.children.iter().collect(); - render_rows(out, &children, &child_prefix, sources, color); + for child in &row.children { + collect_failures(child, &path, sources, color, blocks); } } -fn render_failure(record: &FailureRecord, sources: &Sources, color: bool) -> Option { +fn has_failing_descendant(row: &TestRow) -> bool { + row.children.iter().any(|child| { + matches!(child.status, Status::Failed | Status::Aborted) || has_failing_descendant(child) + }) +} + +fn render_failure( + record: &FailureRecord, + sources: &Sources, + color: bool, +) -> Option<(String, String)> { let info = sources.get(&record.file)?; let span = Span::new(record.file, record.lo, record.hi.saturating_sub(record.lo)); let (label, notes): (String, Vec) = if record.kind == "labeled" { - let notes = record + let label = record .operands .iter() - .map(|operand| { - let source = info - .source - .get(operand.lo as usize..operand.hi as usize) - .unwrap_or(&operand.label); - format!("{}: `{}` is `{}`", operand.label, source, operand.value) - }) - .collect(); - (record.message.clone(), notes) + .map(|operand| format!("{}: {}", operand.label, operand.value)) + .collect::>() + .join(" · "); + (label, Vec::new()) } else { let label = record .operands @@ -460,31 +600,137 @@ fn render_failure(record: &FailureRecord, sources: &Sources, color: bool) -> Opt if !notes.is_empty() { diagnostic = diagnostic.with_note(notes.join("\n")); } - Some(diagnostics::render::render_to_string( - &diagnostic, - &info.source, - &info.filename, - color, - )) + let rendered = + diagnostics::render::render_to_string(&diagnostic, &info.source, &info.filename, color); + let body = rendered.lines().skip(1).collect::>().join("\n"); + Some((record.message.clone(), body)) } -fn summary(rows: &[TestRow], total: Duration) -> String { +fn summary(rows: &[TestRow], total: Duration, color: bool) -> String { let passed = rows.iter().filter(|r| r.status == Status::Passed).count(); let failed = rows.iter().filter(|r| r.status == Status::Failed).count(); let aborted = rows.iter().filter(|r| r.status == Status::Aborted).count(); let not_run = rows.iter().filter(|r| r.status == Status::NotRun).count(); - let mut parts = vec![format!("{passed} passed")]; + let any_failure = failed > 0 || aborted > 0; + let glyph = mark( + if any_failure { + Status::Failed + } else { + Status::Passed + }, + color, + ); + + let mut parts = Vec::new(); if failed > 0 { - parts.push(format!("{failed} failed")); + parts.push(red(&format!("{failed} failed"), color)); } if aborted > 0 { - parts.push(format!("{aborted} aborted")); + parts.push(red(&format!("{aborted} aborted"), color)); } + parts.push(green(&format!("{passed} passed"), color)); if not_run > 0 { parts.push(format!("{not_run} not run")); } - format!(" {} {}\n", parts.join(", "), format_elapsed(total)) + + format!( + " {glyph} {} {}\n", + parts.join(" · "), + format_elapsed(total) + ) +} + +fn green(text: &str, color: bool) -> String { + if color { + text.green().to_string() + } else { + text.to_string() + } +} + +fn red(text: &str, color: bool) -> String { + if color { + text.red().to_string() + } else { + text.to_string() + } +} + +fn format_description(text: &str, color: bool) -> String { + if !color { + return text.to_string(); + } + let mut out = String::new(); + let mut rest = text; + while let Some(open) = rest.find('`') { + let prose = &rest[..open]; + if !prose.is_empty() { + out.push_str(&prose.dimmed().to_string()); + } + let after = &rest[open + 1..]; + match after.find('`') { + Some(close) => { + let code = &after[..close]; + if !code.is_empty() { + out.push_str(&code.bright_magenta().dimmed().to_string()); + } + rest = &after[close + 1..]; + } + None => { + out.push_str(&format!("`{after}").dimmed().to_string()); + return out; + } + } + } + if !rest.is_empty() { + out.push_str(&rest.dimmed().to_string()); + } + out +} + +fn terminal_width() -> usize { + terminal_size::terminal_size_of(std::io::stderr()) + .map(|(w, _)| w.0 as usize) + .unwrap_or(100) +} + +fn wrap_description(text: &str, width: usize, max_lines: usize) -> Vec { + let width = width.max(8); + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if !current.is_empty() && current.chars().count() + 1 + word.chars().count() <= width { + current.push(' '); + current.push_str(word); + continue; + } + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + let mut rest = word; + while rest.chars().count() > width { + let split = rest + .char_indices() + .nth(width) + .map(|(i, _)| i) + .unwrap_or(rest.len()); + lines.push(rest[..split].to_string()); + rest = &rest[split..]; + } + current = rest.to_string(); + } + if !current.is_empty() { + lines.push(current); + } + if lines.len() > max_lines { + lines.truncate(max_lines); + if let Some(last) = lines.last_mut() { + let head: String = last.chars().take(width.saturating_sub(1)).collect(); + *last = format!("{}…", head.trim_end()); + } + } + lines } /// A non-`Passed` row fails the run only when `go test` itself did; a filtered test must not. @@ -499,7 +745,7 @@ fn mark(status: Status, color: bool) -> String { let symbol = match status { Status::Passed => "✓", Status::Failed | Status::Aborted => "✗", - Status::NotRun => "·", + Status::NotRun => "⊘", }; if !color { return symbol.to_string(); @@ -595,6 +841,25 @@ mod tests { format!(r#"{{"i":0,"n":1,"d":"{}"}}"#, hex_encode(inner_json)) } + #[test] + fn wrap_description_wraps_and_truncates() { + let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu"; + + let short = wrap_description("alpha beta", 40, 3); + assert_eq!(short, vec!["alpha beta"]); + + let wrapped = wrap_description(text, 16, 3); + assert!(wrapped.len() <= 3); + assert!(wrapped.iter().all(|l| l.chars().count() <= 16)); + assert!( + wrapped.last().unwrap().ends_with('…'), + "a too-long description truncates its last line, got: {wrapped:?}" + ); + + let long_word = wrap_description("supercalifragilisticexpialidocious", 10, 3); + assert!(long_word.iter().all(|l| l.chars().count() <= 10)); + } + #[test] fn all_pass_groups_by_package() { let index = index(&[(ENTRY_MODULE_ID, "root_smoke"), ("math", "adds_numbers")]); @@ -607,7 +872,7 @@ mod tests { assert!(text.contains(" demo\n")); assert!(text.contains("✓ root_smoke")); - assert!(text.contains(" demo/math\n")); + assert!(text.contains(" math\n")); assert!(text.contains("✓ adds_numbers")); assert!(text.contains("2 passed")); assert_eq!(exit_code(&report.rows, true), 0); @@ -657,9 +922,10 @@ mod tests { let report = build_report(&index, &events, "demo"); let text = render(&report, &no_sources(), false, Duration::from_millis(1)); + let tree = text.split("Failures").next().unwrap_or(&text); assert!( - !text.contains("✗ parent") && !text.contains("✓ parent"), - "a grouping carries no sigil, got:\n{text}" + !tree.contains("✗ parent") && !tree.contains("✓ parent"), + "a grouping carries no sigil in the tree, got:\n{text}" ); assert!(text.contains("✓ child")); assert!( @@ -717,7 +983,7 @@ mod tests { let report = build_report(&index, &[], "demo"); let text = render(&report, &no_sources(), false, Duration::from_millis(1)); - assert!(text.contains("· ghost (not run)")); + assert!(text.contains("⊘ ghost")); assert_eq!(exit_code(&report.rows, false), 1); } @@ -729,8 +995,8 @@ mod tests { assert_eq!(exit_code(&report.rows, true), 0); let text = render(&report, &no_sources(), false, Duration::from_millis(1)); - assert!(text.contains("· filtered (not run)")); - assert!(text.contains("1 passed, 1 not run")); + assert!(text.contains("⊘ filtered")); + assert!(text.contains("1 passed · 1 not run")); } #[test] diff --git a/crates/cli/src/output.rs b/crates/cli/src/output.rs index 254f84c3..f2e4a310 100644 --- a/crates/cli/src/output.rs +++ b/crates/cli/src/output.rs @@ -149,29 +149,16 @@ pub fn capitalize_first(s: &str) -> String { } } -pub fn print_preview_notice() { +pub fn print_preview_notice(feature: &str, plural: bool) { eprintln!(); + let verb = if plural { "are" } else { "is" }; if use_color() { eprintln!( - " ! Support for third-party Go dependencies is in {}", + " ! {feature} {verb} in {} · Bug reports are welcome", "early preview".yellow().underline() ); } else { - eprintln!(" ! Support for third-party Go dependencies is in early preview"); - } - eprintln!(" ! Bug reports are welcome: https://github.com/ivov/lisette/issues"); - eprintln!(); -} - -pub fn print_test_unfinished_notice() { - eprintln!(); - if use_color() { - eprintln!( - " ! Test runner under {}, not ready for use", - "active development".yellow().underline() - ); - } else { - eprintln!(" ! Test runner under active development, not ready for use"); + eprintln!(" ! {feature} {verb} in early preview · Bug reports are welcome"); } } diff --git a/crates/diagnostics/src/attribute.rs b/crates/diagnostics/src/attribute.rs index 3ff15475..ffd0e293 100644 --- a/crates/diagnostics/src/attribute.rs +++ b/crates/diagnostics/src/attribute.rs @@ -143,8 +143,10 @@ pub fn test_invalid_argument(attribute_span: &Span) -> LisetteDiagnostic { pub fn test_unsupported_signature(name_span: &Span) -> LisetteDiagnostic { LisetteDiagnostic::error("Unsupported test function signature") .with_attribute_code("test_unsupported_signature") - .with_span_label(name_span, "must take no parameters and return nothing") - .with_help("Write the test as `fn name() { ... }`") + .with_span_label(name_span, "this test signature is not supported") + .with_help( + "A test is `fn name()` or `fn name(t: TestContext)`, optionally returning `Result<(), error>`.", + ) } pub fn equality_on_tuple_struct(attribute_span: &Span) -> LisetteDiagnostic { diff --git a/crates/emit/src/expressions/top_items.rs b/crates/emit/src/expressions/top_items.rs index 7da98761..af1c8c46 100644 --- a/crates/emit/src/expressions/top_items.rs +++ b/crates/emit/src/expressions/top_items.rs @@ -127,9 +127,15 @@ impl Planner<'_> { let test_name = go_name::go_test_function_name(name); let code = self.emit_function(function, None, is_public, fx); + let span = function.name_span; + let recover = format!( + "defer {test_kit}.Recover(t, {}, {}, {})", + span.file_id, + span.byte_offset, + span.byte_offset + span.byte_length, + ); let call = format!("{callee}({test_kit}.New(t))"); let body = if function.return_type.is_result() { - let span = function.name_span; format!( "if err := {call}; err != nil {{\n\t\t{test_kit}.Fail(t, {}, {}, {}, \"result_err\", \"test returned Err\", {test_kit}.ErrOperand(err))\n\t}}", span.file_id, @@ -139,7 +145,7 @@ impl Planner<'_> { } else { call }; - let wrapper = format!("func {test_name}(t *{testing}.T) {{\n\t{body}\n}}"); + let wrapper = format!("func {test_name}(t *{testing}.T) {{\n\t{recover}\n\t{body}\n}}"); format!("{doc_comment}{code}\n\n{wrapper}") } diff --git a/crates/emit/src/patterns/sites.rs b/crates/emit/src/patterns/sites.rs index b7370485..62e7e980 100644 --- a/crates/emit/src/patterns/sites.rs +++ b/crates/emit/src/patterns/sites.rs @@ -367,9 +367,9 @@ impl Planner<'_> { .current_test_handle() .expect("let assert without a test handle should be rejected by semantics"); fx.require_testkit(); - fx.require_fmt(); + fx.require_stdlib(); let testkit = go_name::GeneratedPackage::TestKit.qualifier(); - let fmt = go_name::GeneratedPackage::Fmt.qualifier(); + let prelude = go_name::GeneratedPackage::Prelude.qualifier(); self.scope.record_go_use(subject_var); let (file, lo, hi) = ( span.file_id, @@ -377,7 +377,7 @@ impl Planner<'_> { span.byte_offset + span.byte_length, ); let call = format!( - "{handle}.FailAssert({file}, {lo}, {hi}, \"let_assert\", \"pattern did not match\", {testkit}.Operand{{Value: {fmt}.Sprintf(\"%v\", {subject_var})}})\n" + "{handle}.FailAssert({file}, {lo}, {hi}, \"let_assert\", \"pattern did not match\", {testkit}.Operand{{Value: {prelude}.Debug({subject_var})}})\n" ); LoweredBlock { statements: vec![LoweredStatement::RawGo(call)], diff --git a/crates/emit/src/plan/lower.rs b/crates/emit/src/plan/lower.rs index 0f7505ca..3402dd26 100644 --- a/crates/emit/src/plan/lower.rs +++ b/crates/emit/src/plan/lower.rs @@ -486,9 +486,11 @@ impl Planner<'_> { fx: &mut EmitEffects, ) -> AssertShape { fx.require_fmt(); - let (test_kit, fmt) = ( + fx.require_stdlib(); + let (test_kit, fmt, prelude) = ( go_name::GeneratedPackage::TestKit.qualifier(), go_name::GeneratedPackage::Fmt.qualifier(), + go_name::GeneratedPackage::Prelude.qualifier(), ); let lhs = self.stage_assert_operand(left, "assertLeft", statements, fx); let rhs = self.stage_assert_operand(right, "assertRight", statements, fx); @@ -508,13 +510,12 @@ impl Planner<'_> { condition, kind: "relation", operands: format!( - ", {test_kit}.Operand{{Value: {fmt}.Sprintf(\"left: %v, right: %v\", {lhs}, {rhs})}}" + ", {test_kit}.Operand{{Value: {fmt}.Sprintf(\"left: %s · right: %s\", {prelude}.Debug({lhs}), {prelude}.Debug({rhs}))}}" ), } } - /// `assert recv.equals(arg)`: compare via the canonical equals lowering, - /// reporting each operand by its source text and value. + /// `assert recv.equals(arg)`: compare via the canonical equals lowering, reporting `left`/`right`. fn lower_labeled_assert( &mut self, recv: &Expression, @@ -522,13 +523,12 @@ impl Planner<'_> { statements: &mut Vec, fx: &mut EmitEffects, ) -> AssertShape { - fx.require_fmt(); - let (test_kit, fmt) = ( + fx.require_stdlib(); + let (test_kit, prelude) = ( go_name::GeneratedPackage::TestKit.qualifier(), - go_name::GeneratedPackage::Fmt.qualifier(), + go_name::GeneratedPackage::Prelude.qualifier(), ); let recv_ty = recv.get_type(); - let (recv_span, arg_span) = (recv.get_span(), arg.get_span()); let lhs = self.stage_assert_operand(recv, "assertLeft", statements, fx); let rhs = self.stage_assert_operand(arg, "assertRight", statements, fx); let condition = self.render_equality(&lhs, &rhs, &recv_ty, fx); @@ -536,11 +536,7 @@ impl Planner<'_> { condition, kind: "labeled", operands: format!( - ", {test_kit}.Operand{{Label: \"left\", Value: {fmt}.Sprintf(\"%v\", {lhs}), Lo: {}, Hi: {}}}, {test_kit}.Operand{{Label: \"right\", Value: {fmt}.Sprintf(\"%v\", {rhs}), Lo: {}, Hi: {}}}", - recv_span.byte_offset, - recv_span.byte_offset + recv_span.byte_length, - arg_span.byte_offset, - arg_span.byte_offset + arg_span.byte_length, + ", {test_kit}.Operand{{Label: \"left\", Value: {prelude}.Debug({lhs})}}, {test_kit}.Operand{{Label: \"right\", Value: {prelude}.Debug({rhs})}}" ), } } diff --git a/crates/stdlib/test_prelude.d.lis b/crates/stdlib/test_prelude.d.lis index 5459103b..4667de18 100644 --- a/crates/stdlib/test_prelude.d.lis +++ b/crates/stdlib/test_prelude.d.lis @@ -2,8 +2,8 @@ pub type TestContext impl TestContext { - /// Run `body` as a named subtest. Returns whether it succeeded. - pub fn run(self, name: string, body: fn(TestContext) -> ()) -> bool + /// Run `body` as a named subtest. + pub fn run(self, name: string, body: fn(TestContext) -> ()) /// Signal that this test may run in parallel with other parallel tests. pub fn parallel(self) diff --git a/prelude/debug.go b/prelude/debug.go new file mode 100644 index 00000000..6a8e93bb --- /dev/null +++ b/prelude/debug.go @@ -0,0 +1,23 @@ +package lisette + +import ( + "fmt" + "strconv" +) + +// Debugger renders a value for diagnostics, quoting nested strings (unlike Stringer's display form). +type Debugger interface { + DebugString() string +} + +// Debug renders v for a diagnostic: strings quoted, a Debugger via DebugString, else display form. +func Debug(v any) string { + switch x := v.(type) { + case string: + return strconv.Quote(x) + case Debugger: + return x.DebugString() + default: + return fmt.Sprintf("%v", x) + } +} diff --git a/prelude/option.go b/prelude/option.go index 1aea4f75..452acca3 100644 --- a/prelude/option.go +++ b/prelude/option.go @@ -104,6 +104,13 @@ func (opt Option[T]) String() string { return "None" } +func (opt Option[T]) DebugString() string { + if opt.Tag == OptionSome { + return fmt.Sprintf("Some(%s)", Debug(opt.SomeVal)) + } + return "None" +} + func (opt Option[T]) IsZero() bool { return opt.Tag == OptionNone } diff --git a/prelude/result.go b/prelude/result.go index 8608c625..a28f1fd5 100644 --- a/prelude/result.go +++ b/prelude/result.go @@ -66,6 +66,13 @@ func (res Result[T, E]) String() string { return fmt.Sprintf("Err(%v)", res.ErrVal) } +func (res Result[T, E]) DebugString() string { + if res.Tag == ResultOk { + return fmt.Sprintf("Ok(%s)", Debug(res.OkVal)) + } + return fmt.Sprintf("Err(%s)", Debug(res.ErrVal)) +} + func ResultMap[T any, U any, E any](res Result[T, E], f func(T) U) Result[U, E] { if res.Tag == ResultOk { return Result[U, E]{Tag: ResultOk, OkVal: f(res.OkVal)} diff --git a/prelude/testkit/testkit.go b/prelude/testkit/testkit.go index 66992af7..8a0bd763 100644 --- a/prelude/testkit/testkit.go +++ b/prelude/testkit/testkit.go @@ -21,8 +21,8 @@ func New(t *testing.T) TestContext { } // Run runs body as a named subtest, re-wrapping the subtest's *testing.T. -func (c TestContext) Run(name string, body func(TestContext)) bool { - return c.t.Run(name, func(inner *testing.T) { +func (c TestContext) Run(name string, body func(TestContext)) { + c.t.Run(name, func(inner *testing.T) { body(TestContext{t: inner}) }) } @@ -32,6 +32,14 @@ func (c TestContext) Parallel() { c.t.Parallel() } +// Recover turns an uncaught panic into a Lisette failure anchored at the test, so it reports like an +// assertion instead of dumping a Go stack. Test wrappers defer this. +func Recover(t *testing.T, file int, lo, hi uint32) { + if r := recover(); r != nil { + Fail(t, file, lo, hi, "panic", fmt.Sprintf("panic: %v", r)) + } +} + // FailAssert reports an `assert` failure over the same channel as Fail. func (c TestContext) FailAssert(file int, lo, hi uint32, kind, message string, operands ...Operand) { Fail(c.t, file, lo, hi, kind, message, operands...) diff --git a/tests/spec/build/mod.rs b/tests/spec/build/mod.rs index 2bbbe748..8da4536f 100644 --- a/tests/spec/build/mod.rs +++ b/tests/spec/build/mod.rs @@ -7510,8 +7510,8 @@ fn assert_lowers_to_decomposed_failure_call() { "a comparison `assert` must report a relation, got:\n{go}" ); assert!( - go.contains("left: %v, right: %v"), - "a relation `assert` must format both operands, got:\n{go}" + go.contains("left: %s · right: %s") && go.contains("lisette.Debug("), + "a relation `assert` must format both operands through Debug, got:\n{go}" ); assert!( go.contains("\"bare\""), @@ -7553,10 +7553,6 @@ fn assert_equals_lowers_to_labeled_failure() { go.contains("Label: \"left\"") && go.contains("Label: \"right\""), "a labeled record must carry both operands, got:\n{go}" ); - assert!( - go.contains("Lo:") && go.contains("Hi:"), - "labeled operands must carry source spans, got:\n{go}" - ); } #[test] @@ -7744,7 +7740,7 @@ fn let_assert_lowers_to_failure_on_mismatch() { "a `let assert` mismatch must report through the test channel, got:\n{go}" ); assert!( - go.contains("fmt.Sprintf(\"%v\","), + go.contains("lisette.Debug("), "a `let assert` failure must include the actual value, got:\n{go}" ); }