From f78e535995eb2f68ea97314876f1cc0625efc648 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Tue, 8 Sep 2026 17:01:07 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(analyser):=20explain=20augmented=20assi?= =?UTF-8?q?gnment=20type=20mismatches=20=F0=9F=94=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_analyser/src/analyser.rs | 75 ++++++++++++++++++++++++- ndc_bin/src/diagnostic.rs | 84 +++++++++++++++++++++++++-- ndc_lsp/src/backend.rs | 2 +- ndc_lsp/src/diagnostics.rs | 106 ++++++++++++++++++++++++++++++++--- ndc_parser/src/expression.rs | 2 + ndc_parser/src/parser.rs | 1 + 6 files changed, 256 insertions(+), 14 deletions(-) diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index 37f62942..7433a9df 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -341,6 +341,7 @@ impl Analyser { } Expression::OpAssignment { l_value, + l_value_span, r_value, operation, plan, @@ -402,10 +403,12 @@ impl Analyser { // change the concrete left type. Reject it here rather // than falling through to an ordinary operation whose // erased return type could widen the same target. - self.emit(AnalysisError::mismatched_types( + self.emit(AnalysisError::augmented_operand_mismatch( &right_type, &left_type, - *span, + r_value.span, + *l_value_span, + operation, )); *plan = AugmentedAssignmentPlan::Unresolved; None @@ -1270,6 +1273,8 @@ pub struct AnalysisError { text: String, span: Span, help_text: Option, + primary_label: Option, + related_labels: Vec<(Span, String)>, } impl AnalysisError { @@ -1283,11 +1288,47 @@ impl AnalysisError { self.help_text.as_deref() } + /// Label for the primary span, when the error can identify its role. + pub fn primary_label(&self) -> Option<&str> { + self.primary_label.as_deref() + } + + /// Other source locations that explain the primary error. + pub fn related_labels(&self) -> &[(Span, String)] { + &self.related_labels + } + + fn augmented_operand_mismatch( + found: &StaticType, + expected: &StaticType, + right_span: Span, + left_span: Span, + operation: &str, + ) -> Self { + let mut error = Self::mismatched_types(found, expected, right_span); + error.primary_label = Some(format!("right operand inferred as {found}")); + error.related_labels.push(( + left_span, + format!( + "left operand has type {expected}; `{operation}=` requires a compatible right operand" + ), + )); + if *found == StaticType::Any { + error.help_text = Some( + "If the right operand is a recursive call, add an explicit return type to the function (such as `-> Bool` for a boolean result). Unannotated recursive calls use Any while the function body is checked." + .to_string(), + ); + } + error + } + fn invalid_type_annotation(err: &StaticTypeConstructionError, span: Span) -> Self { Self { text: err.to_string(), span, help_text: Some(err.help_text().to_string()), + primary_label: None, + related_labels: Vec::new(), } } @@ -1296,6 +1337,8 @@ impl AnalysisError { text: format!("type `{name}` does not take generic arguments"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1304,6 +1347,8 @@ impl AnalysisError { text: format!("Struct '{name}' is not allowed to shadow the built-in type '{name}'"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1312,6 +1357,8 @@ impl AnalysisError { text: format!("Illegal redefinition of struct '{name}'"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1320,6 +1367,8 @@ impl AnalysisError { text: format!("Illegal redefinition of field '{field}' in struct '{struct_name}'"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1330,6 +1379,8 @@ impl AnalysisError { ), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1338,6 +1389,8 @@ impl AnalysisError { text: format!("mismatched types: found {found} but expected {expected}"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1346,6 +1399,8 @@ impl AnalysisError { text: format!("invalid cast: {found} can never be {target}"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1360,6 +1415,8 @@ impl AnalysisError { ), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1368,6 +1425,8 @@ impl AnalysisError { text: format!("Illegal redefinition of parameter {param}"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } fn unable_to_index_into(typ: &StaticType, span: Span) -> Self { @@ -1375,6 +1434,8 @@ impl AnalysisError { text: format!("Unable to index into {typ}"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } fn unable_to_unpack_type(typ: &StaticType, span: Span) -> Self { @@ -1382,6 +1443,8 @@ impl AnalysisError { text: format!("Invalid unpacking of {typ}"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } fn lvalue_required_to_be_single_identifier(span: Span) -> Self { @@ -1389,6 +1452,8 @@ impl AnalysisError { text: "This lvalue is required to be a single identifier".to_string(), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1400,6 +1465,8 @@ impl AnalysisError { ), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1423,6 +1490,8 @@ impl AnalysisError { text: format!("Unable to invoke {typ} as a function."), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } @@ -1431,6 +1500,8 @@ impl AnalysisError { text: format!("Identifier {ident} has not previously been declared"), span, help_text: None, + primary_label: None, + related_labels: Vec::new(), } } } diff --git a/ndc_bin/src/diagnostic.rs b/ndc_bin/src/diagnostic.rs index ae92b7ce..c4a2ed2c 100644 --- a/ndc_bin/src/diagnostic.rs +++ b/ndc_bin/src/diagnostic.rs @@ -84,13 +84,17 @@ fn into_diagnostics(err: InterpreterError) -> Vec> { .iter() .map(|cause| { let span = cause.span(); + let mut labels = vec![ + Label::primary(span.source_id(), span.range()) + .with_message(cause.primary_label().unwrap_or("related to this")), + ]; + labels.extend(cause.related_labels().iter().map(|(span, message)| { + Label::secondary(span.source_id(), span.range()).with_message(message) + })); let mut d = Diagnostic::error() .with_code("resolver") .with_message(cause.to_string()) - .with_labels(vec![ - Label::primary(span.source_id(), span.range()) - .with_message("related to this"), - ]); + .with_labels(labels); if let Some(help) = cause.help_text() { d = d.with_notes(vec![help.to_owned()]); } @@ -132,3 +136,75 @@ pub fn emit_error(source_db: &SourceDb, err: InterpreterError) { let _ = term::emit_to_write_style(&mut writer.lock(), &config, &files, diagnostic); } } + +#[cfg(test)] +mod tests { + use super::*; + use codespan_reporting::diagnostic::LabelStyle; + use ndc_interpreter::Interpreter; + + #[test] + fn recursive_assignment_labels_both_operands_and_explains_any() { + let source = "fn possible() { let ok = false; ok |= possible(); return ok; }"; + let mut interpreter = Interpreter::new(); + interpreter.configure(ndc_stdlib::register); + let error = interpreter + .disassemble_str(source) + .expect_err("recursive call uses Any"); + let diagnostics = into_diagnostics(error); + assert_eq!(diagnostics.len(), 1); + let diagnostic = &diagnostics[0]; + assert_eq!( + diagnostic.message, + "mismatched types: found Any but expected Bool" + ); + assert_eq!(diagnostic.labels.len(), 2); + let primary = &diagnostic.labels[0]; + assert_eq!(primary.style, LabelStyle::Primary); + assert_eq!(&source[primary.range.clone()], "possible()"); + assert_eq!(primary.message, "right operand inferred as Any"); + let secondary = &diagnostic.labels[1]; + assert_eq!(secondary.style, LabelStyle::Secondary); + assert_eq!(&source[secondary.range.clone()], "ok"); + assert_eq!(secondary.range.start, source.find("ok |=").unwrap()); + assert!(secondary.message.contains("left operand has type Bool")); + assert!(secondary.message.contains("`|=`")); + assert_eq!(primary.file_id, secondary.file_id); + assert!(diagnostic.notes[0].contains("explicit return type")); + + let mut output = Vec::new(); + term::emit_to_io_write( + &mut output, + &term::Config::default(), + &DiagnosticFiles(interpreter.source_db()), + diagnostic, + ) + .expect("render diagnostic"); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("right operand inferred as Any")); + assert!(output.contains("left operand has type Bool")); + assert!(output.contains("Unannotated recursive calls use Any")); + + let annotated = source.replace("fn possible()", "fn possible() -> Bool"); + let mut interpreter = Interpreter::new(); + interpreter.configure(ndc_stdlib::register); + interpreter + .disassemble_str(&annotated) + .expect("annotation resolves the mismatch"); + } + + #[test] + fn ordinary_type_error_keeps_its_single_label() { + let source = "let value: Bool = 1;"; + let mut interpreter = Interpreter::new(); + interpreter.configure(ndc_stdlib::register); + let error = interpreter + .disassemble_str(source) + .expect_err("type mismatch"); + let diagnostics = into_diagnostics(error); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].labels.len(), 1); + assert_eq!(diagnostics[0].labels[0].message, "related to this"); + assert!(diagnostics[0].notes.is_empty()); + } +} diff --git a/ndc_lsp/src/backend.rs b/ndc_lsp/src/backend.rs index b4ae6cdb..ee2a63e7 100644 --- a/ndc_lsp/src/backend.rs +++ b/ndc_lsp/src/backend.rs @@ -100,7 +100,7 @@ impl Backend { .ok() .map(|(expressions, analysis_result)| { for err in &analysis_result.errors { - diagnostics.push(diagnostics::analysis_error_to_diagnostic(text, err)); + diagnostics.push(diagnostics::analysis_error_to_diagnostic(text, uri, err)); } (expressions, analysis_result) }) diff --git a/ndc_lsp/src/diagnostics.rs b/ndc_lsp/src/diagnostics.rs index 57b34426..efa120db 100644 --- a/ndc_lsp/src/diagnostics.rs +++ b/ndc_lsp/src/diagnostics.rs @@ -1,6 +1,8 @@ use ndc_analyser::AnalysisError; use ndc_lexer::{Lexer, SourceId, Span, TokenLocation}; -use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity}; +use tower_lsp::lsp_types::{ + Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location, Url, +}; use crate::util::span_to_range; @@ -17,12 +19,27 @@ fn make_diagnostic(text: &str, span: Span, message: String) -> Diagnostic { /// /// An LSP diagnostic has no separate note channel the way a terminal report /// does, so help text is folded into the message instead of being dropped. -pub fn analysis_error_to_diagnostic(text: &str, err: &AnalysisError) -> Diagnostic { - let message = match err.help_text() { - Some(help) => format!("{err}. {help}"), - None => err.to_string(), - }; - make_diagnostic(text, err.span(), message) +pub fn analysis_error_to_diagnostic(text: &str, uri: &Url, err: &AnalysisError) -> Diagnostic { + let mut message = err.to_string(); + if let Some(label) = err.primary_label() { + message.push_str(&format!(". {label}")); + } + if let Some(help) = err.help_text() { + message.push_str(&format!(". {help}")); + } + let mut diagnostic = make_diagnostic(text, err.span(), message); + if !err.related_labels().is_empty() { + diagnostic.related_information = Some( + err.related_labels() + .iter() + .map(|(span, message)| DiagnosticRelatedInformation { + location: Location::new(uri.clone(), span_to_range(text, *span)), + message: message.clone(), + }) + .collect(), + ); + } + diagnostic } /// Lex and parse the source text, returning any diagnostics and (on success) @@ -48,3 +65,78 @@ pub fn lex_and_parse(text: &str) -> (Vec, Option Diagnostic { + let mut interpreter = Interpreter::new(); + interpreter.configure(ndc_stdlib::register); + let (_, result) = interpreter.analyse_str(source).expect("source parses"); + assert_eq!(result.errors.len(), 1, "{:?}", result.errors); + let uri = Url::parse("file:///test.ndc").unwrap(); + analysis_error_to_diagnostic(source, &uri, &result.errors[0]) + } + + #[test] + fn recursive_assignment_points_to_call_and_links_expected_operand() { + let source = "fn possible() {\n let ok = false;\n ok |= possible();\n return ok;\n}"; + let diagnostic = analyse_diagnostic(source); + assert_eq!( + diagnostic.range, + Range::new(Position::new(2, 8), Position::new(2, 18)) + ); + assert!(diagnostic.message.contains("right operand inferred as Any")); + assert!(diagnostic.message.contains("explicit return type")); + let related = diagnostic.related_information.unwrap(); + assert_eq!(related.len(), 1); + assert_eq!(related[0].location.uri.as_str(), "file:///test.ndc"); + assert_eq!( + related[0].location.range, + Range::new(Position::new(2, 2), Position::new(2, 4)) + ); + assert!(related[0].message.contains("left operand has type Bool")); + assert!(related[0].message.contains("`|=`")); + } + + #[test] + fn indexed_assignment_preserves_grouping_and_utf16_positions() { + let source = + "let values = [[1]];\nlet rhs = [0.5];\nlet marker = \"😀\"; (values[0]) ++=\n rhs;"; + let diagnostic = analyse_diagnostic(source); + assert_eq!( + diagnostic.range, + Range::new(Position::new(3, 2), Position::new(3, 5)) + ); + assert!( + diagnostic + .message + .contains("right operand inferred as List") + ); + assert!(!diagnostic.message.contains("recursive")); + let related = diagnostic.related_information.unwrap(); + assert_eq!( + related[0].location.range, + Range::new(Position::new(2, 19), Position::new(2, 30)) + ); + assert!( + related[0] + .message + .contains("left operand has type List") + ); + assert!(related[0].message.contains("`++=`")); + } + + #[test] + fn ordinary_type_error_keeps_its_message_and_has_no_related_locations() { + let diagnostic = analyse_diagnostic("let value: Bool = 1;"); + assert_eq!( + diagnostic.message, + "mismatched types: found Int but expected Bool" + ); + assert!(diagnostic.related_information.is_none()); + } +} diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index 9c3c8670..c77316d0 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -124,6 +124,8 @@ pub enum Expression { }, OpAssignment { l_value: Lvalue, + /// Original target span, including grouping and index delimiters. + l_value_span: Span, r_value: Box, operation: String, plan: AugmentedAssignmentPlan, diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index 095cc9ed..c775073d 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -391,6 +391,7 @@ impl Parser { let expression = self.tuple_expression(Self::single_expression, false)?; let end = expression.span; let op_assign = Expression::OpAssignment { + l_value_span: start, l_value: Lvalue::try_from(maybe_lvalue) .expect("guaranteed to produce an lvalue"), r_value: Box::new(expression), From 83eed807d94fe393faa985443796faf5dc1657ab Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 9 Sep 2026 10:46:46 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(analyser):=20retain=20operand=20types?= =?UTF-8?q?=20during=20vector=20fallback=20=F0=9F=94=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_analyser/src/analyser.rs | 90 +------------------ ndc_analyser/src/scope.rs | 24 ++++- ndc_bin/src/diagnostic.rs | 36 ++++---- ndc_lsp/src/diagnostics.rs | 21 +++-- .../068_scalar_op_assignment_any_rhs.ndc | 26 ++++++ .../069_recursive_bool_op_assignment.ndc | 12 +++ .../070_inferred_op_assignment_widens.ndc | 9 ++ ...notated_op_assignment_rejects_widening.ndc | 4 + ..._annotated_list_op_assignment_mismatch.ndc | 3 + .../073_map_op_assignment_any_rhs.ndc | 5 ++ .../015_vector_any_with_known_scalar.ndc | 19 ++++ 11 files changed, 129 insertions(+), 120 deletions(-) create mode 100644 tests/functional/programs/004_basic/068_scalar_op_assignment_any_rhs.ndc create mode 100644 tests/functional/programs/004_basic/069_recursive_bool_op_assignment.ndc create mode 100644 tests/functional/programs/004_basic/070_inferred_op_assignment_widens.ndc create mode 100644 tests/functional/programs/004_basic/071_annotated_op_assignment_rejects_widening.ndc create mode 100644 tests/functional/programs/004_basic/072_annotated_list_op_assignment_mismatch.ndc create mode 100644 tests/functional/programs/004_basic/073_map_op_assignment_any_rhs.ndc create mode 100644 tests/functional/programs/013_vector_math/015_vector_any_with_known_scalar.ndc diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index 7433a9df..131dde72 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -1313,12 +1313,6 @@ impl AnalysisError { "left operand has type {expected}; `{operation}=` requires a compatible right operand" ), )); - if *found == StaticType::Any { - error.help_text = Some( - "If the right operand is a recursive call, add an explicit return type to the function (such as `-> Bool` for a boolean result). Unannotated recursive calls use Any while the function body is checked." - .to_string(), - ); - } error } @@ -1586,88 +1580,8 @@ mod tests { } #[test] - fn inferred_index_augmented_assignment_widens_element_type() { - let add = StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Float]), - return_type: Box::new(StaticType::Number), - }; - assert_eq!( - analyse_last_type_with_globals( - "let values = [1]; values[0] += 0.5; values", - vec![("+".to_string(), add)], - ), - StaticType::List(Box::new(StaticType::Any)), - ); - } - - #[test] - fn inferred_identifier_assignments_widen_subsequent_reads() { - assert_eq!(analyse_last_type("let x = 3; x = 0.5; x"), StaticType::Any,); - - let add = StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Float]), - return_type: Box::new(StaticType::Float), - }; - assert_eq!( - analyse_last_type_with_globals("let x = 3; x += 0.5; x", vec![("+".to_string(), add)],), - StaticType::Any, - ); - } - - #[test] - fn annotated_identifier_augmented_assignment_rejects_widening() { - let add = StaticType::Function { - parameters: Some(vec![StaticType::Int, StaticType::Float]), - return_type: Box::new(StaticType::Float), - }; - assert_analysis_error( - "let x: Int = 3; x += 0.5;", - vec![("+".to_string(), add)], - "mismatched types: found Float but expected Int", - ); - } - - #[test] - fn compatible_specialized_assignment_preserves_left_type() { - let list_any = StaticType::List(Box::new(StaticType::Any)); - let append = StaticType::Function { - parameters: Some(vec![list_any.clone(), list_any.clone()]), - return_type: Box::new(list_any), - }; - - assert_eq!( - analyse_last_type_with_globals( - "let values = [1]; values ++= [2]; values", - vec![("++=".to_string(), append)], - ), - StaticType::List(Box::new(StaticType::Int)), - ); - } - - #[test] - fn incompatible_specialized_assignment_is_rejected() { - let list_any = StaticType::List(Box::new(StaticType::Any)); - let concat = StaticType::Function { - parameters: Some(vec![list_any.clone(), list_any.clone()]), - return_type: Box::new(list_any), - }; - - assert_analysis_error( - "let values = [1]; values ++= [\"two\"];", - vec![ - ("++=".to_string(), concat.clone()), - ("++".to_string(), concat.clone()), - ], - "mismatched types: found List but expected List", - ); - assert_analysis_error( - "let values: List = [1]; values ++= [\"two\"];", - vec![ - ("++=".to_string(), concat.clone()), - ("++".to_string(), concat), - ], - "mismatched types: found List but expected List", - ); + fn inferred_identifier_assignment_widens_subsequent_reads() { + assert_eq!(analyse_last_type("let x = 3; x = 0.5; x"), StaticType::Any); } #[test] diff --git a/ndc_analyser/src/scope.rs b/ndc_analyser/src/scope.rs index 4e57e299..7b32bc09 100644 --- a/ndc_analyser/src/scope.rs +++ b/ndc_analyser/src/scope.rs @@ -1,3 +1,4 @@ +use itertools::Itertools; use ndc_core::StaticType; use ndc_core::r#struct::StructInfo; use ndc_parser::{Binding, Candidate, CaptureSource, ResolvedVar}; @@ -809,8 +810,27 @@ impl ScopeTree { if !sig.iter().any(|t| matches!(t, StaticType::Any)) { return None; } - let permissive: Vec = vec![StaticType::Any; sig.len()]; - let vars = self.candidates_for_sig(ident, &permissive); + // An Any argument may be a tuple at runtime, but known scalar + // arguments still constrain every element-wise call. Erasing those + // types would admit map mutation overloads for e.g. Bool |= Any. + // Sequence may also hide a tuple: it can either be broadcast as + // one value or supply T elements. Keep both interpretations without + // erasing the constraints on the other arguments. + let signatures = sig + .iter() + .map(|arg| match arg { + StaticType::Sequence(element) => vec![arg.clone(), *element.clone()], + _ => vec![arg.clone()], + }) + .multi_cartesian_product(); + let mut vars = Vec::new(); + for signature in signatures { + for var in self.candidates_for_sig(ident, &signature) { + if !vars.contains(&var) { + vars.push(var); + } + } + } if vars.is_empty() { None } else { diff --git a/ndc_bin/src/diagnostic.rs b/ndc_bin/src/diagnostic.rs index c4a2ed2c..b57e20cc 100644 --- a/ndc_bin/src/diagnostic.rs +++ b/ndc_bin/src/diagnostic.rs @@ -144,33 +144,35 @@ mod tests { use ndc_interpreter::Interpreter; #[test] - fn recursive_assignment_labels_both_operands_and_explains_any() { - let source = "fn possible() { let ok = false; ok |= possible(); return ok; }"; + fn augmented_assignment_labels_both_operands() { + let source = "let values = [1]; values ++= [0.5];"; let mut interpreter = Interpreter::new(); interpreter.configure(ndc_stdlib::register); let error = interpreter .disassemble_str(source) - .expect_err("recursive call uses Any"); + .expect_err("incompatible list elements"); let diagnostics = into_diagnostics(error); assert_eq!(diagnostics.len(), 1); let diagnostic = &diagnostics[0]; assert_eq!( diagnostic.message, - "mismatched types: found Any but expected Bool" + "mismatched types: found List but expected List" ); assert_eq!(diagnostic.labels.len(), 2); let primary = &diagnostic.labels[0]; assert_eq!(primary.style, LabelStyle::Primary); - assert_eq!(&source[primary.range.clone()], "possible()"); - assert_eq!(primary.message, "right operand inferred as Any"); + assert_eq!(&source[primary.range.clone()], "[0.5]"); + assert_eq!(primary.message, "right operand inferred as List"); let secondary = &diagnostic.labels[1]; assert_eq!(secondary.style, LabelStyle::Secondary); - assert_eq!(&source[secondary.range.clone()], "ok"); - assert_eq!(secondary.range.start, source.find("ok |=").unwrap()); - assert!(secondary.message.contains("left operand has type Bool")); - assert!(secondary.message.contains("`|=`")); + assert_eq!(&source[secondary.range.clone()], "values"); + assert_eq!(secondary.range.start, source.find("values ++=").unwrap()); + assert_eq!( + secondary.message, + "left operand has type List; `++=` requires a compatible right operand" + ); assert_eq!(primary.file_id, secondary.file_id); - assert!(diagnostic.notes[0].contains("explicit return type")); + assert!(diagnostic.notes.is_empty()); let mut output = Vec::new(); term::emit_to_io_write( @@ -181,16 +183,8 @@ mod tests { ) .expect("render diagnostic"); let output = String::from_utf8(output).unwrap(); - assert!(output.contains("right operand inferred as Any")); - assert!(output.contains("left operand has type Bool")); - assert!(output.contains("Unannotated recursive calls use Any")); - - let annotated = source.replace("fn possible()", "fn possible() -> Bool"); - let mut interpreter = Interpreter::new(); - interpreter.configure(ndc_stdlib::register); - interpreter - .disassemble_str(&annotated) - .expect("annotation resolves the mismatch"); + assert!(output.contains(&primary.message)); + assert!(output.contains(&secondary.message)); } #[test] diff --git a/ndc_lsp/src/diagnostics.rs b/ndc_lsp/src/diagnostics.rs index efa120db..4787f02c 100644 --- a/ndc_lsp/src/diagnostics.rs +++ b/ndc_lsp/src/diagnostics.rs @@ -82,24 +82,28 @@ mod tests { } #[test] - fn recursive_assignment_points_to_call_and_links_expected_operand() { - let source = "fn possible() {\n let ok = false;\n ok |= possible();\n return ok;\n}"; + fn augmented_assignment_points_to_rhs_and_links_target() { + let source = "let values = [1];\nvalues ++= [0.5];"; let diagnostic = analyse_diagnostic(source); assert_eq!( diagnostic.range, - Range::new(Position::new(2, 8), Position::new(2, 18)) + Range::new(Position::new(1, 11), Position::new(1, 16)) + ); + assert_eq!( + diagnostic.message, + "mismatched types: found List but expected List. right operand inferred as List" ); - assert!(diagnostic.message.contains("right operand inferred as Any")); - assert!(diagnostic.message.contains("explicit return type")); let related = diagnostic.related_information.unwrap(); assert_eq!(related.len(), 1); assert_eq!(related[0].location.uri.as_str(), "file:///test.ndc"); assert_eq!( related[0].location.range, - Range::new(Position::new(2, 2), Position::new(2, 4)) + Range::new(Position::new(1, 0), Position::new(1, 6)) + ); + assert_eq!( + related[0].message, + "left operand has type List; `++=` requires a compatible right operand" ); - assert!(related[0].message.contains("left operand has type Bool")); - assert!(related[0].message.contains("`|=`")); } #[test] @@ -116,7 +120,6 @@ mod tests { .message .contains("right operand inferred as List") ); - assert!(!diagnostic.message.contains("recursive")); let related = diagnostic.related_information.unwrap(); assert_eq!( related[0].location.range, diff --git a/tests/functional/programs/004_basic/068_scalar_op_assignment_any_rhs.ndc b/tests/functional/programs/004_basic/068_scalar_op_assignment_any_rhs.ndc new file mode 100644 index 00000000..6f9079d9 --- /dev/null +++ b/tests/functional/programs/004_basic/068_scalar_op_assignment_any_rhs.ndc @@ -0,0 +1,26 @@ +// An unknown rhs must not make map/set mutation overloads apply to scalars. +fn opaque(value) => value; + +let sum = 10; +sum += opaque(3); +assert_eq(sum, 13); + +let difference = 10; +difference -= opaque(3); +assert_eq(difference, 7); + +let bits = 4; +bits |= opaque(3); +assert_eq(bits, 7); + +let possible = false; +possible |= opaque(true); +assert_eq(possible, true); + +let certain = true; +certain &= opaque(false); +assert_eq(certain, false); + +let toggled = 7; +toggled ~= opaque(3); +assert_eq(toggled, 4); diff --git a/tests/functional/programs/004_basic/069_recursive_bool_op_assignment.ndc b/tests/functional/programs/004_basic/069_recursive_bool_op_assignment.ndc new file mode 100644 index 00000000..fb1b6179 --- /dev/null +++ b/tests/functional/programs/004_basic/069_recursive_bool_op_assignment.ndc @@ -0,0 +1,12 @@ +// Recursive calls have an unknown return type while the body is analysed. +// Boolean |= must still fall back to ordinary Boolean |. +fn possible(depth: Int) { + if depth == 0 { + return true; + } + let result = false; + result |= possible(depth - 1); + result +} + +assert_eq(possible(3), true); diff --git a/tests/functional/programs/004_basic/070_inferred_op_assignment_widens.ndc b/tests/functional/programs/004_basic/070_inferred_op_assignment_widens.ndc new file mode 100644 index 00000000..ba206c77 --- /dev/null +++ b/tests/functional/programs/004_basic/070_inferred_op_assignment_widens.ndc @@ -0,0 +1,9 @@ +fn replace(previous: Int, replacement: String) -> String => replacement; + +let value = 1; +value replace= "two"; +assert_eq(value ++ "!", "two!"); + +let values = [1]; +values[0] replace= "two"; +assert_eq(values[0] ++ "!", "two!"); diff --git a/tests/functional/programs/004_basic/071_annotated_op_assignment_rejects_widening.ndc b/tests/functional/programs/004_basic/071_annotated_op_assignment_rejects_widening.ndc new file mode 100644 index 00000000..bc403bce --- /dev/null +++ b/tests/functional/programs/004_basic/071_annotated_op_assignment_rejects_widening.ndc @@ -0,0 +1,4 @@ +// expect-error: mismatched types: found String but expected Int +fn replace(previous: Int, replacement: String) -> String => replacement; +let value: Int = 1; +value replace= "two"; diff --git a/tests/functional/programs/004_basic/072_annotated_list_op_assignment_mismatch.ndc b/tests/functional/programs/004_basic/072_annotated_list_op_assignment_mismatch.ndc new file mode 100644 index 00000000..5956f256 --- /dev/null +++ b/tests/functional/programs/004_basic/072_annotated_list_op_assignment_mismatch.ndc @@ -0,0 +1,3 @@ +// expect-error: mismatched types: found List but expected List +let values: List = [1]; +values ++= [0.5]; diff --git a/tests/functional/programs/004_basic/073_map_op_assignment_any_rhs.ndc b/tests/functional/programs/004_basic/073_map_op_assignment_any_rhs.ndc new file mode 100644 index 00000000..dd626d55 --- /dev/null +++ b/tests/functional/programs/004_basic/073_map_op_assignment_any_rhs.ndc @@ -0,0 +1,5 @@ +// An actual container mutation must still reject an unknown rhs. +// expect-error: mismatched types: found Any but expected Map +fn opaque(value) => value; +let values = %{1: 2}; +values |= opaque(%{3: "four"}); diff --git a/tests/functional/programs/013_vector_math/015_vector_any_with_known_scalar.ndc b/tests/functional/programs/013_vector_math/015_vector_any_with_known_scalar.ndc new file mode 100644 index 00000000..50f48522 --- /dev/null +++ b/tests/functional/programs/013_vector_math/015_vector_any_with_known_scalar.ndc @@ -0,0 +1,19 @@ +// Preserve scalar constraints without losing vector dispatch for an Any tuple. +fn opaque(value) => value; + +assert_eq(10 - opaque((1, 2)), (9, 8)); +assert_eq(opaque((10, 20)) - 1, (9, 19)); + +let difference = 10; +difference -= opaque((1, 2)); +assert_eq(difference, (9, 8)); + +let bits = 4; +bits |= opaque((1, 2)); +assert_eq(bits, (5, 6)); + +// A Sequence annotation can also hide the tuple shape. +fn subtract(values: Sequence, rhs) => values - rhs; +fn subtract_from(lhs, values: Sequence) => lhs - values; +assert_eq(subtract((10, 20), 1), (9, 19)); +assert_eq(subtract_from(10, (1, 2)), (9, 8)); From c90a641f606dea1982ede73904c7463620e16f3f Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 9 Sep 2026 13:58:27 +0200 Subject: [PATCH 3/3] =?UTF-8?q?test(cli):=20remove=20diagnostic=20unit=20t?= =?UTF-8?q?ests=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ndc_bin/src/diagnostic.rs | 66 --------------------------------------- 1 file changed, 66 deletions(-) diff --git a/ndc_bin/src/diagnostic.rs b/ndc_bin/src/diagnostic.rs index b57e20cc..f6021e1c 100644 --- a/ndc_bin/src/diagnostic.rs +++ b/ndc_bin/src/diagnostic.rs @@ -136,69 +136,3 @@ pub fn emit_error(source_db: &SourceDb, err: InterpreterError) { let _ = term::emit_to_write_style(&mut writer.lock(), &config, &files, diagnostic); } } - -#[cfg(test)] -mod tests { - use super::*; - use codespan_reporting::diagnostic::LabelStyle; - use ndc_interpreter::Interpreter; - - #[test] - fn augmented_assignment_labels_both_operands() { - let source = "let values = [1]; values ++= [0.5];"; - let mut interpreter = Interpreter::new(); - interpreter.configure(ndc_stdlib::register); - let error = interpreter - .disassemble_str(source) - .expect_err("incompatible list elements"); - let diagnostics = into_diagnostics(error); - assert_eq!(diagnostics.len(), 1); - let diagnostic = &diagnostics[0]; - assert_eq!( - diagnostic.message, - "mismatched types: found List but expected List" - ); - assert_eq!(diagnostic.labels.len(), 2); - let primary = &diagnostic.labels[0]; - assert_eq!(primary.style, LabelStyle::Primary); - assert_eq!(&source[primary.range.clone()], "[0.5]"); - assert_eq!(primary.message, "right operand inferred as List"); - let secondary = &diagnostic.labels[1]; - assert_eq!(secondary.style, LabelStyle::Secondary); - assert_eq!(&source[secondary.range.clone()], "values"); - assert_eq!(secondary.range.start, source.find("values ++=").unwrap()); - assert_eq!( - secondary.message, - "left operand has type List; `++=` requires a compatible right operand" - ); - assert_eq!(primary.file_id, secondary.file_id); - assert!(diagnostic.notes.is_empty()); - - let mut output = Vec::new(); - term::emit_to_io_write( - &mut output, - &term::Config::default(), - &DiagnosticFiles(interpreter.source_db()), - diagnostic, - ) - .expect("render diagnostic"); - let output = String::from_utf8(output).unwrap(); - assert!(output.contains(&primary.message)); - assert!(output.contains(&secondary.message)); - } - - #[test] - fn ordinary_type_error_keeps_its_single_label() { - let source = "let value: Bool = 1;"; - let mut interpreter = Interpreter::new(); - interpreter.configure(ndc_stdlib::register); - let error = interpreter - .disassemble_str(source) - .expect_err("type mismatch"); - let diagnostics = into_diagnostics(error); - assert_eq!(diagnostics.len(), 1); - assert_eq!(diagnostics[0].labels.len(), 1); - assert_eq!(diagnostics[0].labels[0].message, "related to this"); - assert!(diagnostics[0].notes.is_empty()); - } -}