From ff44dd974daee32237235e3d9627e7a4c2820024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 19 Jun 2026 23:05:21 +0200 Subject: [PATCH] feat: render failing `#[test]` results as source diagnostics --- crates/cli/src/go_cli.rs | 3 + crates/cli/src/handlers/build.rs | 5 +- crates/cli/src/handlers/test/mod.rs | 5 +- crates/cli/src/handlers/test/report.rs | 277 ++++++++++++++++-- crates/cli/src/pipeline.rs | 5 +- crates/diagnostics/src/render.rs | 20 ++ crates/emit/src/expressions/top_items.rs | 18 +- .../checker/registration/test_functions.rs | 19 +- prelude/testkit/testkit.go | 67 ++++- tests/ui/errors/mod.rs | 42 +++ 10 files changed, 430 insertions(+), 31 deletions(-) diff --git a/crates/cli/src/go_cli.rs b/crates/cli/src/go_cli.rs index fa4ee3346..b97de2143 100644 --- a/crates/cli/src/go_cli.rs +++ b/crates/cli/src/go_cli.rs @@ -535,6 +535,9 @@ pub struct GoTestEvent { pub output: Option, /// `build-*` events name the package here, not in `package`. pub import_path: Option, + /// `attr` events carry a key/value. + pub key: Option, + pub value: Option, } pub struct TestRun { diff --git a/crates/cli/src/handlers/build.rs b/crates/cli/src/handlers/build.rs index b6c6728a8..800d28c03 100644 --- a/crates/cli/src/handlers/build.rs +++ b/crates/cli/src/handlers/build.rs @@ -9,7 +9,7 @@ use crate::lock::acquire_target_lock; use crate::workspace::WorkspaceBindgen; use diagnostics::render::{self, Filter}; use lisette::fs::{LocalFileSystem, prune_orphan_go_files}; -use lisette::pipeline::{CompileConfig, CompilePhase, TestIndex, compile}; +use lisette::pipeline::{CompileConfig, CompilePhase, Sources, TestIndex, compile}; pub fn emit(path: Option, sourcemap: bool) -> i32 { with_locked_project(path, |prep| { @@ -167,6 +167,7 @@ pub(super) struct BuildOptions { pub(super) struct BuildOutcome { pub code: i32, pub test_index: TestIndex, + pub sources: Sources, } impl BuildOutcome { @@ -174,6 +175,7 @@ impl BuildOutcome { Self { code, test_index: TestIndex::default(), + sources: Sources::default(), } } } @@ -427,6 +429,7 @@ pub(super) fn build_locked(prep: &BuildPrep, options: BuildOptions) -> BuildOutc BuildOutcome { code: 0, test_index: result.test_index, + sources: result.sources, } } diff --git a/crates/cli/src/handlers/test/mod.rs b/crates/cli/src/handlers/test/mod.rs index 9947c4c57..68f1fa364 100644 --- a/crates/cli/src/handlers/test/mod.rs +++ b/crates/cli/src/handlers/test/mod.rs @@ -51,7 +51,10 @@ pub fn test(path: Option, go_flags: Vec) -> i32 { &run.events, &prep.manifest.project.name, ); - eprint!("{}", render(&report, use_color(), started.elapsed())); + eprint!( + "{}", + render(&report, &outcome.sources, use_color(), started.elapsed()) + ); let build_error = report.build_output.trim(); if !build_error.is_empty() { diff --git a/crates/cli/src/handlers/test/report.rs b/crates/cli/src/handlers/test/report.rs index c92eb3e6f..da2b8db1a 100644 --- a/crates/cli/src/handlers/test/report.rs +++ b/crates/cli/src/handlers/test/report.rs @@ -3,10 +3,18 @@ use std::time::Duration; use owo_colors::OwoColorize; use semantics::store::ENTRY_MODULE_ID; +use serde::Deserialize; +use syntax::ast::Span; use crate::go_cli::GoTestEvent; use crate::output::format_elapsed; -use lisette::pipeline::TestIndex; +use diagnostics::LisetteDiagnostic; +use lisette::pipeline::{Sources, TestIndex}; + +/// Per (package, test): expected chunk count `n` and the gathered `(index, hex)` chunks. +type FailChunks = HashMap<(String, String), (usize, Vec<(usize, String)>)>; + +const FAIL_ATTR_KEY: &str = "lisette-fail"; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Status { @@ -16,12 +24,37 @@ pub enum Status { NotRun, } +/// One framed chunk of a failure record: `d` concatenated over `i in 0..n`. +#[derive(Deserialize)] +struct FailEnvelope { + i: usize, + n: usize, + d: String, +} + +#[derive(Deserialize, Clone)] +struct Operand { + label: String, + value: String, +} + +#[derive(Deserialize, Clone)] +pub struct FailureRecord { + file: u32, + lo: u32, + hi: u32, + message: String, + #[serde(default)] + operands: Vec, +} + pub struct TestRow { pub package: String, pub name: String, pub status: Status, pub elapsed: Option, pub output: String, + pub failure: Option, pub children: Vec, } @@ -41,8 +74,21 @@ pub fn build_report(index: &TestIndex, events: &[GoTestEvent], go_module: &str) let mut package_output: HashMap = HashMap::new(); let mut failed_packages: HashSet = HashSet::new(); let mut build_failed_packages: HashSet = HashSet::new(); + let mut fail_chunks: FailChunks = HashMap::new(); for event in events { + if event.action == "attr" + && event.key.as_deref() == Some(FAIL_ATTR_KEY) + && let (Some(test), Some(value)) = (&event.test, &event.value) + && let Ok(envelope) = serde_json::from_str::(value) + { + let entry = fail_chunks + .entry((event.package.clone(), test.clone())) + .or_insert((envelope.n, Vec::new())); + entry.0 = envelope.n; + entry.1.push((envelope.i, envelope.d)); + continue; + } let Some(test) = &event.test else { match event.action.as_str() { "build-output" => { @@ -93,6 +139,8 @@ pub fn build_report(index: &TestIndex, events: &[GoTestEvent], go_module: &str) } } + let failures = reassemble_failures(fail_chunks); + let mut rows = Vec::new(); for test in index.tests() { let prefix = format!("{}.", test.module_id); @@ -112,13 +160,15 @@ pub fn build_report(index: &TestIndex, events: &[GoTestEvent], go_module: &str) None if started.contains(&key) => (Status::Aborted, None), None => (Status::NotRun, None), }; - let children = collect_children(&package, &go_name, &terminal, &started, &outputs); + let children = + collect_children(&package, &go_name, &terminal, &started, &outputs, &failures); rows.push(TestRow { package, name: fn_name.to_string(), status, elapsed, output: outputs.get(&key).cloned().unwrap_or_default(), + failure: failures.get(&key).cloned(), children, }); } @@ -135,12 +185,53 @@ fn go_test_name(fn_name: &str) -> String { emit::go_test_function_name(fn_name) } +/// Requires every index `0..n`; a missing chunk drops to raw output, not a truncated diagnostic. +fn reassemble_failures(chunks: FailChunks) -> HashMap<(String, String), FailureRecord> { + chunks + .into_iter() + .filter_map(|(key, (n, parts))| reassemble_one(n, parts).map(|record| (key, record))) + .collect() +} + +fn reassemble_one(n: usize, parts: Vec<(usize, String)>) -> Option { + if n == 0 { + return None; + } + let mut slots: Vec> = vec![None; n]; + for (i, d) in parts { + *slots.get_mut(i)? = Some(d); + } + let mut joined = String::new(); + for slot in slots { + joined.push_str(&slot?); + } + let bytes = decode_hex(&joined)?; + serde_json::from_slice::(&bytes).ok() +} + +fn decode_hex(hex: &str) -> Option> { + if !hex.len().is_multiple_of(2) { + return None; + } + let nibble = |b: u8| match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + }; + hex.as_bytes() + .chunks_exact(2) + .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?)) + .collect() +} + fn collect_children( package: &str, parent: &str, terminal: &HashMap<(String, String), (Status, Option)>, started: &HashSet<(String, String)>, outputs: &HashMap<(String, String), String>, + failures: &HashMap<(String, String), FailureRecord>, ) -> Vec { let prefix = format!("{parent}/"); let mut direct: Vec<&String> = terminal @@ -169,7 +260,8 @@ fn collect_children( status, elapsed, output: outputs.get(&key).cloned().unwrap_or_default(), - children: collect_children(package, full, terminal, started, outputs), + failure: failures.get(&key).cloned(), + children: collect_children(package, full, terminal, started, outputs, failures), } }) .collect() @@ -182,7 +274,7 @@ fn package_of_import_path(import_path: &str) -> &str { .map_or(import_path, |(pkg, _)| pkg) } -pub fn render(report: &Report, color: bool, total: Duration) -> String { +pub fn render(report: &Report, sources: &Sources, color: bool, total: Duration) -> String { let mut out = String::from("\n"); if report.rows.is_empty() { @@ -198,7 +290,7 @@ pub fn render(report: &Report, color: bool, total: Duration) -> String { 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, " ", color); + render_rows(&mut out, &group, " ", sources, color); // Crash before any test ran (init/`TestMain` panic): cause is package-level only. if !report.build_failed_packages.contains(package) @@ -222,7 +314,7 @@ pub fn render(report: &Report, color: bool, total: Duration) -> String { out } -fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, color: bool) { +fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, sources: &Sources, color: bool) { for (i, row) in rows.iter().enumerate() { let last = i + 1 == rows.len(); let branch = if last { "└── " } else { "├── " }; @@ -249,7 +341,15 @@ fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, color: bool) { let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " }); - if matches!(row.status, Status::Failed | Status::Aborted) { + if let Some(block) = 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() { @@ -261,10 +361,31 @@ fn render_rows(out: &mut String, rows: &[&TestRow], prefix: &str, color: bool) { } let children: Vec<&TestRow> = row.children.iter().collect(); - render_rows(out, &children, &child_prefix, color); + render_rows(out, &children, &child_prefix, sources, color); } } +fn render_failure(record: &FailureRecord, sources: &Sources, color: bool) -> Option { + let info = sources.get(&record.file)?; + let span = Span::new(record.file, record.lo, record.hi.saturating_sub(record.lo)); + let label = record + .operands + .first() + .map(|operand| operand.value.clone()) + .unwrap_or_else(|| record.message.clone()); + let mut diagnostic = + LisetteDiagnostic::error(record.message.clone()).with_span_primary_label(&span, label); + for operand in record.operands.iter().skip(1) { + diagnostic = diagnostic.with_note(format!("{}: {}", operand.label, operand.value)); + } + Some(diagnostics::render::render_to_string( + &diagnostic, + &info.source, + &info.filename, + color, + )) +} + fn summary(rows: &[TestRow], total: Duration) -> String { let passed = rows.iter().filter(|r| r.status == Status::Passed).count(); let failed = rows.iter().filter(|r| r.status == Status::Failed).count(); @@ -319,6 +440,7 @@ fn dim(text: &str, color: bool) -> String { #[cfg(test)] mod tests { use super::*; + use lisette::pipeline::SourceInfo; use syntax::ast::Span; use syntax::program::TestFunction; @@ -348,6 +470,8 @@ mod tests { elapsed: Some(0.003), output: output.map(str::to_string), import_path: None, + key: None, + value: None, } } @@ -359,9 +483,36 @@ mod tests { elapsed: None, output: Some(output.to_string()), import_path: Some(format!("{package} [{package}.test]")), + key: None, + value: None, } } + fn attr_event(package: &str, test: &str, value: &str) -> GoTestEvent { + GoTestEvent { + action: "attr".to_string(), + package: package.to_string(), + test: Some(test.to_string()), + elapsed: None, + output: None, + import_path: None, + key: Some(FAIL_ATTR_KEY.to_string()), + value: Some(value.to_string()), + } + } + + fn no_sources() -> Sources { + Sources::default() + } + + fn hex_encode(s: &str) -> String { + s.bytes().map(|b| format!("{b:02x}")).collect() + } + + fn fail_value(inner_json: &str) -> String { + format!(r#"{{"i":0,"n":1,"d":"{}"}}"#, hex_encode(inner_json)) + } + #[test] fn all_pass_groups_by_package() { let index = index(&[(ENTRY_MODULE_ID, "root_smoke"), ("math", "adds_numbers")]); @@ -370,7 +521,7 @@ mod tests { event("pass", "demo/math", Some("TestAddsNumbers"), None), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(7)); + let text = render(&report, &no_sources(), false, Duration::from_millis(7)); assert!(text.contains(" demo\n")); assert!(text.contains("✓ root_smoke")); @@ -394,7 +545,7 @@ mod tests { assert_eq!(report.rows[0].children.len(), 1); assert_eq!(report.rows[0].children[0].name, "alpha"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); let parent_line = text.lines().position(|l| l.contains("parent")).unwrap(); let child_line = text.lines().position(|l| l.contains("alpha")).unwrap(); assert!(child_line > parent_line, "subtest renders under its parent"); @@ -422,7 +573,7 @@ mod tests { event("fail", "demo", Some("TestParent"), None), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!( !text.contains("✗ parent") && !text.contains("✓ parent"), @@ -457,7 +608,7 @@ mod tests { assert_eq!(inner.name, "inner"); assert_eq!(inner.status, Status::Failed); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!(text.contains("boom")); assert_eq!(exit_code(&report.rows, false), 1); } @@ -470,7 +621,7 @@ mod tests { event("output", "demo", Some("TestBoom"), Some("panic: boom\n")), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(2)); + let text = render(&report, &no_sources(), false, Duration::from_millis(2)); assert!(text.contains("✗ boom")); assert!(text.contains("panic: boom")); @@ -482,7 +633,7 @@ mod tests { fn declared_test_with_no_event_is_not_run() { let index = index(&[(ENTRY_MODULE_ID, "ghost")]); let report = build_report(&index, &[], "demo"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!(text.contains("· ghost (not run)")); assert_eq!(exit_code(&report.rows, false), 1); @@ -495,7 +646,7 @@ mod tests { let report = build_report(&index, &events, "demo"); assert_eq!(exit_code(&report.rows, true), 0); - let text = render(&report, false, Duration::from_millis(1)); + 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")); } @@ -532,7 +683,7 @@ mod tests { event("fail", "demo/b", None, None), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!(report.build_output.contains("undefined: foo")); assert!(text.contains("panic: boom in b")); @@ -564,7 +715,7 @@ mod tests { ), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!(text.contains("✗ hangs (aborted)")); assert!(text.contains("panic: test timed out")); @@ -581,7 +732,7 @@ mod tests { event("fail", "demo", None, None), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!(report.rows.iter().all(|r| r.status == Status::NotRun)); assert!(text.contains("panic: init blew up")); @@ -599,7 +750,7 @@ mod tests { event("fail", "demo/b", None, None), ]; let report = build_report(&index, &events, "demo"); - let text = render(&report, false, Duration::from_millis(1)); + let text = render(&report, &no_sources(), false, Duration::from_millis(1)); assert!(text.contains("panic: boom in b")); } @@ -614,8 +765,94 @@ mod tests { #[test] fn empty_index_reports_no_tests() { let report = build_report(&TestIndex::default(), &[], "demo"); - let text = render(&report, false, Duration::from_millis(0)); + let text = render(&report, &no_sources(), false, Duration::from_millis(0)); assert!(text.contains("No tests found")); assert_eq!(exit_code(&report.rows, true), 0); } + + #[test] + fn failure_record_reassembles_and_attaches() { + let index = index(&[(ENTRY_MODULE_ID, "parses")]); + let inner = r#"{"file":7,"lo":3,"hi":9,"message":"test returned Err","operands":[{"label":"error","value":"boom"}]}"#; + let events = vec![ + event("run", "demo", Some("TestParses"), None), + attr_event("demo", "TestParses", &fail_value(inner)), + event("fail", "demo", Some("TestParses"), None), + ]; + let report = build_report(&index, &events, "demo"); + let record = report.rows[0] + .failure + .as_ref() + .expect("a lisette-fail record must attach to the failing test"); + assert_eq!(record.file, 7); + assert_eq!((record.lo, record.hi), (3, 9)); + assert_eq!(record.operands[0].value, "boom"); + assert_eq!(exit_code(&report.rows, false), 1); + } + + #[test] + fn failure_record_reassembles_from_out_of_order_chunks() { + let index = index(&[(ENTRY_MODULE_ID, "big")]); + let inner = r#"{"file":1,"lo":0,"hi":3,"message":"test returned Err","operands":[{"label":"error","value":"日本語"}]}"#; + let hex = hex_encode(inner); + let (first, second) = hex.split_at(hex.len() / 2); + let events = vec![ + attr_event( + "demo", + "TestBig", + &format!(r#"{{"i":1,"n":2,"d":"{second}"}}"#), + ), + attr_event( + "demo", + "TestBig", + &format!(r#"{{"i":0,"n":2,"d":"{first}"}}"#), + ), + event("fail", "demo", Some("TestBig"), None), + ]; + let report = build_report(&index, &events, "demo"); + let record = report.rows[0] + .failure + .as_ref() + .expect("two chunks must reassemble into one record"); + assert_eq!(record.operands[0].value, "日本語"); + } + + #[test] + fn missing_chunk_yields_no_record() { + let index = index(&[(ENTRY_MODULE_ID, "big")]); + let events = vec![ + attr_event("demo", "TestBig", r#"{"i":0,"n":3,"d":"7b"}"#), + attr_event("demo", "TestBig", r#"{"i":2,"n":3,"d":"7d"}"#), + event("fail", "demo", Some("TestBig"), None), + ]; + let report = build_report(&index, &events, "demo"); + assert!( + report.rows[0].failure.is_none(), + "an incomplete record must not produce a (truncated) diagnostic" + ); + } + + #[test] + fn failure_renders_spanned_block_when_source_known() { + let index = index(&[(ENTRY_MODULE_ID, "parses")]); + let inner = r#"{"file":7,"lo":3,"hi":9,"message":"test returned Err","operands":[{"label":"error","value":"boom"}]}"#; + let events = vec![ + attr_event("demo", "TestParses", &fail_value(inner)), + event("fail", "demo", Some("TestParses"), None), + ]; + let report = build_report(&index, &events, "demo"); + + let mut sources = no_sources(); + sources.insert( + 7, + SourceInfo { + source: "fn parses() {}\n".to_string(), + filename: "x.test.lis".to_string(), + }, + ); + let text = render(&report, &sources, false, Duration::from_millis(1)); + assert!(text.contains("test returned Err"), "got:\n{text}"); + assert!(text.contains("boom"), "got:\n{text}"); + assert!(text.contains("x.test.lis"), "got:\n{text}"); + } } diff --git a/crates/cli/src/pipeline.rs b/crates/cli/src/pipeline.rs index 41f91db69..fb1fe3bce 100644 --- a/crates/cli/src/pipeline.rs +++ b/crates/cli/src/pipeline.rs @@ -21,6 +21,9 @@ pub struct SourceInfo { pub filename: String, } +/// Per-file source, with key as file_id, for mapping diagnostics back to source text. +pub type Sources = HashMap; + #[derive(Debug, Clone)] pub struct CompileConfig { pub target_phase: CompilePhase, @@ -38,7 +41,7 @@ pub struct CompileResult { pub output: Vec, pub errors: Vec, pub lints: Vec, - pub sources: HashMap, + pub sources: Sources, pub user_file_count: usize, pub live_modules: Vec, pub emit_stamps: Vec, diff --git a/crates/diagnostics/src/render.rs b/crates/diagnostics/src/render.rs index bc8c8cc64..676a3b51d 100644 --- a/crates/diagnostics/src/render.rs +++ b/crates/diagnostics/src/render.rs @@ -143,6 +143,26 @@ fn render( } } +pub fn render_to_string( + diagnostic: &LisetteDiagnostic, + source: &str, + filename: &str, + use_color: bool, +) -> String { + let handler = if use_color { + color_handler(Style::new().red()) + } else { + nocolor_handler() + }; + let report = diagnostic + .clone() + .with_color(use_color) + .with_source_code(IndexedSource::new(source), filename.to_string()); + let mut output = String::new(); + let _ = handler.render_report(&mut output, report.as_ref()); + output +} + fn render_group Option<(String, String)>>( diagnostics: &[&LisetteDiagnostic], highlight: Style, diff --git a/crates/emit/src/expressions/top_items.rs b/crates/emit/src/expressions/top_items.rs index 719c4c866..fbd915dc2 100644 --- a/crates/emit/src/expressions/top_items.rs +++ b/crates/emit/src/expressions/top_items.rs @@ -24,18 +24,30 @@ impl Planner<'_> { if self.facts.is_test(&self.facts.qualified_current(name)) { let callee = self.pick_go_function_name(function, false, is_public); let test_name = go_name::go_test_function_name(name); + let test_kit = go_name::GeneratedPackage::TestKit.qualifier(); fx.require_testing(); let testing = go_name::GeneratedPackage::Testing.qualifier(); let call = if function.params.is_empty() { format!("{callee}()") } else { fx.require_testkit(); + format!("{callee}({test_kit}.New(t))") + }; + let body = if function.return_type.is_result() { + fx.require_testkit(); + let span = function.name_span; + let (file, lo, hi) = ( + span.file_id, + span.byte_offset, + span.byte_offset + span.byte_length, + ); format!( - "{callee}({}.New(t))", - go_name::GeneratedPackage::TestKit.qualifier() + "if err := {call}; err != nil {{\n\t\t{test_kit}.Fail(t, {file}, {lo}, {hi}, \"result_err\", \"test returned Err\", {test_kit}.ErrOperand(err))\n\t}}" ) + } else { + call }; - let wrapper = format!("func {test_name}(t *{testing}.T) {{\n\t{call}\n}}"); + let wrapper = format!("func {test_name}(t *{testing}.T) {{\n\t{body}\n}}"); format!("{doc_comment}{code}\n\n{wrapper}") } else { format!("{}{}", doc_comment, code) diff --git a/crates/semantics/src/checker/registration/test_functions.rs b/crates/semantics/src/checker/registration/test_functions.rs index ed4b12cf0..af7cfdfc6 100644 --- a/crates/semantics/src/checker/registration/test_functions.rs +++ b/crates/semantics/src/checker/registration/test_functions.rs @@ -78,14 +78,29 @@ fn flag_misplaced_methods(methods: &[Expression], sink: &LocalSink) { } } -fn is_unit_return(annotation: &Annotation) -> bool { +fn is_unit_annotation(annotation: &Annotation) -> bool { match annotation { Annotation::Unknown => true, Annotation::Tuple { elements, .. } => elements.is_empty(), + Annotation::Constructor { name, params, .. } => name == "Unit" && params.is_empty(), _ => false, } } +fn is_supported_return(annotation: &Annotation) -> bool { + if is_unit_annotation(annotation) { + return true; + } + matches!( + annotation, + Annotation::Constructor { name, params, .. } + if name == "Result" + && params.len() == 2 + && is_unit_annotation(¶ms[0]) + && matches!(¶ms[1], Annotation::Constructor { name, .. } if name == "error") + ) +} + fn module_shadows_test_context(store: &Store, module_id: &str) -> bool { let qualified = format!("{module_id}.TestContext"); store @@ -152,7 +167,7 @@ fn collect_test_candidates( }; if !generics.is_empty() || !params_supported(params, context_shadowed) - || !is_unit_return(return_annotation) + || !is_supported_return(return_annotation) { sink.push(diagnostics::attribute::test_unsupported_signature( name_span, diff --git a/prelude/testkit/testkit.go b/prelude/testkit/testkit.go index 3d8b831b3..0b2bd4391 100644 --- a/prelude/testkit/testkit.go +++ b/prelude/testkit/testkit.go @@ -1,8 +1,12 @@ -// Package testkit backs Lisette's test-only TestContext. It is imported only by -// generated *_test.go files, so `go build` never pulls testing into production. +// Package testkit backs Lisette's TestContext, kept separate so production builds never import testing. package testkit -import "testing" +import ( + "encoding/hex" + "encoding/json" + "fmt" + "testing" +) // TestContext wraps *testing.T. The field is unexported so Lisette code reaches // the handle only through the methods below. @@ -27,3 +31,60 @@ func (c TestContext) Run(name string, body func(TestContext)) bool { func (c TestContext) Parallel() { c.t.Parallel() } + +type Operand struct { + Label string `json:"label"` + Value string `json:"value"` +} + +func ErrOperand(value any) Operand { + return Operand{Label: "error", Value: fmt.Sprintf("%v", value)} +} + +type failRecord struct { + File int `json:"file"` + Lo uint32 `json:"lo"` + Hi uint32 `json:"hi"` + Kind string `json:"kind"` + Message string `json:"message"` + Operands []Operand `json:"operands"` +} + +// failEnvelope frames one chunk; `lis` orders by I and concatenates D over 0..N. +type failEnvelope struct { + I int `json:"i"` + N int `json:"n"` + D string `json:"d"` +} + +// Fail reports a test failure to `lis` over the t.Attr channel. The payload is hex +// (not raw JSON) so a chunk boundary never splits a UTF-8 rune and the chunk needs no +// JSON escaping, keeping each attr line's length predictable. +func Fail(t *testing.T, file int, lo, hi uint32, kind, message string, operands ...Operand) { + inner, err := json.Marshal(failRecord{file, lo, hi, kind, message, operands}) + if err != nil { + t.Fatalf("lisette: failed to encode failure record: %v", err) + return + } + payload := hex.EncodeToString(inner) + + // test2json drops a framing line over 4096 bytes; size the hex chunk so the whole + // `=== ATTR lisette-fail ` line stays under it, with margin for + // the envelope skeleton and the i/n digits. + budget := max(4096-64-len(t.Name()), 256) + chunks := (len(payload) + budget - 1) / budget + if chunks == 0 { + chunks = 1 + } + for i := 0; i < chunks; i++ { + start := i * budget + end := min(start+budget, len(payload)) + env, err := json.Marshal(failEnvelope{I: i, N: chunks, D: payload[start:end]}) + if err != nil { + t.Fatalf("lisette: failed to encode failure envelope: %v", err) + return + } + t.Attr("lisette-fail", string(env)) + } + t.FailNow() +} diff --git a/tests/ui/errors/mod.rs b/tests/ui/errors/mod.rs index 00f0715c3..edf460c33 100644 --- a/tests/ui/errors/mod.rs +++ b/tests/ui/errors/mod.rs @@ -3874,6 +3874,48 @@ fn test_attribute_with_return_rejected() { ); } +#[test] +fn test_attribute_with_result_unit_error_return_accepted() { + let fs = test_attribute_fs( + "pub fn add(a: int, b: int) -> int { a + b }", + "import \"go:errors\"\n\n#[test]\nfn checks() -> Result<(), error> { Err(errors.New(\"x\")) }", + ); + let result = infer_module("_entry_", fs); + assert!( + !has_code(&result, "test_unsupported_signature"), + "a `Result<(), error>` test must be accepted, got: {:?}", + result.errors + ); +} + +#[test] +fn test_attribute_with_non_error_result_return_rejected() { + let fs = test_attribute_fs( + "pub fn add(a: int, b: int) -> int { a + b }", + "struct MyErr {}\n\n#[test]\nfn checks() -> Result<(), MyErr> { Err(MyErr {}) }", + ); + let result = infer_module("_entry_", fs); + assert!( + has_code(&result, "test_unsupported_signature"), + "a non-`error` Result test is deferred and must be rejected, got: {:?}", + result.errors + ); +} + +#[test] +fn local_error_type_shadow_rejected() { + let fs = test_attribute_fs( + "pub fn add(a: int, b: int) -> int { a + b }", + "interface error { fn code() -> int }\n\n#[test]\nfn checks() -> Result<(), error> { Ok(()) }", + ); + let result = infer_module("_entry_", fs); + assert!( + has_code(&result, "prelude_type_shadowed"), + "shadowing the prelude `error` type must be rejected, got: {:?}", + result.errors + ); +} + #[test] fn test_attribute_with_generics_rejected() { let fs = test_attribute_fs(