From d6adf44a4520d787476deb80b98b4107bdbd95bd Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 13:57:22 +0100 Subject: [PATCH 01/39] =?UTF-8?q?feat(analyser):=20keep=20each=20variable'?= =?UTF-8?q?s=20type=20fixed=20=F0=9F=93=8C=20(#286)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A variable now keeps the type its first value or its annotation gave it. This is step 1 of the type system RFC (#281). Before, an assignment of another type widened the variable to a join of the two types, but calls already resolved against the narrower type kept their pick (#279). Behaviour changes: - Assigning a value that doesn't fit is an error: `let x = 1; x = 2.5` reports "mismatched types: found Float but expected Int". The same goes for augmented assignment (`x += 0.5`), loop variables, destructuring assignment, and index writes, which no longer widen their container (`let xs = [1]; xs[0] = "a"`). - What an empty `[]` or `%{}`, or a `None`, will hold is settled by the first write: after `let m = %{}; m["a"] = 1`, `m` is a `Map`, and `m["b"] = "two"` is an error. When nothing settles it, it keeps the type it had before (`List`, `Map`, `Option`). In-place operations settle them too: after `let xs = []; xs ++= [1, 2]`, `xs` is a `List`. Only writes and in-place operations settle these types for now; `xs.push(1)` doesn't, because `push` isn't generic yet. - An annotated `let` is checked the same way, so `let o: Option = None` and `let p: (Int, Option) = (1, None)` are accepted. The annotation check used to reject both. - A value of type `Any` can be stored anywhere, since `Any` is the dynamic type. That keeps `sum += value` working while iterating a map whose types aren't known. - `let pos = (); pos = (1, 3)` is now an error. A variable that gets its value later holds an `Option`: `let pos = None; pos = Some((1, 3))`. Internals: - `StaticType::Var` is a type variable. Only the analyser creates them, and every method in `ndc_core` treats one as `Any`. The analyser settles its variables with unification (`TypeVars` in `types.rs`). Variables made in a function body fall back to their default when the body ends, so a function's type never carries one. Every remaining variable falls back at the end of each batch, before the compiler or the language server see the analysis; a debug assertion checks that none is left. - `TypeBinding` is gone. Inferred and annotated types behave the same now, so the type table just maps symbols to types. Programs and tests written for widening now state the new rules. The manual's sections on reassignment, augmented assignment and casts describe them, and every example in those sections was run through the interpreter. Known gap until step 2: a call doesn't settle the types of its arguments, because built-in signatures aren't generic yet. In `let xs = []; xs.push("a"); xs[0] = 1`, the push leaves `xs`'s element type unsettled, and the write then settles it as `Int`. Step 2 unifies every argument with the picked overload's signature, which closes this. Fixes #279. Closes #143: the reassignment in its example is now reported where it happens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/augmented-assignment.md | 11 +- manual/src/features/casts.md | 10 +- manual/src/reference/variables-and-scopes.md | 45 +++-- ndc_analyser/src/analyser/assignments.rs | 159 +++++++-------- ndc_analyser/src/analyser/calls.rs | 4 +- ndc_analyser/src/analyser/declarations.rs | 57 ++---- ndc_analyser/src/analyser/mod.rs | 57 +++++- ndc_analyser/src/analyser/tests.rs | 190 +++++++++++------- ndc_analyser/src/overloads.rs | 53 +++-- ndc_analyser/src/types.rs | 189 +++++++++++------ ndc_core/src/lib.rs | 2 +- ndc_core/src/static_type.rs | 101 +++++++++- ...ndex_assignment_keeps_the_element_type.ndc | 5 + .../060_inferred_index_assignment_widens.ndc | 11 - .../070_a_variable_keeps_its_type.ndc | 6 + ...ast_suggested_when_no_overload_matches.ndc | 5 +- ...n_returns_what_its_captured_list_holds.ndc | 9 + ...a_loop_variable_keeps_the_element_type.ndc | 6 + .../050_remove_on_widened_sequence.ndc | 7 - ..._place_append_settles_the_element_type.ndc | 6 + ...ce_append_settles_each_list_in_a_tuple.ndc | 7 + .../027_op_assign_empty_map_error.ndc | 6 +- ...mpty_map_takes_the_types_written_to_it.ndc | 6 + .../010_vector_op_assignment.ndc | 9 +- .../bug0015_unpack_mismatched_tuple_arity.ndc | 7 +- ...7_closures_keep_block_scoped_variables.ndc | 2 +- 26 files changed, 616 insertions(+), 354 deletions(-) create mode 100644 tests/functional/programs/004_basic/060_an_index_assignment_keeps_the_element_type.ndc delete mode 100644 tests/functional/programs/004_basic/060_inferred_index_assignment_widens.ndc create mode 100644 tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc create mode 100644 tests/functional/programs/005_functions/059_a_function_returns_what_its_captured_list_holds.ndc create mode 100644 tests/functional/programs/006_lists/050_a_loop_variable_keeps_the_element_type.ndc delete mode 100644 tests/functional/programs/006_lists/050_remove_on_widened_sequence.ndc create mode 100644 tests/functional/programs/006_lists/051_an_in_place_append_settles_the_element_type.ndc create mode 100644 tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc create mode 100644 tests/functional/programs/007_map_and_set/029_an_empty_map_takes_the_types_written_to_it.ndc diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index 40f79128..8713d282 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -91,13 +91,16 @@ mixed ++= ["two"]; assert_eq(mixed, [1, "two"]); ``` -Operators without an in-place implementation behave like `target = target op value` -and may widen the inferred type of the target, just like a regular assignment: +Operators without an in-place implementation behave like `target = target op value`, +so the result has to fit the target's type, just like in a regular assignment: ```ndc let numbers = [1]; -numbers[0] += 0.5; // fine: the element type widens from Int to Any -assert_eq(numbers, [1.5]); +numbers[0] += 0.5; // ERROR: Int + Float is a Float, which doesn't fit in List + +let floats = [1.0]; +floats[0] += 0.5; // fine +assert_eq(floats, [1.5]); ``` ## Optimization diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index c079eda3..8c41ae40 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -60,14 +60,12 @@ that follow form one, and as a less-than comparison otherwise, so The analyser only accepts what it can prove. Where it cannot, a cast states the value's type and checks it at the cast site. -Reassigning a variable widens its type, which can lose the detail a function -needs: +A wider annotation than the value needs can lose the detail a function needs: ```ndc -let line = "a b c"; -line = line.split(" "); // line is now Sequence, not List -line.remove(0); // error: no `remove` matches Sequence, Int -assert_eq((line as List).remove(0), "a"); +let words: Sequence = "a b c".split(" "); +words.remove(0); // error: no `remove` matches Sequence, Int +assert_eq((words as List).remove(0), "a"); ``` A specialized `op=` mutates its target in place and keeps its type, so it only diff --git a/manual/src/reference/variables-and-scopes.md b/manual/src/reference/variables-and-scopes.md index 933a0124..a2dafbbd 100644 --- a/manual/src/reference/variables-and-scopes.md +++ b/manual/src/reference/variables-and-scopes.md @@ -42,30 +42,47 @@ print(x); // 3 ## Reassignment -The `=` operator can be used to reassign a value to an existing variable. When you reassign a variable to a value of a different type, the variable's type is widened to the least upper bound (LUB) of the old and new types. +The `=` operator assigns a new value to an existing variable. A variable keeps the type its first value gave it, so a new value has to fit that type: ```ndc -let x = 1; // the initializer and declaration have type Int -x = 2; // subsequent reads still have type Int -x = 3.14; // subsequent reads have type Any (Int and Float are siblings) -x; // type is Any +let x = 1; // x is Int +x = 2; // fine +x = 3.14; // ERROR: mismatched types: found Float but expected Int ``` -Augmented assignment widens an inferred binding from the operation's result in the same way: +Augmented assignment is checked the same way, against the result of the operation: ```ndc let total = 3; -total += 0.5; // Int + Float returns Float, so subsequent reads have type Any -total; // type is Any +total += 0.5; // ERROR: Int + Float is a Float, which doesn't fit in Int + +let sum = 3.0; +sum += 0.5; // fine ``` +What an empty list or map holds, or what `None` will hold, isn't known yet. The first assignment that stores something settles it, and later ones have to fit: + ```ndc -let pos = (); // type is () -pos = (1, 2); // type widens to Sequence -pos = ("a", "b"); // type is still Sequence +let counts = %{}; // key and value types not known yet +counts["a"] = 1; // counts is Map from here on +counts["b"] = "two"; // ERROR: mismatched types: found String but expected Int ``` -> **Tip:** For the best type inference, initialize variables with a value that matches the intended type. For example, use `let pos = (0, 0);` instead of `let pos = ();` if you intend to store a 2-tuple of numbers. +Only assignments settle these types. When nothing does, as in `let xs = []; xs.push(1)`, they stay `Any`. + +A variable that only gets its real value later holds an `Option`: + +```ndc +let pos = None; +pos = Some((1, 2)); +``` + +To store values of different types in one variable, annotate it with `Any`: + +```ndc +let x: Any = 1; +x = "text"; // fine +``` ## Type annotations @@ -99,7 +116,7 @@ let m: Map = %{}; // m is Map let nested: List> = [[]]; ``` -Once a binding has an annotation, it stays locked to that type. Reassignment and augmented assignment can't widen it the way they widen an inferred binding: +An annotated variable keeps its annotated type, just as an inferred one keeps the type of its first value: ```ndc let x: Int = 5; @@ -107,8 +124,6 @@ x = "test"; // ERROR: mismatched types x += 0.5; // ERROR: the Float result doesn't fit in Int ``` -If you want a binding that widens freely, just leave the annotation off. Annotations are opt-in. - The same syntax shows up on function parameters and return types — see the [Function](./types/function.md) page. See [Types](./types.md) for the full list of names you can use, including generics like `List`, `Map`, and tuple shorthand `(Int, String)`. diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 1f2e00fe..210080f1 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -2,7 +2,7 @@ use super::Analyser; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall}; use itertools::izip; -use ndc_analysis::{Binding, Candidate}; +use ndc_analysis::Binding; use ndc_core::{NodeId, StaticType}; use ndc_lexer::Span; use ndc_parser::{AssignmentTarget, AssignmentTargetLocation, ExpressionLocation}; @@ -21,6 +21,27 @@ impl Analyser { ) -> StaticType { let left_type = self.analyse_assignment_target(l_value, span); let right_type = self.analyse_expr(r_value); + // An in-place operation such as `++=` keeps its target's type, so + // what it stores has to fit that type. Where the target's type isn't + // fully known, the right operand settles it: in + // `let xs = []; xs ++= [1]`, the elements of `xs` are `Int`. + let left_type = if left_type.any_var(&|_| true) { + match &left_type { + // A tuple target with a scalar operand applies the operand to + // each element: `t ++= [1]` appends to every list in `t`. + StaticType::Tuple(targets) if !matches!(right_type, StaticType::Tuple(_)) => { + for target in targets { + self.vars.unify(target, &right_type); + } + } + _ => { + self.vars.unify(&left_type, &right_type); + } + } + self.vars.resolve(&left_type) + } else { + left_type + }; let arg_types = vec![left_type.clone(), right_type.clone()]; let ResolvedCall { @@ -118,12 +139,19 @@ impl Analyser { AssignmentTarget::Identifier { .. } => { let symbol = self.resolver.assigned_symbol(lvalue.id); self.picks.variables.insert(lvalue.id, symbol); - self.types.get_type(symbol).clone() + self.vars.resolve(self.types.get_type(symbol)) } AssignmentTarget::Index { index, value } => { // The container before the index, in source order like the resolver. let type_of_index_target = self.analyse_expr(value); let index_type = self.analyse_expr(index); + // Writing `m[k]` settles the key type of a map whose keys + // aren't known yet, as in `let m = %{}; m["a"] = 1`. + if let StaticType::Map { key, .. } = &type_of_index_target + && matches!(**key, StaticType::Var(_)) + { + self.vars.unify(key, &index_type); + } let get_args = [type_of_index_target.clone(), index_type.clone()]; let set_args = [ @@ -248,8 +276,42 @@ impl Analyser { } } - /// Validate a value that will be stored through an lvalue, widening an - /// inferred binding when the location has a stable variable to update. + /// Whether a value of `value_type` can be stored where values of + /// `stored_type` are kept, settling type variables on the way: in + /// `let xs = []; xs[0] = 1`, the write settles the element type of `xs` + /// as `Int`. The stored type never widens to fit the value. An `Any` + /// value fits anywhere, as `Any` is the dynamic type. + pub(super) fn accepts_write( + &mut self, + stored_type: &StaticType, + value_type: &StaticType, + ) -> bool { + if self.vars.unify(stored_type, value_type) { + return true; + } + let stored_type = self.vars.resolve(stored_type); + let value_type = self.vars.resolve(value_type); + value_type == StaticType::Any || value_type.is_subtype(&stored_type) + } + + /// Reports that a value of `value_type` can't be stored where values of + /// `stored_type` are kept. + pub(super) fn emit_rejected_write( + &mut self, + stored_type: &StaticType, + value_type: &StaticType, + span: Span, + ) { + let error = AnalysisError::mismatched_types( + &self.vars.resolve(value_type), + &self.vars.resolve(stored_type), + span, + ); + self.emit(error); + } + + /// Checks a value that will be stored through an lvalue against the type + /// stored there. pub(super) fn validate_assignment_write( &mut self, lvalue: &AssignmentTargetLocation, @@ -261,95 +323,34 @@ impl Analyser { AssignmentTarget::Identifier { .. } if self.picks.variables.contains_key(&lvalue.id) => { - let target = self.resolver.assigned_symbol(lvalue.id); - let widened = stored_type.lub(value_type); - if widened != *stored_type - && let Err(annotated_type) = self.types.update_binding_type(target, widened) - && !value_type.is_subtype(&annotated_type) - { - self.emit(AnalysisError::mismatched_types( - value_type, - &annotated_type, - span, - )); + if !self.accepts_write(stored_type, value_type) { + self.emit_rejected_write(stored_type, value_type, span); } } AssignmentTarget::Member { .. } => { - if !value_type.is_subtype(stored_type) { - self.emit(AnalysisError::mismatched_types( - value_type, - stored_type, - span, - )); + if !self.accepts_write(stored_type, value_type) { + self.emit_rejected_write(stored_type, value_type, span); } } - AssignmentTarget::Index { value, index, .. } => { - if value_type.is_subtype(stored_type) { + AssignmentTarget::Index { index, .. } => { + if self.accepts_write(stored_type, value_type) { return; } - + // `xs[0..2] = (1, 2)` stores the tuple's elements. let is_slice = self .result .analysis .expr_types .get(&index.id) .is_some_and(Self::index_type_is_slice); - let (stored_element_type, value_element_type) = if is_slice { - let Some(stored_element_type) = stored_type.index_element_type() else { - self.emit(AnalysisError::mismatched_types( - value_type, - stored_type, - span, - )); - return; - }; - let Some(value_element_type) = value_type.sequence_element_type() else { - self.emit(AnalysisError::mismatched_types( - value_type, - stored_type, - span, - )); - return; - }; - (stored_element_type, value_element_type) - } else { - (stored_type.clone(), value_type.clone()) - }; - - if matches!( - self.picks.bindings.get(&value.id), - Some(Binding::Resolved(Candidate::Scalar(_))) - ) { - let target = self.resolver.innermost_reference(value.id); - let container_type = self.types.get_type(target).clone(); - // An earlier target of the same destructuring may already - // have widened the container, as in `l[0], l[1] = …`, so - // check against its current type rather than `stored_type`. - if container_type - .index_element_type() - .is_some_and(|element| value_element_type.is_subtype(&element)) - { - return; - } - let widened_container = container_type.lub( - &container_type - .with_element_type(stored_element_type.lub(&value_element_type)), - ); - if widened_container != container_type - && self - .types - .update_binding_type(target, widened_container) - .is_ok() - { - return; - } + if is_slice + && let Some(stored_element) = stored_type.index_element_type() + && let Some(value_element) = value_type.sequence_element_type() + && self.accepts_write(&stored_element, &value_element) + { + return; } - - self.emit(AnalysisError::mismatched_types( - value_type, - stored_type, - span, - )); + self.emit_rejected_write(stored_type, value_type, span); } // Each target gets the element unpacked for it, as in a `let`. AssignmentTarget::Sequence(targets) => { diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 80bb16ee..f768737b 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -37,7 +37,7 @@ impl Analyser { /// Overload resolution over the types declared so far. pub(super) fn overloads(&self) -> Overloads<'_> { - Overloads::new(&self.types, self.resolver.symbols()) + Overloads::new(&self.types, &self.vars, self.resolver.symbols()) } /// Analyses `receiver.member`, the call `member(receiver)`. @@ -93,7 +93,7 @@ impl Analyser { } return match callee_type { StaticType::Function { return_type, .. } => *return_type, - StaticType::Any => StaticType::Any, + StaticType::Any | StaticType::Var(_) => StaticType::Any, other => { self.emit(AnalysisError::not_callable(&other, span)); StaticType::Any diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 07104843..8c8e4b6c 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -1,6 +1,5 @@ use super::Analyser; use crate::error::AnalysisError; -use crate::types::TypeBinding; use itertools::{Itertools, izip}; use ndc_core::r#struct::StructInfo; use ndc_core::{NodeId, StaticType, TypeSignature}; @@ -23,6 +22,7 @@ impl Analyser { return_annotation: Option<&TypeExpr>, span: Span, ) -> StaticType { + let first_var = self.vars.count(); let type_signature = TypeSignature::from_annotated_bindings( parameters .iter() @@ -58,8 +58,7 @@ impl Analyser { parameters: type_signature.types(), return_type: Box::new(annotated_return.clone().unwrap_or_default()), }; - self.types - .declare(symbol, TypeBinding::Inferred(placeholder)); + self.types.declare(symbol, placeholder); Some(symbol) } else { None @@ -82,6 +81,11 @@ impl Analyser { let implicit_return = self.analyse_expr(body); let explicit_return = self.return_type_stack.pop().unwrap(); self.loop_depth = outer_loop_depth; + // Nothing outside the body can settle the type variables made in it, + // so the function's type doesn't carry any. + self.vars.settle_from(first_var); + let implicit_return = self.vars.resolve(&implicit_return); + let explicit_return = explicit_return.map(|ret| self.vars.resolve(&ret)); // Combine explicit `return` types with the block's implicit return type. let inferred_return = match explicit_return { @@ -117,7 +121,7 @@ impl Analyser { // `let`, evaluates to unit. An anonymous one evaluates to the function. match declared { Some(symbol) => { - let _ = self.types.update_binding_type(symbol, function_type); + self.types.declare(symbol, function_type); self.picks.variables.insert(id, symbol); StaticType::unit() } @@ -154,16 +158,12 @@ impl Analyser { let symbols = self.resolver.struct_symbols(id).clone(); self.struct_types .insert(symbols.type_symbol, Rc::clone(&info)); - self.types.declare( - symbols.constructor, - TypeBinding::Annotated(info.constructor_type()), - ); + self.types + .declare(symbols.constructor, info.constructor_type()); self.picks.variables.insert(id, symbols.constructor); for (index, &(getter, setter)) in symbols.accessors.iter().enumerate() { - self.types - .declare(getter, TypeBinding::Annotated(info.getter_type(index))); - self.types - .declare(setter, TypeBinding::Annotated(info.setter_type(index))); + self.types.declare(getter, info.getter_type(index)); + self.types.declare(setter, info.setter_type(index)); } self.picks.structs.insert(id, StructInfo::clone(&info)); @@ -200,15 +200,8 @@ impl Analyser { }; for (param, &node) in parameters.iter().zip(parameter_nodes) { - let has_annotation = param.type_name != StaticType::Any; - let binding = if has_annotation { - TypeBinding::Annotated(param.type_name.clone()) - } else { - TypeBinding::Inferred(StaticType::Any) - }; - let symbol = self.resolver.declared_symbol(node); - self.types.declare(symbol, binding); + self.types.declare(symbol, param.type_name.clone()); } parameters @@ -229,28 +222,20 @@ impl Analyser { match &lvalue.pattern { BindingPattern::Identifier { span, .. } => { // If there is a type annotation and the given type is not a subtype of the annotated type we emit an error + // The value is checked like an assignment to the annotated + // variable, so `let o: Option = None` settles `None`. if let Some(expected_type) = &expected_type - && !found_type.is_subtype(expected_type) + && !self.accepts_write(expected_type, &found_type) { - self.emit(AnalysisError::mismatched_types( - &found_type, - expected_type, - *span, - )); + self.emit_rejected_write(expected_type, &found_type, *span); } - let type_binding = match expected_type { - Some(annotated) => TypeBinding::Annotated(annotated), - None => TypeBinding::Inferred(found_type), - }; - + // An annotation decides the type, or else the value does. + let typ = expected_type.unwrap_or(found_type); let symbol = self.resolver.declared_symbol(lvalue.id); - self.types.declare(symbol, type_binding.clone()); + self.types.declare(symbol, typ.clone()); self.picks.variables.insert(lvalue.id, symbol); - self.result - .analysis - .variable_types - .insert(lvalue.id, type_binding.typ().clone()); + self.result.analysis.variable_types.insert(lvalue.id, typ); } BindingPattern::Sequence(seq) => { // If the type is a fixed-length Tuple whose arity doesn't match diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index b34fb0d8..2b8938e5 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -1,6 +1,6 @@ use crate::error::AnalysisError; use crate::overloads::CallKind; -use crate::types::{TypeBinding, TypeTable}; +use crate::types::{TypeTable, TypeVars}; use ahash::AHashMap; use ndc_analysis::{Analysis, Binding, Candidate}; use ndc_core::StaticType; @@ -37,6 +37,8 @@ pub struct Analyser { struct_registry: StructRegistry, /// The type of every value declared so far. types: TypeTable, + /// What the type variables in those types stand for, once known. + vars: TypeVars, /// What each struct's type symbol stands for, so `p: Point` lowers to /// `Point`'s struct type. Every other type name is built in. struct_types: AHashMap>, @@ -67,11 +69,12 @@ impl Analyser { ); let mut types = TypeTable::default(); for ((_, typ), symbol) in natives.into_iter().zip(resolver.native_symbols()) { - types.declare(symbol, TypeBinding::Inferred(typ)); + types.declare(symbol, typ); } Self { resolver, types, + vars: TypeVars::default(), struct_registry: StructRegistry::default(), struct_types: AHashMap::new(), return_type_stack: Vec::new(), @@ -85,6 +88,24 @@ impl Analyser { /// Take the accumulated analysis result (including any errors), /// resetting it for the next analysis. pub fn take_result(&mut self) -> AnalysisResult { + // Nothing after this batch can settle its type variables any more, + // so the ones left unsettled get their fallbacks, and no variable + // leaves the analyser. + self.vars.settle_from(0); + self.types.resolve_all(&self.vars); + let analysis = &mut self.result.analysis; + for table in [ + &mut analysis.expr_types, + &mut analysis.variable_types, + &mut analysis.inferred_return_types, + &mut analysis.function_types, + ] { + for typ in table.values_mut() { + *typ = self.vars.resolve(typ); + debug_assert!(!typ.any_var(&|_| true), "unsettled type variable in {typ}"); + } + } + let mut result = std::mem::take(&mut self.result); Layout::new(&self.resolver).lay_out( std::mem::take(&mut self.picks), @@ -159,8 +180,7 @@ impl Analyser { .extend(name_errors.into_iter().map(AnalysisError::from)); let declared = self.resolver.current_bindings()[bound_before..].to_vec(); for symbol in declared { - self.types - .declare(symbol, TypeBinding::Inferred(StaticType::Any)); + self.types.declare(symbol, StaticType::Any); } return StaticType::Any; } @@ -263,7 +283,10 @@ impl Analyser { ) -> StaticType { match expression { Expression::BoolLiteral(_) => StaticType::Bool, - Expression::NoneLiteral => StaticType::Option(Box::new(StaticType::Any)), + // What `None`, `[]` and `%{}` hold is only known from later uses. + Expression::NoneLiteral => { + StaticType::Option(Box::new(self.vars.fresh(StaticType::Any))) + } Expression::StringLiteral(_) => StaticType::String, Expression::NumericLiteral(NumericLiteral::Int64(_)) => StaticType::Int, Expression::NumericLiteral(NumericLiteral::Float64(_)) => StaticType::Float, @@ -288,7 +311,7 @@ impl Analyser { let symbol = self.resolver.innermost_reference(*id); let binding = Binding::Resolved(Candidate::Scalar(symbol)); self.picks.bindings.insert(*id, binding); - self.types.get_type(symbol).clone() + self.vars.resolve(self.types.get_type(symbol)) } Expression::Statement(inner) => { let typ = self.analyse_expr(inner); @@ -435,9 +458,11 @@ impl Analyser { StaticType::Tuple(types) } Expression::List { values } => { - let element_type = self.analyse_multiple_expression_with_same_type(values); - - StaticType::List(Box::new(element_type.unwrap_or(StaticType::Any))) + let element_type = match self.analyse_multiple_expression_with_same_type(values) { + Some(element_type) => element_type, + None => self.vars.fresh(StaticType::Any), + }; + StaticType::List(Box::new(element_type)) } Expression::Map { values, default } => { let mut key_type: Option = None; @@ -456,9 +481,19 @@ impl Analyser { Self::fold_lub(&mut value_type, default_type); } + let key_type = match key_type { + Some(key_type) => key_type, + None => self.vars.fresh(StaticType::Any), + }; + // A literal with keys but no values, like `%{1, 2}`, is a set. + let value_type = match value_type { + Some(value_type) => value_type, + None if values.is_empty() => self.vars.fresh(StaticType::unit()), + None => StaticType::unit(), + }; StaticType::Map { - key: Box::new(key_type.unwrap_or(StaticType::Any)), - value: Box::new(value_type.unwrap_or_else(StaticType::unit)), + key: Box::new(key_type), + value: Box::new(value_type), } } Expression::Return { value } => { diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 1f681389..7987f63c 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -12,11 +12,15 @@ fn analyse_with_globals( .expect("lex failed"); let expressions = Parser::from_tokens(tokens).parse().expect("parse failed"); let mut analyser = Analyser::new(globals); - let mut last_type = StaticType::unit(); for expression in &expressions { - last_type = analyser.analyse(expression); + analyser.analyse(expression); } - (last_type, analyser.take_result()) + let result = analyser.take_result(); + // The last statement's type as the whole program settled it. + let last_type = expressions.last().map_or_else(StaticType::unit, |last| { + result.analysis.expr_types[&last.id].clone() + }); + (last_type, result) } fn analyse_last_type_with_globals(source: &str, globals: Vec<(String, StaticType)>) -> StaticType { @@ -122,12 +126,6 @@ fn break_and_continue_need_a_loop_in_their_own_function() { } } -#[test] -fn destructuring_assignment_widens_each_target() { - let typ = analyse_last_type("let a = 1; let b = 2; (a, b) = (\"s\", 2); a"); - assert_eq!(typ, StaticType::Int.lub(&StaticType::String)); -} - #[test] fn break_and_continue_inside_a_loop_are_accepted() { for source in [ @@ -177,71 +175,6 @@ fn a_function_returns_the_join_of_its_body_and_every_return() { ); } -#[test] -fn inferred_list_index_assignment_widens_element_type() { - assert_eq!( - analyse_last_type_with_globals( - "let values = [1]; values[0] = \"two\"; values", - index_natives() - ), - StaticType::List(Box::new(StaticType::Any)), - ); -} - -#[test] -fn inferred_list_slice_assignment_widens_element_type() { - assert_eq!( - analyse_last_type_with_globals( - "let values = [1]; values[0..1] = [\"two\"]; values", - index_natives() - ), - StaticType::List(Box::new(StaticType::Any)), - ); -} - -#[test] -fn inferred_map_index_assignment_widens_value_and_preserves_key_type() { - assert_eq!( - analyse_last_type_with_globals( - "let values = %{\"one\": 1}; values[\"two\"] = \"two\"; values", - index_natives() - ), - StaticType::Map { - key: Box::new(StaticType::String), - value: Box::new(StaticType::Any), - }, - ); -} - -#[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", - [index_natives(), vec![("+".to_string(), add)]].concat(), - ), - 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 { @@ -475,6 +408,115 @@ fn same_arity_overloads_with_different_parameter_types_are_allowed() { ); } +#[test] +fn a_variable_keeps_its_inferred_type() { + let add = StaticType::Function { + parameters: Some(vec![StaticType::Int, StaticType::Float]), + return_type: Box::new(StaticType::Float), + }; + let globals = [index_natives(), vec![("+".to_string(), add)]].concat(); + let scalar = "mismatched types: found Float but expected Int"; + let list = "mismatched types: found List but expected List"; + for (source, expected) in [ + ("let x = 3; x = 0.5;", scalar), + ("let x = 3; x += 0.5;", scalar), + ("let a = 1; let b = 2; (a, b) = (0.5, 2);", scalar), + ("for x in [1] { x = 0.5; }", scalar), + ("let values = [1]; values[0] = 0.5;", scalar), + ("let values = [1]; values[0..1] = [0.5];", list), + ("let values = [1]; values[0] += 0.5;", scalar), + ("let values = %{\"one\": 1}; values[\"two\"] = 0.5;", scalar), + ] { + assert_eq!(errors(source, globals.clone()), vec![expected], "{source}"); + } +} + +#[test] +fn an_any_value_can_be_stored_anywhere() { + let any_value = vec![( + "anything".to_string(), + StaticType::Function { + parameters: Some(vec![]), + return_type: Box::new(StaticType::Any), + }, + )]; + assert_eq!( + analyse_last_type_with_globals("let x = 3; x = anything(); x", any_value), + StaticType::Int + ); +} + +#[test] +fn the_first_write_settles_what_an_empty_literal_holds() { + let list_of = |element| StaticType::List(Box::new(element)); + assert_eq!( + analyse_last_type_with_globals("let xs = []; xs[0] = 1; xs", index_natives()), + list_of(StaticType::Int) + ); + assert_eq!( + analyse_last_type_with_globals("let xs = []; xs[0] = [1.5]; xs", index_natives()), + list_of(list_of(StaticType::Float)) + ); + assert_eq!( + analyse_last_type_with_globals("let m = %{}; m[\"a\"] = 1; m", index_natives()), + StaticType::Map { + key: Box::new(StaticType::String), + value: Box::new(StaticType::Int), + } + ); + // Reads before the write see the settled type too, once the batch ends. + assert_eq!( + analyse_last_type_with_globals("let xs = []; let ys = xs; xs[0] = 1; ys", index_natives()), + list_of(StaticType::Int) + ); +} + +#[test] +fn an_annotation_settles_what_its_value_holds() { + let option_of = |inner| StaticType::Option(Box::new(inner)); + assert_eq!( + analyse_last_type("let o: Option = None; o"), + option_of(StaticType::Int) + ); + assert_eq!( + analyse_last_type("let p: (Int, Option) = (1, None); p"), + StaticType::Tuple(vec![StaticType::Int, option_of(StaticType::Int)]) + ); +} + +#[test] +fn what_nothing_settles_falls_back_to_any() { + assert_eq!( + analyse_last_type("let xs = []; xs"), + StaticType::List(Box::new(StaticType::Any)) + ); + assert_eq!( + analyse_last_type("None"), + StaticType::Option(Box::new(StaticType::Any)) + ); + // An empty map with nothing written to it is an empty set. + assert_eq!( + analyse_last_type("%{}"), + StaticType::Map { + key: Box::new(StaticType::Any), + value: Box::new(StaticType::unit()), + } + ); +} + +#[test] +fn a_function_settles_its_own_type_variables_when_its_body_ends() { + // `f` returns a list of unknown elements: they fall back to `Any` when + // `f` ends, so a caller's write can't settle them for every caller. + assert_eq!( + analyse_last_type_with_globals( + "fn f() { [] } let xs = f(); xs[0] = \"a\"; f()", + index_natives() + ), + StaticType::List(Box::new(StaticType::Any)) + ); +} + /// `len`, a native taking a `String`, for the test below. fn len_native() -> Vec<(String, StaticType)> { vec![( diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 2e24977c..fc5c7c65 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -1,4 +1,4 @@ -use crate::types::TypeTable; +use crate::types::{TypeTable, TypeVars}; use ndc_analysis::{Binding, Candidate}; use ndc_core::StaticType; use ndc_resolver::{BindingKind, Candidates, SymbolId, SymbolTable}; @@ -97,13 +97,20 @@ fn push_unique(out: &mut Vec, item: T) { #[derive(Clone, Copy)] pub(crate) struct Overloads<'a> { types: &'a TypeTable, + /// What the type variables in those types stand for so far. A function + /// that returns a captured `let xs = []` returns what `xs` holds. + vars: &'a TypeVars, /// Tells a `fn` from a plain binding like a `let`, which doesn't join overloads. symbols: &'a SymbolTable, } impl<'a> Overloads<'a> { - pub(crate) fn new(types: &'a TypeTable, symbols: &'a SymbolTable) -> Self { - Self { types, symbols } + pub(crate) fn new(types: &'a TypeTable, vars: &'a TypeVars, symbols: &'a SymbolTable) -> Self { + Self { + types, + vars, + symbols, + } } /// The candidate groups a call can reach, nearest scope first. A plain @@ -452,7 +459,10 @@ impl<'a> Overloads<'a> { positions, }); } - if !sig.iter().any(|t| matches!(t, StaticType::Any)) { + if !sig + .iter() + .any(|t| matches!(t, StaticType::Any | StaticType::Var(_))) + { return None; } let permissive: Vec = vec![StaticType::Any; sig.len()]; @@ -526,10 +536,12 @@ impl<'a> Overloads<'a> { .zip(sig) .all(|(param, arg)| !param.is_incompatible_with(arg)) } + // A variable whose type isn't known yet may hold a function. StaticType::Function { parameters: None, .. } - | StaticType::Any => true, + | StaticType::Any + | StaticType::Var(_) => true, _ => false, } } @@ -538,7 +550,7 @@ impl<'a> Overloads<'a> { /// turned out not to carry a function type (e.g. `Any`-typed callee). fn scalar_return_type(self, symbol: SymbolId) -> StaticType { match self.types.get_type(symbol) { - StaticType::Function { return_type, .. } => return_type.as_ref().clone(), + StaticType::Function { return_type, .. } => self.vars.resolve(return_type), _ => StaticType::Any, } } @@ -596,7 +608,6 @@ impl<'a> Overloads<'a> { #[cfg(test)] mod tests { use super::*; - use crate::types::TypeBinding; use ndc_resolver::{BindingKind, SymbolTable}; use std::cell::RefCell; @@ -631,7 +642,7 @@ mod tests { .into_iter() .map(|typ| { let symbol = symbol(BindingKind::Function); - table.declare(symbol, TypeBinding::Inferred(typ)); + table.declare(symbol, typ); symbol }) .collect(); @@ -655,8 +666,9 @@ mod tests { sig: &[StaticType], kind: CallKind, ) -> ResolvedCall { - SYMBOLS - .with_borrow(|table| Overloads::new(types, table).resolve_call(candidates, sig, kind)) + SYMBOLS.with_borrow(|table| { + Overloads::new(types, &TypeVars::default(), table).resolve_call(candidates, sig, kind) + }) } /// Asserts the call picks `expected` as its single overload. @@ -824,14 +836,12 @@ mod tests { fn a_variable_holding_a_function_ends_the_search() { let (mut types, fns) = table(vec![fn_type(vec![StaticType::Int], StaticType::Int)]); let variable = symbol(BindingKind::Plain); - types.declare( - variable, - TypeBinding::Inferred(fn_type(vec![StaticType::String], StaticType::Int)), - ); + types.declare(variable, fn_type(vec![StaticType::String], StaticType::Int)); let candidates = candidates(&[&[fns[0], variable]]); SYMBOLS.with_borrow(|table| { - let overloads = Overloads::new(&types, table); + let vars = TypeVars::default(); + let overloads = Overloads::new(&types, &vars, table); let resolved = overloads.resolve_call(&candidates, &[StaticType::Int], CallKind::Regular); assert_eq!(resolved.binding, Binding::None); @@ -846,13 +856,11 @@ mod tests { fn a_variable_ending_the_search_hides_only_the_functions_further_out() { let (mut types, fns) = table(vec![fn_type(vec![StaticType::Int], StaticType::Int)]); let variable = symbol(BindingKind::Plain); - types.declare( - variable, - TypeBinding::Inferred(fn_type(vec![StaticType::String], StaticType::Int)), - ); + types.declare(variable, fn_type(vec![StaticType::String], StaticType::Int)); SYMBOLS.with_borrow(|table| { - let overloads = Overloads::new(&types, table); + let vars = TypeVars::default(); + let overloads = Overloads::new(&types, &vars, table); assert!(overloads.hides_functions(&candidates(&[&[variable], &[fns[0]]]))); assert!(!overloads.hides_functions(&candidates(&[&[variable]]))); }); @@ -865,13 +873,14 @@ mod tests { fn a_variable_ends_the_search_whatever_its_type() { let (mut types, fns) = table(vec![fn_type(vec![StaticType::Int], StaticType::Int)]); let variable = symbol(BindingKind::Plain); - types.declare(variable, TypeBinding::Inferred(StaticType::Int)); + types.declare(variable, StaticType::Int); let candidates = candidates(&[&[variable], &[fns[0]]]); let resolved = resolve(&types, &candidates, &[StaticType::Int], CallKind::Regular); assert_eq!(resolved.binding, Binding::None); SYMBOLS.with_borrow(|table| { - let overloads = Overloads::new(&types, table); + let vars = TypeVars::default(); + let overloads = Overloads::new(&types, &vars, table); assert_eq!( overloads.variable_ending_search(&candidates), Some(variable) diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index b4c742c7..36a264cf 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -1,88 +1,151 @@ use ahash::AHashMap; -use ndc_core::StaticType; +use ndc_core::{StaticType, TypeVar}; use ndc_resolver::SymbolId; -#[derive(Debug, Clone)] -pub(crate) enum TypeBinding { - Inferred(StaticType), - Annotated(StaticType), -} - -impl TypeBinding { - pub fn typ(&self) -> &StaticType { - match self { - Self::Inferred(t) | Self::Annotated(t) => t, - } - } - - pub fn is_annotated(&self) -> bool { - matches!(self, Self::Annotated(_)) - } -} - /// The type of every value the name resolver declared, natives included, -/// keyed by its symbol. An assignment can widen an inferred type, but an -/// annotated one is fixed. +/// keyed by its symbol. A value keeps its type: an assignment must fit it. #[derive(Debug, Default)] pub(crate) struct TypeTable { - types: AHashMap, + types: AHashMap, } impl TypeTable { - /// Records the type of a value the resolver declared. - pub(crate) fn declare(&mut self, symbol: SymbolId, binding: TypeBinding) { - self.types.insert(symbol, binding); + /// Records the type of a value the resolver declared. A function's + /// placeholder type is replaced this way once its body is analysed. + pub(crate) fn declare(&mut self, symbol: SymbolId, typ: StaticType) { + self.types.insert(symbol, typ); } pub(crate) fn get_type(&self, symbol: SymbolId) -> &StaticType { self.types .get(&symbol) .expect("internal error: a value is analysed after its declaration") - .typ() } - /// Try to update a value's type. Returns `Err` with the annotated type if - /// the value has an explicit type annotation and cannot be widened. - pub(crate) fn update_binding_type( - &mut self, - symbol: SymbolId, - new_type: StaticType, - ) -> Result<(), StaticType> { - let binding = self - .types - .get_mut(&symbol) - .expect("internal error: a value is analysed after its declaration"); - if binding.is_annotated() { - return Err(binding.typ().clone()); + /// Replaces the settled type variables in every value's type with what + /// they stand for. + pub(crate) fn resolve_all(&mut self, vars: &TypeVars) { + for typ in self.types.values_mut() { + *typ = vars.resolve(typ); + debug_assert!(!typ.any_var(&|_| true), "unsettled type variable in {typ}"); } - *binding = TypeBinding::Inferred(new_type); - Ok(()) } } -#[cfg(test)] -mod tests { - use super::*; - use ndc_resolver::{BindingKind, SymbolTable}; +/// What each type variable of the analysis stands for once something settled +/// it. In `let xs = []; xs[0] = 1;`, the element type of `xs` is a variable +/// that the assignment settles as `Int`. +#[derive(Debug, Default)] +pub(crate) struct TypeVars { + /// What each variable stands for, once settled. + solutions: Vec>, + /// What each variable becomes if nothing settles it: `Any` for the + /// elements of `[]`, which is what `[]` was typed as before. + fallbacks: Vec, +} + +impl TypeVars { + /// A new variable, which becomes `fallback` if nothing settles it. + pub(crate) fn fresh(&mut self, fallback: StaticType) -> StaticType { + let var = + TypeVar(u32::try_from(self.solutions.len()).expect("fewer than 2^32 type variables")); + self.solutions.push(None); + self.fallbacks.push(fallback); + StaticType::Var(var) + } + + /// How many variables there are, so [`Self::settle_from`] can settle + /// the ones made after this point. + pub(crate) fn count(&self) -> usize { + self.solutions.len() + } + + fn solution(&self, var: TypeVar) -> Option<&StaticType> { + self.solutions[var.0 as usize].as_ref() + } + + /// `typ` with every settled variable replaced by what it stands for. + pub(crate) fn resolve(&self, typ: &StaticType) -> StaticType { + typ.replace_vars(&|var| self.solution(var).map(|solution| self.resolve(solution))) + } + + /// `typ`, or what it stands for if it's a settled variable. + fn shallow(&self, typ: &StaticType) -> StaticType { + match typ { + StaticType::Var(var) => self + .solution(*var) + .map_or_else(|| typ.clone(), |solution| self.shallow(solution)), + _ => typ.clone(), + } + } - #[test] - fn inferred_types_can_change_but_annotated_types_are_fixed() { - let mut symbols = SymbolTable::default(); - let inferred = symbols.declare("a".to_string(), BindingKind::Plain); - let annotated = symbols.declare("b".to_string(), BindingKind::Plain); - let mut table = TypeTable::default(); - table.declare(inferred, TypeBinding::Inferred(StaticType::Int)); - table.declare(annotated, TypeBinding::Annotated(StaticType::Int)); + /// Makes `a` and `b` the same type by settling the unsettled variables in + /// either, and returns whether they are the same now. `List` and + /// `List` are, once `?0` is `Int`. `Never` fits any variable without + /// settling it, since a value of that type never arrives. On a mismatch, + /// the variables settled before it stay settled. + pub(crate) fn unify(&mut self, a: &StaticType, b: &StaticType) -> bool { + let (a, b) = (self.shallow(a), self.shallow(b)); + match (&a, &b) { + (StaticType::Var(x), StaticType::Var(y)) if x == y => true, + (StaticType::Var(_), StaticType::Never) | (StaticType::Never, StaticType::Var(_)) => { + true + } + (StaticType::Var(var), other) | (other, StaticType::Var(var)) => { + // A variable can't stand for a type that contains it. + if self.resolve(other).any_var(&|other| other == *var) { + return false; + } + self.solutions[var.0 as usize] = Some(other.clone()); + true + } + (StaticType::Option(x), StaticType::Option(y)) + | (StaticType::Sequence(x), StaticType::Sequence(y)) + | (StaticType::List(x), StaticType::List(y)) + | (StaticType::Iterator(x), StaticType::Iterator(y)) + | (StaticType::MinHeap(x), StaticType::MinHeap(y)) + | (StaticType::MaxHeap(x), StaticType::MaxHeap(y)) + | (StaticType::Deque(x), StaticType::Deque(y)) => self.unify(x, y), + (StaticType::Tuple(xs), StaticType::Tuple(ys)) => { + xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| self.unify(x, y)) + } + (StaticType::Map { key: k1, value: v1 }, StaticType::Map { key: k2, value: v2 }) => { + let keys = self.unify(k1, k2); + let values = self.unify(v1, v2); + keys && values + } + ( + StaticType::Function { + parameters: p1, + return_type: r1, + }, + StaticType::Function { + parameters: p2, + return_type: r2, + }, + ) => { + let parameters = match (p1, p2) { + (Some(p1), Some(p2)) => { + p1.len() == p2.len() && p1.iter().zip(p2).all(|(x, y)| self.unify(x, y)) + } + (None, None) => true, + _ => false, + }; + let returns = self.unify(r1, r2); + parameters && returns + } + _ => a == b, + } + } - assert_eq!( - table.update_binding_type(inferred, StaticType::Number), - Ok(()) - ); - assert_eq!(table.get_type(inferred), &StaticType::Number); - assert_eq!( - table.update_binding_type(annotated, StaticType::Number), - Err(StaticType::Int) - ); - assert_eq!(table.get_type(annotated), &StaticType::Int); + /// Settles every variable made since `first` that nothing settled with + /// its fallback. + pub(crate) fn settle_from(&mut self, first: usize) { + for (solution, fallback) in self.solutions[first..] + .iter_mut() + .zip(&self.fallbacks[first..]) + { + solution.get_or_insert_with(|| fallback.clone()); + } } } diff --git a/ndc_core/src/lib.rs b/ndc_core/src/lib.rs index 9438362b..5179c31c 100644 --- a/ndc_core/src/lib.rs +++ b/ndc_core/src/lib.rs @@ -3,7 +3,7 @@ pub mod static_type; pub mod r#struct; pub use node_id::NodeId; -pub use static_type::{Parameter, StaticType, TypeSignature}; +pub use static_type::{Parameter, StaticType, TypeSignature, TypeVar}; use std::slice::Iter; pub struct FunctionRegistry { diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 4ebd7d46..287850e3 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -49,10 +49,19 @@ impl Parameter { } } +/// A type the analyser hasn't worked out yet. See [`StaticType::Var`]. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub struct TypeVar(pub u32); + #[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] pub enum StaticType { #[default] Any, + /// A type the analyser hasn't worked out yet, such as the element type + /// of `[]` before anything is stored in it. Only the analyser creates + /// these, and it replaces every one before handing its results on. Until + /// then, every method here treats one as `Any`. + Var(TypeVar), /// Bottom type for diverging expressions (`return`, `break`, `continue`). /// `Never` is a subtype of every type: `lub(T, Never) = T`. Never, @@ -234,12 +243,12 @@ impl StaticType { } // Any is the universal supertype - if matches!(other, Self::Any) { + if matches!(other, Self::Any | Self::Var(_)) { return true; } // Only Any satisfies the above; all other types fail here - if matches!(self, Self::Any) { + if matches!(self, Self::Any | Self::Var(_)) { return false; } @@ -349,7 +358,7 @@ impl StaticType { } // Any is the top type - if matches!(self, Self::Any) || matches!(other, Self::Any) { + if matches!(self, Self::Any | Self::Var(_)) || matches!(other, Self::Any | Self::Var(_)) { return Self::Any; } @@ -438,7 +447,7 @@ impl StaticType { fn glb(&self, other: &Self) -> Self { // Any is the top type: glb(Any, T) = T match (self, other) { - (Self::Any, t) | (t, Self::Any) => t.clone(), + (Self::Any | Self::Var(_), t) | (t, Self::Any | Self::Var(_)) => t.clone(), _ if self == other => self.clone(), _ if self.is_subtype(other) => self.clone(), _ if other.is_subtype(self) => other.clone(), @@ -516,11 +525,88 @@ impl StaticType { key.as_ref().clone(), value.as_ref().clone(), ])), - Self::Any => Some(Self::Any), + Self::Any | Self::Var(_) => Some(Self::Any), _ => None, } } + /// This type with each type variable that `replace` gives a type for + /// replaced by that type: `List` becomes `List` when `replace` + /// maps `?0` to `Int`. The other variables stay as they are. + #[must_use] + pub fn replace_vars(&self, replace: &impl Fn(TypeVar) -> Option) -> Self { + let map = |typ: &Self| Box::new(typ.replace_vars(replace)); + match self { + Self::Var(var) => replace(*var).unwrap_or_else(|| self.clone()), + Self::Function { + parameters, + return_type, + } => Self::Function { + parameters: parameters + .as_ref() + .map(|parameters| parameters.iter().map(|p| p.replace_vars(replace)).collect()), + return_type: map(return_type), + }, + Self::Option(t) => Self::Option(map(t)), + Self::Sequence(t) => Self::Sequence(map(t)), + Self::List(t) => Self::List(map(t)), + Self::Iterator(t) => Self::Iterator(map(t)), + Self::MinHeap(t) => Self::MinHeap(map(t)), + Self::MaxHeap(t) => Self::MaxHeap(map(t)), + Self::Deque(t) => Self::Deque(map(t)), + Self::Tuple(elements) => { + Self::Tuple(elements.iter().map(|e| e.replace_vars(replace)).collect()) + } + Self::Map { key, value } => Self::Map { + key: map(key), + value: map(value), + }, + Self::Any + | Self::Never + | Self::Bool + | Self::Struct { .. } + | Self::Number + | Self::Float + | Self::Int + | Self::String => self.clone(), + } + } + + /// Whether some type variable in this type satisfies `test`: + /// `any_var(&|_| true)` asks whether the type has any variables left. + pub fn any_var(&self, test: &impl Fn(TypeVar) -> bool) -> bool { + match self { + Self::Var(var) => test(*var), + Self::Function { + parameters, + return_type, + } => { + return_type.any_var(test) + || parameters + .iter() + .flatten() + .any(|parameter| parameter.any_var(test)) + } + Self::Option(t) + | Self::Sequence(t) + | Self::List(t) + | Self::Iterator(t) + | Self::MinHeap(t) + | Self::MaxHeap(t) + | Self::Deque(t) => t.any_var(test), + Self::Tuple(elements) => elements.iter().any(|e| e.any_var(test)), + Self::Map { key, value } => key.any_var(test) || value.any_var(test), + Self::Any + | Self::Never + | Self::Bool + | Self::Struct { .. } + | Self::Number + | Self::Float + | Self::Int + | Self::String => false, + } + } + #[must_use] pub fn unit() -> Self { Self::Tuple(vec![]) @@ -662,7 +748,7 @@ impl StaticType { /// This is true for `Function` types and for `Any` (which might be a /// function at runtime). Concrete non-function types return `false`. pub fn could_be_callable(&self) -> bool { - matches!(self, Self::Function { .. } | Self::Any) + matches!(self, Self::Function { .. } | Self::Any | Self::Var(_)) } pub fn is_fn_and_matches(&self, types: &[Self]) -> bool { @@ -685,7 +771,7 @@ impl StaticType { pub fn unpack(&self) -> Option + '_>> { match self { // Any just unpacks to an infinite list of Any - Self::Any => Some(Box::new(std::iter::repeat(&Self::Any))), + Self::Any | Self::Var(_) => Some(Box::new(std::iter::repeat(&Self::Any))), Self::List(elem) | Self::Sequence(elem) | Self::Iterator(elem) @@ -711,6 +797,7 @@ impl fmt::Display for StaticType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Any => write!(f, "Any"), + Self::Var(_) => write!(f, "_"), Self::Never => write!(f, "Never"), Self::Bool => write!(f, "Bool"), Self::Function { diff --git a/tests/functional/programs/004_basic/060_an_index_assignment_keeps_the_element_type.ndc b/tests/functional/programs/004_basic/060_an_index_assignment_keeps_the_element_type.ndc new file mode 100644 index 00000000..85d94186 --- /dev/null +++ b/tests/functional/programs/004_basic/060_an_index_assignment_keeps_the_element_type.ndc @@ -0,0 +1,5 @@ +// A container keeps the element type it was inferred with: storing a String +// in a List is an error rather than widening the list. +// expect-error: mismatched types: found String but expected Int +let values = [1]; +values[0] = "two"; diff --git a/tests/functional/programs/004_basic/060_inferred_index_assignment_widens.ndc b/tests/functional/programs/004_basic/060_inferred_index_assignment_widens.ndc deleted file mode 100644 index 121916e2..00000000 --- a/tests/functional/programs/004_basic/060_inferred_index_assignment_widens.ndc +++ /dev/null @@ -1,11 +0,0 @@ -let values = [1]; -values[0] = "two"; -// The concatenation requires values[0] to have widened from Int to Any so -// that operator dispatch is deferred until runtime. -assert_eq(values[0] ++ "!", "two!"); - -let mapped = %{"one": 1}; -mapped["two"] = "two"; -// Map value inference must widen for the same reason while preserving the -// String key type. -assert_eq(mapped["two"] ++ "!", "two!"); diff --git a/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc b/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc new file mode 100644 index 00000000..593b0b2f --- /dev/null +++ b/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc @@ -0,0 +1,6 @@ +// A variable keeps the type its first value gave it, so a call resolved +// against that type, like the `+` in `f`, stays right. +// expect-error: mismatched types: found Float but expected Int +let x = 1; +fn f() { x + 1 } +x = 2.5; diff --git a/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc b/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc index 264e406a..f5d148ee 100644 --- a/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc +++ b/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc @@ -1,6 +1,5 @@ // The call would resolve if the argument type were narrower, so the error // carries help pointing at a cast rather than leaving the user at a dead end. // expect-error: Cast to say what the value holds -let line = "a b c"; -line = line.split(" "); -print(line.remove(0)) +let words: Sequence = "a b c".split(" "); +print(words.remove(0)) diff --git a/tests/functional/programs/005_functions/059_a_function_returns_what_its_captured_list_holds.ndc b/tests/functional/programs/005_functions/059_a_function_returns_what_its_captured_list_holds.ndc new file mode 100644 index 00000000..55d76bd6 --- /dev/null +++ b/tests/functional/programs/005_functions/059_a_function_returns_what_its_captured_list_holds.ndc @@ -0,0 +1,9 @@ +// `get` returns the captured `xs`, so once `xs` is settled as a List, +// a call of `get` is a List as well, and picks the same overload. +fn f(x: List) { "int" } +fn f(x: List) { "any" } +let xs = []; +fn get() { xs } +xs ++= [1]; +print(f(xs), f(get())); +// expect-output: int int diff --git a/tests/functional/programs/006_lists/050_a_loop_variable_keeps_the_element_type.ndc b/tests/functional/programs/006_lists/050_a_loop_variable_keeps_the_element_type.ndc new file mode 100644 index 00000000..f5f9d399 --- /dev/null +++ b/tests/functional/programs/006_lists/050_a_loop_variable_keeps_the_element_type.ndc @@ -0,0 +1,6 @@ +// A loop variable keeps the element type: `line` is a String, so it can't +// hold the list `split` returns. Give the list a name of its own. +// expect-error: mismatched types: found List but expected String +for line in ["a b c"] { + line = line.split(" "); +} diff --git a/tests/functional/programs/006_lists/050_remove_on_widened_sequence.ndc b/tests/functional/programs/006_lists/050_remove_on_widened_sequence.ndc deleted file mode 100644 index 19a1a8a3..00000000 --- a/tests/functional/programs/006_lists/050_remove_on_widened_sequence.ndc +++ /dev/null @@ -1,7 +0,0 @@ -// Reassignment widens `line` to Sequence, which no longer matches the -// List parameter of `remove`. The analyser will not guess that the value is -// really a list — cast to say so. -// expect-output: a ["b","c"] -let line = "a b c"; -line = line.split(" "); -print((line as List).remove(0), line) diff --git a/tests/functional/programs/006_lists/051_an_in_place_append_settles_the_element_type.ndc b/tests/functional/programs/006_lists/051_an_in_place_append_settles_the_element_type.ndc new file mode 100644 index 00000000..2979d89b --- /dev/null +++ b/tests/functional/programs/006_lists/051_an_in_place_append_settles_the_element_type.ndc @@ -0,0 +1,6 @@ +// `++=` appends in place, so the empty list takes the type of what it +// appends, and a later write must fit it. +// expect-error: mismatched types: found String but expected Int +let xs = []; +xs ++= [1, 2]; +xs[0] = "a"; diff --git a/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc b/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc new file mode 100644 index 00000000..11a7d640 --- /dev/null +++ b/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc @@ -0,0 +1,7 @@ +// `++=` on a tuple of lists appends to each one, so each empty list takes +// the type of what it appends. +// expect-error: mismatched types: found String but expected Int +let t = ([], []); +t ++= [1, 2]; +let a, b = t; +a[0] = "x"; diff --git a/tests/functional/programs/007_map_and_set/027_op_assign_empty_map_error.ndc b/tests/functional/programs/007_map_and_set/027_op_assign_empty_map_error.ndc index 592a4897..60a71193 100644 --- a/tests/functional/programs/007_map_and_set/027_op_assign_empty_map_error.ndc +++ b/tests/functional/programs/007_map_and_set/027_op_assign_empty_map_error.ndc @@ -1,5 +1,5 @@ -// An empty map without a default has value type `()`, so no `+` overload can -// ever apply and the analyser rejects the call. -// expect-error: No function called '+' found that matches the arguments '(), Int' +// `%{}` doesn't say what its values are, so `+=` on one of them is checked +// when the program runs, and fails there because the key is missing. +// expect-error: Key not found in map let map = %{}; map["invalid key"] += 3; diff --git a/tests/functional/programs/007_map_and_set/029_an_empty_map_takes_the_types_written_to_it.ndc b/tests/functional/programs/007_map_and_set/029_an_empty_map_takes_the_types_written_to_it.ndc new file mode 100644 index 00000000..cdd13465 --- /dev/null +++ b/tests/functional/programs/007_map_and_set/029_an_empty_map_takes_the_types_written_to_it.ndc @@ -0,0 +1,6 @@ +// The first write to an empty map settles its key and value types, and later +// writes must fit them. +// expect-error: mismatched types: found String but expected Int +let counts = %{}; +counts["a"] = 1; +counts["b"] = "two"; diff --git a/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc b/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc index 88881cd5..c373ba22 100644 --- a/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc +++ b/tests/functional/programs/013_vector_math/010_vector_op_assignment.ndc @@ -1,6 +1,6 @@ // Compound assignment must use the vec return type, not the underlying -// scalar's return. `a += (3, 4)` on a `Tuple` lvalue must -// widen-check against `Tuple`, not `Int`. +// scalar's return. `a += (3, 4)` on a `Tuple` lvalue must be +// checked against `Tuple`, not `Int`. let a: Tuple = (1, 2); a += (3, 4); assert_eq(a, (4, 6)); @@ -14,9 +14,8 @@ let c: Tuple = (3, 4); c *= 2; assert_eq(c, (6, 8)); -// Without an annotation the inferred lvalue type must also widen -// correctly. `d` starts as `Tuple` and a follow-up read -// must still see the precise tuple element types. +// Without an annotation the same holds: `d` is `Tuple`, and a +// follow-up read still sees the precise tuple element types. let d = (1, 2); d += (10, 20); let e: Tuple = d + (100, 200); diff --git a/tests/functional/programs/900_bugs/bug0015_unpack_mismatched_tuple_arity.ndc b/tests/functional/programs/900_bugs/bug0015_unpack_mismatched_tuple_arity.ndc index cdf6a84f..b97ac27d 100644 --- a/tests/functional/programs/900_bugs/bug0015_unpack_mismatched_tuple_arity.ndc +++ b/tests/functional/programs/900_bugs/bug0015_unpack_mismatched_tuple_arity.ndc @@ -1,9 +1,8 @@ -// Destructuring let where the variable was declared as () but later -// reassigned to a 2-tuple should not cause a false "not declared" error. -// The analyser widens the type via LUB on reassignment. +// Destructuring a captured tuple that was reassigned after the function was +// declared should not cause a false "not declared" error. // expect-output: 4 -let pos = (); +let pos = (0, 0); pos = (1, 3); fn use_pos() { diff --git a/tests/functional/programs/900_bugs/bug0037_closures_keep_block_scoped_variables.ndc b/tests/functional/programs/900_bugs/bug0037_closures_keep_block_scoped_variables.ndc index 65dc7e1b..64263e66 100644 --- a/tests/functional/programs/900_bugs/bug0037_closures_keep_block_scoped_variables.ndc +++ b/tests/functional/programs/900_bugs/bug0037_closures_keep_block_scoped_variables.ndc @@ -63,7 +63,7 @@ fn main() { let call = fn() { 0 }; let build = fn() { 0 }; let get = fn() { 0 }; - let set = fn() { 0 }; + let set = fn() { }; { fn helper() { 5 } call = fn() { helper() }; From b618881c8a35a3ed3cf0bee6564f009a0148344b Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 14:40:59 +0100 Subject: [PATCH 02/39] =?UTF-8?q?feat(analyser):=20give=20built-ins=20gene?= =?UTF-8?q?ric=20signatures=20=F0=9F=A7=AC=20(#287)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built-in functions can now declare generic signatures, and a call works out what their type parameters stand for from its arguments. This is step 2a of the type system RFC (#281). It also closes the gap #286 left: a call now settles the types of its arguments. Declaring a signature: - `#[function(signature = "(List, T)")]` replaces the types read off the Rust parameters. The macro parses the signature with `ndc_core`'s parser while it expands, so a malformed signature or a wrong number of parameters is a compile error at the attribute. Hand-registered natives use `StaticType::signature("…")`. - The syntax is the language's own: `(Map, K) -> V`, tuples `(A, B)` and `(A,)`, `Fn(T) -> U`, and `(*)` for any arguments (not allowed in the macro, whose functions take a fixed number). Without `-> …` a function returns `()`. How calls use it: - Overload resolution still picks the overload as before, treating a type parameter like `Any`. The picked signature then gets fresh type variables for its parameters, and every argument is bound to its parameter. `xs.push(1)` settles an empty `xs` as `List`, and the call's type is the instantiated return type (`xs.first()` is an `Int`). - An argument that doesn't fit is an error: `[1].push(1.0)` reports "No function called 'push' found that matches the arguments 'List, Float'" (#129). - A parameter of type `Any`, or a call whose overload is only picked at runtime, settles what's unknown in its arguments as `Any`, because the function may store anything in them. A call picked at runtime has `Any` in place of its candidates' type parameters. - Index writes are calls too: `xs[i] = v` commits the `[]=` signature, with the container's element type standing in for the value, and `m[k] += v` also commits `[]` to read the old value. A failed read is reported once. - Calling a function value, as in `fs[0](x)`, binds its arguments to the function's parameter types, like a named call. A generic built-in used as a value (`let p = push`) gets one instantiated type there. - At runtime, a type parameter matches any value, like `Any`. Converted so far: the list module, and the `[]`, `[]=`, `++` and `++=` operators for lists and maps. A list is indexed by an `Int` (giving a `T`), by a range (giving a `List`), or by an index of unknown type (giving `Any`), so an untyped index keeps the allocation-free path. The rest of the stdlib follows in 2b. Behaviour changes: - `++` and `++=` on lists keep one element type, so `[1] ++ ["a"]` is an error. A `List` accepts both. - A map is read and written with keys of its key type: after `let m = %{}; m[1] = 2`, `m["a"] = 3` is an error. - A map whose default is a function, as in `%{:fn() => []}`, has the function's return type as its value type, because the function is called to make each missing value. - Calling a function value with arguments it doesn't accept is an error: "A Function(Int) -> Int doesn't accept the arguments 'String'". - `pop`, `pop_left`, `first` and `last` are typed to return `T`. On an empty list `pop` and `pop_left` return `()` at runtime, as before. - `xs.remove(xs.len())` reports "index … is out of bounds" instead of panicking. Still to come: - In-place operators keep the rule from #286 that settles their target from the right operand, because they don't commit a signature yet. That comes with constraint solving in step 3, which also lets overload matching itself see through type variables. - Until 2b converts them, built-ins that take `Sequence` or `Any` settle a not-yet-known list as `List`, so `let xs = []; xs.len; xs.push(1); xs.push("a")` is accepted. - An annotated map with a function default, `let m: Map> = %{:fn() => []}`, is rejected, because the closure doesn't see the expected type. Expected function types come in 2c. - Binding a type parameter is order-dependent where subtyping still applies; that goes with subtyping in step 4. Tests and programs that relied on the old types were updated: cyclic lists are annotated `List`, maps mixing numeric key types are annotated `Map`, and two map tests use keys of one type. The manual's section on reassignment describes how calls settle types. While checking the manual I found two examples that don't run on `feat/type-checker`, before this change too: tuple concatenation with `++=` in `tuple.md`, and the closure example in `function.md`, whose `x.sum()` finds no overload for a `List`. Closes #129. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + manual/src/reference/variables-and-scopes.md | 10 +- ndc_analyser/src/analyser/assignments.rs | 108 ++++--- ndc_analyser/src/analyser/calls.rs | 114 +++++++- ndc_analyser/src/analyser/mod.rs | 26 +- ndc_analyser/src/analyser/tests.rs | 139 ++++++++- ndc_analyser/src/error.rs | 15 + ndc_analyser/src/overloads.rs | 22 +- ndc_analyser/src/types.rs | 108 ++++++- ndc_core/src/lib.rs | 1 + ndc_core/src/signature.rs | 268 ++++++++++++++++++ ndc_core/src/static_type.rs | 76 +++-- ndc_macros/Cargo.toml | 2 + ndc_macros/src/function.rs | 67 ++++- ndc_stdlib/src/index.rs | 81 ++---- ndc_stdlib/src/list.rs | 57 ++-- ndc_vm/src/value/mod.rs | 18 +- .../001_math/049_numeric_key_identity.ndc | 8 +- ...0_a_call_picked_at_runtime_returns_any.ndc | 5 + .../053_push_checks_the_element_type.ndc | 4 + .../054_remove_past_the_end_is_an_error.ndc | 4 + .../019_map_set_comprehensions.ndc | 1 - .../024_op_assign_default_value.ndc | 6 +- ...p_is_written_with_keys_of_its_key_type.ndc | 5 + .../017_encode_error_cycle.ndc | 2 +- .../025_lossy_error_cycle.ndc | 2 +- .../bug0033_cyclic_container_diagnostic.ndc | 2 +- 27 files changed, 950 insertions(+), 202 deletions(-) create mode 100644 ndc_core/src/signature.rs create mode 100644 tests/functional/programs/005_functions/060_a_call_picked_at_runtime_returns_any.ndc create mode 100644 tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc create mode 100644 tests/functional/programs/006_lists/054_remove_past_the_end_is_an_error.ndc create mode 100644 tests/functional/programs/007_map_and_set/030_a_map_is_written_with_keys_of_its_key_type.ndc diff --git a/Cargo.lock b/Cargo.lock index 8294e325..064cc820 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,6 +1245,7 @@ dependencies = [ name = "ndc_macros" version = "0.1.0" dependencies = [ + "ndc_core", "proc-macro2", "quote", "syn", diff --git a/manual/src/reference/variables-and-scopes.md b/manual/src/reference/variables-and-scopes.md index a2dafbbd..62e6663c 100644 --- a/manual/src/reference/variables-and-scopes.md +++ b/manual/src/reference/variables-and-scopes.md @@ -68,7 +68,15 @@ counts["a"] = 1; // counts is Map from here on counts["b"] = "two"; // ERROR: mismatched types: found String but expected Int ``` -Only assignments settle these types. When nothing does, as in `let xs = []; xs.push(1)`, they stay `Any`. +Calls settle them too, through the types the called function takes. `push` takes a `List` and a `T`, so pushing an `Int` makes the list a `List`, and later pushes have to fit: + +```ndc +let xs = []; +xs.push(1); // xs is List from here on +xs.push("two"); // ERROR: No function called 'push' found that matches the arguments 'List, String' +``` + +When nothing settles them, they stay `Any`. A variable that only gets its real value later holds an `Option`: diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 210080f1..811832a3 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -25,7 +25,7 @@ impl Analyser { // what it stores has to fit that type. Where the target's type isn't // fully known, the right operand settles it: in // `let xs = []; xs ++= [1]`, the elements of `xs` are `Int`. - let left_type = if left_type.any_var(&|_| true) { + let left_type = if left_type.has_vars() { match &left_type { // A tuple target with a scalar operand applies the operand to // each element: `t ++= [1]` appends to every list in `t`. @@ -143,58 +143,78 @@ impl Analyser { } AssignmentTarget::Index { index, value } => { // The container before the index, in source order like the resolver. - let type_of_index_target = self.analyse_expr(value); + let container = self.analyse_expr(value); let index_type = self.analyse_expr(index); - // Writing `m[k]` settles the key type of a map whose keys - // aren't known yet, as in `let m = %{}; m["a"] = 1`. - if let StaticType::Map { key, .. } = &type_of_index_target - && matches!(**key, StaticType::Var(_)) - { - self.vars.unify(key, &index_type); - } - - let get_args = [type_of_index_target.clone(), index_type.clone()]; - let set_args = [ - type_of_index_target.clone(), - index_type.clone(), - StaticType::Any, - ]; + // `xs[i] = v` is the call `[]=(xs, i, v)`. The value isn't + // analysed yet, so what the container holds stands in for + // it, and a slice like `xs[0..2]` stores a list of that. + let element = container.index_element_type(); + let stored = match &element { + Some(element) + if Self::index_type_is_slice(&index_type) + && matches!(container, StaticType::List(_)) => + { + StaticType::List(Box::new(element.clone())) + } + Some(element) => element.clone(), + None => StaticType::Any, + }; // Indexing isn't operator-form for vec purposes: there's no // natural broadcast story for `(list_a, list_b)[i]`. Only an // augmented assignment reads the old value through `[]`. + let errors_before = self.result.errors.len(); if let Some(getters) = self.resolver.getter_candidates(lvalue.id) { - let getter = self - .overloads() - .resolve_call(getters, &get_args, CallKind::Regular) - .binding; - self.picks.getters.insert(lvalue.id, getter); - } - let setter = self - .overloads() - .resolve_call( - self.resolver.setter_candidates(lvalue.id), - &set_args, - CallKind::Regular, - ) - .binding; - self.picks.setters.insert(lvalue.id, setter); - - if Self::index_type_is_slice(&index_type) - && let StaticType::List(element) = &type_of_index_target - { - return StaticType::List(element.clone()); + let get_args = [container.clone(), index_type.clone()]; + let getter = + self.overloads() + .resolve_call(getters, &get_args, CallKind::Regular); + if matches!(getter.binding, Binding::None) { + self.emit(AnalysisError::function_not_found("[]", &get_args, span)); + } else { + self.commit_call( + &getter.binding, + &get_args, + getter.return_type, + "[]", + span, + ); + } + self.picks.getters.insert(lvalue.id, getter.binding); } - if let Some(t) = type_of_index_target.index_element_type() { - t - } else { - self.emit(AnalysisError::unable_to_index_into( - &type_of_index_target, - span, - )); - StaticType::Any + // Committing the setter checks the index against the + // container, and settles what neither is known to hold yet, + // as the key type in `let m = %{}; m["a"] = 1`. + let set_args = [container.clone(), index_type, stored.clone()]; + let setter = self.overloads().resolve_call( + self.resolver.setter_candidates(lvalue.id), + &set_args, + CallKind::Regular, + ); + // When reading the old value failed, storing the new one + // fails the same way, which is reported once. + let read_failed = self.result.errors.len() > errors_before; + match setter.binding { + _ if read_failed => {} + Binding::None if element.is_none() && !container.is_unknown() => { + self.emit(AnalysisError::unable_to_index_into(&container, span)); + } + Binding::None => { + self.emit(AnalysisError::function_not_found("[]=", &set_args, span)); + } + _ => { + self.commit_call( + &setter.binding, + &set_args, + setter.return_type, + "[]=", + span, + ); + } } + self.picks.setters.insert(lvalue.id, setter.binding); + self.vars.resolve(&stored) } // What `(a, b) = …` can store is a tuple of what each target can. AssignmentTarget::Sequence(seq) => StaticType::Tuple( diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index f768737b..f0575b21 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -1,11 +1,11 @@ use super::Analyser; use crate::error::AnalysisError; use crate::overloads::{CallKind, Overloads, ResolvedCall}; -use ndc_analysis::Binding; +use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType}; use ndc_lexer::Span; use ndc_parser::{Expression, ExpressionLocation}; -use ndc_resolver::Candidates; +use ndc_resolver::{Candidates, SymbolId}; impl Analyser { /// Why no candidate accepts a call of `name` with arguments of these types: @@ -35,6 +35,56 @@ impl Analyser { } } + /// Settles the types a call's arguments leave unknown from the overload + /// it picked, and returns the call's type. The picked signature gets new + /// variables for its type parameters, and each argument is passed where + /// its parameter is expected: `push(xs, 1)` with `push(List, T)` + /// settles `xs` as a `List`, and `push(xs, "a")` then doesn't fit. + /// When the pick is left to the program as it runs, any function may get + /// the arguments, so what's unknown in them becomes `Any`. + pub(super) fn commit_call( + &mut self, + binding: &Binding, + arguments: &[StaticType], + return_type: StaticType, + name: &str, + span: Span, + ) -> StaticType { + let picked = match binding { + Binding::Resolved(Candidate::Scalar(symbol)) => Some(*symbol), + // Element-wise operators on tuples leave the types they have. + Binding::Resolved(Candidate::Vec(_)) => return return_type, + Binding::Dynamic(_) | Binding::None => None, + }; + let signature = picked.map(|symbol| { + let declared = self.types.get_type(symbol).clone(); + self.vars.instantiate(&declared) + }); + let Some(StaticType::Function { + parameters: Some(parameters), + return_type: declared_return, + }) = signature + else { + for argument in arguments { + self.vars.settle_unknowns(argument, &StaticType::Any); + } + return return_type; + }; + + let mut fits = true; + for (parameter, argument) in parameters.iter().zip(arguments) { + fits &= self.vars.bind_argument(parameter, argument); + } + if !fits { + let arguments = arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + self.emit(AnalysisError::function_not_found(name, &arguments, span)); + } + self.vars.resolve(&declared_return) + } + /// Overload resolution over the types declared so far. pub(super) fn overloads(&self) -> Overloads<'_> { Overloads::new(&self.types, &self.vars, self.resolver.symbols()) @@ -64,10 +114,22 @@ impl Analyser { ); if matches!(binding, Binding::None) { - let error = self.no_match(getters, member, &[receiver_type], member_span); + let error = self.no_match( + getters, + member, + std::slice::from_ref(&receiver_type), + member_span, + ); self.emit(error); } + let return_type = self.commit_call( + &binding, + std::slice::from_ref(&receiver_type), + return_type, + member, + member_span, + ); self.picks.getters.insert(id, binding); return_type } @@ -88,12 +150,47 @@ impl Analyser { // get their slots first. let Expression::Identifier { name, .. } = &function.expression else { let callee_type = self.analyse_expr(function); - for arg in arguments { - self.analyse_expr(arg); - } + let arguments = arguments + .iter() + .map(|arg| self.analyse_expr(arg)) + .collect::>(); return match callee_type { - StaticType::Function { return_type, .. } => *return_type, - StaticType::Any | StaticType::Var(_) => StaticType::Any, + // The callee's type says what it takes, like a picked overload's. + StaticType::Function { + parameters: Some(parameters), + return_type, + } if parameters.len() == arguments.len() => { + let mut fits = true; + for (parameter, argument) in parameters.iter().zip(&arguments) { + fits &= self.vars.bind_argument(parameter, argument); + } + if !fits { + let arguments = arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + let callee_type = StaticType::Function { + parameters: Some(parameters), + return_type: return_type.clone(), + }; + self.emit(AnalysisError::callee_rejects_arguments( + &self.vars.resolve(&callee_type), + &arguments, + span, + )); + } + self.vars.resolve(&return_type) + } + // Nothing tells what the callee does with its arguments. + callee if callee.could_be_callable() => { + for argument in &arguments { + self.vars.settle_unknowns(argument, &StaticType::Any); + } + match callee { + StaticType::Function { return_type, .. } => *return_type, + _ => StaticType::Any, + } + } other => { self.emit(AnalysisError::not_callable(&other, span)); StaticType::Any @@ -125,6 +222,7 @@ impl Analyser { return StaticType::Any; } + let return_type = self.commit_call(&binding, &type_sig, return_type, name, span); self.picks.bindings.insert(function.id, binding); return_type } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 2b8938e5..acb25da8 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -102,7 +102,7 @@ impl Analyser { ] { for typ in table.values_mut() { *typ = self.vars.resolve(typ); - debug_assert!(!typ.any_var(&|_| true), "unsettled type variable in {typ}"); + debug_assert!(!typ.has_vars(), "unsettled type variable in {typ}"); } } @@ -239,7 +239,8 @@ impl Analyser { } } if let Some(default) = default { - let found = self.analyse_with_expected(default, value); + let found = + Self::default_value_type(self.analyse_with_expected(default, value)); Self::fold_lub(&mut value_type, found); } StaticType::Map { @@ -311,7 +312,14 @@ impl Analyser { let symbol = self.resolver.innermost_reference(*id); let binding = Binding::Resolved(Candidate::Scalar(symbol)); self.picks.bindings.insert(*id, binding); - self.vars.resolve(self.types.get_type(symbol)) + // A generic built-in used as a value, as in `let p = push`, + // has one type there, which its uses settle. + let typ = self.vars.resolve(self.types.get_type(symbol)); + if typ.contains(&|part| matches!(part, StaticType::Generic(_))) { + self.vars.instantiate(&typ) + } else { + typ + } } Expression::Statement(inner) => { let typ = self.analyse_expr(inner); @@ -477,7 +485,7 @@ impl Analyser { // Reads can produce the default value, so its type is part of // the map's value type. if let Some(default) = default { - let default_type = self.analyse_expr(default); + let default_type = Self::default_value_type(self.analyse_expr(default)); Self::fold_lub(&mut value_type, default_type); } @@ -592,6 +600,16 @@ impl Analyser { element_type } + /// The type of the values a map default of type `default` produces. A + /// function default, as in `%{:fn() => []}`, is called for each missing + /// key, so the values are what it returns. + fn default_value_type(default: StaticType) -> StaticType { + match default { + StaticType::Function { return_type, .. } => *return_type, + other => other, + } + } + /// Fold a new type into an accumulator via least-upper-bound. fn fold_lub(acc: &mut Option, new_type: StaticType) { match acc { diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 7987f63c..c99de234 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -40,14 +40,21 @@ fn analyse_last_type(source: &str) -> StaticType { /// `[]` and `[]=`, which index reads and writes go through. Name resolution /// rejects index access without them, as it would any other unknown function. fn index_natives() -> Vec<(String, StaticType)> { - let any_function = StaticType::Function { - parameters: None, - return_type: Box::new(StaticType::Any), - }; - vec![ - ("[]".to_string(), any_function.clone()), - ("[]=".to_string(), any_function), + // The stdlib's list and map overloads. + [ + ("[]", "(List, Int) -> T"), + ("[]", "(List, Iterator) -> List"), + ("[]", "(Map, K) -> V"), + ("[]=", "(List, Int, T)"), + ("[]=", "(List, Iterator, Sequence)"), + ("[]=", "(Map, K, V)"), ] + .into_iter() + .map(|(name, signature)| { + let typ = StaticType::signature(signature).expect("valid signature"); + (name.to_string(), typ) + }) + .collect() } /// Every error analysing `source` reports, in order. @@ -484,6 +491,124 @@ fn an_annotation_settles_what_its_value_holds() { ); } +/// Natives with these signatures, as a stdlib would declare them. +fn natives(signatures: &[(&str, &str)]) -> Vec<(String, StaticType)> { + signatures + .iter() + .map(|(name, signature)| { + let typ = StaticType::signature(signature).expect("valid signature"); + ((*name).to_string(), typ) + }) + .collect() +} + +#[test] +fn a_call_settles_its_arguments_through_the_picked_signature() { + let globals = natives(&[("push", "(List, T)")]); + assert_eq!( + analyse_last_type_with_globals("let xs = []; push(xs, 1); xs", globals.clone()), + StaticType::List(Box::new(StaticType::Int)) + ); + assert_eq!( + errors("let xs = [1]; push(xs, \"a\");", globals), + vec!["No function called 'push' found that matches the arguments 'List, String'"] + ); +} + +#[test] +fn each_call_instantiates_the_signature_afresh() { + let globals = natives(&[("first", "(List) -> T")]); + assert_eq!( + analyse_last_type_with_globals("first([1.5])", globals.clone()), + StaticType::Float + ); + assert_eq!( + analyse_last_type_with_globals("first([1]); first([\"a\"])", globals), + StaticType::String + ); +} + +#[test] +fn a_call_picked_at_runtime_has_no_type_parameters_in_its_type() { + // `xs` could be anything, so `reversed` is picked when the program runs + // and its `T` is never known. + let globals = natives(&[("reversed", "(List) -> List")]); + assert_eq!( + analyse_last_type_with_globals("fn f(xs) { reversed(xs) } f", globals), + StaticType::Function { + parameters: Some(vec![StaticType::Any]), + return_type: Box::new(StaticType::List(Box::new(StaticType::Any))), + } + ); +} + +#[test] +fn a_generic_built_in_used_as_a_value_has_one_type() { + let globals = natives(&[("push", "(List, T)")]); + assert_eq!( + analyse_last_type_with_globals("let p = push; let xs = [1]; p(xs, 2); p", globals), + StaticType::Function { + parameters: Some(vec![ + StaticType::List(Box::new(StaticType::Int)), + StaticType::Int + ]), + return_type: Box::new(StaticType::unit()), + } + ); +} + +#[test] +fn calling_a_function_value_checks_its_arguments() { + assert_eq!( + errors( + "let f = fn(x: Int) { x }; let g = [f]; g[0](\"a\");", + index_natives() + ), + vec!["A Function(Int) -> Int doesn't accept the arguments 'String'"] + ); +} + +#[test] +fn an_index_write_checks_the_index_against_the_container() { + let plus = natives(&[("+", "(Int, Int) -> Int")]); + let globals = [index_natives(), plus].concat(); + for (source, expected) in [ + ( + "let m = %{1: 2}; m[\"a\"] = 3;", + "No function called '[]=' found that matches the arguments 'Map, String, Int'", + ), + // Reading the old value fails first, and is the one error reported. + ( + "let m = %{\"a\": 1}; m[0] += 3;", + "No function called '[]' found that matches the arguments 'Map, Int'", + ), + ("let a = 5; a[0] = 1;", "Unable to index into Int"), + ] { + assert_eq!(errors(source, globals.clone()), vec![expected], "{source}"); + } +} + +#[test] +fn an_any_parameter_settles_what_its_argument_leaves_unknown_as_any() { + // `keep` may store anything in the list, so it can't become a List. + let globals = [natives(&[("keep", "(List)")]), index_natives()].concat(); + assert_eq!( + analyse_last_type_with_globals("let xs = []; keep(xs); xs[0] = 1; xs", globals), + StaticType::List(Box::new(StaticType::Any)) + ); +} + +#[test] +fn a_default_function_gives_a_map_the_values_it_returns() { + assert_eq!( + analyse_last_type("%{:fn() => 1}"), + StaticType::Map { + key: Box::new(StaticType::Any), + value: Box::new(StaticType::Int), + } + ); +} + #[test] fn what_nothing_settles_falls_back_to_any() { assert_eq!( diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index 156ff329..075c8b03 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -195,6 +195,21 @@ impl AnalysisError { } } + pub(crate) fn callee_rejects_arguments( + callee: &StaticType, + types: &[StaticType], + span: Span, + ) -> Self { + Self { + text: format!( + "A {callee} doesn't accept the arguments '{}'", + types.iter().join(", ") + ), + span, + help_text: None, + } + } + pub(crate) fn not_callable(typ: &StaticType, span: Span) -> Self { Self { text: format!("Unable to invoke {typ} as a function."), diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index fc5c7c65..27c99fba 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -459,10 +459,7 @@ impl<'a> Overloads<'a> { positions, }); } - if !sig - .iter() - .any(|t| matches!(t, StaticType::Any | StaticType::Var(_))) - { + if !sig.iter().any(StaticType::is_unknown) { return None; } let permissive: Vec = vec![StaticType::Any; sig.len()]; @@ -536,21 +533,26 @@ impl<'a> Overloads<'a> { .zip(sig) .all(|(param, arg)| !param.is_incompatible_with(arg)) } - // A variable whose type isn't known yet may hold a function. StaticType::Function { parameters: None, .. - } - | StaticType::Any - | StaticType::Var(_) => true, - _ => false, + } => true, + // A value whose type isn't known may hold a function. + unknown => unknown.is_unknown(), } } /// Return type of a scalar candidate. Falls back to `Any` if the binding /// turned out not to carry a function type (e.g. `Any`-typed callee). + /// + /// A type parameter such as `T` in `first(List) -> T` is `Any` + /// here: only a call that commits to the overload knows what it stands + /// for, and one picked at runtime never does. fn scalar_return_type(self, symbol: SymbolId) -> StaticType { match self.types.get_type(symbol) { - StaticType::Function { return_type, .. } => self.vars.resolve(return_type), + StaticType::Function { return_type, .. } => self + .vars + .resolve(return_type) + .replace(&|part| matches!(part, StaticType::Generic(_)).then_some(StaticType::Any)), _ => StaticType::Any, } } diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 36a264cf..2fd68aa6 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -27,7 +27,7 @@ impl TypeTable { pub(crate) fn resolve_all(&mut self, vars: &TypeVars) { for typ in self.types.values_mut() { *typ = vars.resolve(typ); - debug_assert!(!typ.any_var(&|_| true), "unsettled type variable in {typ}"); + debug_assert!(!typ.has_vars(), "unsettled type variable in {typ}"); } } } @@ -66,7 +66,10 @@ impl TypeVars { /// `typ` with every settled variable replaced by what it stands for. pub(crate) fn resolve(&self, typ: &StaticType) -> StaticType { - typ.replace_vars(&|var| self.solution(var).map(|solution| self.resolve(solution))) + typ.replace(&|part| match part { + StaticType::Var(var) => self.solution(*var).map(|solution| self.resolve(solution)), + _ => None, + }) } /// `typ`, or what it stands for if it's a settled variable. @@ -93,7 +96,10 @@ impl TypeVars { } (StaticType::Var(var), other) | (other, StaticType::Var(var)) => { // A variable can't stand for a type that contains it. - if self.resolve(other).any_var(&|other| other == *var) { + if self + .resolve(other) + .contains(&|part| *part == StaticType::Var(*var)) + { return false; } self.solutions[var.0 as usize] = Some(other.clone()); @@ -138,6 +144,102 @@ impl TypeVars { } } + /// `typ` with each type parameter, such as `T` in `(List, T)`, + /// replaced by a new variable of its own. A call does this to the + /// signature of the overload it picked, so its arguments settle what `T` + /// stands for at that call. + pub(crate) fn instantiate(&mut self, typ: &StaticType) -> StaticType { + let names = std::cell::RefCell::new(Vec::>::new()); + typ.contains(&|part| { + if let StaticType::Generic(name) = part + && !names.borrow().contains(name) + { + names.borrow_mut().push(name.clone()); + } + false + }); + let vars = names + .into_inner() + .into_iter() + .map(|name| (name, self.fresh(StaticType::Any))) + .collect::>(); + typ.replace(&|part| match part { + StaticType::Generic(name) => vars + .iter() + .find(|(generic, _)| generic == name) + .map(|(_, var)| var.clone()), + _ => None, + }) + } + + /// Settles the parts of `argument` that aren't known yet by passing it + /// where a `parameter` is expected, and returns whether it fits there. In + /// `push(xs, 1)` with `push(List, ?0)` and `xs: List`, both + /// variables become `Int`. A parameter of type `Any` settles what's + /// unknown in the argument as `Any`, because the function may store + /// anything in it. + pub(crate) fn bind_argument(&mut self, parameter: &StaticType, argument: &StaticType) -> bool { + let (parameter, argument) = (self.shallow(parameter), self.shallow(argument)); + match (¶meter, &argument) { + (_, StaticType::Never) => true, + (StaticType::Any, _) => { + self.settle_unknowns(&argument, &StaticType::Any); + true + } + (StaticType::Var(_), _) | (_, StaticType::Var(_)) => self.unify(¶meter, &argument), + (StaticType::Option(p), StaticType::Option(a)) + | (StaticType::List(p), StaticType::List(a)) + | (StaticType::Iterator(p), StaticType::Iterator(a)) + | (StaticType::MinHeap(p), StaticType::MinHeap(a)) + | (StaticType::MaxHeap(p), StaticType::MaxHeap(a)) + | (StaticType::Deque(p), StaticType::Deque(a)) + | (StaticType::Sequence(p), StaticType::Sequence(a)) + // A function passed as an argument returns what the parameter's + // function type returns: `map(xs, fn(x) => x + 1)` settles `U`. + | ( + StaticType::Function { + return_type: p, .. + }, + StaticType::Function { + return_type: a, .. + }, + ) => self.bind_argument(p, a), + (StaticType::Tuple(ps), StaticType::Tuple(args)) if ps.len() == args.len() => { + ps.iter().zip(args).all(|(p, a)| self.bind_argument(p, a)) + } + (StaticType::Map { key: pk, value: pv }, StaticType::Map { key: ak, value: av }) => { + let keys = self.bind_argument(pk, ak); + let values = self.bind_argument(pv, av); + keys && values + } + // Any sequence passes as a `Sequence` of its elements. + (StaticType::Sequence(p), _) if argument.sequence_element_type().is_some() => { + let element = argument + .sequence_element_type() + .expect("checked by the guard"); + self.bind_argument(p, &element) + } + _ => { + let argument = self.resolve(&argument); + argument.is_unknown() || argument.is_subtype(&self.resolve(¶meter)) + } + } + } + + /// Settles every unsettled variable in `typ` as `to`. + pub(crate) fn settle_unknowns(&mut self, typ: &StaticType, to: &StaticType) { + let unsettled = std::cell::RefCell::new(Vec::new()); + self.resolve(typ).contains(&|part| { + if let StaticType::Var(var) = part { + unsettled.borrow_mut().push(*var); + } + false + }); + for var in unsettled.into_inner() { + self.solutions[var.0 as usize].get_or_insert_with(|| to.clone()); + } + } + /// Settles every variable made since `first` that nothing settled with /// its fallback. pub(crate) fn settle_from(&mut self, first: usize) { diff --git a/ndc_core/src/lib.rs b/ndc_core/src/lib.rs index 5179c31c..c8521294 100644 --- a/ndc_core/src/lib.rs +++ b/ndc_core/src/lib.rs @@ -1,4 +1,5 @@ mod node_id; +mod signature; pub mod static_type; pub mod r#struct; diff --git a/ndc_core/src/signature.rs b/ndc_core/src/signature.rs new file mode 100644 index 00000000..64b48f93 --- /dev/null +++ b/ndc_core/src/signature.rs @@ -0,0 +1,268 @@ +//! Built-in function signatures written in the language's own type syntax, +//! such as `(List, T) -> Option`. + +use crate::StaticType; + +impl StaticType { + /// The function type a built-in's signature describes: + /// `(List, T) -> Option` declares the type parameter `T`, two + /// parameters and a return type. Without `-> …` the function returns + /// `()`. `(*)` takes any number of arguments of any type, and + /// `Fn(Int) -> Int` is a function type. + pub fn signature(text: &str) -> Result { + let mut parser = SignatureParser { + tokens: tokenize(text)?, + position: 0, + generics: Vec::new(), + }; + let signature = parser.signature()?; + match parser.peek() { + None => Ok(signature), + Some(token) => Err(format!("unexpected '{token}' after the signature")), + } + } +} + +fn tokenize(text: &str) -> Result, String> { + let mut tokens = Vec::new(); + let mut chars = text.char_indices().peekable(); + while let Some((start, c)) = chars.next() { + match c { + c if c.is_whitespace() => {} + '<' | '>' | '(' | ')' | ',' | '*' => tokens.push(c.to_string()), + '-' if chars.next_if(|&(_, next)| next == '>').is_some() => { + tokens.push("->".to_string()); + } + c if c.is_alphabetic() || c == '_' => { + let mut end = start + c.len_utf8(); + while let Some((index, next)) = + chars.next_if(|&(_, next)| next.is_alphanumeric() || next == '_') + { + end = index + next.len_utf8(); + } + tokens.push(text[start..end].to_string()); + } + other => return Err(format!("unexpected '{other}' in signature '{text}'")), + } + } + Ok(tokens) +} + +struct SignatureParser { + tokens: Vec, + position: usize, + /// The type parameters the signature declared, such as `T` in ``. + generics: Vec, +} + +impl SignatureParser { + fn peek(&self) -> Option<&str> { + self.tokens.get(self.position).map(String::as_str) + } + + fn next(&mut self) -> Result { + let token = self + .tokens + .get(self.position) + .cloned() + .ok_or_else(|| "the signature ends too early".to_string())?; + self.position += 1; + Ok(token) + } + + fn eat(&mut self, token: &str) -> bool { + let found = self.peek() == Some(token); + if found { + self.position += 1; + } + found + } + + fn expect(&mut self, token: &str) -> Result<(), String> { + let found = self.next()?; + if found == token { + Ok(()) + } else { + Err(format!("expected '{token}' but found '{found}'")) + } + } + + /// `(…) -> R`, where the type parameters and the return type are + /// optional. + fn signature(&mut self) -> Result { + if self.eat("<") { + loop { + let name = self.next()?; + if self.generics.contains(&name) { + return Err(format!("type parameter '{name}' is declared twice")); + } + self.generics.push(name); + if !self.eat(",") { + break; + } + } + self.expect(">")?; + } + self.function() + } + + /// `(…) -> R` after a function's name or after `Fn`. + fn function(&mut self) -> Result { + self.expect("(")?; + let parameters = if self.eat("*") { + self.expect(")")?; + None + } else { + Some(self.list(")")?) + }; + let return_type = if self.eat("->") { + self.typ()? + } else { + StaticType::unit() + }; + Ok(StaticType::Function { + parameters, + return_type: Box::new(return_type), + }) + } + + /// Types separated by commas up to `close`, which is consumed. + fn list(&mut self, close: &str) -> Result, String> { + let mut types = Vec::new(); + while !self.eat(close) { + types.push(self.typ()?); + if !self.eat(",") { + self.expect(close)?; + break; + } + } + Ok(types) + } + + fn typ(&mut self) -> Result { + let token = self.next()?; + match token.as_str() { + // `()` is unit, `(A, B)` and `(A,)` are tuples, and `(A)` is just `A`. + "(" => { + let start = self.position; + let mut elements = self.list(")")?; + let trailing_comma = self.tokens[start..self.position] + .ends_with(&[",".to_string(), ")".to_string()]); + if elements.len() == 1 && !trailing_comma { + Ok(elements.remove(0)) + } else { + Ok(StaticType::Tuple(elements)) + } + } + "Fn" => self.function(), + name if self.generics.iter().any(|generic| generic == name) => { + Ok(StaticType::Generic(name.into())) + } + name => { + let arguments = if self.eat("<") { + self.list(">")? + } else { + Vec::new() + }; + StaticType::from_name_and_args(name, arguments).map_err(|error| error.to_string()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn function(parameters: Option>, return_type: StaticType) -> StaticType { + StaticType::Function { + parameters, + return_type: Box::new(return_type), + } + } + + fn generic(name: &str) -> StaticType { + StaticType::Generic(name.into()) + } + + #[test] + fn a_signature_declares_its_parameters_and_return_type() { + assert_eq!( + StaticType::signature("(List, T) -> Option"), + Ok(function( + Some(vec![StaticType::List(Box::new(generic("T"))), generic("T")]), + StaticType::Option(Box::new(generic("T"))), + )) + ); + assert_eq!( + StaticType::signature("(String, Int) -> Bool"), + Ok(function( + Some(vec![StaticType::String, StaticType::Int]), + StaticType::Bool + )) + ); + } + + #[test] + fn a_signature_without_a_return_type_returns_unit() { + assert_eq!( + StaticType::signature("()"), + Ok(function(Some(vec![]), StaticType::unit())) + ); + } + + #[test] + fn tuples_function_types_and_variadics_parse() { + assert_eq!( + StaticType::signature("(Map) -> List<(K, V)>"), + Ok(function( + Some(vec![StaticType::Map { + key: Box::new(generic("K")), + value: Box::new(generic("V")), + }]), + StaticType::List(Box::new(StaticType::Tuple(vec![ + generic("K"), + generic("V") + ]))), + )) + ); + assert_eq!( + StaticType::signature("(List, Fn(T) -> U) -> List"), + Ok(function( + Some(vec![ + StaticType::List(Box::new(generic("T"))), + function(Some(vec![generic("T")]), generic("U")), + ]), + StaticType::List(Box::new(generic("U"))), + )) + ); + assert_eq!( + StaticType::signature("(*) -> Any"), + Ok(function(None, StaticType::Any)) + ); + assert_eq!( + StaticType::signature("((Int,), (Int))"), + Ok(function( + Some(vec![ + StaticType::Tuple(vec![StaticType::Int]), + StaticType::Int + ]), + StaticType::unit() + )) + ); + } + + #[test] + fn a_malformed_signature_is_rejected() { + for text in [ + "(List)", + "(T)", + "(Int", + "(Int) ->", + "(Int) -> Int extra", + "(Int; Int)", + ] { + assert!(StaticType::signature(text).is_err(), "{text}"); + } + } +} diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 287850e3..5b9edd0d 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -62,6 +62,11 @@ pub enum StaticType { /// these, and it replaces every one before handing its results on. Until /// then, every method here treats one as `Any`. Var(TypeVar), + /// A type parameter of a generic built-in's signature, such as `T` in + /// `push(List, T)`. Each call stands for its own type in place of + /// it, which the analyser works out from the arguments. Every method + /// here treats one as `Any`. + Generic(Box), /// Bottom type for diverging expressions (`return`, `break`, `continue`). /// `Never` is a subtype of every type: `lub(T, Never) = T`. Never, @@ -243,12 +248,12 @@ impl StaticType { } // Any is the universal supertype - if matches!(other, Self::Any | Self::Var(_)) { + if other.is_unknown() { return true; } // Only Any satisfies the above; all other types fail here - if matches!(self, Self::Any | Self::Var(_)) { + if self.is_unknown() { return false; } @@ -358,7 +363,7 @@ impl StaticType { } // Any is the top type - if matches!(self, Self::Any | Self::Var(_)) || matches!(other, Self::Any | Self::Var(_)) { + if self.is_unknown() || other.is_unknown() { return Self::Any; } @@ -447,7 +452,7 @@ impl StaticType { fn glb(&self, other: &Self) -> Self { // Any is the top type: glb(Any, T) = T match (self, other) { - (Self::Any | Self::Var(_), t) | (t, Self::Any | Self::Var(_)) => t.clone(), + (unknown, t) | (t, unknown) if unknown.is_unknown() => t.clone(), _ if self == other => self.clone(), _ if self.is_subtype(other) => self.clone(), _ if other.is_subtype(self) => other.clone(), @@ -525,26 +530,34 @@ impl StaticType { key.as_ref().clone(), value.as_ref().clone(), ])), - Self::Any | Self::Var(_) => Some(Self::Any), + unknown if unknown.is_unknown() => Some(Self::Any), _ => None, } } - /// This type with each type variable that `replace` gives a type for - /// replaced by that type: `List` becomes `List` when `replace` - /// maps `?0` to `Int`. The other variables stay as they are. + /// Whether this type stands for every type: `Any`, a type variable or + /// a type parameter, which are all `Any` to the methods here. + pub fn is_unknown(&self) -> bool { + matches!(self, Self::Any | Self::Var(_) | Self::Generic(_)) + } + + /// This type with each part that `replace` gives a type for replaced by + /// that type, looking at the outer parts first: `List` becomes + /// `List` when `replace` maps `?0` to `Int`. #[must_use] - pub fn replace_vars(&self, replace: &impl Fn(TypeVar) -> Option) -> Self { - let map = |typ: &Self| Box::new(typ.replace_vars(replace)); + pub fn replace(&self, replace: &impl Fn(&Self) -> Option) -> Self { + if let Some(replaced) = replace(self) { + return replaced; + } + let map = |typ: &Self| Box::new(typ.replace(replace)); match self { - Self::Var(var) => replace(*var).unwrap_or_else(|| self.clone()), Self::Function { parameters, return_type, } => Self::Function { parameters: parameters .as_ref() - .map(|parameters| parameters.iter().map(|p| p.replace_vars(replace)).collect()), + .map(|parameters| parameters.iter().map(|p| p.replace(replace)).collect()), return_type: map(return_type), }, Self::Option(t) => Self::Option(map(t)), @@ -555,13 +568,15 @@ impl StaticType { Self::MaxHeap(t) => Self::MaxHeap(map(t)), Self::Deque(t) => Self::Deque(map(t)), Self::Tuple(elements) => { - Self::Tuple(elements.iter().map(|e| e.replace_vars(replace)).collect()) + Self::Tuple(elements.iter().map(|e| e.replace(replace)).collect()) } Self::Map { key, value } => Self::Map { key: map(key), value: map(value), }, Self::Any + | Self::Var(_) + | Self::Generic(_) | Self::Never | Self::Bool | Self::Struct { .. } @@ -572,20 +587,21 @@ impl StaticType { } } - /// Whether some type variable in this type satisfies `test`: - /// `any_var(&|_| true)` asks whether the type has any variables left. - pub fn any_var(&self, test: &impl Fn(TypeVar) -> bool) -> bool { + /// Whether this type or any of its parts satisfies `test`. + pub fn contains(&self, test: &impl Fn(&Self) -> bool) -> bool { + if test(self) { + return true; + } match self { - Self::Var(var) => test(*var), Self::Function { parameters, return_type, } => { - return_type.any_var(test) + return_type.contains(test) || parameters .iter() .flatten() - .any(|parameter| parameter.any_var(test)) + .any(|parameter| parameter.contains(test)) } Self::Option(t) | Self::Sequence(t) @@ -593,10 +609,12 @@ impl StaticType { | Self::Iterator(t) | Self::MinHeap(t) | Self::MaxHeap(t) - | Self::Deque(t) => t.any_var(test), - Self::Tuple(elements) => elements.iter().any(|e| e.any_var(test)), - Self::Map { key, value } => key.any_var(test) || value.any_var(test), + | Self::Deque(t) => t.contains(test), + Self::Tuple(elements) => elements.iter().any(|e| e.contains(test)), + Self::Map { key, value } => key.contains(test) || value.contains(test), Self::Any + | Self::Var(_) + | Self::Generic(_) | Self::Never | Self::Bool | Self::Struct { .. } @@ -607,6 +625,11 @@ impl StaticType { } } + /// Whether any type variable is left in this type. + pub fn has_vars(&self) -> bool { + self.contains(&|t| matches!(t, Self::Var(_))) + } + #[must_use] pub fn unit() -> Self { Self::Tuple(vec![]) @@ -748,7 +771,7 @@ impl StaticType { /// This is true for `Function` types and for `Any` (which might be a /// function at runtime). Concrete non-function types return `false`. pub fn could_be_callable(&self) -> bool { - matches!(self, Self::Function { .. } | Self::Any | Self::Var(_)) + matches!(self, Self::Function { .. }) || self.is_unknown() } pub fn is_fn_and_matches(&self, types: &[Self]) -> bool { @@ -771,7 +794,10 @@ impl StaticType { pub fn unpack(&self) -> Option + '_>> { match self { // Any just unpacks to an infinite list of Any - Self::Any | Self::Var(_) => Some(Box::new(std::iter::repeat(&Self::Any))), + // The same types as `is_unknown`, spelled out for this exhaustive match. + Self::Any | Self::Var(_) | Self::Generic(_) => { + Some(Box::new(std::iter::repeat(&Self::Any))) + } Self::List(elem) | Self::Sequence(elem) | Self::Iterator(elem) @@ -810,7 +836,7 @@ impl fmt::Display for StaticType { .as_deref() .map_or(String::from("*"), |p| p.iter().join(", ")) ), - Self::Struct { name, .. } => write!(f, "{name}"), + Self::Generic(name) | Self::Struct { name, .. } => write!(f, "{name}"), Self::Option(elem) => write!(f, "Option<{elem}>"), Self::Number => write!(f, "Number"), Self::Float => write!(f, "Float"), diff --git a/ndc_macros/Cargo.toml b/ndc_macros/Cargo.toml index 327f95f8..9a62eb55 100644 --- a/ndc_macros/Cargo.toml +++ b/ndc_macros/Cargo.toml @@ -9,6 +9,8 @@ license = "MIT" proc-macro = true [dependencies] +# Parses `signature = "…"` while the macro expands, so a bad one is a compile error. +ndc_core.workspace = true proc-macro2 = "1.0.106" quote = "1.0.46" syn = { version = "2.0.118", features = ["full", "extra-traits"] } diff --git a/ndc_macros/src/function.rs b/ndc_macros/src/function.rs index 6f174047..f99c1230 100644 --- a/ndc_macros/src/function.rs +++ b/ndc_macros/src/function.rs @@ -21,6 +21,7 @@ pub fn wrap_function(function: &syn::ItemFn) -> syn::Result )]; let mut return_type = None; + let mut signature: Option = None; let mut documentation_buffer = String::new(); for attr in &function.attrs { @@ -37,6 +38,10 @@ pub fn wrap_function(function: &syn::ItemFn) -> syn::Result let value: syn::Type = meta.value()?.parse()?; return_type = Some(map_type(&value)?); Ok(()) + } else if meta.path.is_ident("signature") { + // #[function(signature = "(List, T) -> Option")] + signature = Some(meta.value()?.parse()?); + Ok(()) } else { Err(meta.error("unsupported property on function")) } @@ -51,6 +56,16 @@ pub fn wrap_function(function: &syn::ItemFn) -> syn::Result } } + if let Some(signature) = &signature { + if return_type.is_some() { + return Err(syn::Error::new_spanned( + signature, + "a signature already has a return type: drop `return_type`", + )); + } + check_signature(signature, function.sig.inputs.len())?; + } + let return_type = match return_type { Some(t) => t, None => map_return_type(&function.sig.output)?, @@ -73,6 +88,7 @@ pub fn wrap_function(function: &syn::ItemFn) -> syn::Result &ident, function_name, &return_type, + signature.as_ref(), &documentation_buffer, ) }) @@ -278,11 +294,40 @@ fn map_type_path(p: &syn::TypePath) -> syn::Result { } } +/// Checks that a signature such as `(List, T)` parses, and that it +/// declares as many parameters as the Rust function takes. +fn check_signature(signature: &syn::LitStr, expected: usize) -> syn::Result<()> { + let parsed = ndc_core::StaticType::signature(&signature.value()) + .map_err(|error| syn::Error::new_spanned(signature, error))?; + let ndc_core::StaticType::Function { + parameters: Some(parameters), + .. + } = parsed + else { + return Err(syn::Error::new_spanned( + signature, + "a native function takes a fixed number of arguments, so `(*)` doesn't fit", + )); + }; + if parameters.len() == expected { + Ok(()) + } else { + Err(syn::Error::new_spanned( + signature, + format!( + "the signature declares {} parameters but the function takes {expected}", + parameters.len() + ), + )) + } +} + fn wrap_single( function: &syn::ItemFn, identifier: &syn::Ident, register_as_function_name: &proc_macro2::Literal, return_type: &TokenStream, + signature: Option<&syn::LitStr>, docs: &str, ) -> syn::Result { let inner_ident = format_ident!("{}_inner", identifier); @@ -300,6 +345,7 @@ fn wrap_single( register_as_function_name, docs, return_type, + signature, ) .ok_or_else(|| { syn::Error::new_spanned( @@ -350,6 +396,7 @@ fn try_generate_vm_native( fn_name: &proc_macro2::Literal, docs: &str, return_type_override: &TokenStream, + signature: Option<&syn::LitStr>, ) -> Option { use crate::vm_convert::{try_vm_input, try_vm_return}; @@ -415,15 +462,27 @@ fn try_generate_vm_native( } else { quote! { Some(String::from(#docs)) } }; + // A signature replaces the types read off the Rust parameters. + let static_type = if let Some(signature) = signature { + quote! { + ndc_core::StaticType::signature(#signature).unwrap_or_else(|error| { + panic!("invalid signature of {}: {error}", #fn_name) + }) + } + } else { + quote! { + ndc_core::StaticType::Function { + parameters: Some(vec![#(#param_types.clone()),*]), + return_type: Box::new(#return_static_type), + } + } + }; let native_let = quote! { let native: std::rc::Rc = std::rc::Rc::new(ndc_vm::value::NativeFunction { name: String::from(#fn_name), documentation: #documentation_tokens, - static_type: ndc_core::StaticType::Function { - parameters: Some(vec![#(#param_types.clone()),*]), - return_type: Box::new(#return_static_type), - }, + static_type: #static_type, func: #func_variant, }); }; diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index e9a58e0f..07efdb68 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -55,19 +55,21 @@ fn register_get(env: &mut FunctionRegistry>) { let doc = "Retrieves an element by index. Supports negative indices and range slicing.".to_string(); - // [] -> Any - env.declare_global_fn(Rc::new(NativeFunction { - name: "[]".to_string(), - documentation: Some(doc.clone()), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::List(Box::new(StaticType::Any)), - StaticType::Any, - ]), - return_type: Box::new(StaticType::Any), - }, - func: make_get_func_simple(), - })); + // An element by its index, or a slice by a range of indices. + for signature in [ + "(List, Int) -> T", + "(List, Iterator) -> List", + // An index whose type isn't known could be either, and keeps the + // allocation-free path rather than falling back to `[](Any, Any)`. + "(List, Any) -> Any", + ] { + env.declare_global_fn(Rc::new(NativeFunction { + name: "[]".to_string(), + documentation: Some(doc.clone()), + static_type: StaticType::signature(signature).expect("valid signature"), + func: make_get_func_simple(), + })); + } // [] -> String env.declare_global_fn(Rc::new(NativeFunction { @@ -107,20 +109,10 @@ fn register_get(env: &mut FunctionRegistry>) { func: make_get_func_simple(), })); - // [] -> Any env.declare_global_fn(Rc::new(NativeFunction { name: "[]".to_string(), documentation: Some(doc), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::Map { - key: Box::new(StaticType::Any), - value: Box::new(StaticType::Any), - }, - StaticType::Any, - ]), - return_type: Box::new(StaticType::Any), - }, + static_type: StaticType::signature("(Map, K) -> V").expect("valid signature"), func: make_get_func(), })); } @@ -128,20 +120,20 @@ fn register_get(env: &mut FunctionRegistry>) { fn register_set(env: &mut FunctionRegistry>) { let doc = "Sets an element by index. Supports negative indices and range slicing for lists and strings.".to_string(); - // []= -> () - env.declare_global_fn(Rc::new(NativeFunction { - name: "[]=".to_string(), - documentation: Some(doc.clone()), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::List(Box::new(StaticType::Any)), - StaticType::Any, - StaticType::Any, - ]), - return_type: Box::new(StaticType::unit()), - }, - func: make_set_func(), - })); + // An element by its index, or a slice by a range of indices. + for signature in [ + "(List, Int, T)", + "(List, Iterator, Sequence)", + // An index whose type isn't known could be either. + "(List, Any, Any)", + ] { + env.declare_global_fn(Rc::new(NativeFunction { + name: "[]=".to_string(), + documentation: Some(doc.clone()), + static_type: StaticType::signature(signature).expect("valid signature"), + func: make_set_func(), + })); + } // []= -> () env.declare_global_fn(Rc::new(NativeFunction { @@ -158,21 +150,10 @@ fn register_set(env: &mut FunctionRegistry>) { func: make_set_func(), })); - // []= -> () env.declare_global_fn(Rc::new(NativeFunction { name: "[]=".to_string(), documentation: Some(doc), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::Map { - key: Box::new(StaticType::Any), - value: Box::new(StaticType::Any), - }, - StaticType::Any, - StaticType::Any, - ]), - return_type: Box::new(StaticType::unit()), - }, + static_type: StaticType::signature("(Map, K, V)").expect("valid signature"), func: make_set_func(), })); } diff --git a/ndc_stdlib/src/list.rs b/ndc_stdlib/src/list.rs index f4be2b66..6b44f0f1 100644 --- a/ndc_stdlib/src/list.rs +++ b/ndc_stdlib/src/list.rs @@ -9,7 +9,7 @@ mod inner { use num::{BigInt, ToPrimitive}; /// Converts any sequence into a list - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List")] pub fn list(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -19,16 +19,19 @@ mod inner { } /// Returns `true` if the list contains the given element. + #[function(signature = "(List, T) -> Bool")] pub fn contains(list: &[Value], elem: Value) -> bool { list.contains(&elem) } /// Returns `true` if the list contains the given subsequence. + #[function(signature = "(List, List) -> Bool")] pub fn contains_subsequence(list: &[Value], subsequence: &[Value]) -> bool { list.windows(subsequence.len()).contains(&subsequence) } /// Returns the starting index of the first occurrence of `subsequence` in `list`, or unit if not found. + #[function(signature = "(List, List) -> Any")] pub fn find_subsequence(list: &[Value], subsequence: &[Value]) -> Value { let result = list .windows(subsequence.len()) @@ -43,6 +46,7 @@ mod inner { } /// Inserts an element at the given index, shifting all elements after it to the right. + #[function(signature = "(List, Int, T)")] pub fn insert(list: &mut Vec, index: usize, elem: Value) -> anyhow::Result<()> { if index > list.len() { return Err(anyhow!("index {index} is out of bounds")); @@ -52,8 +56,9 @@ mod inner { } /// Removes and returns the element at position `index` within the list, shifting all elements after it to the left. + #[function(signature = "(List, Int) -> T")] pub fn remove(list: &mut Vec, index: usize) -> anyhow::Result { - if index > list.len() { + if index >= list.len() { return Err(anyhow!("index {index} is out of bounds")); } @@ -61,27 +66,31 @@ mod inner { } /// Removes all instances of `element` from `list` + #[function(signature = "(List, T)")] pub fn remove_element(list: &mut Vec, element: Value) { list.retain(|cur| cur != &element); } /// Appends `elem` to the back of `list` + #[function(signature = "(List, T)")] pub fn push(list: &mut Vec, elem: Value) { list.push(elem); } /// Moves elements from `other` to `list`, leaving `other` empty. + #[function(signature = "(List, List)")] pub fn append(list: &mut Vec, other: &mut Vec) { list.append(other); } /// Copies elements from `other` to `list`, not touching `other`. + #[function(signature = "(List, List)")] pub fn extend(list: &mut Vec, other: &[Value]) { list.extend_from_slice(other); } /// Removes and returns the last element from the list, or `None` if empty. - #[function(name = "pop?", return_type = Option<_>)] + #[function(name = "pop?", signature = "(List) -> Option")] pub fn maybe_pop(list: &mut Vec) -> Value { match list.pop() { None => Value::None, @@ -90,12 +99,13 @@ mod inner { } /// Removes and returns the last element from the list, or unit if empty. + #[function(signature = "(List) -> T")] pub fn pop(list: &mut Vec) -> Value { list.pop().unwrap_or_else(Value::unit) } /// Removes and returns the first element from the list, or `None` if empty. - #[function(name = "pop_left?", return_type = Option<_>)] + #[function(name = "pop_left?", signature = "(List) -> Option")] pub fn maybe_pop_left(list: &mut Vec) -> Value { if list.is_empty() { return Value::None; @@ -104,6 +114,7 @@ mod inner { } /// Removes and returns the first element from the list, or unit if empty. + #[function(signature = "(List) -> T")] pub fn pop_left(list: &mut Vec) -> Value { if list.is_empty() { return Value::unit(); @@ -128,6 +139,7 @@ mod inner { /// Returns a copy of the element at `index`, or an error if the index is out /// of bounds. The index must be non-negative; use `index` for indexing from /// the end. + #[function(signature = "(List, Int) -> T")] pub fn get(list: &[Value], index: usize) -> anyhow::Result { list.get(index) .cloned() @@ -137,7 +149,7 @@ mod inner { /// Returns a copy of the element at `index`, or `None` if `index` is past /// the end of the list. A negative or oversized index is a conversion error, /// not `None`; use `index?` for indexing from the end. - #[function(name = "get?", return_type = Option<_>)] + #[function(name = "get?", signature = "(List, Int) -> Option")] pub fn maybe_get(list: &[Value], index: usize) -> Value { match list.get(index) { None => Value::None, @@ -148,6 +160,7 @@ mod inner { /// Returns a copy of the element at `i`, wrapping negative indices from the /// end of the list; errors if the index is out of bounds. The named form of /// the `[]` operator. + #[function(signature = "(List, Int) -> T")] pub fn index(list: &[Value], i: &BigInt) -> anyhow::Result { wrap_index(i, list.len()) .map(|idx| list[idx].clone()) @@ -157,7 +170,7 @@ mod inner { /// Returns a copy of the element at `i`, wrapping negative indices from the /// end of the list; returns `None` if the index is out of bounds. The /// non-throwing twin of the `[]` operator. - #[function(name = "index?", return_type = Option<_>)] + #[function(name = "index?", signature = "(List, Int) -> Option")] pub fn maybe_index(list: &[Value], i: &BigInt) -> Value { match wrap_index(i, list.len()) { Some(idx) => Value::Object(Rc::new(Object::Some(list[idx].clone()))), @@ -166,22 +179,25 @@ mod inner { } /// Creates a copy of the list with its elements in reverse order - #[function(return_type = Vec<_>)] + #[function(signature = "(List) -> List")] pub fn reversed(list: &[Value]) -> Value { Value::list(list.iter().rev().cloned().collect()) } /// Removes all values from the list + #[function(signature = "(List)")] pub fn clear(list: &mut Vec) { list.clear(); } /// Returns `true` if the list contains no elements + #[function(signature = "(List) -> Bool")] pub fn is_empty(list: &[Value]) -> bool { list.is_empty() } /// Splits the collection into two at the given index. + #[function(signature = "(List, Int) -> List")] pub fn split_off(list: &mut Vec, index: usize) -> anyhow::Result { if index > list.len() { return Err(anyhow!("index {index} is out of bounds")); @@ -192,11 +208,13 @@ mod inner { /// Shortens the list, keeping the first `len` elements and dropping the rest. /// If `len` is greater or equal to the length of the list, nothing happens. + #[function(signature = "(List, Int)")] pub fn truncate(list: &mut Vec, len: usize) { list.truncate(len); } /// Returns a copy of the first element of the list or results in an error if the list is empty. + #[function(signature = "(List) -> T")] pub fn first(list: &[Value]) -> anyhow::Result { list.first() .cloned() @@ -204,7 +222,7 @@ mod inner { } /// Returns a copy of the first element, or `None` if the list is empty. - #[function(name = "first?", return_type = Option<_>)] + #[function(name = "first?", signature = "(List) -> Option")] pub fn maybe_first(list: &[Value]) -> Value { match list.first() { None => Value::None, @@ -213,6 +231,7 @@ mod inner { } /// Returns a copy of the last element of the list or results in an error if the list is empty. + #[function(signature = "(List) -> T")] pub fn last(list: &[Value]) -> anyhow::Result { list.last() .cloned() @@ -220,7 +239,7 @@ mod inner { } /// Returns a copy of the last element, or `None` if the list is empty. - #[function(name = "last?", return_type = Option<_>)] + #[function(name = "last?", signature = "(List) -> Option")] pub fn maybe_last(list: &[Value]) -> Value { match list.last() { None => Value::None, @@ -229,7 +248,7 @@ mod inner { } /// Returns the Cartesian product of two lists as a list of tuples. - #[function(return_type = Vec<_>)] + #[function(signature = "(List, List) -> List<(T, U)>")] pub fn cartesian_product(list_a: &[Value], list_b: &[Value]) -> Value { Value::list( list_a @@ -256,13 +275,8 @@ pub mod ops { let native = Rc::new(NativeFunction { name: "++".to_string(), documentation: Some("Concatenates two lists into a new list.".to_string()), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::List(Box::new(StaticType::Any)), - StaticType::List(Box::new(StaticType::Any)), - ]), - return_type: Box::new(StaticType::List(Box::new(StaticType::Any))), - }, + static_type: StaticType::signature("(List, List) -> List") + .expect("valid signature"), func: NativeFunc::Simple(Box::new(|args| { let [left, right] = args else { return Err(VmError::native(format!( @@ -320,13 +334,8 @@ pub mod ops { documentation: Some( "Appends all elements from the right list to the left list in place.".to_string(), ), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::List(Box::new(StaticType::Any)), - StaticType::List(Box::new(StaticType::Any)), - ]), - return_type: Box::new(StaticType::List(Box::new(StaticType::Any))), - }, + static_type: StaticType::signature("(List, List) -> List") + .expect("valid signature"), func: NativeFunc::Simple(Box::new(|args| { let [left, right] = args else { return Err(VmError::native(format!( diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 742a78cc..8d7da722 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -329,7 +329,8 @@ impl Value { /// `self.static_type().is_subtype(param)`. pub fn matches_param(&self, param: &StaticType) -> bool { match param { - StaticType::Any => true, + // A type parameter like `T` in `push(List, T)` takes any value. + param if param.is_unknown() => true, StaticType::Int => { matches!(self, Self::Int(_)) } @@ -339,21 +340,16 @@ impl Value { StaticType::String => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::String(_))) } - StaticType::List(t) if matches!(t.as_ref(), StaticType::Any) => { + StaticType::List(t) if t.is_unknown() => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::List(_))) } - StaticType::Deque(t) if matches!(t.as_ref(), StaticType::Any) => { + StaticType::Deque(t) if t.is_unknown() => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::Deque(_))) } - StaticType::Map { key, value } - if matches!( - (key.as_ref(), value.as_ref()), - (StaticType::Any, StaticType::Any) - ) => - { + StaticType::Map { key, value } if key.is_unknown() && value.is_unknown() => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::Map { .. })) } - StaticType::Sequence(t) if matches!(t.as_ref(), StaticType::Any) => { + StaticType::Sequence(t) if t.is_unknown() => { matches!(self, Self::Object(o) if matches!( o.as_ref(), Object::List(_) @@ -404,7 +400,7 @@ impl Value { // Static types are finite trees, so this pair cannot be re-entered // before its result is stored. let result = match target { - StaticType::Any => true, + target if target.is_unknown() => true, StaticType::Option(inner) => match self { Self::None => true, Self::Object(object) => match object.as_ref() { diff --git a/tests/functional/programs/001_math/049_numeric_key_identity.ndc b/tests/functional/programs/001_math/049_numeric_key_identity.ndc index 148f3caa..ce7f6606 100644 --- a/tests/functional/programs/001_math/049_numeric_key_identity.ndc +++ b/tests/functional/programs/001_math/049_numeric_key_identity.ndc @@ -1,24 +1,24 @@ // Hashing takes a fast path for values an f64 represents exactly. Equal // values must land in the same bucket whichever mode holds them, and unequal // ones must not be merged by the shortcut. -let dyadic = %{}; +let dyadic: Map = %{}; dyadic[0.5] = "float"; dyadic[1n/2n] = "rational"; assert_eq(dyadic.len(), 1); -let inexact = %{}; +let inexact: Map = %{}; inexact[1n/3n] = "exact"; inexact[1.0/3.0] = "approx"; assert_eq(inexact.len(), 2); -let integers = %{}; +let integers: Map = %{}; integers[5] = "int"; integers[5.0] = "float"; integers[5n] = "number"; assert_eq(integers.len(), 1); // a big integer that is still exactly an f64 agrees with that float -let big = %{}; +let big: Map = %{}; big[2n ^ 100n] = "bigint"; big[Number(2.0 ^ 100)] = "float"; assert_eq(big.len(), 1); diff --git a/tests/functional/programs/005_functions/060_a_call_picked_at_runtime_returns_any.ndc b/tests/functional/programs/005_functions/060_a_call_picked_at_runtime_returns_any.ndc new file mode 100644 index 00000000..87bcc382 --- /dev/null +++ b/tests/functional/programs/005_functions/060_a_call_picked_at_runtime_returns_any.ndc @@ -0,0 +1,5 @@ +// `xs` could be anything, so `first` is picked when the program runs and +// its `T` isn't known: the call is `Any`, and calling its result is fine. +fn invoke(xs) { xs.first()() } +print(invoke([fn() => 5])); +// expect-output: 5 diff --git a/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc b/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc new file mode 100644 index 00000000..182586c8 --- /dev/null +++ b/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc @@ -0,0 +1,4 @@ +// `push` takes a List and a T, so a list of integers only takes integers. +// expect-error: No function called 'push' found that matches the arguments 'List, Float' +let x = [1]; +x.push(1.0); diff --git a/tests/functional/programs/006_lists/054_remove_past_the_end_is_an_error.ndc b/tests/functional/programs/006_lists/054_remove_past_the_end_is_an_error.ndc new file mode 100644 index 00000000..e69afd42 --- /dev/null +++ b/tests/functional/programs/006_lists/054_remove_past_the_end_is_an_error.ndc @@ -0,0 +1,4 @@ +// Removing at the list's length is out of bounds: an error, not a crash. +// expect-error: index 2 is out of bounds +let xs = [1, 2]; +xs.remove(2); diff --git a/tests/functional/programs/007_map_and_set/019_map_set_comprehensions.ndc b/tests/functional/programs/007_map_and_set/019_map_set_comprehensions.ndc index 096e525f..b56d9cb9 100644 --- a/tests/functional/programs/007_map_and_set/019_map_set_comprehensions.ndc +++ b/tests/functional/programs/007_map_and_set/019_map_set_comprehensions.ndc @@ -10,4 +10,3 @@ assert(def[64]); assert(def[49]); assert(!def[59]); assert(!def[1010101]); -assert(!def["haha"]); diff --git a/tests/functional/programs/007_map_and_set/024_op_assign_default_value.ndc b/tests/functional/programs/007_map_and_set/024_op_assign_default_value.ndc index 43f90d9b..badb92d6 100644 --- a/tests/functional/programs/007_map_and_set/024_op_assign_default_value.ndc +++ b/tests/functional/programs/007_map_and_set/024_op_assign_default_value.ndc @@ -1,7 +1,7 @@ let map = %{:0}; // map with default 0 map["foo"] += 3; -map[0] += 10; -map[0] += 10; +map["bar"] += 10; +map["bar"] += 10; assert_eq(map["foo"], 3); -assert_eq(map[0], 20); \ No newline at end of file +assert_eq(map["bar"], 20); \ No newline at end of file diff --git a/tests/functional/programs/007_map_and_set/030_a_map_is_written_with_keys_of_its_key_type.ndc b/tests/functional/programs/007_map_and_set/030_a_map_is_written_with_keys_of_its_key_type.ndc new file mode 100644 index 00000000..3f65a21e --- /dev/null +++ b/tests/functional/programs/007_map_and_set/030_a_map_is_written_with_keys_of_its_key_type.ndc @@ -0,0 +1,5 @@ +// The first write settles the map's key type, and later writes must use it. +// expect-error: No function called '[]=' found that matches the arguments 'Map, String, Int' +let m = %{}; +m[1] = 2; +m["a"] = 3; diff --git a/tests/functional/programs/605_stdlib_serde/017_encode_error_cycle.ndc b/tests/functional/programs/605_stdlib_serde/017_encode_error_cycle.ndc index 6aea15eb..63fa2fcc 100644 --- a/tests/functional/programs/605_stdlib_serde/017_encode_error_cycle.ndc +++ b/tests/functional/programs/605_stdlib_serde/017_encode_error_cycle.ndc @@ -1,4 +1,4 @@ -let l = [1, 2]; +let l: List = [1, 2]; l.push(l); json_encode(l); // expect-error: cannot convert a value that contains itself to JSON diff --git a/tests/functional/programs/605_stdlib_serde/025_lossy_error_cycle.ndc b/tests/functional/programs/605_stdlib_serde/025_lossy_error_cycle.ndc index 88478a7d..d2229e2c 100644 --- a/tests/functional/programs/605_stdlib_serde/025_lossy_error_cycle.ndc +++ b/tests/functional/programs/605_stdlib_serde/025_lossy_error_cycle.ndc @@ -1,4 +1,4 @@ -let l = [1, 2]; +let l: List = [1, 2]; l.push(l); json_encode_lossy(l); // expect-error: cannot convert a value that contains itself to JSON diff --git a/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc b/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc index aa7f0fe2..f83a7c1b 100644 --- a/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc +++ b/tests/functional/programs/900_bugs/bug0033_cyclic_container_diagnostic.ndc @@ -2,6 +2,6 @@ // forever and aborted the process with a stack overflow. The bounded // description stops at a fixed depth instead. // expect-error: cannot compare -let a = []; +let a: List = []; a.push(a); print(a < 1); From c48cd325dd10c090d1d7833dbc2c7dd83064525f Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 15:23:23 +0100 Subject: [PATCH 03/39] =?UTF-8?q?feat(stdlib):=20give=20the=20standard=20l?= =?UTF-8?q?ibrary=20generic=20signatures=20=F0=9F=A7=AC=20(#288)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the standard library now declares generic signatures, so element, key and value types flow through calls the way they do for lists since #287. This is step 2b of the type system RFC (#281). A parameter that accepts any value and only reads it now has its own type parameter instead of `Any`, so passing a value whose type isn't settled yet no longer settles it as `Any`. For example `==` and `<` are `(A, B) -> Bool`, and `len` is `(Sequence) -> Int`. So `let xs = []; print(xs == [], xs.len); xs.push(1); xs.push("a")` now rejects the second push. Functions whose result really is unknown, like `json_decode`, still return `Any`. What changed, by area: - Sequences: `map` returns `List` of what its function returns. `filter`, `sorted`, `take` and `find` keep the element type. `fold` and `reduce` return the accumulator type, `enumerate` gives `(Int, T)`, and `max`/`min` give `T`. `zip` of two sequences gives `List<(A, B)>`; zipping three or more lists, which was wrongly typed as pairs, is `List`. `in`, `max` and `min` gained map overloads, because at runtime they look at a map's keys. - Containers: deque, heap and map functions are generic. `keys` is `List`, `values` is `List`, and a deque is indexed and sliced like a list. `set` makes a `Map`. `union`/`|` and `~` keep one map type, while `&`, `intersection` and `-=` ignore the right operand's values. - Values and strings: `Some` is `(T) -> Option`, `unwrap` is `(Option) -> T`, and `unwrap_or` takes a default of the option's type. `clone` and `deepcopy` keep their argument's type, `frequencies` makes a `Map`, and comparisons, `string`, `<>`, `assert_eq` and `json_encode` take their own type parameters. `captures` was declared `List` but returns one list per match, `List>`. Behaviour changes: - Mixing types where a signature keeps one is an error: pushing a `String` onto a deque of `Int`, `Some(1).unwrap_or("x")`, `"a" in (1..10)`, or `|` between a map and a set. Programs that mix types on purpose annotate the container, e.g. `MinHeap`. - `split_once`, `strip_prefix`, `strip_suffix` and `strip_circumfix` returned `()` when the pattern didn't match, although they were typed to return a string. They now report the mismatch as an error (`"hello" doesn't start with "x"`), and the new `split_once?`, `strip_prefix?`, `strip_suffix?` and `strip_circumfix?` return an `Option`. The manual's string page describes both. - `max(a, b)` and `min(a, b)` on two values still return `Any`, because `(T, T) -> T` would reject `max(1, 1.5)`. Kept less precise than the RFC wants, because of what the analyser and VM can do today: - Function parameters are `Fn(*) -> U`, not `Fn(T) -> Bool`. Overload matching treats function parameters contravariantly, so `Fn(T)` rejects `fn(x: Int) => …`. And runtime dispatch can't match a lambda that returns `Any` against a concrete return type. This needs expected function types (2c) and the call constraints of step 3. - Inserting into a set, `insert(set, key)`, takes `Map` rather than `Map`, and `transposed`, `multi_cartesian_product` and `flat_map` return lists of `Any`. Runtime dispatch never matches a container parameter whose element type is known. - `combinations` and `permutations` yield tuples whose length is a runtime value, so their elements stay `Any`. - `-=` and `&=` with a set on the right of a map are still rejected by the in-place eligibility check, which is deferred to the work on `op=`. Programs that mixed types on purpose were annotated (`Map`, `MinHeap`, `MaxHeap`), and the range test binds its wrongly typed values to `Any` so it still checks the runtime error. The cast examples in the manual and two cast tests no longer use `keys` as their source of a `List`, since `keys` is now precise. Apart from the string examples changed above, all manual examples print the same output as before, and every benchmark program runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/casts.md | 8 +- manual/src/reference/types/string.md | 30 ++- .../overload-dispatch-collections.md | 10 +- ndc_stdlib/src/aoc.rs | 1 + ndc_stdlib/src/cmp.rs | 4 + ndc_stdlib/src/deque.rs | 19 +- ndc_stdlib/src/hash_map.rs | 37 +-- ndc_stdlib/src/heap.rs | 16 +- ndc_stdlib/src/index.rs | 26 +-- ndc_stdlib/src/math.rs | 71 +++--- ndc_stdlib/src/rand.rs | 3 +- ndc_stdlib/src/regex.rs | 2 +- ndc_stdlib/src/sequence.rs | 210 +++++++++++------- ndc_stdlib/src/serde.rs | 2 + ndc_stdlib/src/string.rs | 126 ++++++++--- ndc_stdlib/src/value.rs | 12 +- .../071_some_has_the_type_of_its_value.ndc | 4 + ...rap_returns_the_type_inside_the_option.ndc | 9 + ...or_takes_a_default_of_the_options_type.ndc | 4 + .../006_lists/028_destructure_in_for.ndc | 3 +- ...ng_a_list_leaves_its_element_type_open.ndc | 7 + ...set_operators_keep_the_left_value_type.ndc | 7 + .../008_iterators/002_range_contains.ndc | 17 +- .../programs/011_heap/010_float_ordering.ndc | 4 +- .../011_heap/011_numeric_types_ordering.ndc | 6 +- ..._a_popped_value_keeps_the_element_type.ndc | 5 + .../004_push_checks_the_element_type.ndc | 5 + .../005_a_deque_element_keeps_its_type.ndc | 10 + .../016_casts/002_list_downcast_from_any.ndc | 6 +- .../024_cast_selects_typed_overload.ndc | 2 +- .../602_stdlib_string/008_strip_prefix.ndc | 9 +- .../602_stdlib_string/009_strip_suffix.ndc | 9 +- .../602_stdlib_string/010_strip_circumfix.ndc | 13 +- .../602_stdlib_string/011_split_once.ndc | 7 + ...t_once_without_the_pattern_is_an_error.ndc | 4 + ...rip_prefix_without_a_match_is_an_error.ndc | 4 + ...rip_suffix_without_a_match_is_an_error.ndc | 4 + ..._circumfix_without_a_match_is_an_error.ndc | 4 + ...ns_a_list_of_what_its_function_returns.ndc | 5 + .../018_filter_keeps_the_element_type.ndc | 5 + ...x_min_and_in_look_at_the_keys_of_a_map.ndc | 7 + ..._tuples_as_long_as_the_number_of_lists.ndc | 11 + .../026_lossy_error_heap_cycle.ndc | 2 +- 43 files changed, 519 insertions(+), 231 deletions(-) create mode 100644 tests/functional/programs/004_basic/071_some_has_the_type_of_its_value.ndc create mode 100644 tests/functional/programs/004_basic/072_unwrap_returns_the_type_inside_the_option.ndc create mode 100644 tests/functional/programs/004_basic/073_unwrap_or_takes_a_default_of_the_options_type.ndc create mode 100644 tests/functional/programs/006_lists/055_comparing_or_printing_a_list_leaves_its_element_type_open.ndc create mode 100644 tests/functional/programs/007_map_and_set/031_set_operators_keep_the_left_value_type.ndc create mode 100644 tests/functional/programs/011_heap/012_a_popped_value_keeps_the_element_type.ndc create mode 100644 tests/functional/programs/012_deque/004_push_checks_the_element_type.ndc create mode 100644 tests/functional/programs/012_deque/005_a_deque_element_keeps_its_type.ndc create mode 100644 tests/functional/programs/602_stdlib_string/011_split_once.ndc create mode 100644 tests/functional/programs/602_stdlib_string/012_split_once_without_the_pattern_is_an_error.ndc create mode 100644 tests/functional/programs/602_stdlib_string/013_strip_prefix_without_a_match_is_an_error.ndc create mode 100644 tests/functional/programs/602_stdlib_string/014_strip_suffix_without_a_match_is_an_error.ndc create mode 100644 tests/functional/programs/602_stdlib_string/015_strip_circumfix_without_a_match_is_an_error.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/017_map_returns_a_list_of_what_its_function_returns.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/018_filter_keeps_the_element_type.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/019_max_min_and_in_look_at_the_keys_of_a_map.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/020_zip_makes_tuples_as_long_as_the_number_of_lists.ndc diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index 8c41ae40..7857f0f9 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -3,13 +3,11 @@ The `as` operator asserts that a value has the given type: ```ndc -let values = %{}; -values.insert(1); -values.insert(2); +let values: List = [1, 2]; -// keys returns List; the cast recovers the element type so the +// values is a List; the cast recovers the element type so the // result works with functions that expect a List. -let keys = values.keys as List; +let ints = values as List; ``` A cast checks the value without converting it. `5 as Float` is an error diff --git a/manual/src/reference/types/string.md b/manual/src/reference/types/string.md index bf7bf625..d6333e03 100644 --- a/manual/src/reference/types/string.md +++ b/manual/src/reference/types/string.md @@ -37,18 +37,38 @@ let string = r###"raw string with r#"stop doing this"#"###; `strip_prefix(string, prefix)` and `strip_suffix(string, suffix)` remove one matching prefix or suffix. `strip_circumfix(string, prefix, suffix)` removes both only when -both match without overlapping. Each function returns a new string on success or -`()` on mismatch, leaving the original string unchanged. Matches are exact and -case-sensitive; empty patterns match without removing any text. +both match without overlapping. Each function returns a new string, leaving the +original string unchanged, and it's an error when the pattern doesn't match. Matches +are exact and case-sensitive; empty patterns match without removing any text. ```ndc assert_eq("foobar".strip_prefix("foo"), "bar"); assert_eq("foobar".strip_suffix("bar"), "foo"); assert_eq("[hello]".strip_circumfix("[", "]"), "hello"); assert_eq("foofoo".strip_prefix("foo"), "foo"); -assert_eq("hello".strip_prefix("x"), ()); -assert_eq("aaa".strip_circumfix("aa", "aa"), ()); assert_eq("[]".strip_circumfix("[", "]"), ""); +"hello".strip_prefix("x"); // ERROR: "hello" doesn't start with "x" +``` + +When a mismatch isn't an error, the `?` variants return an `Option` instead: + +```ndc +assert_eq("foobar".strip_prefix?("foo"), Some("bar")); +assert_eq("hello".strip_prefix?("x"), None); +assert_eq("aaa".strip_circumfix?("aa", "aa"), None); +``` + +## Splitting once + +`split_once(string, pattern)` splits a string at the first occurrence of `pattern` into +the parts before and after it. It's an error when `pattern` doesn't occur, and +`split_once?` returns `None` instead: + +```ndc +let (key, value) = "port=80".split_once("="); +assert_eq(key, "port"); +assert_eq("a=b=c".split_once("="), ("a", "b=c")); +assert_eq("abc".split_once?("="), None); ``` ## Operators diff --git a/manual/src/troubleshooting/overload-dispatch-collections.md b/manual/src/troubleshooting/overload-dispatch-collections.md index 27819c1d..39b9e315 100644 --- a/manual/src/troubleshooting/overload-dispatch-collections.md +++ b/manual/src/troubleshooting/overload-dispatch-collections.md @@ -61,7 +61,7 @@ values.sum() // as in `value as List`. ``` -Overloads that differ only by container *kind* (e.g. `pop(List)` vs `pop(MinHeap)`) +Overloads that differ only by container *kind* (e.g. `pop(List)` vs `pop(MinHeap)`) are still distinguished by the kind check alone. User-defined functions cannot yet declare typed container parameters (the syntax is not implemented), so user overloads always use `Any`. @@ -72,12 +72,10 @@ State the element type with a [cast](../features/casts.md). A cast scans the val cost lands at a site you wrote rather than inside every dispatch: ```ndc -let values = %{}; -values.insert(1); -values.insert(2); +let values: List = [1, 2, 3]; -// keys is typed List; the cast recovers Int so sum can resolve. -assert_eq((values.keys as List).sum(), 3); +// values is typed List; the cast recovers Int so sum can resolve. +assert_eq((values as List).sum(), 6); ``` Otherwise, move the call to a location where Andy C++ can infer the argument types diff --git a/ndc_stdlib/src/aoc.rs b/ndc_stdlib/src/aoc.rs index a35ffb66..a7b2a287 100644 --- a/ndc_stdlib/src/aoc.rs +++ b/ndc_stdlib/src/aoc.rs @@ -7,6 +7,7 @@ mod inner { use std::rc::Rc; /// Counts the occurrences of each item in a sequence and returns a map with the frequencies. + #[function(signature = "(Sequence) -> Map")] pub fn frequencies(seq: SeqValue) -> anyhow::Result { let mut counts: HashMap = HashMap::new(); diff --git a/ndc_stdlib/src/cmp.rs b/ndc_stdlib/src/cmp.rs index 32e0244d..e41917ac 100644 --- a/ndc_stdlib/src/cmp.rs +++ b/ndc_stdlib/src/cmp.rs @@ -17,6 +17,7 @@ mod inner { } /// Produces an error if the arguments aren't equal to each other. + #[function(signature = "(A, B)")] pub fn assert_eq(left: Value, right: Value) -> anyhow::Result<()> { if left == right { Ok(()) @@ -28,6 +29,7 @@ mod inner { } /// Produces an error if the arguments are equal to each other. + #[function(signature = "(A, B)")] pub fn assert_ne(left: Value, right: Value) -> anyhow::Result<()> { if left == right { Err(anyhow!(format!( @@ -49,6 +51,7 @@ mod inner { } /// Returns the larger of `left` and `right`, preferring `left` if they are equal. + #[function(signature = "(A, B) -> Any")] pub fn max(left: Value, right: Value) -> anyhow::Result { match left.partial_cmp(&right) { Some(Ordering::Equal | Ordering::Greater) => Ok(left), @@ -62,6 +65,7 @@ mod inner { } /// Returns the smaller of `left` and `right`, preferring `left` if they are equal. + #[function(signature = "(A, B) -> Any")] pub fn min(left: Value, right: Value) -> anyhow::Result { match left.partial_cmp(&right) { Some(Ordering::Equal | Ordering::Less) => Ok(left), diff --git a/ndc_stdlib/src/deque.rs b/ndc_stdlib/src/deque.rs index e22f0c78..79376778 100644 --- a/ndc_stdlib/src/deque.rs +++ b/ndc_stdlib/src/deque.rs @@ -8,7 +8,7 @@ mod inner { /// Creates a new `Deque` type. /// /// The `Deque` type allows the user to quickly append and remove elements from both the start and the end of the list. - #[function(name = "Deque", return_type = VecDeque)] + #[function(name = "Deque", signature = "() -> Deque")] pub fn create_deque() -> Value { Value::Object(std::rc::Rc::new(Object::Deque(std::cell::RefCell::new( VecDeque::new(), @@ -16,16 +16,19 @@ mod inner { } /// Pushes the `value` to the front of the `deque`. + #[function(signature = "(Deque, T)")] pub fn push_front(deque: &mut VecDeque, value: Value) { deque.push_front(value); } /// Appends the `value` to the end of the `deque`. + #[function(signature = "(Deque, T)")] pub fn push_back(deque: &mut VecDeque, value: Value) { deque.push_back(value); } /// Removes and returns the first element of the `deque`. + #[function(signature = "(Deque) -> T")] pub fn pop_front(deque: &mut VecDeque) -> anyhow::Result { deque .pop_front() @@ -33,7 +36,7 @@ mod inner { } /// Removes and returns the first element of the `deque` as an `Option` returning `None` if the queue is empty. - #[function(name = "pop_front?", return_type = Option)] + #[function(name = "pop_front?", signature = "(Deque) -> Option")] pub fn maybe_pop_front(deque: &mut VecDeque) -> Value { match deque.pop_front() { None => Value::None, @@ -42,6 +45,7 @@ mod inner { } /// Removes and returns the last element of the `deque`. + #[function(signature = "(Deque) -> T")] pub fn pop_back(deque: &mut VecDeque) -> anyhow::Result { deque .pop_back() @@ -49,7 +53,7 @@ mod inner { } /// Removes and returns the last element of the `deque` as an `Option` returning `None` if the queue is empty. - #[function(name = "pop_back?", return_type = Option)] + #[function(name = "pop_back?", signature = "(Deque) -> Option")] pub fn maybe_pop_back(deque: &mut VecDeque) -> Value { match deque.pop_back() { None => Value::None, @@ -58,6 +62,7 @@ mod inner { } /// Returns (but does not remove) the first element of the `deque`. + #[function(signature = "(Deque) -> T")] pub fn front(deque: &VecDeque) -> anyhow::Result { deque .front() @@ -66,7 +71,7 @@ mod inner { } /// Returns (but does not remove) the first element of the `deque` as an `Option` returning `None` if the queue is empty. - #[function(name = "front?", return_type = Option)] + #[function(name = "front?", signature = "(Deque) -> Option")] pub fn maybe_front(deque: &VecDeque) -> Value { match deque.front().cloned() { None => Value::None, @@ -75,6 +80,7 @@ mod inner { } /// Returns (but does not remove) the last element of the `deque`. + #[function(signature = "(Deque) -> T")] pub fn back(deque: &VecDeque) -> anyhow::Result { deque .back() @@ -83,7 +89,7 @@ mod inner { } /// Returns (but does not remove) the last element of the `deque` as an `Option` returning `None` if the queue is empty. - #[function(name = "back?", return_type = Option)] + #[function(name = "back?", signature = "(Deque) -> Option")] pub fn maybe_back(deque: &VecDeque) -> Value { match deque.back().cloned() { None => Value::None, @@ -92,16 +98,19 @@ mod inner { } /// Returns `true` if the `deque` contains the `value` or `false` otherwise. + #[function(signature = "(Deque, T) -> Bool")] pub fn contains(deque: &VecDeque, value: Value) -> bool { deque.contains(&value) } /// Returns `true` if the `deque` is empty and `false` otherwise. + #[function(signature = "(Deque) -> Bool")] pub fn is_empty(deque: &VecDeque) -> bool { deque.is_empty() } /// Removes all elements from the `deque` + #[function(signature = "(Deque)")] pub fn clear(deque: &mut VecDeque) { deque.clear(); } diff --git a/ndc_stdlib/src/hash_map.rs b/ndc_stdlib/src/hash_map.rs index e085fbb9..46abcedf 100644 --- a/ndc_stdlib/src/hash_map.rs +++ b/ndc_stdlib/src/hash_map.rs @@ -9,7 +9,7 @@ mod inner { /// Returns a list of all the keys in the map or set. /// /// Note that for a set this will return the values in the set. - #[function(return_type = Vec<_>)] + #[function(signature = "(Map) -> List")] pub fn keys(map: &mut HashMap) -> Value { Value::Object(Rc::new(Object::list( map.keys().cloned().collect::>(), @@ -19,7 +19,7 @@ mod inner { /// Returns a list of all the values in the map. /// /// Note that for sets this will return a list of unit types, you should use keys if you want the values in the set. - #[function(return_type = Vec<_>)] + #[function(signature = "(Map) -> List")] pub fn values(map: &mut HashMap) -> Value { Value::Object(Rc::new(Object::list( map.values().cloned().collect::>(), @@ -27,12 +27,13 @@ mod inner { } /// Removes a key from the map or a value from a set. + #[function(signature = "(Map, K)")] pub fn remove(map: &mut HashMap, key: Value) { map.remove(&key); } /// Removes all keys from the `left` map/set that are present in the `right` map/set. - #[function(name = "remove")] + #[function(name = "remove", signature = "(Map, Map)")] pub fn remove_map(left: &mut HashMap, right: &HashMap) { for (key, _) in right { left.remove(key); @@ -40,24 +41,28 @@ mod inner { } /// Insert a value into a map. - #[function(name = "insert")] + #[function(name = "insert", signature = "(Map, K, V)")] pub fn insert_map(map: &mut HashMap, key: Value, value: Value) { map.insert(key, value); } /// Inserts a value into a set. - #[function(name = "insert")] + #[function(name = "insert", signature = "(Map, K)")] pub fn insert_set(map: &mut HashMap, key: Value) { map.insert(key, Value::unit()); } /// Returns true if the map or set contains no elements. + #[function(signature = "(Map) -> Bool")] pub fn is_empty(map: &HashMap) -> bool { map.is_empty() } /// Intersection-assign: retains only elements present in both maps or sets. - #[function(name = "&=")] + #[function( + name = "&=", + signature = "(Map, Map) -> Map" + )] pub fn intersect_assign( lhs: MapValue, rhs: &HashMap, @@ -75,7 +80,7 @@ mod inner { } /// Union-assign: adds all elements from the right map or set into the left. - #[function(name = "|=")] + #[function(name = "|=", signature = "(Map, Map) -> Map")] pub fn union_assign(lhs: MapValue, rhs: &HashMap) -> anyhow::Result { { let Value::Object(ref obj) = lhs else { @@ -93,7 +98,10 @@ mod inner { } /// Difference-assign: removes all elements from the left map or set that are present in the right. - #[function(name = "-=")] + #[function( + name = "-=", + signature = "(Map, Map) -> Map" + )] pub fn difference_assign( lhs: MapValue, rhs: &HashMap, @@ -111,7 +119,7 @@ mod inner { } /// Symmetric-difference-assign: retains only elements present in exactly one of the two maps or sets. - #[function(name = "~=")] + #[function(name = "~=", signature = "(Map, Map) -> Map")] pub fn symmetric_difference_assign( lhs: MapValue, rhs: &HashMap, @@ -132,7 +140,7 @@ mod inner { /// Returns the union (elements that are in either `left` or `right`) of two maps or sets. /// /// This is the same as evaluating the expression `left | right`. - #[function(alias = "|")] + #[function(alias = "|", signature = "(Map, Map) -> Map")] pub fn union(left: MapValue, right: &HashMap) -> anyhow::Result { let Value::Object(ref obj) = left else { anyhow::bail!("| requires a map on the left side"); @@ -154,7 +162,10 @@ mod inner { /// Returns the intersection (elements that are in both `left` and `right`) of two maps or sets. /// /// This is the same as evaluating the expression `left & right`. - #[function(alias = "&")] + #[function( + alias = "&", + signature = "(Map, Map) -> Map" + )] pub fn intersection(left: MapValue, right: &HashMap) -> anyhow::Result { let Value::Object(ref obj) = left else { anyhow::bail!("& requires a map on the left side"); @@ -176,7 +187,7 @@ mod inner { /// Returns the symmetric difference (elements that are either in `left` or `right` but not both) of two maps or sets. /// /// This is the same as evaluating the expression `left ~ right`. - #[function(alias = "~")] + #[function(alias = "~", signature = "(Map, Map) -> Map")] pub fn symmetric_difference( left: MapValue, right: &HashMap, @@ -199,7 +210,7 @@ mod inner { } /// Converts the given sequence to set. - #[function(return_type = Map<_, ()>)] + #[function(signature = "(Sequence) -> Map")] pub fn set(seq: SeqValue) -> anyhow::Result { use ndc_vm::hash_map::HashMap; diff --git a/ndc_stdlib/src/heap.rs b/ndc_stdlib/src/heap.rs index a49a99f4..58373ba1 100644 --- a/ndc_stdlib/src/heap.rs +++ b/ndc_stdlib/src/heap.rs @@ -7,7 +7,7 @@ mod inner { use std::collections::BinaryHeap; /// Creates a new empty min-heap (priority queue where the smallest element is popped first). - #[function(name = "MinHeap", return_type = MinHeap<_>)] + #[function(name = "MinHeap", signature = "() -> MinHeap")] pub fn create_min_heap() -> Value { Value::Object(std::rc::Rc::new(Object::MinHeap(std::cell::RefCell::new( BinaryHeap::new(), @@ -15,7 +15,7 @@ mod inner { } /// Creates a new empty max-heap (priority queue where the largest element is popped first). - #[function(name = "MaxHeap", return_type = MaxHeap<_>)] + #[function(name = "MaxHeap", signature = "() -> MaxHeap")] pub fn create_max_heap() -> Value { Value::Object(std::rc::Rc::new(Object::MaxHeap(std::cell::RefCell::new( BinaryHeap::new(), @@ -23,7 +23,7 @@ mod inner { } /// Removes and returns the smallest element from the min-heap, or `None` if empty. - #[function(name = "pop?", return_type = Option)] + #[function(name = "pop?", signature = "(MinHeap) -> Option")] pub fn maybe_min_pop(heap: &mut BinaryHeap>) -> Value { match heap.pop() { None => Value::None, @@ -32,7 +32,7 @@ mod inner { } /// Removes and returns the largest element from the max-heap, or `None` if empty. - #[function(name = "pop?", return_type = Option)] + #[function(name = "pop?", signature = "(MaxHeap) -> Option")] pub fn maybe_max_pop(heap: &mut BinaryHeap) -> Value { match heap.pop() { None => Value::None, @@ -41,7 +41,7 @@ mod inner { } /// Removes and returns the smallest element from the min-heap, or errors if empty. - #[function(name = "pop")] + #[function(name = "pop", signature = "(MinHeap) -> T")] pub fn min_pop(heap: &mut BinaryHeap>) -> anyhow::Result { heap.pop() .map(|Reverse(OrdValue(v))| v) @@ -49,7 +49,7 @@ mod inner { } /// Removes and returns the largest element from the max-heap, or errors if empty. - #[function(name = "pop")] + #[function(name = "pop", signature = "(MaxHeap) -> T")] pub fn max_pop(heap: &mut BinaryHeap) -> anyhow::Result { heap.pop() .map(|OrdValue(v)| v) @@ -57,13 +57,13 @@ mod inner { } /// Pushes a value onto the min-heap. - #[function(name = "push")] + #[function(name = "push", signature = "(MinHeap, T)")] pub fn min_push(heap: &mut BinaryHeap>, value: Value) { heap.push(Reverse(OrdValue(value))); } /// Pushes a value onto the max-heap. - #[function(name = "push")] + #[function(name = "push", signature = "(MaxHeap, T)")] pub fn max_push(heap: &mut BinaryHeap, value: Value) { heap.push(OrdValue(value)); } diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index 07efdb68..5c500161 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -95,19 +95,19 @@ fn register_get(env: &mut FunctionRegistry>) { func: make_get_func(), })); - // [] -> Any - env.declare_global_fn(Rc::new(NativeFunction { - name: "[]".to_string(), - documentation: Some(doc.clone()), - static_type: StaticType::Function { - parameters: Some(vec![ - StaticType::Deque(Box::new(StaticType::Any)), - StaticType::Any, - ]), - return_type: Box::new(StaticType::Any), - }, - func: make_get_func_simple(), - })); + // A deque is indexed like a list, and a range of it is a list too. + for signature in [ + "(Deque, Int) -> T", + "(Deque, Iterator) -> List", + "(Deque, Any) -> Any", + ] { + env.declare_global_fn(Rc::new(NativeFunction { + name: "[]".to_string(), + documentation: Some(doc.clone()), + static_type: StaticType::signature(signature).expect("valid signature"), + func: make_get_func_simple(), + })); + } env.declare_global_fn(Rc::new(NativeFunction { name: "[]".to_string(), diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index fd92ce7c..fceebd86 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -83,6 +83,23 @@ fn declare( })); } +/// Declares a native whose type is a generic signature such as +/// `(A, B) -> Bool`, see [`StaticType::signature`]. +fn declare_signature( + env: &mut FunctionRegistry>, + name: &str, + signature: &str, + documentation: impl Into, + func: impl Fn(&[Value]) -> Result + 'static, +) { + env.declare_global_fn(Rc::new(NativeFunction { + name: name.to_string(), + documentation: Some(documentation.into()), + static_type: StaticType::signature(signature).expect("valid signature"), + func: NativeFunc::Simple(Box::new(func)), + })); +} + /// Declares a native whose operands and result have fixed types. /// /// A type name is written once and used twice: as the operand's declared @@ -527,32 +544,25 @@ fn register_comparisons(env: &mut FunctionRegistry>) { "Returns whether the left value is less than or equal to the right.", ), ] { - declare( - env, - name, - vec![StaticType::Any, StaticType::Any], - StaticType::Bool, - docs, - move |args| Ok(Value::Bool(predicate(compare_operands(args)?))), - ); + declare_signature(env, name, "(A, B) -> Bool", docs, move |args| { + Ok(Value::Bool(predicate(compare_operands(args)?))) + }); } - declare( + declare_signature( env, "==", - vec![StaticType::Any, StaticType::Any], - StaticType::Bool, + "(A, B) -> Bool", "Returns whether two values are equal.", |args| { arity(args, 2)?; Ok(Value::Bool(args[0] == args[1])) }, ); - declare( + declare_signature( env, "!=", - vec![StaticType::Any, StaticType::Any], - StaticType::Bool, + "(A, B) -> Bool", "Returns whether two values are not equal.", |args| { arity(args, 2)?; @@ -564,21 +574,14 @@ fn register_comparisons(env: &mut FunctionRegistry>) { ("<=>", false, "Performs a three-way comparison."), (">=<", true, "Performs a reverse three-way comparison."), ] { - declare( - env, - name, - vec![StaticType::Any, StaticType::Any], - StaticType::Int, - docs, - move |args| { - let result = match compare_operands(args)? { - Ordering::Less => -1, - Ordering::Equal => 0, - Ordering::Greater => 1, - }; - Ok(Value::Int(if reverse { -result } else { result })) - }, - ); + declare_signature(env, name, "(A, B) -> Int", docs, move |args| { + let result = match compare_operands(args)? { + Ordering::Less => -1, + Ordering::Equal => 0, + Ordering::Greater => 1, + }; + Ok(Value::Int(if reverse { -result } else { result })) + }); } } @@ -1078,22 +1081,20 @@ fn bigint_number(value: BigInt) -> Value { } fn register_conversions(env: &mut FunctionRegistry>) { - declare( + declare_signature( env, "int", - vec![StaticType::Any], - StaticType::Int, + "(A) -> Int", "Converts a value to a checked i64 Int.", |args| { arity(args, 1)?; convert_to_int(&args[0]).map(Value::Int) }, ); - declare( + declare_signature( env, "float", - vec![StaticType::Any], - StaticType::Float, + "(A) -> Float", "Converts a value to a Float.", |args| { arity(args, 1)?; diff --git a/ndc_stdlib/src/rand.rs b/ndc_stdlib/src/rand.rs index f6eea107..1aecc8bc 100644 --- a/ndc_stdlib/src/rand.rs +++ b/ndc_stdlib/src/rand.rs @@ -25,6 +25,7 @@ mod inner { use ndc_vm::value::AdvancedNumber; /// Randomly shuffles the elements of the list in place. + #[function(signature = "(List)")] pub fn shuffle(list: &mut [Value]) { list.shuffle(&mut rand::rng()); } @@ -32,7 +33,7 @@ mod inner { /// Returns a copy of the input sequence converted to a list with the elements shuffled in random order. /// /// Note: this currently does consume iterators - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List")] pub fn shuffled(list: SeqValue) -> anyhow::Result { Ok(Value::list( list.try_into_iter() diff --git a/ndc_stdlib/src/regex.rs b/ndc_stdlib/src/regex.rs index d5d14059..6bc04caf 100644 --- a/ndc_stdlib/src/regex.rs +++ b/ndc_stdlib/src/regex.rs @@ -43,7 +43,7 @@ mod inner { } /// Returns all capture groups from the first match of the regular expression. - #[function(return_type = Vec)] + #[function(signature = "(String, String) -> List>")] pub fn captures(haystack: &str, regex: &str) -> Result { let r = Regex::new(regex)?; diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index 8a1c9af9..b6a087a4 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -60,11 +60,15 @@ mod inner { use std::rc::Rc; /// Returns `true` if the element is contained in the sequence. - #[function(name = "in")] + #[function(name = "in", signature = "(T, Sequence) -> Bool")] pub fn op_contains(elem: Value, seq: SeqValue) -> bool { - match &seq { + contains(&elem, &seq) + } + + fn contains(elem: &Value, seq: &Value) -> bool { + match seq { Value::Object(obj) => match obj.as_ref() { - Object::String(s) => match &elem { + Object::String(s) => match elem { Value::Object(e) => match e.as_ref() { Object::String(needle) => { if Rc::ptr_eq(s, needle) { @@ -76,17 +80,17 @@ mod inner { }, _ => false, }, - Object::List(l) => l.borrow().contains(&elem), - Object::Tuple(t) => t.contains(&elem), - Object::Map { entries, .. } => entries.borrow().contains_key(&elem), - Object::Deque(d) => d.borrow().contains(&elem), - Object::MinHeap(h) => h.borrow().iter().any(|v| v.0.0 == elem), - Object::MaxHeap(h) => h.borrow().iter().any(|v| v.0 == elem), + Object::List(l) => l.borrow().contains(elem), + Object::Tuple(t) => t.contains(elem), + Object::Map { entries, .. } => entries.borrow().contains_key(elem), + Object::Deque(d) => d.borrow().contains(elem), + Object::MinHeap(h) => h.borrow().iter().any(|v| v.0.0 == *elem), + Object::MaxHeap(h) => h.borrow().iter().any(|v| v.0 == *elem), Object::Iterator(i) => { { let iter = i.borrow(); if let Some((start, end, inclusive)) = iter.range_bounds() { - let Value::Int(n) = elem else { return false }; + let &Value::Int(n) = elem else { return false }; return if inclusive { n >= start && n <= end } else { @@ -94,14 +98,14 @@ mod inner { }; } if let Some(start) = iter.unbounded_range_start() { - let Value::Int(n) = elem else { return false }; + let &Value::Int(n) = elem else { return false }; return n >= start; } } // Finite non-range iterator — linear scan loop { match i.borrow_mut().next() { - Some(v) if v == elem => return true, + Some(v) if v == *elem => return true, Some(_) => {} None => return false, } @@ -113,8 +117,31 @@ mod inner { } } + /// Returns `true` if the map contains the given key. + #[function(name = "in", signature = "(K, Map) -> Bool")] + pub fn op_contains_key(key: Value, map: SeqValue) -> bool { + contains(&key, &map) + } + + /// Returns the highest key in the map. + #[function(name = "max", signature = "(Map) -> K")] + pub fn max_key(map: SeqValue) -> anyhow::Result { + highest(map) + } + + /// Returns the lowest key in the map. + #[function(name = "min", signature = "(Map) -> K")] + pub fn min_key(map: SeqValue) -> anyhow::Result { + lowest(map) + } + /// Returns the highest element in the sequence. + #[function(signature = "(Sequence) -> T")] pub fn max(seq: SeqValue) -> anyhow::Result { + highest(seq) + } + + fn highest(seq: Value) -> anyhow::Result { match seq { Value::Object(obj) => match Rc::unwrap_or_clone(obj) { Object::String(s) => { @@ -143,11 +170,13 @@ mod inner { } /// Returns the element for which the key function returns the highest value. + #[function(signature = "(Sequence, Fn(*) -> U) -> T")] pub fn max_by_key(seq: SeqValue, func: &mut VmCallable<'_>) -> anyhow::Result { by_key(seq, func, Ordering::Greater) } /// Returns the element for which the key function returns the lowest value. + #[function(signature = "(Sequence, Fn(*) -> U) -> T")] pub fn min_by_key(seq: SeqValue, func: &mut VmCallable<'_>) -> anyhow::Result { by_key(seq, func, Ordering::Less) } @@ -157,6 +186,7 @@ mod inner { /// The comparator function takes two elements and returns a number. A positive result means the /// first argument is greater than the second, a negative result means the first argument is /// less than the second, and zero means they are equal. + #[function(signature = "(Sequence, Fn(*) -> U) -> T")] pub fn max_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { by_comp(seq, comp, Ordering::Greater) } @@ -166,12 +196,18 @@ mod inner { /// The comparator function takes two elements and returns a number. A positive result means the /// first argument is greater than the second, a negative result means the first argument is /// less than the second, and zero means they are equal. + #[function(signature = "(Sequence, Fn(*) -> U) -> T")] pub fn min_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { by_comp(seq, comp, Ordering::Less) } /// Returns the lowest element in the sequence. + #[function(signature = "(Sequence) -> T")] pub fn min(seq: SeqValue) -> anyhow::Result { + lowest(seq) + } + + fn lowest(seq: Value) -> anyhow::Result { match seq { Value::Object(obj) => match Rc::unwrap_or_clone(obj) { Object::String(s) => { @@ -200,7 +236,7 @@ mod inner { } /// Sorts the list in place. - #[function(return_type = ())] + #[function(signature = "(List)")] pub fn sort(list: &mut Vec) -> anyhow::Result<()> { let mut err: Option = None; list.sort_by(|a, b| { @@ -229,7 +265,7 @@ mod inner { /// - for values equal to `0` the first argument is equal to the second argument /// /// This function only works for strings and lists and will throw errors otherwise. - #[function(return_type = ())] + #[function(signature = "(List, Fn(*) -> U)")] pub fn sort_by(list: &mut Vec, comp: &mut VmCallable<'_>) -> anyhow::Result<()> { let mut err: Option = None; list.sort_by(|left, right| { @@ -257,7 +293,7 @@ mod inner { } /// Returns a sorted copy of the input sequence as a list. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List")] pub fn sorted(seq: SeqValue) -> anyhow::Result { let mut list: Vec = seq .try_into_iter() @@ -275,7 +311,7 @@ mod inner { /// - for values lower than `0` the first argument is smaller than the second argument /// - for values higher than `0` the first argument is greater than the second argument /// - for values equal to `0` the first argument is equal to the second argument - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Fn(*) -> U) -> List")] pub fn sorted_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { let mut list: Vec = seq .try_into_iter() @@ -312,6 +348,7 @@ mod inner { } /// Returns the length of the sequence, for strings this returns the number of UTF-8 characters. + #[function(signature = "(Sequence) -> Int")] pub fn len(seq: SeqValue) -> anyhow::Result { match &seq { Value::Object(obj) => match obj.as_ref() { @@ -333,7 +370,7 @@ mod inner { } /// Returns a lazy iterator yielding `(index, value)` tuples for each element of `seq`. - #[function(return_type = Iterator)] + #[function(signature = "(Sequence) -> Iterator<(Int, T)>")] pub fn enumerate(seq: SeqValue) -> anyhow::Result { let iter = EnumerateIter::new(seq).ok_or_else(|| anyhow!("enumerate requires a sequence"))?; @@ -341,6 +378,7 @@ mod inner { } /// Reduces/folds the given sequence using the given combining function and a custom initial value. + #[function(signature = "(Sequence, A, Fn(*) -> A) -> A")] pub fn fold( seq: SeqValue, initial: Value, @@ -355,6 +393,7 @@ mod inner { } /// Reduces/folds the given sequence using the given combining function. + #[function(signature = "(Sequence, Fn(*) -> T) -> T")] pub fn reduce(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut iterator = seq .try_into_iter() @@ -366,7 +405,7 @@ mod inner { } /// Filters the given sequence using the `predicate`. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Fn(*) -> U) -> List")] pub fn filter(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for element in seq @@ -386,6 +425,7 @@ mod inner { } /// Returns the number of elements in the input sequence for which the given `predicate` returns `true`. + #[function(signature = "(Sequence, Fn(*) -> U) -> Int")] pub fn count(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { let mut out = 0i64; for element in seq @@ -402,6 +442,7 @@ mod inner { } /// Returns the value of the first element for which the `predicate` is true for the given input sequence. + #[function(signature = "(Sequence, Fn(*) -> U) -> T")] pub fn find(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for element in seq .try_into_iter() @@ -420,6 +461,7 @@ mod inner { } /// Returns the first index of the element for which the `predicate` is true in the input sequence. + #[function(signature = "(Sequence, Fn(*) -> U) -> Int")] pub fn locate(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for (idx, element) in seq .try_into_iter() @@ -436,7 +478,7 @@ mod inner { } /// Returns the first index of the element or produces an error - #[function(name = "locate")] + #[function(name = "locate", signature = "(Sequence, T) -> Int")] pub fn locate_element(seq: SeqValue, element: Value) -> anyhow::Result { for (idx, el) in seq .try_into_iter() @@ -451,6 +493,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for none of the elements in `seq`. + #[function(signature = "(Sequence, Fn(*) -> U) -> Bool")] pub fn none(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -471,7 +514,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for all the elements in `seq`. - #[function(return_type = bool)] + #[function(signature = "(Sequence, Fn(*) -> U) -> Bool")] pub fn all(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -492,6 +535,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for any of the elements in `seq`. + #[function(signature = "(Sequence, Fn(*) -> U) -> Bool")] pub fn any(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -512,7 +556,7 @@ mod inner { } /// Applies the function to each element in a sequence returning the result as a list. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Fn(*) -> U) -> List")] pub fn map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for item in seq @@ -525,7 +569,7 @@ mod inner { } /// Applies a function to each item in a sequence, flattens the resulting sequences, and returns a single combined sequence. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Fn(*) -> U) -> List")] pub fn flat_map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for item in seq @@ -542,6 +586,7 @@ mod inner { } /// Returns the first element of the sequence or the `default` value otherwise. + #[function(signature = "(Sequence, T) -> T")] pub fn first_or(seq: SeqValue, default: Value) -> Value { seq.try_into_iter() .and_then(|mut i| i.next()) @@ -549,6 +594,7 @@ mod inner { } /// Returns the first element of the sequence or the return value of the given function. + #[function(signature = "(Sequence, Fn() -> T) -> T")] pub fn first_or_else(seq: SeqValue, default: &mut VmCallable<'_>) -> anyhow::Result { if let Some(item) = seq.try_into_iter().and_then(|mut i| i.next()) { return Ok(item); @@ -557,7 +603,7 @@ mod inner { } /// Returns the `k` sized combinations of the given sequence `seq` as a lazy iterator of tuples. - #[function(return_type = Iterator)] + #[function(signature = "(Sequence, Int) -> Iterator")] pub fn combinations(seq: SeqValue, k: usize) -> anyhow::Result { let iter = CombinationsIter::new(seq, k) .ok_or_else(|| anyhow!("combinations requires a sequence"))?; @@ -565,14 +611,14 @@ mod inner { } /// Returns a lazy iterator yielding the first `n` elements of `seq`. - #[function(return_type = Iterator)] + #[function(signature = "(Sequence, Int) -> Iterator")] pub fn take(seq: SeqValue, n: usize) -> anyhow::Result { let iter = TakeIter::new(seq, n).ok_or_else(|| anyhow!("take requires a sequence"))?; Ok(Value::iterator(iter.into_shared())) } /// Returns the `k` sized permutations of the given sequence `seq` as a list of tuples. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Int) -> List")] pub fn permutations(seq: SeqValue, k: usize) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -601,7 +647,7 @@ mod inner { } /// Returns all prefixes of a sequence, each as a list; for strings, returns string prefixes. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List>")] pub fn prefixes(seq: SeqValue) -> anyhow::Result { // A sequence-typed caller can still supply a string. if let Value::Object(ref obj) = seq @@ -635,7 +681,7 @@ mod inner { } /// Returns all suffixes of a sequence, each as a list; for strings, returns all trailing substrings. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List>")] pub fn suffixes(seq: SeqValue) -> anyhow::Result { // A sequence-typed caller can still supply a string. if let Value::Object(ref obj) = seq @@ -655,7 +701,7 @@ mod inner { } /// Transposes a sequence of sequences, turning rows into columns, and returns the result as a list of lists. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List>")] pub fn transposed(seq: SeqValue) -> anyhow::Result { let main: Vec = seq .try_into_iter() @@ -678,7 +724,7 @@ mod inner { } /// Return a list of all windows, wrapping back to the first elements when the window would otherwise exceed the length of source list, producing tuples of size 2. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List<(T, T)>")] pub fn circular_tuple_windows(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -692,7 +738,7 @@ mod inner { } /// Returns a list of all size-2 windows in `seq`. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List>")] pub fn pairwise(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -705,7 +751,10 @@ mod inner { } /// Applies a function to each pair of consecutive elements in a sequence and returns the results as a list. - #[function(name = "pairwise", return_type = Vec<_>)] + #[function( + name = "pairwise", + signature = "(Sequence, Fn(*) -> U) -> List" + )] pub fn pairwise_map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let main: Vec = seq .try_into_iter() @@ -719,7 +768,7 @@ mod inner { } /// Returns a list of all contiguous windows of `length` size. The windows overlap. If the `seq` is shorter than size, the iterator returns no values. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Int) -> List>")] pub fn windows(seq: SeqValue, length: usize) -> anyhow::Result { if length == 0 { return Err(anyhow!("window size must be non-zero")); @@ -738,7 +787,7 @@ mod inner { /// /// The powerset of a set contains all subsets including the empty set and the full input set. A powerset has length `2^n` where `n` is the length of the input set. /// Each list produced by this function represents a subset of the elements in the source sequence. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List>")] pub fn subsequences(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -750,7 +799,10 @@ mod inner { } /// Return a list that represents the powerset of the elements of `seq` that are exactly `length` long. - #[function(name = "subsequences", return_type = Vec<_>)] + #[function( + name = "subsequences", + signature = "(Sequence, Int) -> List>" + )] pub fn subsequences_len(seq: SeqValue, length: usize) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -788,7 +840,7 @@ mod inner { /// [3,"c",false] /// ] /// ``` - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence) -> List>")] pub fn multi_cartesian_product(seq: SeqValue) -> anyhow::Result { let mut iterators = Vec::new(); for elem in seq @@ -812,7 +864,7 @@ mod inner { /// Split the input sequence into evenly sized chunks. If the input length of the sequence /// is not dividable by the chunk_size the last chunk will contain fewer elements. - #[function(return_type = Vec<_>)] + #[function(signature = "(Sequence, Int) -> List>")] pub fn chunks(seq: SeqValue, chunk_size: usize) -> anyhow::Result { if chunk_size == 0 { return Err(anyhow!("chunk size must be non-zero")); @@ -828,7 +880,7 @@ mod inner { } /// Returns an infinite iterator that repeats the given value. - #[function(return_type = Iterator)] + #[function(signature = "(T) -> Iterator")] pub fn repeat(value: Value) -> Value { Value::iterator(Rc::new(std::cell::RefCell::new(ndc_vm::RepeatIter::new( value, @@ -836,7 +888,7 @@ mod inner { } /// Returns an iterator that repeats the given value `times` times. - #[function(name = "repeat", return_type = Iterator)] + #[function(name = "repeat", signature = "(T, Int) -> Iterator")] pub fn repeat_times(value: Value, times: usize) -> Value { Value::iterator(Rc::new(std::cell::RefCell::new( ndc_vm::RepeatIter::new_limited(value, times), @@ -912,45 +964,47 @@ pub mod extra { use std::rc::Rc; pub fn register(env: &mut FunctionRegistry>) { - env.declare_global_fn(Rc::new(NativeFunction { - name: "zip".to_string(), - documentation: Some("Zips two or more sequences together into a list of tuples. The result length is the minimum length of the inputs.".to_string()), - static_type: StaticType::Function { - parameters: None, - return_type: Box::new(StaticType::List(Box::new(StaticType::Tuple(vec![ - StaticType::Any, - StaticType::Any, - ])))), - }, - func: NativeFunc::Simple(Box::new(|args| { - if args.len() < 2 { - return Err(VmError::native( - "zip must be called with 2 or more arguments".to_string(), - )); - } - let iters: Option>> = args - .iter() - .map(|arg| match arg { - Value::Object(obj) => match obj.as_ref() { - Object::List(l) => Some(l.borrow().clone()), - Object::Tuple(t) => Some(t.clone()), - _ => None, - }, - _ => None, - }) - .collect(); - let iters = - iters.ok_or_else(|| VmError::native("zip requires sequences".to_string()))?; - let min_len = iters.iter().map(|v| v.len()).min().unwrap_or(0); - let result: Vec = (0..min_len) - .map(|i| { - Value::Object(Rc::new(Object::Tuple( - iters.iter().map(|v| v[i].clone()).collect(), - ))) - }) - .collect(); - Ok(Value::Object(Rc::new(Object::list(result)))) - })), - })); + for signature in [ + // Two sequences zip into pairs of their elements. + "(Sequence, Sequence) -> List<(A, B)>", + // More make tuples as long as the number of arguments. + "(*) -> List", + ] { + env.declare_global_fn(Rc::new(NativeFunction { + name: "zip".to_string(), + documentation: Some("Zips two or more sequences together into a list of tuples. The result length is the minimum length of the inputs.".to_string()), + static_type: StaticType::signature(signature).expect("valid signature"), + func: NativeFunc::Simple(Box::new(zip)), + })); + } + } + + fn zip(args: &[Value]) -> Result { + if args.len() < 2 { + return Err(VmError::native( + "zip must be called with 2 or more arguments".to_string(), + )); + } + let iters: Option>> = args + .iter() + .map(|arg| match arg { + Value::Object(obj) => match obj.as_ref() { + Object::List(l) => Some(l.borrow().clone()), + Object::Tuple(t) => Some(t.clone()), + _ => None, + }, + _ => None, + }) + .collect(); + let iters = iters.ok_or_else(|| VmError::native("zip requires sequences".to_string()))?; + let min_len = iters.iter().map(|v| v.len()).min().unwrap_or(0); + let result: Vec = (0..min_len) + .map(|i| { + Value::Object(Rc::new(Object::Tuple( + iters.iter().map(|v| v[i].clone()).collect(), + ))) + }) + .collect(); + Ok(Value::Object(Rc::new(Object::list(result)))) } } diff --git a/ndc_stdlib/src/serde.rs b/ndc_stdlib/src/serde.rs index 0c540a85..3f989f4e 100644 --- a/ndc_stdlib/src/serde.rs +++ b/ndc_stdlib/src/serde.rs @@ -227,6 +227,7 @@ mod inner { /// heaps, structs, functions, maps with non-string keys or a default value, /// and values that contain themselves. Use `json_encode_lossy` to convert /// those anyway. + #[function(signature = "(A) -> String")] pub fn json_encode(input: Value) -> anyhow::Result { let v = value_to_json(&input, false, &mut HashSet::new())?; Ok(v.to_string()) @@ -242,6 +243,7 @@ mod inner { /// `None` and non-finite floats become `null`. There is no lossy /// counterpart for `json_decode` because these conversions cannot be /// reversed. Functions and values that contain themselves are still errors. + #[function(signature = "(A) -> String")] pub fn json_encode_lossy(input: Value) -> anyhow::Result { let v = value_to_json(&input, true, &mut HashSet::new())?; Ok(v.to_string()) diff --git a/ndc_stdlib/src/string.rs b/ndc_stdlib/src/string.rs index a781c45e..a98639d4 100644 --- a/ndc_stdlib/src/string.rs +++ b/ndc_stdlib/src/string.rs @@ -1,5 +1,5 @@ use ndc_macros::export_module; -use ndc_vm::value::{SeqValue, Value, VmString}; +use ndc_vm::value::{Object, SeqValue, Value, VmString}; use std::rc::Rc; @@ -25,11 +25,24 @@ pub fn join_to_string(list: SeqValue, sep: &str) -> anyhow::Result { } } +/// `Some(value)`, or `None` when there's no value. +fn option(value: Option) -> Value { + value.map_or(Value::None, |value| { + Value::Object(Rc::new(Object::Some(value))) + }) +} + +/// `string` without `prefix` and `suffix`, when it starts and ends with them +/// without the two overlapping. +fn circumfix<'a>(string: &'a str, prefix: &str, suffix: &str) -> Option<&'a str> { + string.strip_prefix(prefix)?.strip_suffix(suffix) +} + #[export_module] mod inner { /// Concatenates two values into a string. - #[function(name = "<>")] + #[function(name = "<>", signature = "(A, B) -> String")] pub fn op_string_concat(left: Value, right: Value) -> String { format!("{left}{right}") } @@ -47,6 +60,7 @@ mod inner { } /// Returns the provided value as a string + #[function(signature = "(A) -> String")] pub fn string(value: Value) -> String { format!("{value}") } @@ -111,6 +125,7 @@ mod inner { } /// Joins elements of the sequence into a single string using `sep` as the separator. + #[function(signature = "(Sequence, String) -> String")] pub fn join(list: SeqValue, sep: &str) -> anyhow::Result { join_to_string(list, sep) } @@ -122,6 +137,7 @@ mod inner { } /// Joins paragraphs into a single string, inserting blank lines between them. + #[function(signature = "(Sequence) -> String")] pub fn unparagraphs(list: SeqValue) -> anyhow::Result { join_to_string(list, "\n\n") } @@ -133,6 +149,7 @@ mod inner { } /// Joins lines into a single string, inserting newline characters between them. + #[function(signature = "(Sequence) -> String")] pub fn unlines(list: SeqValue) -> anyhow::Result { join_to_string(list, "\n") } @@ -144,6 +161,7 @@ mod inner { } /// Joins words into a single string, separating them with spaces. + #[function(signature = "(Sequence) -> String")] pub fn unwords(list: SeqValue) -> anyhow::Result { join_to_string(list, " ") } @@ -170,39 +188,91 @@ mod inner { Value::list(string.split(pattern).map(Value::string).collect()) } - /// Splits the string at the first occurrence of `pattern`, returning a tuple-like value. - #[function(return_type = (String, String))] - pub fn split_once(string: &str, pattern: &str) -> Value { - match string.split_once(pattern) { - Some((fst, snd)) => Value::tuple(vec![Value::string(fst), Value::string(snd)]), - None => Value::unit(), - } - } - - /// Removes one matching prefix, returning `()` if it does not match. - #[function(return_type = String)] - pub fn strip_prefix(string: &str, prefix: &str) -> Value { + /// Splits the string at the first occurrence of `pattern` into the parts + /// before and after it. It's an error if `pattern` doesn't occur; use + /// `split_once?` to get `None` instead. + #[function(signature = "(String, String) -> (String, String)")] + pub fn split_once(string: &str, pattern: &str) -> anyhow::Result { + let (before, after) = string + .split_once(pattern) + .ok_or_else(|| anyhow!("{string:?} doesn't contain {pattern:?}"))?; + Ok(Value::tuple(vec![ + Value::string(before), + Value::string(after), + ])) + } + + /// Splits the string at the first occurrence of `pattern` into the parts + /// before and after it, or returns `None` if `pattern` doesn't occur. + #[function( + name = "split_once?", + signature = "(String, String) -> Option<(String, String)>" + )] + pub fn maybe_split_once(string: &str, pattern: &str) -> Value { + option( + string.split_once(pattern).map(|(before, after)| { + Value::tuple(vec![Value::string(before), Value::string(after)]) + }), + ) + } + + /// Removes one matching prefix. It's an error if the string doesn't start + /// with `prefix`; use `strip_prefix?` to get `None` instead. + pub fn strip_prefix(string: &str, prefix: &str) -> anyhow::Result { string .strip_prefix(prefix) - .map_or_else(Value::unit, Value::string) + .map(str::to_string) + .ok_or_else(|| anyhow!("{string:?} doesn't start with {prefix:?}")) } - /// Removes one matching suffix, returning `()` if it does not match. - #[function(return_type = String)] - pub fn strip_suffix(string: &str, suffix: &str) -> Value { - string - .strip_suffix(suffix) - .map_or_else(Value::unit, Value::string) + /// Removes one matching prefix, or returns `None` if the string doesn't + /// start with `prefix`. + #[function( + name = "strip_prefix?", + signature = "(String, String) -> Option" + )] + pub fn maybe_strip_prefix(string: &str, prefix: &str) -> Value { + option(string.strip_prefix(prefix).map(Value::string)) } - /// Removes one matching prefix and suffix, returning `()` if either fails to match. - /// The prefix and suffix must not overlap. Empty patterns match without removing text. - #[function(return_type = String)] - pub fn strip_circumfix(string: &str, prefix: &str, suffix: &str) -> Value { + /// Removes one matching suffix. It's an error if the string doesn't end + /// with `suffix`; use `strip_suffix?` to get `None` instead. + pub fn strip_suffix(string: &str, suffix: &str) -> anyhow::Result { string - .strip_prefix(prefix) - .and_then(|rest| rest.strip_suffix(suffix)) - .map_or_else(Value::unit, Value::string) + .strip_suffix(suffix) + .map(str::to_string) + .ok_or_else(|| anyhow!("{string:?} doesn't end with {suffix:?}")) + } + + /// Removes one matching suffix, or returns `None` if the string doesn't + /// end with `suffix`. + #[function( + name = "strip_suffix?", + signature = "(String, String) -> Option" + )] + pub fn maybe_strip_suffix(string: &str, suffix: &str) -> Value { + option(string.strip_suffix(suffix).map(Value::string)) + } + + /// Removes one matching prefix and suffix, which must not overlap. Empty + /// patterns match without removing text. It's an error if they don't both + /// match; use `strip_circumfix?` to get `None` instead. + pub fn strip_circumfix(string: &str, prefix: &str, suffix: &str) -> anyhow::Result { + circumfix(string, prefix, suffix) + .map(str::to_string) + .ok_or_else(|| { + anyhow!("{string:?} doesn't start with {prefix:?} and end with {suffix:?} without overlap") + }) + } + + /// Removes one matching prefix and suffix, which must not overlap, or + /// returns `None` if they don't both match. + #[function( + name = "strip_circumfix?", + signature = "(String, String, String) -> Option" + )] + pub fn maybe_strip_circumfix(string: &str, prefix: &str, suffix: &str) -> Value { + option(circumfix(string, prefix, suffix).map(Value::string)) } /// Removes leading and trailing whitespace from the string. diff --git a/ndc_stdlib/src/value.rs b/ndc_stdlib/src/value.rs index adbd8509..b54b930a 100644 --- a/ndc_stdlib/src/value.rs +++ b/ndc_stdlib/src/value.rs @@ -6,6 +6,7 @@ mod inner { use std::rc::Rc; /// Returns the documentation string for a function, or an empty string if none is available. + #[function(signature = "(Fn(*) -> R) -> String")] pub fn docs(value: Value) -> anyhow::Result { match &value { Value::Object(obj) => match obj.as_ref() { @@ -25,6 +26,7 @@ mod inner { /// Returns the reference count for the value, if the value is not reference counted it will return 0. /// /// Note: this function does increase the ref count by 1 + #[function(signature = "(A) -> Int")] pub fn ref_count(value: Value) -> usize { match value { Value::Object(rc) => Rc::strong_count(&rc), @@ -33,18 +35,19 @@ mod inner { } /// Creates a new instance of `Some` - #[function(name = "Some", return_type = Option)] + #[function(name = "Some", signature = "(T) -> Option")] pub fn some(value: Value) -> Value { Value::Object(Rc::new(Object::Some(value))) } /// Creates a new instance of `None` - #[function(return_type = Option<_>)] + #[function(signature = "() -> Option")] pub fn none() -> Value { Value::None } /// Returns true if the argument is `Some`. + #[function(signature = "(A) -> Bool")] pub fn is_some(value: Value) -> bool { matches!( value, @@ -54,11 +57,13 @@ mod inner { } /// Returns true if the argument is `None`. + #[function(signature = "(A) -> Bool")] pub fn is_none(value: Value) -> bool { matches!(value, Value::None) } /// Extracts the value from an Option or errors if it's either None or a non-Option type + #[function(signature = "(Option) -> T")] pub fn unwrap(value: Value) -> anyhow::Result { match value { Value::Object(obj) => match obj.as_ref() { @@ -71,6 +76,7 @@ mod inner { } /// Extracts the value from an Option, returning `default` when it is `None`. + #[function(signature = "(Option, T) -> T")] pub fn unwrap_or(value: Value, default: Value) -> anyhow::Result { match value { Value::Object(obj) => match obj.as_ref() { @@ -83,11 +89,13 @@ mod inner { } /// Returns a shallow copy of the given value. + #[function(signature = "(T) -> T")] pub fn clone(value: Value) -> Value { value.shallow_clone() } /// Returns a deep copy of the given value, duplicating all nested structures. + #[function(signature = "(T) -> T")] pub fn deepcopy(value: Value) -> Value { value.deep_copy() } diff --git a/tests/functional/programs/004_basic/071_some_has_the_type_of_its_value.ndc b/tests/functional/programs/004_basic/071_some_has_the_type_of_its_value.ndc new file mode 100644 index 00000000..389761e8 --- /dev/null +++ b/tests/functional/programs/004_basic/071_some_has_the_type_of_its_value.ndc @@ -0,0 +1,4 @@ +// `Some(1)` is an Option, so the variable can't later hold an Option. +// expect-error: mismatched types: found Option but expected Option +let o = Some(1); +o = Some("a"); diff --git a/tests/functional/programs/004_basic/072_unwrap_returns_the_type_inside_the_option.ndc b/tests/functional/programs/004_basic/072_unwrap_returns_the_type_inside_the_option.ndc new file mode 100644 index 00000000..c7a124a3 --- /dev/null +++ b/tests/functional/programs/004_basic/072_unwrap_returns_the_type_inside_the_option.ndc @@ -0,0 +1,9 @@ +// `none()` leaves the option's type open until `Some(2)` settles it, and +// `unwrap` returns the type inside: `xs` becomes a List. +let o = none(); +o = Some(2); +let xs = []; +xs.push(o.unwrap()); +xs.push(o.unwrap_or(0) + 1); +xs.push("a"); +// expect-error: No function called 'push' found that matches the arguments 'List, String' diff --git a/tests/functional/programs/004_basic/073_unwrap_or_takes_a_default_of_the_options_type.ndc b/tests/functional/programs/004_basic/073_unwrap_or_takes_a_default_of_the_options_type.ndc new file mode 100644 index 00000000..c32f5c06 --- /dev/null +++ b/tests/functional/programs/004_basic/073_unwrap_or_takes_a_default_of_the_options_type.ndc @@ -0,0 +1,4 @@ +// `unwrap_or` returns the type inside the option, so its default must have that type too. +// expect-error: No function called 'unwrap_or' found that matches the arguments 'Option, String' +let o = Some(1); +print(o.unwrap_or("none")); diff --git a/tests/functional/programs/006_lists/028_destructure_in_for.ndc b/tests/functional/programs/006_lists/028_destructure_in_for.ndc index 29d17cea..a506d1b7 100644 --- a/tests/functional/programs/006_lists/028_destructure_in_for.ndc +++ b/tests/functional/programs/006_lists/028_destructure_in_for.ndc @@ -5,7 +5,8 @@ let map = %{ 3: "quux", }; -let set = %{}; +// The set holds both the Int keys and the String values. +let set: Map = %{}; for key, value in map { set.insert(key); set.insert(value); diff --git a/tests/functional/programs/006_lists/055_comparing_or_printing_a_list_leaves_its_element_type_open.ndc b/tests/functional/programs/006_lists/055_comparing_or_printing_a_list_leaves_its_element_type_open.ndc new file mode 100644 index 00000000..43a83001 --- /dev/null +++ b/tests/functional/programs/006_lists/055_comparing_or_printing_a_list_leaves_its_element_type_open.ndc @@ -0,0 +1,7 @@ +// `==` and `string` only read their arguments, so they don't settle the +// element type of an empty list: the first `push` does. +// expect-error: No function called 'push' found that matches the arguments 'List, String' +let xs = []; +print(xs == [], xs.string); +xs.push(1); +xs.push("a"); diff --git a/tests/functional/programs/007_map_and_set/031_set_operators_keep_the_left_value_type.ndc b/tests/functional/programs/007_map_and_set/031_set_operators_keep_the_left_value_type.ndc new file mode 100644 index 00000000..8cd44322 --- /dev/null +++ b/tests/functional/programs/007_map_and_set/031_set_operators_keep_the_left_value_type.ndc @@ -0,0 +1,7 @@ +// `&` keeps the left map's values whatever the right one holds, while `|` +// mixes the values of both maps, so they must be of one type. +// expect-error: No function called '|' found that matches the arguments 'Map, Map' +let ages = %{"ann": 31, "bob": 42}; +let kept: Map = ages & set(["ann"]); +assert_eq(kept.values.sum, 31); +ages | set(["cid"]); diff --git a/tests/functional/programs/008_iterators/002_range_contains.ndc b/tests/functional/programs/008_iterators/002_range_contains.ndc index 74da3c79..4cde5b6e 100644 --- a/tests/functional/programs/008_iterators/002_range_contains.ndc +++ b/tests/functional/programs/008_iterators/002_range_contains.ndc @@ -24,12 +24,17 @@ assert_eq(-1 in (-10..=-1), true); // -1 is at the upper bound assert_eq(-1 in (-10..-1), false); // 0 is outside the negative range assert_eq(0 in (-10..=-1), false); // 0 is outside the negative range -// Invalid types -assert_eq("string" in (1..10), false); // Non-integer type -assert_eq(3.14 in (1..10), false); // Float type -assert_eq(true in (1..10), false); // Boolean type -assert_eq(() in (1..10), false); // Null type -assert_eq([1, 2, 3] in (1..10), false); // Array type +// Invalid types, typed `Any` so that only the range itself can tell +let string: Any = "string"; +let float: Any = 3.14; +let boolean: Any = true; +let unit: Any = (); +let array: Any = [1, 2, 3]; +assert_eq(string in (1..10), false); // Non-integer type +assert_eq(float in (1..10), false); // Float type +assert_eq(boolean in (1..10), false); // Boolean type +assert_eq(unit in (1..10), false); // Null type +assert_eq(array in (1..10), false); // Array type // Boundary cases assert_eq(1 in (1..=1), true); // Single-value inclusive range diff --git a/tests/functional/programs/011_heap/010_float_ordering.ndc b/tests/functional/programs/011_heap/010_float_ordering.ndc index a2999348..6bc635cc 100644 --- a/tests/functional/programs/011_heap/010_float_ordering.ndc +++ b/tests/functional/programs/011_heap/010_float_ordering.ndc @@ -20,7 +20,7 @@ assert_eq(h.pop, 2.2); assert_eq(h.pop, 1.0); // MinHeap: int vs float with fractional tiebreaker (5 < 5.3, 4.9 < 5) -let h = MinHeap(); +let h: MinHeap = MinHeap(); h.push(5); h.push(5.3); h.push(4.9); @@ -29,7 +29,7 @@ assert_eq(h.pop, 5); assert_eq(h.pop, 5.3); // int equal to float (5 == 5.0) in a MaxHeap -let h = MaxHeap(); +let h: MaxHeap = MaxHeap(); h.push(5); h.push(5.0); h.push(6); diff --git a/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc b/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc index c5c37904..578c1ab1 100644 --- a/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc +++ b/tests/functional/programs/011_heap/011_numeric_types_ordering.ndc @@ -1,8 +1,8 @@ // Tests heap ordering with BigInt, Rational, and Complex values, exercising // the VM's cross-type OrdValue comparator path. -// MinHeap with BigInts (values outside i64 range) -let h = MinHeap(); +// MinHeap with BigInts (values outside i64 range) and an Int +let h: MinHeap = MinHeap(); h.push(9223372036854775808n); // i64::MAX + 1 h.push(9223372036854775809n); h.push(9223372036854775807); // i64::MAX (fits in i64) @@ -20,7 +20,7 @@ assert_eq(h.pop, 5n / 3n); assert_eq(h.pop, 1n / 2n); // MinHeap: mixed int and Rational -let h = MinHeap(); +let h: MinHeap = MinHeap(); h.push(2); h.push(3n / 2n); // 1.5 h.push(5n / 2n); // 2.5 diff --git a/tests/functional/programs/011_heap/012_a_popped_value_keeps_the_element_type.ndc b/tests/functional/programs/011_heap/012_a_popped_value_keeps_the_element_type.ndc new file mode 100644 index 00000000..a1fe961e --- /dev/null +++ b/tests/functional/programs/011_heap/012_a_popped_value_keeps_the_element_type.ndc @@ -0,0 +1,5 @@ +// A MinHeap pops Strings, so adding an Int to a popped value is an error. +// expect-error: No function called '+' found that matches the arguments 'String, Int' +let heap = MinHeap(); +heap.push("a"); +heap.pop() + 1; diff --git a/tests/functional/programs/012_deque/004_push_checks_the_element_type.ndc b/tests/functional/programs/012_deque/004_push_checks_the_element_type.ndc new file mode 100644 index 00000000..60f61287 --- /dev/null +++ b/tests/functional/programs/012_deque/004_push_checks_the_element_type.ndc @@ -0,0 +1,5 @@ +// The first push settles what a deque holds, and later pushes must fit it. +// expect-error: No function called 'push_front' found that matches the arguments 'Deque, String' +let queue = Deque(); +queue.push_back(1); +queue.push_front("a"); diff --git a/tests/functional/programs/012_deque/005_a_deque_element_keeps_its_type.ndc b/tests/functional/programs/012_deque/005_a_deque_element_keeps_its_type.ndc new file mode 100644 index 00000000..c6843920 --- /dev/null +++ b/tests/functional/programs/012_deque/005_a_deque_element_keeps_its_type.ndc @@ -0,0 +1,10 @@ +// Reading from a Deque, by index, slice or pop, gives Ints and Lists of Ints. +// expect-output: 3 +let queue = Deque(); +queue.push_back(1); +queue.push_back(2); +queue.push_back(3); +let first: Int = queue[0]; +let rest: List = queue[1..]; +let last: Int = queue.pop_back(); +print(first + rest.sum - last); diff --git a/tests/functional/programs/016_casts/002_list_downcast_from_any.ndc b/tests/functional/programs/016_casts/002_list_downcast_from_any.ndc index 46127c56..ca7ff06d 100644 --- a/tests/functional/programs/016_casts/002_list_downcast_from_any.ndc +++ b/tests/functional/programs/016_casts/002_list_downcast_from_any.ndc @@ -1,6 +1,6 @@ -// `keys` returns List; the cast recovers the element type so calls on -// the result can resolve against Int overloads. -let values = %{}; +// The keys of a `Map` are a List; the cast recovers the element +// type so calls on the result can resolve against Int overloads. +let values: Map = %{}; values.insert(3); values.insert(1); values.insert(2); diff --git a/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc b/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc index 48b36fb8..cb27fa3b 100644 --- a/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc +++ b/tests/functional/programs/016_casts/024_cast_selects_typed_overload.ndc @@ -2,7 +2,7 @@ // separate Int, Float, and Number overloads, and a value the analyser only // knows as List matches none of them, so each cast below picks a // different overload at compile time. -let values = %{}; +let values: Map = %{}; values.insert(1); values.insert(2); assert_eq((values.keys as List).sum(), 3); diff --git a/tests/functional/programs/602_stdlib_string/008_strip_prefix.ndc b/tests/functional/programs/602_stdlib_string/008_strip_prefix.ndc index cb9803a7..9a42959e 100644 --- a/tests/functional/programs/602_stdlib_string/008_strip_prefix.ndc +++ b/tests/functional/programs/602_stdlib_string/008_strip_prefix.ndc @@ -1,12 +1,13 @@ assert_eq(strip_prefix("foobar", "foo"), "bar"); +assert_eq("foobar".strip_prefix?("foo"), Some("bar")); assert_eq("foofoo".strip_prefix("foo"), "foo"); -assert_eq("foobar".strip_prefix("bar"), ()); -assert_eq("foo".strip_prefix("foobar"), ()); -assert_eq("Foo".strip_prefix("foo"), ()); +assert_eq("foobar".strip_prefix?("bar"), None); +assert_eq("foo".strip_prefix?("foobar"), None); +assert_eq("Foo".strip_prefix?("foo"), None); assert_eq("foo".strip_prefix("foo"), ""); assert_eq("foo".strip_prefix(""), "foo"); assert_eq("".strip_prefix(""), ""); -assert_eq("".strip_prefix("foo"), ()); +assert_eq("".strip_prefix?("foo"), None); assert_eq("❤你好".strip_prefix("❤"), "你好"); let original = "foobar"; let stripped = original.strip_prefix("foo"); diff --git a/tests/functional/programs/602_stdlib_string/009_strip_suffix.ndc b/tests/functional/programs/602_stdlib_string/009_strip_suffix.ndc index 730b657a..9d1a4ebc 100644 --- a/tests/functional/programs/602_stdlib_string/009_strip_suffix.ndc +++ b/tests/functional/programs/602_stdlib_string/009_strip_suffix.ndc @@ -1,12 +1,13 @@ assert_eq(strip_suffix("foobar", "bar"), "foo"); +assert_eq("foobar".strip_suffix?("bar"), Some("foo")); assert_eq("barbar".strip_suffix("bar"), "bar"); -assert_eq("foobar".strip_suffix("foo"), ()); -assert_eq("bar".strip_suffix("foobar"), ()); -assert_eq("BAR".strip_suffix("bar"), ()); +assert_eq("foobar".strip_suffix?("foo"), None); +assert_eq("bar".strip_suffix?("foobar"), None); +assert_eq("BAR".strip_suffix?("bar"), None); assert_eq("bar".strip_suffix("bar"), ""); assert_eq("bar".strip_suffix(""), "bar"); assert_eq("".strip_suffix(""), ""); -assert_eq("".strip_suffix("bar"), ()); +assert_eq("".strip_suffix?("bar"), None); assert_eq("你好❤".strip_suffix("❤"), "你好"); let original = "foobar"; let stripped = original.strip_suffix("bar"); diff --git a/tests/functional/programs/602_stdlib_string/010_strip_circumfix.ndc b/tests/functional/programs/602_stdlib_string/010_strip_circumfix.ndc index 2706700e..cd030edd 100644 --- a/tests/functional/programs/602_stdlib_string/010_strip_circumfix.ndc +++ b/tests/functional/programs/602_stdlib_string/010_strip_circumfix.ndc @@ -1,17 +1,18 @@ assert_eq(strip_circumfix("[hello]", "[", "]"), "hello"); +assert_eq("[hello]".strip_circumfix?("[", "]"), Some("hello")); assert_eq("[[hello]]".strip_circumfix("[", "]"), "[hello]"); assert_eq("hello".strip_circumfix("", ""), "hello"); -assert_eq("[hello".strip_circumfix("[", "]"), ()); -assert_eq("hello]".strip_circumfix("[", "]"), ()); -assert_eq("hello".strip_circumfix("[", "]"), ()); -assert_eq("aaa".strip_circumfix("aa", "aa"), ()); -assert_eq("a".strip_circumfix("a", "a"), ()); +assert_eq("[hello".strip_circumfix?("[", "]"), None); +assert_eq("hello]".strip_circumfix?("[", "]"), None); +assert_eq("hello".strip_circumfix?("[", "]"), None); +assert_eq("aaa".strip_circumfix?("aa", "aa"), None); +assert_eq("a".strip_circumfix?("a", "a"), None); assert_eq("[]".strip_circumfix("[", "]"), ""); assert_eq("hello".strip_circumfix("", ""), "hello"); assert_eq("hello]".strip_circumfix("", "]"), "hello"); assert_eq("[hello".strip_circumfix("[", ""), "hello"); assert_eq("".strip_circumfix("", ""), ""); -assert_eq("".strip_circumfix("[", "]"), ()); +assert_eq("".strip_circumfix?("[", "]"), None); assert_eq("「你好❤」".strip_circumfix("「", "」"), "你好❤"); let original = "[hello]"; let stripped = original.strip_circumfix("[", "]"); diff --git a/tests/functional/programs/602_stdlib_string/011_split_once.ndc b/tests/functional/programs/602_stdlib_string/011_split_once.ndc new file mode 100644 index 00000000..8dc8dbd3 --- /dev/null +++ b/tests/functional/programs/602_stdlib_string/011_split_once.ndc @@ -0,0 +1,7 @@ +assert_eq("key=value".split_once("="), ("key", "value")); +assert_eq("a=b=c".split_once("="), ("a", "b=c")); +assert_eq("=b".split_once("="), ("", "b")); +assert_eq("key=value".split_once?("="), Some(("key", "value"))); +assert_eq("keyvalue".split_once?("="), None); +let (key, value) = "port: 80".split_once(": "); +assert_eq(key ++ value, "port80"); diff --git a/tests/functional/programs/602_stdlib_string/012_split_once_without_the_pattern_is_an_error.ndc b/tests/functional/programs/602_stdlib_string/012_split_once_without_the_pattern_is_an_error.ndc new file mode 100644 index 00000000..bc5a2773 --- /dev/null +++ b/tests/functional/programs/602_stdlib_string/012_split_once_without_the_pattern_is_an_error.ndc @@ -0,0 +1,4 @@ +// `split_once?` returns `None` when the pattern doesn't occur; `split_once` +// reports it. +// expect-error: doesn't contain +"keyvalue".split_once("="); diff --git a/tests/functional/programs/602_stdlib_string/013_strip_prefix_without_a_match_is_an_error.ndc b/tests/functional/programs/602_stdlib_string/013_strip_prefix_without_a_match_is_an_error.ndc new file mode 100644 index 00000000..fd4c1a1e --- /dev/null +++ b/tests/functional/programs/602_stdlib_string/013_strip_prefix_without_a_match_is_an_error.ndc @@ -0,0 +1,4 @@ +// `strip_prefix?` returns `None` when the prefix doesn't match; `strip_prefix` +// reports it. +// expect-error: doesn't start with +"hello".strip_prefix("x"); diff --git a/tests/functional/programs/602_stdlib_string/014_strip_suffix_without_a_match_is_an_error.ndc b/tests/functional/programs/602_stdlib_string/014_strip_suffix_without_a_match_is_an_error.ndc new file mode 100644 index 00000000..981cacc6 --- /dev/null +++ b/tests/functional/programs/602_stdlib_string/014_strip_suffix_without_a_match_is_an_error.ndc @@ -0,0 +1,4 @@ +// `strip_suffix?` returns `None` when the suffix doesn't match; `strip_suffix` +// reports it. +// expect-error: doesn't end with +"hello".strip_suffix("x"); diff --git a/tests/functional/programs/602_stdlib_string/015_strip_circumfix_without_a_match_is_an_error.ndc b/tests/functional/programs/602_stdlib_string/015_strip_circumfix_without_a_match_is_an_error.ndc new file mode 100644 index 00000000..ff2361c7 --- /dev/null +++ b/tests/functional/programs/602_stdlib_string/015_strip_circumfix_without_a_match_is_an_error.ndc @@ -0,0 +1,4 @@ +// The prefix and suffix may not overlap, so this doesn't match. +// `strip_circumfix?` would return `None`; `strip_circumfix` reports it. +// expect-error: and end with +"aaa".strip_circumfix("aa", "aa"); diff --git a/tests/functional/programs/603_stdlib_seq/017_map_returns_a_list_of_what_its_function_returns.ndc b/tests/functional/programs/603_stdlib_seq/017_map_returns_a_list_of_what_its_function_returns.ndc new file mode 100644 index 00000000..9d165691 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/017_map_returns_a_list_of_what_its_function_returns.ndc @@ -0,0 +1,5 @@ +// `map` returns a list of what its function returns, so mapping numbers to +// strings gives a List, which only takes strings. +// expect-error: No function called 'push' found that matches the arguments 'List, Int' +let labels = [1, 2, 3].map(fn(n) => "label"); +labels.push(4); diff --git a/tests/functional/programs/603_stdlib_seq/018_filter_keeps_the_element_type.ndc b/tests/functional/programs/603_stdlib_seq/018_filter_keeps_the_element_type.ndc new file mode 100644 index 00000000..fffcd4ef --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/018_filter_keeps_the_element_type.ndc @@ -0,0 +1,5 @@ +// `filter` returns some of the elements it is given, so filtering a +// List gives a List, which only takes integers. +// expect-error: No function called 'push' found that matches the arguments 'List, String' +let big = [1, 2, 3].filter(fn(n) => n > 1); +big.push("4"); diff --git a/tests/functional/programs/603_stdlib_seq/019_max_min_and_in_look_at_the_keys_of_a_map.ndc b/tests/functional/programs/603_stdlib_seq/019_max_min_and_in_look_at_the_keys_of_a_map.ndc new file mode 100644 index 00000000..b081a0d7 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/019_max_min_and_in_look_at_the_keys_of_a_map.ndc @@ -0,0 +1,7 @@ +// A map is a sequence of (key, value) pairs, but `max`, `min` and `in` look +// at its keys, so the max of a Map is a String. +// expect-output: b a true +let m = %{"a": 1, "b": 2}; +let highest: String = m.max(); +let lowest: String = m.min(); +print(highest, lowest, "a" in m); diff --git a/tests/functional/programs/603_stdlib_seq/020_zip_makes_tuples_as_long_as_the_number_of_lists.ndc b/tests/functional/programs/603_stdlib_seq/020_zip_makes_tuples_as_long_as_the_number_of_lists.ndc new file mode 100644 index 00000000..74ec505c --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/020_zip_makes_tuples_as_long_as_the_number_of_lists.ndc @@ -0,0 +1,11 @@ +// `zip` of two lists gives pairs of their elements, and `zip` of three lists +// gives triples. +// expect-output: a +// expect-output: 9 +// expect-output: 12 +let (number, letter) = zip([1, 2], ["a", "b"])[0]; +let text: String = letter; +print(text); +for (a, b, c) in zip([1, 2], [3, 4], [5, 6]) { + print(a + b + c); +} diff --git a/tests/functional/programs/605_stdlib_serde/026_lossy_error_heap_cycle.ndc b/tests/functional/programs/605_stdlib_serde/026_lossy_error_heap_cycle.ndc index 8dd784c2..03f77031 100644 --- a/tests/functional/programs/605_stdlib_serde/026_lossy_error_heap_cycle.ndc +++ b/tests/functional/programs/605_stdlib_serde/026_lossy_error_heap_cycle.ndc @@ -1,4 +1,4 @@ -let h = MaxHeap(); +let h: MaxHeap = MaxHeap(); h.push(h); json_encode_lossy(h); // expect-error: cannot convert a value that contains itself to JSON From 74124087e2a1ed338448a9c66d2c27992c308ca8 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 15:45:33 +0100 Subject: [PATCH 04/39] =?UTF-8?q?feat(analyser):=20add=20function=20types?= =?UTF-8?q?=20to=20annotations=20=F0=9F=8E=AF=20(#289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function types can now be written in annotations, and a function passed as an argument is checked against the function type it's passed for. This is the first part of step 2c of the type system RFC (#281). Writing function types: - `Fn(A, B) -> C` is a function taking an `A` and a `B` and returning a `C`; without `-> …` it returns `()`. It works in every annotation: `let f: Fn(Int) -> Int = …`, parameters, return types (`fn make() -> Fn(Int) -> Int`) and nested function types. - Types are shown the same way in messages: `Fn(Int) -> Int` instead of `Function(Int) -> Int`. Passing functions: - A function passed for a function parameter has to take what the parameter passes and return what it returns, where both types know it. An unannotated lambda, `Fn(Any) -> Any`, fits any `Fn(Int) -> Int` and is checked as the program runs. `fn(x: String) => 1`, `fn(x) => "a"` and `fn(a, b) => 1` are rejected where an `Fn(Int) -> Int` is expected. - A type parameter in an expected function type, like `T` in `Fn(T) -> U`, fits a typed lambda. The lambda's parameter types then settle it, so `xs.map(fn(x: Int) => x)` makes an empty `xs` a `List`, and `[1].map(fn(x: String) => x)` is an error. - Calling a function value through a variable or an expression checks its arguments as before, now with the function types an annotation gives. - Runtime dispatch checks only that a function argument takes the right number of arguments, because a function's types can't be checked while the program runs. The stdlib's higher-order functions now declare their real function types, instead of `Fn(*)`, which #288 had to use: - `map(Sequence, Fn(T) -> U) -> List` and `flat_map(Sequence, Fn(T) -> Sequence) -> List`. - `filter`, `count`, `find`, `locate`, `any`, `all` and `none` take an `Fn(T) -> Bool`, which they already required at runtime. So `[1, 2].filter(fn(x) => 1)` is now an error. - `fold(Sequence, A, Fn(A, T) -> A) -> A`, `reduce(Sequence, Fn(T, T) -> T) -> T`, and comparators like `max_by` and `sort_by` take an `Fn(T, T) -> U`. Overload matching only lets a signature's type parameters fit any function parameter. An `Any` argument still means the overload is picked at runtime. A first version that let every unknown type fit made `Any` arguments match overloads exactly, and failed 10 tests. A test map whose keys are two getters of different types now declares `Map`. The join of two function types picks the first one; the RFC turns such mixed literals into `Any` in step 4. The manual's function page has a section on function types. Comparing all manual examples against `feat/type-checker`, the only differences are the new examples, plus a map loop whose iteration order changes between runs on both builds. Next in 2c: lambdas take their parameter types from the function type they're passed for, so an unannotated `fn(x) => x + 1` in `xs.map(…)` gets the element type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types.md | 2 +- manual/src/reference/types/function.md | 23 ++++++ ndc_analyser/src/analyser/mod.rs | 12 +++ ndc_analyser/src/analyser/tests.rs | 79 ++++++++++++++++++- ndc_analyser/src/error.rs | 2 +- ndc_analyser/src/overloads.rs | 38 ++++++++- ndc_analyser/src/types.rs | 32 ++++++-- ndc_core/src/static_type.rs | 9 ++- ndc_parser/src/parser.rs | 28 +++++++ ndc_parser/src/type_expr.rs | 24 +++++- ndc_parser/tests/type_annotations.rs | 45 +++++++++++ ndc_resolver/src/name_resolution.rs | 12 +++ ndc_stdlib/src/sequence.rs | 36 ++++----- ndc_vm/src/value/mod.rs | 14 ++++ .../061_function_types_in_annotations.ndc | 14 ++++ ...nt_must_take_what_the_parameter_passes.ndc | 5 ++ .../015_struct/006_accessor_as_map_key.ndc | 3 +- ...ilter_takes_a_predicate_returning_bool.ndc | 4 + ...nst_what_a_function_passes_and_returns.ndc | 10 +++ 19 files changed, 358 insertions(+), 34 deletions(-) create mode 100644 tests/functional/programs/005_functions/061_function_types_in_annotations.ndc create mode 100644 tests/functional/programs/005_functions/062_a_function_argument_must_take_what_the_parameter_passes.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/022_lambdas_are_checked_against_what_a_function_passes_and_returns.ndc diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index 1fbced09..bc2b455e 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -19,7 +19,7 @@ The type system is hierarchical with `Any` at the root: * [`Deque`](./types/deque.md): a double-ended queue * [`MinHeap` / `MaxHeap`](./types/min-max-heap.md): min/max heap * `Iterator`: produces values when consumed (currently only from range expressions like `5..100`) - * [`Function`](./types/function.md) + * [`Fn(A, B) -> C`](./types/function.md): a function taking an `A` and a `B` and returning a `C` * [Structs](./types/struct.md): user-defined record types, one per `struct` declaration These are also the names you write in annotations. Generic types take their parameters in angle brackets: diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index 5cd72181..bd055b1d 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -149,6 +149,29 @@ fn bad() -> Int { "hello" } // ERROR: mismatched types A return-type annotation also helps the analyser understand recursive calls — without it, a recursive call resolves against an unknown return type and you can lose precision. +### Function types + +The type of a function is written `Fn(A, B) -> C`: it takes an `A` and a `B` and returns a `C`. Without `-> …` the function returns `()`: + +```ndc +let twice: Fn(Int) -> Int = fn(x: Int) => x * 2; +let log: Fn(String) = fn(s: String) { print(s); }; + +fn apply(f: Fn(Int) -> Int, x: Int) -> Int { f(x) } +fn make_adder(n: Int) -> Fn(Int) -> Int { fn(x: Int) => x + n } + +assert_eq(apply(twice, 4), 8); +assert_eq(apply(make_adder(10), 1), 11); +``` + +A function passed for a function parameter has to take what the parameter passes and return what it returns. Parts whose type isn't known, like the parameter of an unannotated lambda, are checked when the program runs: + +```ndc +fn apply(f: Fn(Int) -> Int, x: Int) -> Int { f(x) } +apply(fn(x) => x - 1, 1); // fine: x isn't annotated +apply(fn(s: String) => 1, 4); // ERROR: the function takes a String, but apply passes an Int +``` + ## Function overloading A name can refer to several functions, called overloads, as long as they differ in their number of parameters, their parameter types, or both: diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index acb25da8..83d550ff 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -134,6 +134,18 @@ impl Analyser { TypeExpr::Tuple { elements, .. } => { StaticType::Tuple(elements.iter().map(|e| self.lower_type_expr(e)).collect()) } + TypeExpr::Function { + parameters, + return_type, + .. + } => StaticType::Function { + parameters: Some(parameters.iter().map(|p| self.lower_type_expr(p)).collect()), + return_type: Box::new( + return_type + .as_ref() + .map_or_else(StaticType::unit, |r| self.lower_type_expr(r)), + ), + }, TypeExpr::Name { name, args, diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index c99de234..2f8d8bb1 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -564,7 +564,7 @@ fn calling_a_function_value_checks_its_arguments() { "let f = fn(x: Int) { x }; let g = [f]; g[0](\"a\");", index_natives() ), - vec!["A Function(Int) -> Int doesn't accept the arguments 'String'"] + vec!["A function of type Fn(Int) -> Int doesn't accept the arguments 'String'"] ); } @@ -588,6 +588,83 @@ fn an_index_write_checks_the_index_against_the_container() { } } +#[test] +fn a_function_annotation_types_a_variable() { + let int_to_int = StaticType::Function { + parameters: Some(vec![StaticType::Int]), + return_type: Box::new(StaticType::Int), + }; + assert_eq!( + analyse_last_type("let f: Fn(Int) -> Int = fn(x: Int) => x; f"), + int_to_int + ); + assert_eq!( + errors("let f: Fn(Int) -> Int = fn(x: String) => x;", vec![]), + vec!["mismatched types: found Fn(String) -> String but expected Fn(Int) -> Int"] + ); +} + +#[test] +fn a_function_argument_gets_what_the_function_parameter_passes() { + let globals = natives(&[("map", "(Sequence, Fn(T) -> U) -> List")]); + // A typed lambda fits a type parameter, and settles it. + assert_eq!( + analyse_last_type_with_globals( + "let xs = []; map(xs, fn(x: Int) => x); xs", + globals.clone() + ), + StaticType::List(Box::new(StaticType::Int)) + ); + assert_eq!( + analyse_last_type_with_globals("map([1], fn(x: Int) => \"a\")", globals.clone()), + StaticType::List(Box::new(StaticType::String)) + ); + // It has to take what the list holds. + assert_eq!( + errors("map([1], fn(x: String) => x);", globals), + vec![ + "No function called 'map' found that matches the arguments 'List, Fn(String) -> String'" + ] + ); +} + +#[test] +fn a_function_argument_fits_a_function_parameter_where_their_known_parts_agree() { + let apply = "fn apply(f: Fn(Int) -> Int) { f(1) } "; + // An untyped lambda is `Fn(Any) -> Any`, which is checked as the program runs. + assert_eq!( + errors(&format!("{apply}apply(fn(x) => x);"), vec![]), + Vec::::new() + ); + for (lambda, shown) in [ + ("fn(x: String) => 1", "Fn(String) -> Int"), + ("fn(x) => \"a\"", "Fn(Any) -> String"), + ("fn(a, b) => 1", "Fn(Any, Any) -> Int"), + ] { + assert_eq!( + errors(&format!("{apply}apply({lambda});"), vec![]), + vec![format!( + "No function called 'apply' found that matches the arguments '{shown}'" + )], + "{lambda}" + ); + } +} + +#[test] +fn a_function_value_rejects_a_callback_taking_another_number_of_arguments() { + // Called through an expression, so no overload is matched first. + assert_eq!( + errors( + "fn apply(f: Fn(Int) -> Int) { f(1) } let fs = [apply]; fs[0](fn(a, b) => 1);", + index_natives() + ), + vec![ + "A function of type Fn(Fn(Int) -> Int) -> Int doesn't accept the arguments 'Fn(Any, Any) -> Int'" + ] + ); +} + #[test] fn an_any_parameter_settles_what_its_argument_leaves_unknown_as_any() { // `keep` may store anything in the list, so it can't become a List. diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index 075c8b03..b6c3ccc4 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -202,7 +202,7 @@ impl AnalysisError { ) -> Self { Self { text: format!( - "A {callee} doesn't accept the arguments '{}'", + "A function of type {callee} doesn't accept the arguments '{}'", types.iter().join(", ") ), span, diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 27c99fba..5f4b33e6 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -48,6 +48,40 @@ struct ScalarWalk { all_by_name: Vec, } +/// Whether a value of type `arg` might be accepted where `param` is expected +/// when the program runs. For a function passed for a function parameter, +/// only what both types know has to agree: `fn(x) => x * 2`, typed +/// `Fn(Any) -> Any`, can be passed for an `Fn(Int) -> Int`, but +/// `fn(x: String) => 1` can't, and neither can a function of two arguments. +fn could_pass(param: &StaticType, arg: &StaticType) -> bool { + match (param, arg) { + ( + StaticType::Function { + parameters: expected, + return_type: expected_return, + }, + StaticType::Function { + parameters: takes, + return_type: returns, + }, + ) => { + let parameters = match (expected, takes) { + // What the parameter passes has to fit what the function takes. + (Some(expected), Some(takes)) => { + expected.len() == takes.len() + && expected + .iter() + .zip(takes) + .all(|(expected, takes)| could_pass(takes, expected)) + } + _ => true, + }; + parameters && could_pass(expected_return, returns) + } + _ => !param.is_incompatible_with(arg), + } +} + /// Returns `true` iff `more` is a strict subtype of `less` pointwise — i.e. /// every position satisfies `more[i] <: less[i]` and at least one position is /// not bidirectionally equivalent. Used by [`Overloads::most_specific_match`] to order @@ -441,7 +475,7 @@ impl<'a> Overloads<'a> { && parameters .iter() .zip(sig) - .all(|(param, arg)| !param.is_incompatible_with(arg)) + .all(|(param, arg)| could_pass(param, arg)) } /// Try to resolve a vec candidate set for `sig`. @@ -531,7 +565,7 @@ impl<'a> Overloads<'a> { && parameters .iter() .zip(sig) - .all(|(param, arg)| !param.is_incompatible_with(arg)) + .all(|(param, arg)| could_pass(param, arg)) } StaticType::Function { parameters: None, .. diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 2fd68aa6..b51caa76 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -193,17 +193,35 @@ impl TypeVars { | (StaticType::MinHeap(p), StaticType::MinHeap(a)) | (StaticType::MaxHeap(p), StaticType::MaxHeap(a)) | (StaticType::Deque(p), StaticType::Deque(a)) - | (StaticType::Sequence(p), StaticType::Sequence(a)) - // A function passed as an argument returns what the parameter's - // function type returns: `map(xs, fn(x) => x + 1)` settles `U`. - | ( + | (StaticType::Sequence(p), StaticType::Sequence(a)) => self.bind_argument(p, a), + // A function passed as an argument gets the parameter types the + // function type passes it, and returns what the function type + // returns: `map(xs, fn(x: Int) => x + 1)` passes `xs`'s elements + // to `x` and settles `U`. A function parameter whose type isn't + // known takes anything, so it settles nothing. + ( StaticType::Function { - return_type: p, .. + parameters: expected, + return_type: p, }, StaticType::Function { - return_type: a, .. + parameters: takes, + return_type: a, }, - ) => self.bind_argument(p, a), + ) => { + let mut fits = true; + if let (Some(expected), Some(takes)) = (expected, takes) { + // A function taking another number of arguments can't be + // called the way the function type says. + fits = expected.len() == takes.len(); + for (expected, takes) in expected.iter().zip(takes) { + if !takes.is_unknown() { + fits &= self.bind_argument(takes, expected); + } + } + } + fits && self.bind_argument(p, a) + } (StaticType::Tuple(ps), StaticType::Tuple(args)) if ps.len() == args.len() => { ps.iter().zip(args).all(|(p, a)| self.bind_argument(p, a)) } diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 5b9edd0d..290ad01d 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -330,9 +330,14 @@ impl StaticType { (Some(ps1), Some(ps2)) => { ps1.len() == ps2.len() && // Note the reversal: p2 must be subtype of p1! + // A type parameter of a signature, like `T` in + // `Fn(T) -> U`, fits whatever the function takes: + // the call works out what it stands for. An `Any` + // doesn't, as a value of that type is only + // checked when the program runs. ps1.iter() .zip(ps2) - .all(|(p1, p2)| p2.is_subtype(p1)) + .all(|(p1, p2)| matches!(p2, Self::Generic(_)) || p2.is_subtype(p1)) } }; @@ -831,7 +836,7 @@ impl fmt::Display for StaticType { return_type, } => write!( f, - "Function({}) -> {return_type}", + "Fn({}) -> {return_type}", parameters .as_deref() .map_or(String::from("*"), |p| p.iter().join(", ")) diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index da4e243e..12c1a4e7 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -1415,6 +1415,11 @@ impl Parser { }; match token { + Token::Identifier(name) + if name == "Fn" && self.peek_next_token() == Some(&Token::LeftParentheses) => + { + self.function_type() + } Token::Identifier(_) => self.named_or_generic_type(), Token::LeftParentheses => self.tuple_type(), _ => Err(Error::with_help( @@ -1604,6 +1609,29 @@ impl Parser { Ok(greater_span) } + /// Parses `Fn(A, B) -> C`, where `-> C` is optional. + fn function_type(&mut self) -> Result { + let start = self.require_current_token()?.span; + let (parameters, parameters_span) = self.delimited_comma_separated( + &Token::LeftParentheses, + &Token::RightParentheses, + Self::type_annotation, + true, + )?; + let (return_type, span) = if self.consume_token_if(&[Token::RightArrow]).is_some() { + let return_type = self.type_annotation()?; + let span = start.merge(return_type.span()); + (Some(Box::new(return_type)), span) + } else { + (None, start.merge(parameters_span)) + }; + Ok(TypeExpr::Function { + parameters, + return_type, + span, + }) + } + pub fn tuple_type(&mut self) -> Result { let (elements, span) = self.delimited_comma_separated( &Token::LeftParentheses, diff --git a/ndc_parser/src/type_expr.rs b/ndc_parser/src/type_expr.rs index fc11594b..b07c6a33 100644 --- a/ndc_parser/src/type_expr.rs +++ b/ndc_parser/src/type_expr.rs @@ -18,13 +18,22 @@ pub enum TypeExpr { }, /// A tuple type: `(Int, String)`. The unit type `()` is the empty tuple. Tuple { elements: Vec, span: Span }, + /// A function type: `Fn(Int, Int) -> Int`. Without `-> …` the function + /// returns `()`. + Function { + parameters: Vec, + return_type: Option>, + span: Span, + }, } impl TypeExpr { #[must_use] pub fn span(&self) -> Span { match self { - Self::Name { span, .. } | Self::Tuple { span, .. } => *span, + Self::Name { span, .. } | Self::Tuple { span, .. } | Self::Function { span, .. } => { + *span + } } } } @@ -56,6 +65,19 @@ impl fmt::Display for TypeExpr { join(f, elements)?; write!(f, ")") } + Self::Function { + parameters, + return_type, + .. + } => { + write!(f, "Fn(")?; + join(f, parameters)?; + write!(f, ")")?; + match return_type { + Some(return_type) => write!(f, " -> {return_type}"), + None => Ok(()), + } + } } } } diff --git a/ndc_parser/tests/type_annotations.rs b/ndc_parser/tests/type_annotations.rs index 9455a2c1..5886d7a6 100644 --- a/ndc_parser/tests/type_annotations.rs +++ b/ndc_parser/tests/type_annotations.rs @@ -29,6 +29,18 @@ fn names(annotation: &TypeExpr, found: &mut Vec<(String, NodeId)>) { names(element, found); } } + TypeExpr::Function { + parameters, + return_type, + .. + } => { + for parameter in parameters { + names(parameter, found); + } + if let Some(return_type) = return_type { + names(return_type, found); + } + } } } @@ -89,3 +101,36 @@ fn a_cast_target_before_a_comparison_gets_its_own_id() { }); assert_distinct(ids); } + +#[test] +fn a_function_type_has_parameters_and_an_optional_return_type() { + for (source, shown) in [ + ( + "let f: Fn(Int, String) -> Bool = g", + "Fn(Int, String) -> Bool", + ), + ("let f: Fn() = g", "Fn()"), + ( + "let f: Fn(Fn(Int) -> Int) -> List = g", + "Fn(Fn(Int) -> Int) -> List", + ), + ( + "fn h(f: Fn(Int) -> Int, x: Int) -> Fn(Int) -> Int { f }", + "Fn(Int) -> Int", + ), + ] { + let expression = parse_one(source); + let annotation = match expression.expression { + Expression::VariableDeclaration { + annotated_type: Some(annotation), + .. + } => annotation, + Expression::FunctionDeclaration { parameters, .. } => parameters[0] + .annotation + .clone() + .expect("the first parameter is annotated"), + other => panic!("unexpected expression {other:?}"), + }; + assert_eq!(annotation.to_string(), shown, "{source}"); + } +} diff --git a/ndc_resolver/src/name_resolution.rs b/ndc_resolver/src/name_resolution.rs index 00fb7c32..ea3a39fc 100644 --- a/ndc_resolver/src/name_resolution.rs +++ b/ndc_resolver/src/name_resolution.rs @@ -1000,6 +1000,18 @@ impl NameResolver { self.resolve_type(element)?; } } + TypeExpr::Function { + parameters, + return_type, + .. + } => { + for parameter in parameters { + self.resolve_type(parameter)?; + } + if let Some(return_type) = return_type { + self.resolve_type(return_type)?; + } + } } Ok(()) } diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index b6a087a4..9a2655a8 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -170,13 +170,13 @@ mod inner { } /// Returns the element for which the key function returns the highest value. - #[function(signature = "(Sequence, Fn(*) -> U) -> T")] + #[function(signature = "(Sequence, Fn(T) -> U) -> T")] pub fn max_by_key(seq: SeqValue, func: &mut VmCallable<'_>) -> anyhow::Result { by_key(seq, func, Ordering::Greater) } /// Returns the element for which the key function returns the lowest value. - #[function(signature = "(Sequence, Fn(*) -> U) -> T")] + #[function(signature = "(Sequence, Fn(T) -> U) -> T")] pub fn min_by_key(seq: SeqValue, func: &mut VmCallable<'_>) -> anyhow::Result { by_key(seq, func, Ordering::Less) } @@ -186,7 +186,7 @@ mod inner { /// The comparator function takes two elements and returns a number. A positive result means the /// first argument is greater than the second, a negative result means the first argument is /// less than the second, and zero means they are equal. - #[function(signature = "(Sequence, Fn(*) -> U) -> T")] + #[function(signature = "(Sequence, Fn(T, T) -> U) -> T")] pub fn max_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { by_comp(seq, comp, Ordering::Greater) } @@ -196,7 +196,7 @@ mod inner { /// The comparator function takes two elements and returns a number. A positive result means the /// first argument is greater than the second, a negative result means the first argument is /// less than the second, and zero means they are equal. - #[function(signature = "(Sequence, Fn(*) -> U) -> T")] + #[function(signature = "(Sequence, Fn(T, T) -> U) -> T")] pub fn min_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { by_comp(seq, comp, Ordering::Less) } @@ -265,7 +265,7 @@ mod inner { /// - for values equal to `0` the first argument is equal to the second argument /// /// This function only works for strings and lists and will throw errors otherwise. - #[function(signature = "(List, Fn(*) -> U)")] + #[function(signature = "(List, Fn(T, T) -> U)")] pub fn sort_by(list: &mut Vec, comp: &mut VmCallable<'_>) -> anyhow::Result<()> { let mut err: Option = None; list.sort_by(|left, right| { @@ -311,7 +311,7 @@ mod inner { /// - for values lower than `0` the first argument is smaller than the second argument /// - for values higher than `0` the first argument is greater than the second argument /// - for values equal to `0` the first argument is equal to the second argument - #[function(signature = "(Sequence, Fn(*) -> U) -> List")] + #[function(signature = "(Sequence, Fn(T, T) -> U) -> List")] pub fn sorted_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { let mut list: Vec = seq .try_into_iter() @@ -378,7 +378,7 @@ mod inner { } /// Reduces/folds the given sequence using the given combining function and a custom initial value. - #[function(signature = "(Sequence, A, Fn(*) -> A) -> A")] + #[function(signature = "(Sequence, A, Fn(A, T) -> A) -> A")] pub fn fold( seq: SeqValue, initial: Value, @@ -393,7 +393,7 @@ mod inner { } /// Reduces/folds the given sequence using the given combining function. - #[function(signature = "(Sequence, Fn(*) -> T) -> T")] + #[function(signature = "(Sequence, Fn(T, T) -> T) -> T")] pub fn reduce(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut iterator = seq .try_into_iter() @@ -405,7 +405,7 @@ mod inner { } /// Filters the given sequence using the `predicate`. - #[function(signature = "(Sequence, Fn(*) -> U) -> List")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> List")] pub fn filter(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for element in seq @@ -425,7 +425,7 @@ mod inner { } /// Returns the number of elements in the input sequence for which the given `predicate` returns `true`. - #[function(signature = "(Sequence, Fn(*) -> U) -> Int")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> Int")] pub fn count(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { let mut out = 0i64; for element in seq @@ -442,7 +442,7 @@ mod inner { } /// Returns the value of the first element for which the `predicate` is true for the given input sequence. - #[function(signature = "(Sequence, Fn(*) -> U) -> T")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> T")] pub fn find(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for element in seq .try_into_iter() @@ -461,7 +461,7 @@ mod inner { } /// Returns the first index of the element for which the `predicate` is true in the input sequence. - #[function(signature = "(Sequence, Fn(*) -> U) -> Int")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> Int")] pub fn locate(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for (idx, element) in seq .try_into_iter() @@ -493,7 +493,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for none of the elements in `seq`. - #[function(signature = "(Sequence, Fn(*) -> U) -> Bool")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> Bool")] pub fn none(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -514,7 +514,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for all the elements in `seq`. - #[function(signature = "(Sequence, Fn(*) -> U) -> Bool")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> Bool")] pub fn all(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -535,7 +535,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for any of the elements in `seq`. - #[function(signature = "(Sequence, Fn(*) -> U) -> Bool")] + #[function(signature = "(Sequence, Fn(T) -> Bool) -> Bool")] pub fn any(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -556,7 +556,7 @@ mod inner { } /// Applies the function to each element in a sequence returning the result as a list. - #[function(signature = "(Sequence, Fn(*) -> U) -> List")] + #[function(signature = "(Sequence, Fn(T) -> U) -> List")] pub fn map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for item in seq @@ -569,7 +569,7 @@ mod inner { } /// Applies a function to each item in a sequence, flattens the resulting sequences, and returns a single combined sequence. - #[function(signature = "(Sequence, Fn(*) -> U) -> List")] + #[function(signature = "(Sequence, Fn(T) -> Sequence) -> List")] pub fn flat_map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for item in seq @@ -753,7 +753,7 @@ mod inner { /// Applies a function to each pair of consecutive elements in a sequence and returns the results as a list. #[function( name = "pairwise", - signature = "(Sequence, Fn(*) -> U) -> List" + signature = "(Sequence, Fn(T, T) -> U) -> List" )] pub fn pairwise_map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let main: Vec = seq diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 8d7da722..30ba0d4d 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -331,6 +331,20 @@ impl Value { match param { // A type parameter like `T` in `push(List, T)` takes any value. param if param.is_unknown() => true, + // What a function takes and returns can't be checked as the + // program runs, only how many arguments it takes. + StaticType::Function { + parameters: expected, + .. + } => match self.static_type() { + StaticType::Function { + parameters: takes, .. + } => match (expected, takes) { + (Some(expected), Some(takes)) => expected.len() == takes.len(), + _ => true, + }, + _ => false, + }, StaticType::Int => { matches!(self, Self::Int(_)) } diff --git a/tests/functional/programs/005_functions/061_function_types_in_annotations.ndc b/tests/functional/programs/005_functions/061_function_types_in_annotations.ndc new file mode 100644 index 00000000..06404d26 --- /dev/null +++ b/tests/functional/programs/005_functions/061_function_types_in_annotations.ndc @@ -0,0 +1,14 @@ +// `Fn(A, B) -> C` is the type of a function taking an A and a B and +// returning a C. Without `-> …` it returns `()`. +let twice: Fn(Int) -> Int = fn(x: Int) => x * 2; +fn apply(f: Fn(Int) -> Int, x: Int) -> Int { f(x) } +fn make_adder(n: Int) -> Fn(Int) -> Int { fn(x: Int) => x + n } +let log: Fn(String) = fn(s: String) { print(s); }; +log("start"); +print(apply(twice, 4)); +print(apply(make_adder(10), 1)); +print(apply(fn(x) => x - 1, 1)); +// expect-output: start +// expect-output: 8 +// expect-output: 11 +// expect-output: 0 diff --git a/tests/functional/programs/005_functions/062_a_function_argument_must_take_what_the_parameter_passes.ndc b/tests/functional/programs/005_functions/062_a_function_argument_must_take_what_the_parameter_passes.ndc new file mode 100644 index 00000000..7133adbe --- /dev/null +++ b/tests/functional/programs/005_functions/062_a_function_argument_must_take_what_the_parameter_passes.ndc @@ -0,0 +1,5 @@ +// `apply` passes its function an Int, so a function taking a String can't +// be given to it. +// expect-error: No function called 'apply' found that matches the arguments 'Fn(String) -> Int, Int' +fn apply(f: Fn(Int) -> Int, x: Int) -> Int { f(x) } +apply(fn(s: String) => 1, 4); diff --git a/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc b/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc index b5d8e245..26d260c5 100644 --- a/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc +++ b/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc @@ -27,7 +27,8 @@ struct Other { let other_x = x; assert(point_x != other_x); -let both = %{point_x: "Point", other_x: "Other"}; +// The two getters have different types, so the keys are typed `Any`. +let both: Map = %{point_x: "Point", other_x: "Other"}; assert_eq(both.len(), 2); assert_eq(both[point_x], "Point"); assert_eq(both[other_x], "Other"); diff --git a/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc b/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc new file mode 100644 index 00000000..8f3ae5a0 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc @@ -0,0 +1,4 @@ +// `filter` keeps the elements its predicate returns `true` for, so a +// predicate returning an Int is an error. +// expect-error: No function called 'filter' found that matches the arguments 'List, Fn(Any) -> Int' +[1, 2, 3].filter(fn(x) => 1); diff --git a/tests/functional/programs/603_stdlib_seq/022_lambdas_are_checked_against_what_a_function_passes_and_returns.ndc b/tests/functional/programs/603_stdlib_seq/022_lambdas_are_checked_against_what_a_function_passes_and_returns.ndc new file mode 100644 index 00000000..31008d11 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/022_lambdas_are_checked_against_what_a_function_passes_and_returns.ndc @@ -0,0 +1,10 @@ +// A lambda with a typed parameter takes the list's elements, and an untyped +// one returning a value of unknown type is checked as the program runs, even +// when the list's type isn't known either. +fn big(xs) { xs.filter(fn(x) => x > 1) } +print([1, 2, 3].map(fn(x: Int) => x + 1)); +print(big([1, 2, 3])); +print([[true], [false]].filter(fn(x) => x[0])); +// expect-output: [2,3,4] +// expect-output: [2,3] +// expect-output: [[true]] From aae3152384dc6dc945cceb172286715a6bc46bee Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 16:08:23 +0100 Subject: [PATCH 05/39] =?UTF-8?q?feat(analyser):=20give=20lambdas=20the=20?= =?UTF-8?q?function=20type=20expected=20of=20them=20=F0=9F=8E=AF=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lambda now takes its parameter types from a function type that's expected where it's written, when that type is known before the lambda is analysed. This is part of step 2c of the type system RFC (#281). Where a lambda gets an expected function type: - As the value of an annotated variable or of an assignment: in `let f: Fn(Int) -> Int = fn(x) => x + 1`, `x` is an `Int`. - As an argument to a function value, `fs[0](fn(x) => …)`, from the callee's type, which is known before its arguments are analysed. - As a map's default function, which is expected to return the map's value type. So `let m: Map> = %{:fn() => []}` is accepted now; the #287 review found it was rejected. With an expected type, an unannotated parameter takes the expected parameter's type, unless that type is `Any`. The body is checked against the expected return type, so an empty literal returned from it adopts that type. Not included: a lambda passed to a named function, as in `xs.map(fn(x) => x + 1)`, keeps `Any` parameters. A first version gave it the parameter types of the overload its call picks. That needed a stand-in for the lambda during overload matching, and a rule for when the stand-in can't tell overloads apart, which a review then had to patch. Step 3's call constraints solve this without either, so that part was taken out again. Behaviour changes: a lambda body with an expected type is checked with real parameter types, so `let f: Fn(Int) -> Int = fn(x) => x ++ "!"` is reported before the program runs. Internals: `analyse_function_declaration` takes the declaration's parts as a `FunctionDeclaration` struct, plus an optional expected type, and `analyse_with_expected` has an arm for lambdas. The manual's function types section describes where lambdas get their types, and that lambdas passed to named functions don't yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/function.md | 14 ++++- ndc_analyser/src/analyser/calls.rs | 15 ++++- ndc_analyser/src/analyser/declarations.rs | 61 ++++++++++++++++--- ndc_analyser/src/analyser/mod.rs | 57 ++++++++++++++--- ndc_analyser/src/analyser/tests.rs | 43 +++++++++++++ ...ambda_takes_the_expected_function_type.ndc | 14 +++++ ...t_function_returns_the_maps_value_type.ndc | 6 ++ 7 files changed, 189 insertions(+), 21 deletions(-) create mode 100644 tests/functional/programs/005_functions/063_a_lambda_takes_the_expected_function_type.ndc create mode 100644 tests/functional/programs/007_map_and_set/032_a_default_function_returns_the_maps_value_type.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index bd055b1d..c8e7a186 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -164,14 +164,24 @@ assert_eq(apply(twice, 4), 8); assert_eq(apply(make_adder(10), 1), 11); ``` -A function passed for a function parameter has to take what the parameter passes and return what it returns. Parts whose type isn't known, like the parameter of an unannotated lambda, are checked when the program runs: +A function passed for a function parameter has to take what the parameter passes and return what it returns: ```ndc fn apply(f: Fn(Int) -> Int, x: Int) -> Int { f(x) } -apply(fn(x) => x - 1, 1); // fine: x isn't annotated apply(fn(s: String) => 1, 4); // ERROR: the function takes a String, but apply passes an Int ``` +A lambda written where a function type is expected takes its parameter types from it, so it needs no annotations: as the value of an annotated variable, in an assignment, or as an argument to a function value. Its body is checked with those types: + +```ndc +let shout: Fn(String) -> String = fn(s) => s ++ "!"; // s is a String +assert_eq(shout("hi"), "hi!"); + +let double: Fn(Int) -> Int = fn(x) => x ++ "!"; // ERROR: x is an Int, and there's no `++` for an Int and a String +``` + +A lambda passed to a named function, as in `xs.map(fn(x) => x + 1)`, doesn't get its parameter types this way yet. Its unannotated parameters are `Any`, and they are checked when the program runs. + ## Function overloading A name can refer to several functions, called overloads, as long as they differ in their number of parameters, their parameter types, or both: diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index f0575b21..ed165a1c 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -150,9 +150,22 @@ impl Analyser { // get their slots first. let Expression::Identifier { name, .. } = &function.expression else { let callee_type = self.analyse_expr(function); + // The callee's type says what each argument is expected to be, + // which tells a lambda argument what its parameters are. + let expected = match &callee_type { + StaticType::Function { + parameters: Some(parameters), + .. + } if parameters.len() == arguments.len() => Some(parameters.clone()), + _ => None, + }; let arguments = arguments .iter() - .map(|arg| self.analyse_expr(arg)) + .enumerate() + .map(|(position, arg)| match &expected { + Some(expected) => self.analyse_with_expected(arg, &expected[position]), + None => self.analyse_expr(arg), + }) .collect::>(); return match callee_type { // The callee's type says what it takes, like a picked overload's. diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 8c8e4b6c..6dd2af81 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -11,23 +11,59 @@ use ndc_parser::{ use ndc_resolver::SymbolId; use std::rc::Rc; +/// What a function declaration consists of, named like `fn f(x) { … }` or +/// anonymous like `fn(x) => …`. +#[derive(Clone, Copy)] +pub(super) struct FunctionDeclaration<'a> { + pub id: NodeId, + pub name: Option<&'a str>, + pub parameters: &'a [FunctionParameter], + pub body: &'a ExpressionLocation, + pub return_annotation: Option<&'a TypeExpr>, + pub span: Span, +} + impl Analyser { - /// Analyses a function declaration and returns its type. + /// Analyses a function declaration and returns its type. A lambda passed + /// where a function type is expected, as in `xs.map(fn(x) => x + 1)`, + /// gets that type as `expected`: an unannotated parameter takes the + /// expected parameter's type, and the body is checked against the + /// expected return type. pub(super) fn analyse_function_declaration( &mut self, - id: NodeId, - name: Option<&str>, - parameters: &[FunctionParameter], - body: &ExpressionLocation, - return_annotation: Option<&TypeExpr>, - span: Span, + function: FunctionDeclaration<'_>, + expected: Option<&StaticType>, ) -> StaticType { + let FunctionDeclaration { + id, + name, + parameters, + body, + return_annotation, + span, + } = function; let first_var = self.vars.count(); + let (expected_parameters, expected_return) = match expected { + Some(StaticType::Function { + parameters: Some(expected), + return_type, + }) if expected.len() == parameters.len() => { + (Some(expected.as_slice()), Some(return_type.as_ref())) + } + _ => (None, None), + }; let type_signature = TypeSignature::from_annotated_bindings( parameters .iter() - .map(|param| { - let annotation = self.lower_annotation(param.annotation.as_ref()); + .enumerate() + .map(|(index, param)| { + let annotation = + self.lower_annotation(param.annotation.as_ref()) + .or_else(|| { + expected_parameters + .map(|expected| expected[index].clone()) + .filter(|expected| *expected != StaticType::Any) + }); (param.name().to_string(), annotation) }) .collect(), @@ -78,7 +114,12 @@ impl Analyser { .insert(param.lvalue.id, typ.clone()); } - let implicit_return = self.analyse_expr(body); + let implicit_return = match expected_return { + Some(expected) if annotated_return.is_none() => { + self.analyse_with_expected(body, expected) + } + _ => self.analyse_expr(body), + }; let explicit_return = self.return_type_stack.pop().unwrap(); self.loop_depth = outer_loop_depth; // Nothing outside the body can settle the type variables made in it, diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 83d550ff..90ddc662 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -15,6 +15,8 @@ use std::rc::Rc; mod assignments; mod calls; mod declarations; + +use declarations::FunctionDeclaration; #[cfg(test)] mod tests; @@ -251,8 +253,22 @@ impl Analyser { } } if let Some(default) = default { - let found = - Self::default_value_type(self.analyse_with_expected(default, value)); + // A function default makes each missing value, so it's + // expected to return one. + let expected_default = if matches!( + default.expression, + Expression::FunctionDeclaration { name: None, .. } + ) { + StaticType::Function { + parameters: Some(vec![]), + return_type: value.clone(), + } + } else { + value.as_ref().clone() + }; + let found = Self::default_value_type( + self.analyse_with_expected(default, &expected_default), + ); Self::fold_lub(&mut value_type, found); } StaticType::Map { @@ -267,6 +283,28 @@ impl Analyser { })), } } + // A lambda takes the types an expected function type gives it: + // in `let f: Fn(Int) -> Int = fn(x) => x + 1`, `x` is an `Int`. + ( + Expression::FunctionDeclaration { + name: None, + parameters, + body, + return_annotation, + .. + }, + StaticType::Function { .. }, + ) => self.analyse_function_declaration( + FunctionDeclaration { + id: expr_loc.id, + name: None, + parameters, + body, + return_annotation: return_annotation.as_ref(), + span: expr_loc.span, + }, + Some(expected), + ), (Expression::Tuple { values }, StaticType::Tuple(elements)) if values.len() == elements.len() => { @@ -398,12 +436,15 @@ impl Analyser { return_annotation, .. } => self.analyse_function_declaration( - *id, - name.as_deref(), - parameters, - body, - return_annotation.as_ref(), - *span, + FunctionDeclaration { + id: *id, + name: name.as_deref(), + parameters, + body, + return_annotation: return_annotation.as_ref(), + span: *span, + }, + None, ), Expression::Block { statements } => { let mut last = None; diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 2f8d8bb1..247cb811 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -665,6 +665,49 @@ fn a_function_value_rejects_a_callback_taking_another_number_of_arguments() { ); } +#[test] +fn a_lambdas_type_picks_between_overloads_taking_functions() { + let choose = "fn choose(f: Fn() -> Int) { 1 } fn choose(f: Fn() -> String) { \"s\" } "; + assert_eq!( + analyse_last_type(&format!("{choose}choose(fn() => 1)")), + StaticType::Int + ); + assert_eq!( + analyse_last_type(&format!("{choose}choose(fn() => \"a\")")), + StaticType::String + ); +} + +#[test] +fn a_lambda_takes_the_function_type_expected_where_it_is_written() { + let int_to_int = StaticType::Function { + parameters: Some(vec![StaticType::Int]), + return_type: Box::new(StaticType::Int), + }; + // An annotated variable. + assert_eq!( + analyse_last_type("let f: Fn(Int) -> Int = fn(x) => x; f"), + int_to_int + ); + // A function value's parameter. + let globals = natives(&[("++", "(String, String) -> String")]); + assert_eq!( + errors( + "let apply = fn(g: Fn(Int) -> Int) { g(1) }; let fs = [apply]; fs[0](fn(x) => x ++ \"a\");", + [index_natives(), globals].concat() + ), + vec!["No function called '++' found that matches the arguments 'Int, String'"] + ); + // A map's default function returns the map's values. + assert_eq!( + analyse_last_type("let m: Map> = %{:fn() => []}; m"), + StaticType::Map { + key: Box::new(StaticType::String), + value: Box::new(StaticType::List(Box::new(StaticType::Int))), + } + ); +} + #[test] fn an_any_parameter_settles_what_its_argument_leaves_unknown_as_any() { // `keep` may store anything in the list, so it can't become a List. diff --git a/tests/functional/programs/005_functions/063_a_lambda_takes_the_expected_function_type.ndc b/tests/functional/programs/005_functions/063_a_lambda_takes_the_expected_function_type.ndc new file mode 100644 index 00000000..de241ce6 --- /dev/null +++ b/tests/functional/programs/005_functions/063_a_lambda_takes_the_expected_function_type.ndc @@ -0,0 +1,14 @@ +// A lambda written where a function type is expected takes its parameter +// types from it: in an annotated variable, an assignment, or an argument +// to a function value. These lambdas need no annotations. +let double: Fn(Int) -> Int = fn(x) => x * 2; +let shout: Fn(String) -> String = fn(s) => s; +shout = fn(s) => s ++ "!"; +let apply = fn(f: Fn(Int) -> Int, x: Int) -> Int { f(x) }; +let calls = [apply]; +print(double(4)); +print(shout("hi")); +print(calls[0](fn(x) => x + 1, 1)); +// expect-output: 8 +// expect-output: hi! +// expect-output: 2 diff --git a/tests/functional/programs/007_map_and_set/032_a_default_function_returns_the_maps_value_type.ndc b/tests/functional/programs/007_map_and_set/032_a_default_function_returns_the_maps_value_type.ndc new file mode 100644 index 00000000..cd0deed7 --- /dev/null +++ b/tests/functional/programs/007_map_and_set/032_a_default_function_returns_the_maps_value_type.ndc @@ -0,0 +1,6 @@ +// The default function makes each missing value, so it's expected to +// return the map's value type, and the empty list it returns is a List. +// expect-error: No function called 'push' found that matches the arguments 'List, String' +let m: Map> = %{:fn() => []}; +m["a"].push(1); +m["b"].push("x"); From dc58b621ccf80b36976b2045d1b173a0e5369ed8 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 21:18:54 +0100 Subject: [PATCH 06/39] =?UTF-8?q?feat(analyser):=20let=20calls=20wait=20un?= =?UTF-8?q?til=20their=20arguments=20pick=20an=20overload=20=E2=8F=B3=20(#?= =?UTF-8?q?292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3a of RFC #281 ("Step 3 in detail"): the constraint solver, without generic functions yet. Until now each call picked its overload when the analyser reached it, using the argument types known at that moment. Information that arrived later was handled with special cases: the expected type passed into lambdas, the stand-in element type for index writes, and the literal arms of `analyse_with_expected`. Now a call whose arguments still contain type variables waits: - Elimination: the overloads that can't take what's known are crossed off. When one is left, the call commits to it, which settles its arguments from that overload's parameters. It doesn't commit to an overload that fits only because an `Any` parameter takes an argument of another type, since `Any` only loses to an exact match once the arguments are known: `xs[0] = []` on a `List` is still an error. - Forward: once the arguments are known, the call is resolved as before. - A waiting call is retried when a variable it waits on is settled. When a named function or the batch ends, defaults decide what they can first: a call still waiting takes its narrowest overload if every overload that fits is in one scope and none of its arguments is wholly unknown (`m["k"]` on an empty map picks `[](Map, K)` over `[](Any, Any)`), and `a op= b` keeps the type of `b`. Then what nothing settled gets its fallback (`Any`) and the rest is resolved as before, with runtime dispatch where an argument is `Any`. Behaviour changes: - A lambda's unannotated parameters are type variables instead of `Any`, settled by its body, by what it's passed to, and by how it's called. `xs.map(fn(x) => x + 1)` is a `List`, and `ps.sort_by(fn(a, b) => a.score - b.score)` reads `P`'s field. - A lambda stored in a `let` has one type: `let f = fn(x) => x * 2; f(3); f(2.5)` is now an error. A named `fn` still takes `Any`. - Calling a variable whose type isn't known yet, like `g` in `fn(g, v) => g(v)`, makes it a function taking those arguments, and later calls of `g` are checked against that type. - `for x in s` and `let (a, b) = v` wait until the kind of `s` or `v` is known instead of giving `Any`. - An index write `xs[i] = v` is the call `[]=(xs, i, v)` with the real value type. It waits for the value, so a wrong value is reported as `mismatched types` against the element type, or against a list of them for a slice. - `if` branches, list and map literal elements, and `return`s are joined part by part where they share a shape, and a part that isn't known yet waits until it is before joining at the least upper bound. So `fn(x) => if c { x } else { 0 }` doesn't fix `x` as an `Int`, and `if c { [] } else { [] }` is a list that `push` can settle. - `a op= b` with operands that aren't fully known is the waiting call `a = op(a, b)`, and the in-place overload is picked once it resolves. - Field writes (`p.x = v`) settle their receiver like field reads do. A write that no overload takes waits until its target's type is known, and a value that doesn't fit the field is reported once, against the field. - A write (an assignment or an annotated `let`) passes its value like an argument, so `let xs: Sequence = []` settles the list's elements as Ints. - An operator doesn't commit early while an operand is still wholly unknown, as that operand could be a tuple it applies to element-wise: `fn(x) => x ++ "!"` can take `("a", "b")`. - A map default whose type isn't known yet waits until it's known whether it's a function that makes the values. - Calling a function value with the wrong number of arguments is an analysis error instead of a runtime one. - A cast settles what isn't known about its value: `(fn(x) => x) as Fn(Int) -> Int` makes `x` an `Int` and needs no runtime check. - A waiting call, iteration or unpacking whose result was already used at another type is an error once it resolves. - Error messages show parts that aren't known yet as `Any`. Committing a waiting call early is only right if it gets the overload it would get once its arguments are known. Debug builds check this: each early commit is recorded, and at the end of an error-free batch the call is resolved again with the final types and the two must agree. Where a committed overload doesn't take the arguments as they are, such as an `Any` argument, the call keeps runtime dispatch, as it would with the final types. Removed: `analyse_with_expected` (literal and lambda arms), the index-write stand-in, and the analysis-time `commit_call` error path, which now reports through the solver. Not in this PR: with fully known operands `op=` still goes through the old path, including the in-place eligibility gate. Named functions keep `Any` parameters until 3c. Known tradeoff: an annotation no longer reaches a lambda before its body is checked, so `let double: Fn(Int) -> Int = fn(x) => x ++ "!"` reports the mismatch on `double` rather than on `x ++ "!"`. No benchmark got slower: closures and hof_pipeline are 7–8% faster, the others within noise. Analysis stays linear in the number of waiting calls (4000 never-called lambdas: 82 ms, 61 ms before). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --- manual/src/reference/types/function.md | 18 +- ndc_analyser/src/analyser/assignments.rs | 202 ++-- ndc_analyser/src/analyser/calls.rs | 188 +-- ndc_analyser/src/analyser/declarations.rs | 72 +- ndc_analyser/src/analyser/mod.rs | 235 ++-- ndc_analyser/src/analyser/solver.rs | 1024 +++++++++++++++++ ndc_analyser/src/analyser/tests.rs | 368 +++++- ndc_analyser/src/overloads.rs | 48 +- ndc_analyser/src/types.rs | 121 +- ...e_not_yet_known_keeps_the_element_type.ndc | 5 + ...ked_as_soon_as_its_arguments_are_known.ndc | 9 + ...ll_must_return_what_was_expected_of_it.ndc | 5 + ...66_a_lambda_in_a_variable_has_one_type.ndc | 6 + ...ast_gives_a_lambda_its_parameter_types.ndc | 5 + ...akes_its_parameter_types_from_the_call.ndc | 8 + 15 files changed, 1802 insertions(+), 512 deletions(-) create mode 100644 ndc_analyser/src/analyser/solver.rs create mode 100644 tests/functional/programs/004_basic/074_an_index_write_of_a_value_not_yet_known_keeps_the_element_type.ndc create mode 100644 tests/functional/programs/005_functions/064_a_waiting_call_is_picked_as_soon_as_its_arguments_are_known.ndc create mode 100644 tests/functional/programs/005_functions/065_a_waiting_call_must_return_what_was_expected_of_it.ndc create mode 100644 tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc create mode 100644 tests/functional/programs/016_casts/025_a_cast_gives_a_lambda_its_parameter_types.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/023_a_lambda_takes_its_parameter_types_from_the_call.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index c8e7a186..e1c62c46 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -134,7 +134,7 @@ fn add(x: Int, y: Int) -> Int { } ``` -Annotations are optional — leave them off and the parameter is treated as `Any`. Mix and match as you like: +Annotations are optional. A named function's parameter without one is treated as `Any`; a lambda's gets its type from how the lambda is used (see below). Mix and match as you like: ```ndc fn first(xs: List) => xs[0]; // params annotated, return inferred @@ -171,16 +171,26 @@ fn apply(f: Fn(Int) -> Int, x: Int) -> Int { f(x) } apply(fn(s: String) => 1, 4); // ERROR: the function takes a String, but apply passes an Int ``` -A lambda written where a function type is expected takes its parameter types from it, so it needs no annotations: as the value of an annotated variable, in an assignment, or as an argument to a function value. Its body is checked with those types: +A lambda's unannotated parameters get their types from how the lambda is used: the function type it's stored as, what the function it's passed to gives it, and the calls in its body. Its body is checked with those types: ```ndc let shout: Fn(String) -> String = fn(s) => s ++ "!"; // s is a String assert_eq(shout("hi"), "hi!"); -let double: Fn(Int) -> Int = fn(x) => x ++ "!"; // ERROR: x is an Int, and there's no `++` for an Int and a String +let tens = [3, 1, 2].map(fn(x) => x * 10); // x is an Int, so tens is a List + +let double: Fn(Int) -> Int = fn(x) => x ++ "!"; // ERROR: `x ++ "!"` makes x a String, so the lambda is a Fn(String) -> String +``` + +A lambda stored in a variable has one type, like any variable, so it can't take an `Int` in one call and a `Float` in the next: + +```ndc +let twice = fn(x) => x * 2; +twice(3); +twice(2.5); // ERROR: twice takes an Int ``` -A lambda passed to a named function, as in `xs.map(fn(x) => x + 1)`, doesn't get its parameter types this way yet. Its unannotated parameters are `Any`, and they are checked when the program runs. +A named function such as `fn twice(x) => x * 2` can be called with both. A lambda parameter that nothing gives a type is `Any`, and is checked when the program runs. ## Function overloading diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 811832a3..11db4603 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -1,4 +1,5 @@ use super::Analyser; +use super::solver::CallSite; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall}; use itertools::izip; @@ -21,27 +22,29 @@ impl Analyser { ) -> StaticType { let left_type = self.analyse_assignment_target(l_value, span); let right_type = self.analyse_expr(r_value); - // An in-place operation such as `++=` keeps its target's type, so - // what it stores has to fit that type. Where the target's type isn't - // fully known, the right operand settles it: in - // `let xs = []; xs ++= [1]`, the elements of `xs` are `Int`. - let left_type = if left_type.has_vars() { - match &left_type { - // A tuple target with a scalar operand applies the operand to - // each element: `t ++= [1]` appends to every list in `t`. - StaticType::Tuple(targets) if !matches!(right_type, StaticType::Tuple(_)) => { - for target in targets { - self.vars.unify(target, &right_type); - } - } - _ => { - self.vars.unify(&left_type, &right_type); - } - } - self.vars.resolve(&left_type) - } else { - left_type - }; + // `a op= b` is `a = op(a, b)`. Where the operands aren't fully known + // yet, the operator waits for them like any call, and the in-place + // overload is picked along with it. + let (left_type, right_type) = ( + self.vars.resolve(&left_type), + self.vars.resolve(&right_type), + ); + if (left_type.has_vars() || right_type.has_vars()) + && !self + .overloads() + .reachable_symbols(&self.resolver.augmented_candidates(id).operator) + .is_empty() + { + let result = self.constrain_call( + CallSite::OpAssignment(id), + vec![left_type.clone(), right_type], + CallKind::Operator, + operation, + span, + ); + self.validate_assignment_write(l_value, &left_type, &result, span); + return StaticType::unit(); + } let arg_types = vec![left_type.clone(), right_type.clone()]; let ResolvedCall { @@ -145,76 +148,29 @@ impl Analyser { // The container before the index, in source order like the resolver. let container = self.analyse_expr(value); let index_type = self.analyse_expr(index); - // `xs[i] = v` is the call `[]=(xs, i, v)`. The value isn't - // analysed yet, so what the container holds stands in for - // it, and a slice like `xs[0..2]` stores a list of that. - let element = container.index_element_type(); - let stored = match &element { - Some(element) - if Self::index_type_is_slice(&index_type) - && matches!(container, StaticType::List(_)) => - { - StaticType::List(Box::new(element.clone())) - } - Some(element) => element.clone(), - None => StaticType::Any, + // `xs[i] op= v` reads the old value with `[]`, so the value + // written has the type that reading gives. A plain write + // stores a value whose type the write settles. + let stored = match self.resolver.getter_candidates(lvalue.id) { + Some(_) => self.constrain_call( + CallSite::Getter(lvalue.id), + vec![container.clone(), index_type.clone()], + CallKind::Regular, + "[]", + span, + ), + None => self.vars.fresh(StaticType::Any), }; - - // Indexing isn't operator-form for vec purposes: there's no - // natural broadcast story for `(list_a, list_b)[i]`. Only an - // augmented assignment reads the old value through `[]`. - let errors_before = self.result.errors.len(); - if let Some(getters) = self.resolver.getter_candidates(lvalue.id) { - let get_args = [container.clone(), index_type.clone()]; - let getter = - self.overloads() - .resolve_call(getters, &get_args, CallKind::Regular); - if matches!(getter.binding, Binding::None) { - self.emit(AnalysisError::function_not_found("[]", &get_args, span)); - } else { - self.commit_call( - &getter.binding, - &get_args, - getter.return_type, - "[]", - span, - ); - } - self.picks.getters.insert(lvalue.id, getter.binding); - } - - // Committing the setter checks the index against the - // container, and settles what neither is known to hold yet, - // as the key type in `let m = %{}; m["a"] = 1`. - let set_args = [container.clone(), index_type, stored.clone()]; - let setter = self.overloads().resolve_call( - self.resolver.setter_candidates(lvalue.id), - &set_args, + // `xs[i] = v` is the call `[]=(xs, i, v)`, which waits for + // the value's type when nothing else picks the overload. + self.constrain_call( + CallSite::Setter(lvalue.id), + vec![container, index_type, stored.clone()], CallKind::Regular, + "[]=", + span, ); - // When reading the old value failed, storing the new one - // fails the same way, which is reported once. - let read_failed = self.result.errors.len() > errors_before; - match setter.binding { - _ if read_failed => {} - Binding::None if element.is_none() && !container.is_unknown() => { - self.emit(AnalysisError::unable_to_index_into(&container, span)); - } - Binding::None => { - self.emit(AnalysisError::function_not_found("[]=", &set_args, span)); - } - _ => { - self.commit_call( - &setter.binding, - &set_args, - setter.return_type, - "[]=", - span, - ); - } - } - self.picks.setters.insert(lvalue.id, setter.binding); - self.vars.resolve(&stored) + stored } // What `(a, b) = …` can store is a tuple of what each target can. AssignmentTarget::Sequence(seq) => StaticType::Tuple( @@ -227,50 +183,31 @@ impl Analyser { member, member_span, } => { + // `p.x = v` stores through `x=(p, v)`, and what it stores + // has the type `x(p)` reads. let receiver_type = self.analyse_expr(receiver); - let getter_args = [receiver_type.clone()]; - let setter_args = [receiver_type, StaticType::Any]; - let getters = self - .resolver - .getter_candidates(lvalue.id) - .expect("internal error: member target without getter candidates"); - let getter = - self.overloads() - .resolve_call(getters, &getter_args, CallKind::Regular); - let setter_name = format!("{member}="); - let setter = self.overloads().resolve_call( - self.resolver.setter_candidates(lvalue.id), - &setter_args, + let stored = self.constrain_call( + CallSite::Getter(lvalue.id), + vec![receiver_type.clone()], CallKind::Regular, + member, + *member_span, ); - let getter_missing = matches!(getter.binding, Binding::None); - let setter_missing = matches!(setter.binding, Binding::None); - - self.picks.getters.insert(lvalue.id, getter.binding); - self.picks.setters.insert(lvalue.id, setter.binding); - - if getter_missing { - self.emit(AnalysisError::function_not_found( - member, - &getter_args, - *member_span, - )); - } else if setter_missing { - self.emit(AnalysisError::function_not_found( - &setter_name, - &setter_args, - *member_span, - )); - } - - getter.return_type + self.constrain_call( + CallSite::FieldSetter(lvalue.id), + vec![receiver_type, stored.clone()], + CallKind::Regular, + &format!("{member}="), + *member_span, + ); + stored } } } /// An index expression typed as an integer sequence (e.g. `0..2`) selects /// a slice of the container rather than a single element. - fn index_type_is_slice(index_type: &StaticType) -> bool { + pub(super) fn index_type_is_slice(index_type: &StaticType) -> bool { index_type.is_subtype(&StaticType::Iterator(Box::new(StaticType::Int))) } @@ -282,7 +219,10 @@ impl Analyser { /// /// A tuple left-hand side represents vector dispatch, so a scalar right /// operand must be compatible with every concrete tuple element. - fn augmented_rhs_is_compatible(left_type: &StaticType, right_type: &StaticType) -> bool { + pub(super) fn augmented_rhs_is_compatible( + left_type: &StaticType, + right_type: &StaticType, + ) -> bool { match (left_type, right_type) { (StaticType::Tuple(left), StaticType::Tuple(right)) => { left.len() == right.len() @@ -306,9 +246,14 @@ impl Analyser { stored_type: &StaticType, value_type: &StaticType, ) -> bool { - if self.vars.unify(stored_type, value_type) { + // A write passes the value where a value of the stored type is + // expected, as a call passes an argument: `let xs: Sequence = + // []` settles the list's elements as Ints. + let snapshot = self.vars.snapshot(); + if self.vars.bind_argument(stored_type, value_type) { return true; } + self.vars.rollback(snapshot); let stored_type = self.vars.resolve(stored_type); let value_type = self.vars.resolve(value_type); value_type == StaticType::Any || value_type.is_subtype(&stored_type) @@ -323,8 +268,8 @@ impl Analyser { span: Span, ) { let error = AnalysisError::mismatched_types( - &self.vars.resolve(value_type), - &self.vars.resolve(stored_type), + &self.vars.shown(value_type), + &self.vars.shown(stored_type), span, ); self.emit(error); @@ -364,7 +309,8 @@ impl Analyser { .get(&index.id) .is_some_and(Self::index_type_is_slice); if is_slice - && let Some(stored_element) = stored_type.index_element_type() + && let Some(stored_element) = + self.vars.resolve(stored_type).index_element_type() && let Some(value_element) = value_type.sequence_element_type() && self.accepts_write(&stored_element, &value_element) { diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index ed165a1c..eb7ba954 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -1,17 +1,17 @@ use super::Analyser; +use super::solver::CallSite; use crate::error::AnalysisError; -use crate::overloads::{CallKind, Overloads, ResolvedCall}; -use ndc_analysis::{Binding, Candidate}; +use crate::overloads::{CallKind, Overloads}; use ndc_core::{NodeId, StaticType}; use ndc_lexer::Span; use ndc_parser::{Expression, ExpressionLocation}; -use ndc_resolver::{Candidates, SymbolId}; +use ndc_resolver::Candidates; impl Analyser { /// Why no candidate accepts a call of `name` with arguments of these types: /// a variable ended the search, a cast would make an overload fit, or /// nothing fits. - fn no_match( + pub(super) fn no_match( &self, candidates: &Candidates, name: &str, @@ -23,7 +23,7 @@ impl Analyser { let hides_functions = overloads.hides_functions(candidates); AnalysisError::variable_rejects_arguments( name, - self.types.get_type(variable), + &self.vars.shown(self.types.get_type(variable)), types, hides_functions, span, @@ -35,56 +35,6 @@ impl Analyser { } } - /// Settles the types a call's arguments leave unknown from the overload - /// it picked, and returns the call's type. The picked signature gets new - /// variables for its type parameters, and each argument is passed where - /// its parameter is expected: `push(xs, 1)` with `push(List, T)` - /// settles `xs` as a `List`, and `push(xs, "a")` then doesn't fit. - /// When the pick is left to the program as it runs, any function may get - /// the arguments, so what's unknown in them becomes `Any`. - pub(super) fn commit_call( - &mut self, - binding: &Binding, - arguments: &[StaticType], - return_type: StaticType, - name: &str, - span: Span, - ) -> StaticType { - let picked = match binding { - Binding::Resolved(Candidate::Scalar(symbol)) => Some(*symbol), - // Element-wise operators on tuples leave the types they have. - Binding::Resolved(Candidate::Vec(_)) => return return_type, - Binding::Dynamic(_) | Binding::None => None, - }; - let signature = picked.map(|symbol| { - let declared = self.types.get_type(symbol).clone(); - self.vars.instantiate(&declared) - }); - let Some(StaticType::Function { - parameters: Some(parameters), - return_type: declared_return, - }) = signature - else { - for argument in arguments { - self.vars.settle_unknowns(argument, &StaticType::Any); - } - return return_type; - }; - - let mut fits = true; - for (parameter, argument) in parameters.iter().zip(arguments) { - fits &= self.vars.bind_argument(parameter, argument); - } - if !fits { - let arguments = arguments - .iter() - .map(|argument| self.vars.resolve(argument)) - .collect::>(); - self.emit(AnalysisError::function_not_found(name, &arguments, span)); - } - self.vars.resolve(&declared_return) - } - /// Overload resolution over the types declared so far. pub(super) fn overloads(&self) -> Overloads<'_> { Overloads::new(&self.types, &self.vars, self.resolver.symbols()) @@ -100,38 +50,13 @@ impl Analyser { ) -> StaticType { // `xs.len` is the call `len(xs)`, among functions only. let receiver_type = self.analyse_expr(receiver); - let getters = self - .resolver - .getter_candidates(id) - .expect("internal error: member access without getter candidates"); - let ResolvedCall { - binding, - return_type, - } = self.overloads().resolve_call( - getters, - std::slice::from_ref(&receiver_type), + self.constrain_call( + CallSite::Getter(id), + vec![receiver_type], CallKind::Regular, - ); - - if matches!(binding, Binding::None) { - let error = self.no_match( - getters, - member, - std::slice::from_ref(&receiver_type), - member_span, - ); - self.emit(error); - } - - let return_type = self.commit_call( - &binding, - std::slice::from_ref(&receiver_type), - return_type, member, member_span, - ); - self.picks.getters.insert(id, binding); - return_type + ) } /// Resolves a call (regular or operator-form) and returns its result type. @@ -144,56 +69,33 @@ impl Analyser { span: Span, ) -> StaticType { // Higher-order call shapes like `get_function()()` have a non-identifier - // function position; in that case we just analyse the callee as a value - // and trust the runtime to dispatch. The callee comes before the - // arguments, in source order like the resolver, so declarations in it - // get their slots first. + // function position, so the callee is analysed as a value. The callee + // comes before the arguments, in source order like the resolver, so + // declarations in it get their slots first. let Expression::Identifier { name, .. } = &function.expression else { let callee_type = self.analyse_expr(function); - // The callee's type says what each argument is expected to be, - // which tells a lambda argument what its parameters are. - let expected = match &callee_type { - StaticType::Function { - parameters: Some(parameters), - .. - } if parameters.len() == arguments.len() => Some(parameters.clone()), - _ => None, - }; let arguments = arguments .iter() - .enumerate() - .map(|(position, arg)| match &expected { - Some(expected) => self.analyse_with_expected(arg, &expected[position]), - None => self.analyse_expr(arg), - }) + .map(|arg| self.analyse_expr(arg)) .collect::>(); - return match callee_type { + return match self.vars.resolve(&callee_type) { // The callee's type says what it takes, like a picked overload's. - StaticType::Function { - parameters: Some(parameters), - return_type, - } if parameters.len() == arguments.len() => { - let mut fits = true; - for (parameter, argument) in parameters.iter().zip(&arguments) { - fits &= self.vars.bind_argument(parameter, argument); - } - if !fits { - let arguments = arguments - .iter() - .map(|argument| self.vars.resolve(argument)) - .collect::>(); - let callee_type = StaticType::Function { - parameters: Some(parameters), - return_type: return_type.clone(), - }; - self.emit(AnalysisError::callee_rejects_arguments( - &self.vars.resolve(&callee_type), - &arguments, - span, - )); - } - self.vars.resolve(&return_type) - } + callee @ StaticType::Function { + parameters: Some(_), + .. + } => self.bind_signature(&callee, &arguments).unwrap_or_else(|| { + let arguments = arguments + .iter() + .map(|argument| self.vars.shown(argument)) + .collect::>(); + self.emit(AnalysisError::callee_rejects_arguments( + &self.vars.shown(&callee), + &arguments, + span, + )); + StaticType::Any + }), + callee @ StaticType::Var(_) => self.call_unknown_function(&callee, arguments), // Nothing tells what the callee does with its arguments. callee if callee.could_be_callable() => { for argument in &arguments { @@ -213,30 +115,10 @@ impl Analyser { // A named callee declares nothing, so its overload can be picked after // the arguments' types are known. - let mut type_sig = Vec::with_capacity(arguments.len()); - for arg in arguments.iter() { - type_sig.push(self.analyse_expr(arg)); - } - - let ResolvedCall { - binding, - return_type, - } = self.overloads().resolve_call( - self.resolver.reference_candidates(function.id), - &type_sig, - kind, - ); - - if matches!(binding, Binding::None) { - let candidates = self.resolver.reference_candidates(function.id); - let error = self.no_match(candidates, name, &type_sig, span); - self.emit(error); - self.picks.bindings.insert(function.id, binding); - return StaticType::Any; - } - - let return_type = self.commit_call(&binding, &type_sig, return_type, name, span); - self.picks.bindings.insert(function.id, binding); - return_type + let arguments = arguments + .iter() + .map(|arg| self.analyse_expr(arg)) + .collect::>(); + self.constrain_call(CallSite::Call(function.id), arguments, kind, name, span) } } diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 6dd2af81..01be9afb 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -24,15 +24,13 @@ pub(super) struct FunctionDeclaration<'a> { } impl Analyser { - /// Analyses a function declaration and returns its type. A lambda passed - /// where a function type is expected, as in `xs.map(fn(x) => x + 1)`, - /// gets that type as `expected`: an unannotated parameter takes the - /// expected parameter's type, and the body is checked against the - /// expected return type. + /// Analyses a function declaration and returns its type. A named + /// function's unannotated parameters are `Any`. A lambda's are type + /// variables that its uses settle: in `xs.map(fn(x) => x + 1)`, `x` gets + /// the type of `xs`'s elements. pub(super) fn analyse_function_declaration( &mut self, function: FunctionDeclaration<'_>, - expected: Option<&StaticType>, ) -> StaticType { let FunctionDeclaration { id, @@ -43,27 +41,15 @@ impl Analyser { span, } = function; let first_var = self.vars.count(); - let (expected_parameters, expected_return) = match expected { - Some(StaticType::Function { - parameters: Some(expected), - return_type, - }) if expected.len() == parameters.len() => { - (Some(expected.as_slice()), Some(return_type.as_ref())) - } - _ => (None, None), - }; let type_signature = TypeSignature::from_annotated_bindings( parameters .iter() - .enumerate() - .map(|(index, param)| { - let annotation = - self.lower_annotation(param.annotation.as_ref()) - .or_else(|| { - expected_parameters - .map(|expected| expected[index].clone()) - .filter(|expected| *expected != StaticType::Any) - }); + .map(|param| { + let annotation = match self.lower_annotation(param.annotation.as_ref()) { + Some(annotation) => Some(annotation), + None if name.is_none() => Some(self.vars.fresh(StaticType::Any)), + None => None, + }; (param.name().to_string(), annotation) }) .collect(), @@ -114,32 +100,32 @@ impl Analyser { .insert(param.lvalue.id, typ.clone()); } - let implicit_return = match expected_return { - Some(expected) if annotated_return.is_none() => { - self.analyse_with_expected(body, expected) - } - _ => self.analyse_expr(body), - }; + let implicit_return = self.analyse_expr(body); let explicit_return = self.return_type_stack.pop().unwrap(); self.loop_depth = outer_loop_depth; - // Nothing outside the body can settle the type variables made in it, - // so the function's type doesn't carry any. - self.vars.settle_from(first_var); - let implicit_return = self.vars.resolve(&implicit_return); - let explicit_return = explicit_return.map(|ret| self.vars.resolve(&ret)); // Combine explicit `return` types with the block's implicit return type. let inferred_return = match explicit_return { - Some(ret) => ret.lub(&implicit_return), + Some(ret) => self.join(&ret, &implicit_return, span), None => implicit_return, }; + // Nothing outside a named function's body can settle the type + // variables made in it, as its parameters have their types. A + // lambda's variables are settled by how the code around it uses it. + if declared.is_some() { + self.settle_waiting(first_var); + } + let inferred_return = self.vars.resolve(&inferred_return); + // If there is an annotated return type, validate it; // otherwise record the inferred type in the analysis. if let Some(annotated) = &annotated_return { - if !inferred_return.is_subtype(annotated) { + let fits = self.vars.try_unify(annotated, &inferred_return) + || self.vars.resolve(&inferred_return).is_subtype(annotated); + if !fits { self.emit(AnalysisError::mismatched_types( - &inferred_return, + &self.vars.shown(&inferred_return), annotated, span, )); @@ -279,6 +265,16 @@ impl Analyser { self.result.analysis.variable_types.insert(lvalue.id, typ); } BindingPattern::Sequence(seq) => { + // What a value whose type isn't known yet unpacks into is + // settled once its type is. + let found_type = self.vars.resolve(&found_type); + if expected_type.is_none() && matches!(found_type, StaticType::Var(_)) { + let parts = self.constrain_unpacking(found_type, seq.len(), span); + for (sub_lvalue, part) in seq.iter().zip(parts) { + self.declare_binding_pattern(sub_lvalue, None, part, span); + } + return; + } // If the type is a fixed-length Tuple whose arity doesn't match // the number of bindings, fall back to Any for each element. This // can happen when a variable is declared with one type (e.g. ()) diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 90ddc662..8581d050 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -1,22 +1,25 @@ use crate::error::AnalysisError; use crate::overloads::CallKind; use crate::types::{TypeTable, TypeVars}; -use ahash::AHashMap; +use ahash::{AHashMap, AHashSet}; use ndc_analysis::{Analysis, Binding, Candidate}; -use ndc_core::StaticType; use ndc_core::r#struct::{StructInfo, StructRegistry}; +use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_layout::{Layout, Picks}; -use ndc_lexer::NumericLiteral; +use ndc_lexer::{NumericLiteral, Span}; use ndc_parser::{Expression, ExpressionLocation, ForBody, ForIteration, TypeExpr}; use ndc_resolver::{NameResolver, SymbolId}; +use std::collections::BTreeMap; use std::fmt::Debug; use std::rc::Rc; mod assignments; mod calls; mod declarations; +mod solver; use declarations::FunctionDeclaration; +use solver::{Constraint, EarlyPick}; #[cfg(test)] mod tests; @@ -41,6 +44,23 @@ pub struct Analyser { types: TypeTable, /// What the type variables in those types stand for, once known. vars: TypeVars, + /// The calls and other uses that wait for types that aren't known yet, + /// by the order they started waiting in. + waiting: BTreeMap, + /// The number the next waiting constraint gets. + next_serial: u64, + /// The waiting constraints that each unsettled variable would let make + /// progress once settled. + watchers: AHashMap>, + /// [`TypeVars::settled_count`] when the waiting constraints were last + /// woken, so the ones watching a variable settled since are woken next. + watched_settlings: usize, + /// The calls that committed to an overload before their arguments were + /// known, which debug builds check at the end of the batch. + early_picks: Vec, + /// The nodes of `xs[i] op= v` whose read of `xs[i]` failed, so the + /// write, which fails the same way, isn't reported again. + failed_reads: AHashSet, /// What each struct's type symbol stands for, so `p: Point` lowers to /// `Point`'s struct type. Every other type name is built in. struct_types: AHashMap>, @@ -77,6 +97,12 @@ impl Analyser { resolver, types, vars: TypeVars::default(), + waiting: BTreeMap::new(), + next_serial: 0, + watchers: AHashMap::new(), + watched_settlings: 0, + failed_reads: AHashSet::new(), + early_picks: Vec::new(), struct_registry: StructRegistry::default(), struct_types: AHashMap::new(), return_type_stack: Vec::new(), @@ -91,9 +117,15 @@ impl Analyser { /// resetting it for the next analysis. pub fn take_result(&mut self) -> AnalysisResult { // Nothing after this batch can settle its type variables any more, - // so the ones left unsettled get their fallbacks, and no variable - // leaves the analyser. - self.vars.settle_from(0); + // so the ones left unsettled get their fallbacks, what waited on them + // is solved, and no variable leaves the analyser. + self.settle_waiting(0); + debug_assert!(self.waiting.is_empty(), "unsolved: {:?}", self.waiting); + self.watchers.clear(); + if cfg!(debug_assertions) { + self.check_early_picks(); + } + self.failed_reads.clear(); self.types.resolve_all(&self.vars); let analysis = &mut self.result.analysis; for table in [ @@ -203,120 +235,7 @@ impl Analyser { fn analyse_expr(&mut self, expr_loc: &ExpressionLocation) -> StaticType { let typ = self.analyse_inner(expr_loc); - self.result - .analysis - .expr_types - .insert(expr_loc.id, typ.clone()); - typ - } - - /// Like [`Self::analyse_expr`], but with an expected type from an annotation - /// or an annotated target: `let x: List = []` types the literal as - /// `List` instead of `List`. A container literal with no - /// elements has nothing to infer from, so it adopts the expected element - /// types; non-empty literals recurse so nested empties (`[(1, [])]`) - /// adopt too. Everything else falls back to plain analysis, and the - /// caller still subtype-checks the result against the expectation. - /// - /// This is a temporary measure until the type system supports type - /// parameters and proper unification, at which point empty literals can - /// be typed with fresh type variables instead of adopting the annotation. - fn analyse_with_expected( - &mut self, - expr_loc: &ExpressionLocation, - expected: &StaticType, - ) -> StaticType { - let typ = match (&expr_loc.expression, expected) { - ( - Expression::List { values }, - StaticType::List(element) | StaticType::Sequence(element), - ) => { - let mut element_type: Option = None; - for value in values { - let found = self.analyse_with_expected(value, element); - Self::fold_lub(&mut element_type, found); - } - StaticType::List(Box::new( - element_type.unwrap_or_else(|| element.as_ref().clone()), - )) - } - (Expression::Map { values, default }, StaticType::Map { key, value }) => { - let is_empty = values.is_empty(); - let mut key_type: Option = None; - let mut value_type: Option = None; - for (entry_key, entry_value) in values { - let found = self.analyse_with_expected(entry_key, key); - Self::fold_lub(&mut key_type, found); - if let Some(entry_value) = entry_value { - let found = self.analyse_with_expected(entry_value, value); - Self::fold_lub(&mut value_type, found); - } - } - if let Some(default) = default { - // A function default makes each missing value, so it's - // expected to return one. - let expected_default = if matches!( - default.expression, - Expression::FunctionDeclaration { name: None, .. } - ) { - StaticType::Function { - parameters: Some(vec![]), - return_type: value.clone(), - } - } else { - value.as_ref().clone() - }; - let found = Self::default_value_type( - self.analyse_with_expected(default, &expected_default), - ); - Self::fold_lub(&mut value_type, found); - } - StaticType::Map { - key: Box::new(key_type.unwrap_or_else(|| key.as_ref().clone())), - value: Box::new(value_type.unwrap_or_else(|| { - if is_empty { - value.as_ref().clone() - } else { - // A set literal like `%{1, 2}` has unit values. - StaticType::unit() - } - })), - } - } - // A lambda takes the types an expected function type gives it: - // in `let f: Fn(Int) -> Int = fn(x) => x + 1`, `x` is an `Int`. - ( - Expression::FunctionDeclaration { - name: None, - parameters, - body, - return_annotation, - .. - }, - StaticType::Function { .. }, - ) => self.analyse_function_declaration( - FunctionDeclaration { - id: expr_loc.id, - name: None, - parameters, - body, - return_annotation: return_annotation.as_ref(), - span: expr_loc.span, - }, - Some(expected), - ), - (Expression::Tuple { values }, StaticType::Tuple(elements)) - if values.len() == elements.len() => - { - let types = values - .iter() - .zip(elements) - .map(|(value, element)| self.analyse_with_expected(value, element)) - .collect(); - StaticType::Tuple(types) - } - _ => return self.analyse_expr(expr_loc), - }; + self.solve_waiting(); self.result .analysis .expr_types @@ -389,7 +308,14 @@ impl Analyser { Expression::Grouping(expr) => self.analyse_expr(expr), Expression::Cast { value, annotation } => { let target = self.lower_type_expr(annotation); - let found = self.analyse_with_expected(value, &target); + let found = self.analyse_expr(value); + // What isn't known about the value yet is what the cast says + // it is: `(fn(x) => x) as Fn(Int) -> Int` takes an Int, and + // needs no check as the program runs. Known parts are checked. + if self.vars.resolve(&found).has_vars() { + self.vars.try_unify(&found, &target); + } + let found = self.vars.resolve(&found); if !found.overlaps(&target) { self.emit(AnalysisError::impossible_cast(&found, &target, *span)); } @@ -404,10 +330,7 @@ impl Analyser { } => { let annotated_type = self.lower_annotation(annotated_type.as_ref()); let value_span = value.span; - let found_type = match &annotated_type { - Some(expected) => self.analyse_with_expected(value, expected), - None => self.analyse_expr(value), - }; + let found_type = self.analyse_expr(value); self.declare_binding_pattern( l_value, @@ -419,7 +342,7 @@ impl Analyser { } Expression::Assignment { l_value, r_value } => { let old_type = self.analyse_assignment_target(l_value, *span); - let new_type = self.analyse_with_expected(r_value, &old_type); + let new_type = self.analyse_expr(r_value); self.validate_assignment_write(l_value, &old_type, &new_type, *span); StaticType::unit() @@ -435,17 +358,14 @@ impl Analyser { body, return_annotation, .. - } => self.analyse_function_declaration( - FunctionDeclaration { - id: *id, - name: name.as_deref(), - parameters, - body, - return_annotation: return_annotation.as_ref(), - span: *span, - }, - None, - ), + } => self.analyse_function_declaration(FunctionDeclaration { + id: *id, + name: name.as_deref(), + parameters, + body, + return_annotation: return_annotation.as_ref(), + span: *span, + }), Expression::Block { statements } => { let mut last = None; for s in statements { @@ -467,7 +387,7 @@ impl Analyser { StaticType::unit() }; - true_type.lub(&false_type) + self.join(&true_type, &false_type, *span) } Expression::While { expression, @@ -529,17 +449,20 @@ impl Analyser { let mut key_type: Option = None; let mut value_type: Option = None; for (key, value) in values { - Self::fold_lub(&mut key_type, self.analyse_expr(key)); + let found = self.analyse_expr(key); + self.fold_join(&mut key_type, found, key.span); if let Some(value) = value { - Self::fold_lub(&mut value_type, self.analyse_expr(value)); + let found = self.analyse_expr(value); + self.fold_join(&mut value_type, found, value.span); } } // Reads can produce the default value, so its type is part of // the map's value type. if let Some(default) = default { - let default_type = Self::default_value_type(self.analyse_expr(default)); - Self::fold_lub(&mut value_type, default_type); + let default_type = self.analyse_expr(default); + let default_type = self.constrain_default_value(default_type, default.span); + self.fold_join(&mut value_type, default_type, default.span); } let key_type = match key_type { @@ -559,8 +482,11 @@ impl Analyser { } Expression::Return { value } => { let typ = self.analyse_expr(value); - match self.return_type_stack.last_mut() { - Some(slot) => Self::fold_lub(slot, typ), + match self.return_type_stack.pop() { + Some(mut returned) => { + self.fold_join(&mut returned, typ, *span); + self.return_type_stack.push(returned); + } None => self.emit(AnalysisError::return_outside_function(*span)), } StaticType::Never @@ -599,9 +525,7 @@ impl Analyser { let sequence_span = sequence.span; let sequence_type = self.analyse_expr(sequence); - let found_type = sequence_type - .sequence_element_type() - .unwrap_or(StaticType::Any); + let found_type = self.constrain_iteration(sequence_type, sequence_span); self.loop_depth += 1; // A `for` binder has no type annotation. @@ -648,7 +572,8 @@ impl Analyser { ) -> Option { let mut element_type: Option = None; for expression in expressions { - Self::fold_lub(&mut element_type, self.analyse_expr(expression)); + let found = self.analyse_expr(expression); + self.fold_join(&mut element_type, found, expression.span); } element_type } @@ -656,18 +581,18 @@ impl Analyser { /// The type of the values a map default of type `default` produces. A /// function default, as in `%{:fn() => []}`, is called for each missing /// key, so the values are what it returns. - fn default_value_type(default: StaticType) -> StaticType { + pub(super) fn default_value_type(default: StaticType) -> StaticType { match default { StaticType::Function { return_type, .. } => *return_type, other => other, } } - /// Fold a new type into an accumulator via least-upper-bound. - fn fold_lub(acc: &mut Option, new_type: StaticType) { - match acc { - Some(prev) => *prev = prev.lub(&new_type), - None => *acc = Some(new_type), - } + /// Joins a new type into an accumulator, as with [`Self::join`]. + fn fold_join(&mut self, acc: &mut Option, new_type: StaticType, span: Span) { + *acc = Some(match acc.take() { + Some(prev) => self.join(&prev, &new_type, span), + None => new_type, + }); } } diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs new file mode 100644 index 00000000..69522f56 --- /dev/null +++ b/ndc_analyser/src/analyser/solver.rs @@ -0,0 +1,1024 @@ +//! Calls and other uses whose types aren't known yet wait here until they +//! are. In `xs.map(fn(x) => x + 1)`, the `+` waits for `x`'s type, which +//! picking `map` settles from the type of `xs`. + +use super::Analyser; +use crate::error::AnalysisError; +use crate::overloads::{CallKind, ResolvedCall, is_strictly_more_specific}; +use ndc_analysis::{Binding, Candidate}; +use ndc_core::{NodeId, StaticType, TypeVar}; +use ndc_lexer::Span; +use ndc_resolver::{Candidates, SymbolId}; +use std::collections::BTreeSet; + +/// Where a call's candidates come from and where its pick goes. +#[derive(Debug, Clone, Copy)] +pub(super) enum CallSite { + /// A call or an operator: the overloads the callee's name can mean. + Call(NodeId), + /// `x.f`, or reading the old value in `xs[i] += 1`. + Getter(NodeId), + /// The write in `xs[i] = v`. + Setter(NodeId), + /// The write in `p.x = v`. + FieldSetter(NodeId), + /// The operator in `a op= b`, which is `a = op(a, b)`. + OpAssignment(NodeId), +} + +/// Something that waits until the types it depends on are known. +#[derive(Debug)] +pub(super) enum Constraint { + /// A call whose overload can't be picked yet. + Call(CallConstraint), + /// `for x in s`: what iterating over `sequence` gives. + Iterate { + sequence: StaticType, + element: StaticType, + span: Span, + }, + /// `let (a, b) = v`: what each name gets from `value`. + Unpack { + value: StaticType, + parts: Vec, + span: Span, + }, + /// The values a map default makes: what it returns if it's a function + /// that's called for each missing key, and the default itself if not. + MapDefault { + default: StaticType, + value: StaticType, + span: Span, + }, + /// The one type the two branches of an `if` have, once both are known. + Join { + sides: [StaticType; 2], + result: StaticType, + span: Span, + }, +} + +impl Constraint { + /// The types whose variables the constraint waits on. + fn inputs(&self) -> &[StaticType] { + match self { + Self::Call(call) => &call.arguments, + Self::Iterate { sequence, .. } => std::slice::from_ref(sequence), + Self::Unpack { value, .. } => std::slice::from_ref(value), + Self::MapDefault { default, .. } => std::slice::from_ref(default), + Self::Join { sides, .. } => sides, + } + } +} + +/// An overload a call committed to before its arguments were known, which +/// [`Analyser::check_early_picks`] checks once they are. +#[derive(Debug)] +pub(super) struct EarlyPick { + candidates: Candidates, + arguments: Vec, + kind: CallKind, + name: String, + binding: Binding, +} + +#[derive(Debug)] +pub(super) struct CallConstraint { + site: CallSite, + candidates: Candidates, + arguments: Vec, + /// The call's type, which the picked overload's return type settles. + result: StaticType, + kind: CallKind, + name: String, + span: Span, +} + +impl Analyser { + /// Picks the overload a call means and returns the call's type. When the + /// arguments aren't known well enough to pick one, the call waits, and + /// its type is a variable that the pick settles later. + pub(super) fn constrain_call( + &mut self, + site: CallSite, + arguments: Vec, + kind: CallKind, + name: &str, + span: Span, + ) -> StaticType { + let mut call = CallConstraint { + candidates: self.site_candidates(site).clone(), + site, + arguments, + result: StaticType::Never, + kind, + name: name.to_string(), + span, + }; + if let Some(typ) = self.try_call(&call) { + return typ; + } + call.result = self.vars.fresh(StaticType::Any); + let result = call.result.clone(); + self.wait(Constraint::Call(call)); + result + } + + /// What iterating over a value of type `sequence` gives, which waits + /// until the kind of sequence is known. + pub(super) fn constrain_iteration(&mut self, sequence: StaticType, span: Span) -> StaticType { + match self.vars.resolve(&sequence) { + StaticType::Var(_) => { + let element = self.vars.fresh(StaticType::Any); + self.wait(Constraint::Iterate { + sequence, + element: element.clone(), + span, + }); + element + } + known => known.sequence_element_type().unwrap_or(StaticType::Any), + } + } + + /// The types `let (a, b, …) = value` gives each name when the kind of + /// value isn't known yet. They're settled once it is. + pub(super) fn constrain_unpacking( + &mut self, + value: StaticType, + count: usize, + span: Span, + ) -> Vec { + let parts = (0..count) + .map(|_| self.vars.fresh(StaticType::Any)) + .collect::>(); + self.wait(Constraint::Unpack { + value, + parts: parts.clone(), + span, + }); + parts + } + + /// The one type that values of type `a` and `b` both have, as the two + /// branches of an `if`: their least upper bound. Where a part of either + /// isn't known yet, that part waits until it is, so a lambda's parameter + /// isn't fixed by the branch next to it: in `fn(x) => if c { x } else + /// { 0 }`, `x` can still be a `Float`. Parts both sides share are joined + /// part by part, so `if c { [] } else { [1] }` is a list either way. + pub(super) fn join(&mut self, a: &StaticType, b: &StaticType, span: Span) -> StaticType { + let (a, b) = (self.vars.resolve(a), self.vars.resolve(b)); + match (&a, &b) { + (StaticType::Never, _) => b, + (_, StaticType::Never) => a, + _ if a == b => a, + _ if !a.has_vars() && !b.has_vars() => a.lub(&b), + (StaticType::Option(x), StaticType::Option(y)) => { + StaticType::Option(Box::new(self.join(x, y, span))) + } + (StaticType::List(x), StaticType::List(y)) => { + StaticType::List(Box::new(self.join(x, y, span))) + } + (StaticType::Iterator(x), StaticType::Iterator(y)) => { + StaticType::Iterator(Box::new(self.join(x, y, span))) + } + (StaticType::Deque(x), StaticType::Deque(y)) => { + StaticType::Deque(Box::new(self.join(x, y, span))) + } + (StaticType::MinHeap(x), StaticType::MinHeap(y)) => { + StaticType::MinHeap(Box::new(self.join(x, y, span))) + } + (StaticType::MaxHeap(x), StaticType::MaxHeap(y)) => { + StaticType::MaxHeap(Box::new(self.join(x, y, span))) + } + (StaticType::Map { key: k1, value: v1 }, StaticType::Map { key: k2, value: v2 }) => { + StaticType::Map { + key: Box::new(self.join(k1, k2, span)), + value: Box::new(self.join(v1, v2, span)), + } + } + (StaticType::Tuple(xs), StaticType::Tuple(ys)) if xs.len() == ys.len() => { + StaticType::Tuple( + xs.iter() + .zip(ys) + .map(|(x, y)| self.join(x, y, span)) + .collect(), + ) + } + _ => { + let result = self.vars.fresh(StaticType::Any); + self.wait(Constraint::Join { + sides: [a, b], + result: result.clone(), + span, + }); + result + } + } + } + + /// Commits `call` to `picked` while its arguments still have unknown + /// parts, which settles them from `picked`'s parameters, and returns the + /// call's type, or `None` when they don't fit after all. The program + /// calls `picked` directly only where it would once the arguments are + /// known: an `Any` argument, as `xs` in `fn big(xs) { xs.filter(…) }`, is + /// checked as the program runs. + fn commit_early( + &mut self, + call: &CallConstraint, + arguments: &[StaticType], + picked: SymbolId, + ) -> Option { + let typ = self.commit_call( + &Binding::Resolved(Candidate::Scalar(picked)), + arguments, + StaticType::Any, + )?; + let arguments = arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + let binding = if self + .vars + .resolve(self.types.get_type(picked)) + .is_fn_and_matches(&arguments) + { + Binding::Resolved(Candidate::Scalar(picked)) + } else { + Binding::Dynamic(vec![Candidate::Scalar(picked)]) + }; + if cfg!(debug_assertions) { + self.early_picks.push(EarlyPick { + candidates: call.candidates.clone(), + arguments: call.arguments.clone(), + kind: call.kind, + name: call.name.clone(), + binding: binding.clone(), + }); + } + self.record_pick(call.site, binding, &arguments); + Some(typ) + } + + /// Checks that each call that committed to an overload before its + /// arguments were known got the overload it would get now that they + /// are. Committing early is only right when nothing learned later could + /// change the pick, which is easy to get wrong, so debug builds check + /// every early commit the tests make. + pub(super) fn check_early_picks(&mut self) { + let early_picks = std::mem::take(&mut self.early_picks); + // A program with errors may have committed to what was reported. + if !self.result.errors.is_empty() { + return; + } + for pick in early_picks { + let arguments = pick + .arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + let binding = self + .overloads() + .resolve_call(&pick.candidates, &arguments, pick.kind) + .binding; + assert!( + binding == pick.binding, + "internal error: `{}` committed to {:?} before its arguments were known, \ + but with ({}) it means {binding:?}", + pick.name, + pick.binding, + arguments + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), + ); + } + } + /// The values a map default of type `default` makes, which waits until + /// it's known whether the default is a function: in `fn(f) => %{:f}`, it + /// depends on what's passed for `f`. + pub(super) fn constrain_default_value( + &mut self, + default: StaticType, + span: Span, + ) -> StaticType { + match self.vars.resolve(&default) { + StaticType::Var(_) => { + let value = self.vars.fresh(StaticType::Any); + self.wait(Constraint::MapDefault { + default, + value: value.clone(), + span, + }); + value + } + known => Self::default_value_type(known), + } + } + + /// Makes `constraint` wait, in the order it started waiting, until one + /// of the variables it waits on is settled. + fn wait(&mut self, constraint: Constraint) { + let serial = self.next_serial; + self.next_serial += 1; + self.wait_again(serial, constraint); + } + + fn wait_again(&mut self, serial: u64, constraint: Constraint) { + for var in constraint + .inputs() + .iter() + .flat_map(|input| self.vars.unsettled(input)) + { + self.watchers.entry(var).or_default().push(serial); + } + self.waiting.insert(serial, constraint); + } + + /// Tries again each waiting constraint that waits on a variable settled + /// since this last ran, oldest first, until that settles nothing more. + pub(super) fn solve_waiting(&mut self) { + self.solve_waiting_and_report(); + } + + /// [`Self::solve_waiting`], returning the constraints it tried that + /// still wait. + fn solve_waiting_and_report(&mut self) -> BTreeSet { + let mut still_waiting = BTreeSet::new(); + loop { + let settled = self.vars.settled_since(self.watched_settlings); + if settled.is_empty() { + break; + } + let woken = settled + .iter() + .filter_map(|var| self.watchers.remove(var)) + .flatten() + .collect::>(); + self.watched_settlings = self.vars.settled_count(); + for serial in woken { + // A constraint woken by an earlier variable may be solved already. + let Some(mut constraint) = self.waiting.remove(&serial) else { + continue; + }; + if self.try_constraint(&mut constraint) { + still_waiting.remove(&serial); + } else { + self.wait_again(serial, constraint); + still_waiting.insert(serial); + } + } + } + self.watched_settlings = self.vars.settled_count(); + still_waiting + } + + /// Solves what waits on the variables made since `first`, which nothing + /// outside the code they were made in can settle. A call that a default + /// rule can decide is decided by it first: it commits to its narrowest + /// overload, or `a op= b` keeps the type of `b`. What's left gets its + /// fallbacks, oldest constraint first. Constraints that wait only on + /// older variables keep waiting. + pub(super) fn settle_waiting(&mut self, first: usize) { + // Each constraint is checked for a default rule once, and again only + // after something it waits on changed. + let mut unchecked = self.waiting.keys().copied().collect::>(); + unchecked.extend(self.solve_waiting_and_report()); + // Fallbacks only ever make more known, so a constraint found to wait + // on no variable made since `first` never needs them. + let mut fallen_back = 0; + loop { + while let Some(serial) = unchecked.pop_first() { + if !self.waits_on_vars_since(serial, first).is_empty() + && (self.default_to_narrowest(serial) || self.default_to_operand(serial)) + { + unchecked.extend(self.solve_waiting_and_report()); + } + } + let next = self + .waiting + .range(fallen_back..) + .map(|(&serial, _)| serial) + .find_map(|serial| { + let vars = self.waits_on_vars_since(serial, first); + (!vars.is_empty()).then_some((serial, vars)) + }); + let Some((serial, vars)) = next else { + break; + }; + fallen_back = serial; + self.vars.settle_with_fallbacks(&vars); + unchecked.extend(self.solve_waiting_and_report()); + } + self.vars.settle_from(first); + self.solve_waiting(); + // A call can still wait when its arguments are known: a variable's + // type might have settled since, which nothing watches. + let serials = self.waiting.keys().copied().collect::>(); + for serial in serials { + let Some(mut constraint) = self.waiting.remove(&serial) else { + continue; + }; + if !self.try_constraint(&mut constraint) { + self.wait_again(serial, constraint); + } + } + self.solve_waiting(); + } + + /// The unsettled variables made since `first` that the waiting + /// constraint `serial` waits on, if it's still waiting. + fn waits_on_vars_since(&self, serial: u64, first: usize) -> Vec { + self.waiting + .get(&serial) + .map_or_else(Vec::new, |constraint| { + constraint + .inputs() + .iter() + .flat_map(|input| self.vars.unsettled(input)) + .filter(|var| var.0 as usize >= first) + .collect() + }) + } + + /// What nothing else settled in the waiting `a op= b` keeps the type it + /// already had, so it's taken from `b`: in `m["k"] += 3` on an empty + /// map, the values are Ints. Returns whether that settled anything. + fn default_to_operand(&mut self, serial: u64) -> bool { + let Some(Constraint::Call(call)) = self.waiting.get(&serial) else { + return false; + }; + let (CallSite::OpAssignment(_), [target, operand]) = (call.site, &call.arguments[..]) + else { + return false; + }; + let (target, operand) = (target.clone(), operand.clone()); + let before = self.vars.settled_count(); + self.vars.try_unify(&target, &operand) && self.vars.settled_count() > before + } + + /// Solves `constraint` if what it waits on is known, and returns whether + /// it did. + fn try_constraint(&mut self, constraint: &mut Constraint) -> bool { + match constraint { + Constraint::Call(call) => { + let Some(typ) = self.try_call(call) else { + return false; + }; + let result = call.result.clone(); + match call.site { + // What `p.x = v` writes was settled by the value before + // the read said what the field holds, so it's the value + // that doesn't fit. The write fails the same way and + // isn't reported again. + CallSite::Getter(id) if !self.accepts_write(&result, &typ) => { + self.emit_rejected_write(&typ, &result, call.span); + self.failed_reads.insert(id); + } + CallSite::Getter(_) => {} + _ => { + self.settle_waited_for(&result, &typ, call.span); + } + } + true + } + Constraint::Iterate { + sequence, + element, + span, + } => { + let sequence = self.vars.resolve(sequence); + if matches!(sequence, StaticType::Var(_)) { + return false; + } + let found = sequence.sequence_element_type().unwrap_or(StaticType::Any); + let element = element.clone(); + self.settle_waited_for(&element, &found, *span); + true + } + Constraint::Unpack { value, parts, span } => { + let value = self.vars.resolve(value); + if matches!(value, StaticType::Var(_)) { + return false; + } + let found = match &value { + StaticType::Tuple(elements) if elements.len() != parts.len() => { + self.emit(AnalysisError::tuple_arity_mismatch( + parts.len(), + elements.len(), + *span, + )); + None + } + value => { + let found = value + .unpack() + .map(|found| found.take(parts.len()).cloned().collect::>()); + if found.is_none() { + self.emit(AnalysisError::unable_to_unpack_type(value, *span)); + } + found + } + }; + let found = found.unwrap_or_else(|| vec![StaticType::Any; parts.len()]); + for (part, found) in parts.iter().zip(found) { + self.settle_waited_for(part, &found, *span); + } + true + } + Constraint::MapDefault { + default, + value, + span, + } => { + let default = self.vars.resolve(default); + if matches!(default, StaticType::Var(_)) { + return false; + } + let value = value.clone(); + self.settle_waited_for(&value, &Self::default_value_type(default), *span); + true + } + Constraint::Join { + sides: [a, b], + result, + span, + } => { + let (a, b) = (self.vars.resolve(a), self.vars.resolve(b)); + if a.has_vars() || b.has_vars() { + return false; + } + let result = result.clone(); + self.settle_waited_for(&result, &a.lub(&b), *span); + true + } + } + } + + /// Settles `waited`, the type a constraint gave out while it waited, as + /// `found`, the type it turned out to be. Code analysed meanwhile may + /// have settled `waited` already, and then `found` has to fit it. + fn settle_waited_for(&mut self, waited: &StaticType, found: &StaticType, span: Span) { + if !self.accepts_write(waited, found) { + self.emit_rejected_write(waited, found, span); + } + } + + /// Picks the overload `call` means, records it, and returns the call's + /// type, or `None` when the arguments aren't known well enough yet. A + /// call whose arguments are known is resolved the usual way. Otherwise + /// the candidates that can't accept what's known are crossed off, and + /// when one is left, the call commits to it, which settles the arguments + /// from its parameters. + fn try_call(&mut self, call: &CallConstraint) -> Option { + let arguments = call + .arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + let reachable = self.overloads().reachable_symbols(&call.candidates); + + // A variable whose type isn't known yet, like the parameter `f` in + // `fn(f) => f(1)`, holds a function that takes these arguments. + if let [only] = reachable[..] + && let callee @ StaticType::Var(_) = self.vars.resolve(self.types.get_type(only)) + { + let typ = self.call_unknown_function(&callee, arguments); + self.record_pick( + call.site, + Binding::Resolved(Candidate::Scalar(only)), + &call.arguments, + ); + return Some(typ); + } + + // A write waits for the value it stores, which is analysed after the + // target: only then can it tell whether the value is what's wrong. + if let (CallSite::Setter(_) | CallSite::FieldSetter(_), Some(StaticType::Var(_))) = + (call.site, arguments.last()) + { + return None; + } + + if arguments.iter().all(|argument| !argument.has_vars()) { + return Some(self.resolve_call_now(call, &arguments)); + } + + let viable = reachable + .iter() + .copied() + .filter(|&symbol| self.could_accept(symbol, &arguments)) + .collect::>(); + match viable[..] { + // A write that nothing takes may still fit once its target's + // type is known, which also says better what's wrong. + [] if matches!(call.site, CallSite::Setter(_) | CallSite::FieldSetter(_)) => None, + [] => Some(self.resolve_call_now(call, &arguments)), + // An operator may also apply to each element of a tuple, so one + // overload left doesn't decide it while an operand could still + // be a tuple: `fn(x) => x ++ "!"` can take `("a", "b")`. + [only] + if self.fits_without_any(only, &arguments) + && !(call.kind == CallKind::Operator + && arguments + .iter() + .any(|argument| matches!(argument, StaticType::Var(_)))) => + { + let Some(typ) = self.commit_early(call, &arguments, only) else { + self.report_failed_call(call, &arguments); + return Some(StaticType::Any); + }; + Some(typ) + } + _ => None, + } + } + + /// Resolves `call` with the arguments as they are, reporting it when no + /// overload accepts them. + fn resolve_call_now(&mut self, call: &CallConstraint, arguments: &[StaticType]) -> StaticType { + let ResolvedCall { + binding, + return_type, + } = self + .overloads() + .resolve_call(&call.candidates, arguments, call.kind); + if matches!(binding, Binding::None) { + self.report_failed_call(call, arguments); + self.record_pick(call.site, binding, arguments); + return StaticType::Any; + } + let typ = self.commit_call(&binding, arguments, return_type); + self.record_pick(call.site, binding, arguments); + typ.unwrap_or_else(|| { + self.report_failed_call(call, arguments); + StaticType::Any + }) + } + + /// Settles the types a call's arguments leave unknown from the overload + /// it picked, and returns the call's type. The picked signature gets new + /// variables for its type parameters, and each argument is passed where + /// its parameter is expected: `push(xs, 1)` with `push(List, T)` + /// settles `xs` as a `List`, and `push(xs, "a")` then doesn't fit. + /// When the pick is left to the program as it runs, any function may get + /// the arguments, so what's unknown in them becomes `Any`. Returns `None` + /// when an argument doesn't fit the picked overload after all, which + /// settles nothing. + fn commit_call( + &mut self, + binding: &Binding, + arguments: &[StaticType], + return_type: StaticType, + ) -> Option { + let picked = match binding { + Binding::Resolved(Candidate::Scalar(symbol)) => Some(*symbol), + // An operator on tuples applies one overload to each position, + // with a scalar operand used at every position. + Binding::Resolved(Candidate::Vec(symbol)) => { + let Some(length) = arguments.iter().find_map(|argument| match argument { + StaticType::Tuple(elements) => Some(elements.len()), + _ => None, + }) else { + return Some(return_type); + }; + let scalar = Binding::Resolved(Candidate::Scalar(*symbol)); + let mut returns = Vec::with_capacity(length); + for position in 0..length { + let arguments = arguments + .iter() + .map(|argument| match argument { + StaticType::Tuple(elements) => elements[position].clone(), + scalar => scalar.clone(), + }) + .collect::>(); + returns.push(self.commit_call(&scalar, &arguments, StaticType::Any)?); + } + return Some(StaticType::Tuple(returns)); + } + Binding::Dynamic(_) | Binding::None => None, + }; + let signature = picked.map(|symbol| { + let declared = self.vars.resolve(self.types.get_type(symbol)); + self.vars.instantiate(&declared) + }); + let Some(StaticType::Function { + parameters: Some(parameters), + return_type: declared_return, + }) = signature + else { + for argument in arguments { + self.vars.settle_unknowns(argument, &StaticType::Any); + } + return Some(return_type); + }; + + self.bind_signature( + &StaticType::Function { + parameters: Some(parameters), + return_type: declared_return, + }, + arguments, + ) + } + + /// Passes each argument where `signature` expects its parameter, which + /// settles what's unknown in either, and returns what the signature + /// returns. Returns `None` and settles nothing when an argument doesn't + /// fit, or the number of arguments is wrong. + pub(super) fn bind_signature( + &mut self, + signature: &StaticType, + arguments: &[StaticType], + ) -> Option { + let StaticType::Function { + parameters: Some(parameters), + return_type, + } = signature + else { + unreachable!("only a function with known parameters has a signature to bind"); + }; + let snapshot = self.vars.snapshot(); + let fits = parameters.len() == arguments.len() + && parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)); + if !fits { + self.vars.rollback(snapshot); + return None; + } + Some(self.vars.resolve(return_type)) + } + + /// The type of calling a value whose type isn't known yet, like `g` in + /// `fn(g) => g(1)`: the value is a function taking these arguments. A + /// value that can't be one, like `g` in `g(g)`, which would have to take + /// itself, stays unknown, and the call is left to the program as it runs. + pub(super) fn call_unknown_function( + &mut self, + callee: &StaticType, + arguments: Vec, + ) -> StaticType { + let returns = self.vars.fresh(StaticType::Any); + let function = StaticType::Function { + parameters: Some(arguments), + return_type: Box::new(returns.clone()), + }; + if self.vars.try_unify(callee, &function) { + self.vars.resolve(&returns) + } else { + StaticType::Any + } + } + + /// Whether some overload of `call` accepts arguments of these types. + fn call_fits(&mut self, call: &CallConstraint, arguments: &[StaticType]) -> bool { + let snapshot = self.vars.snapshot(); + let ResolvedCall { + binding, + return_type, + } = self + .overloads() + .resolve_call(&call.candidates, arguments, call.kind); + let fits = !matches!(binding, Binding::None) + && self.commit_call(&binding, arguments, return_type).is_some(); + self.vars.rollback(snapshot); + fits + } + + /// Commits the waiting call `serial` to the narrowest overload that + /// fits it, once nothing else can settle its arguments, and returns + /// whether it did. That's the overload the call would pick if the + /// arguments' unknown parts were whatever that overload needs: in + /// `m["k"]` on an empty map, `[](Map, K)` is narrower than + /// `[](Any, Any)`, so the map's keys are Strings. It only commits when + /// every overload that fits is in the same scope, as a nearer one would + /// otherwise win, and when each unknown is inside an argument whose kind + /// is known: an argument nothing at all is known about is `Any`, which + /// the overloads taking `Any` take. + fn default_to_narrowest(&mut self, serial: u64) -> bool { + let Some(Constraint::Call(call)) = self.waiting.get(&serial) else { + return false; + }; + let arguments = call + .arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + if arguments + .iter() + .any(|argument| matches!(argument, StaticType::Var(_))) + { + return false; + } + let groups = self.overloads().reachable_groups(&call.candidates); + let mut fitting = Vec::new(); + for group in groups { + let fits = group + .into_iter() + .filter(|&symbol| self.could_accept(symbol, &arguments)) + .collect::>(); + if !fits.is_empty() { + fitting.push(fits); + } + } + let [group] = &fitting[..] else { + return false; + }; + let Some(narrowest) = self + .most_specific(group) + .filter(|&narrowest| self.fits_without_any(narrowest, &arguments)) + else { + return false; + }; + let Some(Constraint::Call(call)) = self.waiting.remove(&serial) else { + unreachable!("the call was waiting a moment ago"); + }; + let Some(typ) = self.commit_early(&call, &arguments, narrowest) else { + self.wait_again(serial, Constraint::Call(call)); + return false; + }; + let result = call.result.clone(); + self.settle_waited_for(&result, &typ, call.span); + true + } + + /// The one of `symbols` whose parameters are narrower than every other + /// one's, as `[](Map, K)` is than `[](Any, Any)`, if there is one. + /// A single function is the narrowest of one. + fn most_specific(&self, symbols: &[SymbolId]) -> Option { + let parameters = symbols + .iter() + .map( + |&symbol| match self.vars.resolve(self.types.get_type(symbol)) { + StaticType::Function { + parameters: Some(parameters), + .. + } => Some(parameters), + _ => None, + }, + ) + .collect::>>()?; + let (narrowest, _) = parameters.iter().enumerate().find(|&(i, narrower)| { + parameters + .iter() + .enumerate() + .all(|(j, wider)| i == j || is_strictly_more_specific(narrower, wider)) + })?; + Some(symbols[narrowest]) + } + + /// Whether `symbol` is a function whose parameters take these arguments + /// without an `Any` parameter standing in for an argument of another + /// type. One that does is a fallback that only wins over the overloads + /// taking exact types once the arguments are known, so a waiting call + /// doesn't commit to it: `xs[0] = []` on a `List` leaves only + /// `[]=(List, Any, Any)` taking the empty list, but it's still an + /// error once the list's type is known. + fn fits_without_any(&self, symbol: SymbolId, arguments: &[StaticType]) -> bool { + match self.vars.resolve(self.types.get_type(symbol)) { + StaticType::Function { + parameters: Some(parameters), + .. + } => parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| { + *parameter != StaticType::Any || *argument == StaticType::Any + }), + _ => false, + } + } + + /// Whether `symbol` can accept arguments of these types, where each part + /// not known yet fits anything. + fn could_accept(&mut self, symbol: SymbolId, arguments: &[StaticType]) -> bool { + match self.vars.resolve(self.types.get_type(symbol)) { + StaticType::Function { + parameters: None, .. + } => true, + function @ StaticType::Function { + parameters: Some(_), + .. + } => { + let snapshot = self.vars.snapshot(); + let StaticType::Function { + parameters: Some(parameters), + .. + } = self.vars.instantiate(&function) + else { + unreachable!("instantiating a function type gives a function type"); + }; + let fits = parameters.len() == arguments.len() + && parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)); + self.vars.rollback(snapshot); + fits + } + other => other.is_unknown(), + } + } + + fn site_candidates(&self, site: CallSite) -> &Candidates { + match site { + CallSite::Call(id) => self.resolver.reference_candidates(id), + CallSite::Getter(id) => self + .resolver + .getter_candidates(id) + .expect("internal error: a getter call without getter candidates"), + CallSite::Setter(id) | CallSite::FieldSetter(id) => self.resolver.setter_candidates(id), + CallSite::OpAssignment(id) => &self.resolver.augmented_candidates(id).operator, + } + } + + fn record_pick( + &mut self, + site: CallSite, + binding: Binding, + arguments: &[StaticType], + ) { + match site { + CallSite::Call(id) => self.picks.bindings.insert(id, binding), + CallSite::Getter(id) => self.picks.getters.insert(id, binding), + CallSite::Setter(id) | CallSite::FieldSetter(id) => { + self.picks.setters.insert(id, binding) + } + // An in-place overload such as `++=` does the same thing faster, + // so it's used where one takes the operands as they are. + CallSite::OpAssignment(id) => { + let arguments = arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + let in_place = self.overloads().resolve_call( + &self.resolver.augmented_candidates(id).in_place, + &arguments, + CallKind::Operator, + ); + let binding = match in_place.binding { + Binding::Resolved(candidate) + if Self::augmented_rhs_is_compatible(&arguments[0], &arguments[1]) => + { + Binding::Resolved(candidate) + } + _ => binding, + }; + self.picks.augmented_assignments.insert(id, binding) + } + }; + } + + /// Reports that no overload accepts `call`. Writing `xs[i] += 1` reads + /// and writes `xs[i]`, and when the read fails, the write fails the same + /// way, which is reported once. + fn report_failed_call(&mut self, call: &CallConstraint, arguments: &[StaticType]) { + let arguments = &arguments + .iter() + .map(|argument| self.vars.shown(argument)) + .collect::>(); + let error = match call.site { + CallSite::Call(_) | CallSite::Getter(_) | CallSite::OpAssignment(_) => { + if let CallSite::Getter(id) = call.site { + self.failed_reads.insert(id); + } + self.no_match(&call.candidates, &call.name, arguments, call.span) + } + CallSite::Setter(id) | CallSite::FieldSetter(id) if self.failed_reads.contains(&id) => { + return; + } + CallSite::FieldSetter(_) => { + AnalysisError::function_not_found(&call.name, arguments, call.span) + } + CallSite::Setter(_) => match arguments.as_slice() { + [container, ..] + if container.index_element_type().is_none() && !container.is_unknown() => + { + AnalysisError::unable_to_index_into(container, call.span) + } + // The container and index fit, but the value doesn't fit + // what the container holds there: an element, or a list of + // them for a slice like `xs[0..2]`. + [container, index, value] + if let Some(element) = container.index_element_type() + && let expected = if Self::index_type_is_slice(index) { + StaticType::List(Box::new(element)) + } else { + element + } + && self.call_fits( + call, + &[container.clone(), index.clone(), expected.clone()], + ) => + { + AnalysisError::mismatched_types(value, &expected, call.span) + } + _ => AnalysisError::function_not_found(&call.name, arguments, call.span), + }, + }; + self.emit(error); + } +} diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 247cb811..ee483c32 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -48,6 +48,7 @@ fn index_natives() -> Vec<(String, StaticType)> { ("[]=", "(List, Int, T)"), ("[]=", "(List, Iterator, Sequence)"), ("[]=", "(Map, K, V)"), + ("[]=", "(String, Any, String)"), ] .into_iter() .map(|(name, signature)| { @@ -689,14 +690,15 @@ fn a_lambda_takes_the_function_type_expected_where_it_is_written() { analyse_last_type("let f: Fn(Int) -> Int = fn(x) => x; f"), int_to_int ); - // A function value's parameter. - let globals = natives(&[("++", "(String, String) -> String")]); + // A function value's parameter. Either `+` could take `x` until the + // call says it's a `String`. + let globals = natives(&[("+", "(Int, Int) -> Int"), ("+", "(Float, Int) -> Float")]); assert_eq!( errors( - "let apply = fn(g: Fn(Int) -> Int) { g(1) }; let fs = [apply]; fs[0](fn(x) => x ++ \"a\");", + "let apply = fn(g: Fn(String) -> Int) { g(\"a\") }; let fs = [apply]; fs[0](fn(x) => x + 1);", [index_natives(), globals].concat() ), - vec!["No function called '++' found that matches the arguments 'Int, String'"] + vec!["No function called '+' found that matches the arguments 'String, Int'"] ); // A map's default function returns the map's values. assert_eq!( @@ -762,6 +764,364 @@ fn a_function_settles_its_own_type_variables_when_its_body_ends() { ); } +/// `+` with two overloads that both take an `Int` on the right, so `x + 1` +/// can't be picked until `x`'s type is known. +fn ambiguous_plus() -> Vec<(String, StaticType)> { + natives(&[("+", "(Int, Int) -> Int"), ("+", "(Float, Int) -> Float")]) +} + +fn function(parameters: Vec, return_type: StaticType) -> StaticType { + StaticType::Function { + parameters: Some(parameters), + return_type: Box::new(return_type), + } +} + +#[test] +fn a_call_waits_until_its_arguments_pick_an_overload() { + // Either `+` could take `x` until `f` is called with a Float. + assert_eq!( + analyse_last_type_with_globals("let f = fn(x) => x + 1; f(2.5); f", ambiguous_plus()), + function(vec![StaticType::Float], StaticType::Float) + ); +} + +#[test] +fn a_call_commits_to_the_only_overload_that_can_take_its_arguments() { + let globals = natives(&[ + ("plus", "(Int, Int) -> Int"), + ("plus", "(String, String) -> String"), + ]); + assert_eq!( + analyse_last_type_with_globals("let f = fn(x) => plus(x, 1); f", globals), + function(vec![StaticType::Int], StaticType::Int) + ); +} + +#[test] +fn a_lambda_passed_to_a_function_takes_its_parameter_types_from_the_call() { + let globals = [ + natives(&[("map", "(List, Fn(T) -> U) -> List")]), + ambiguous_plus(), + ] + .concat(); + assert_eq!( + analyse_last_type_with_globals("[2.5].map(fn(x) => x + 1)", globals), + StaticType::List(Box::new(StaticType::Float)) + ); +} + +#[test] +fn a_lambda_in_a_variable_has_one_type() { + assert_eq!( + errors("let f = fn(x) => x + 1; f(1); f(2.5);", ambiguous_plus()), + vec!["The variable 'f' holds a function that doesn't accept the arguments 'Float'"] + ); +} + +#[test] +fn a_variable_holding_an_unknown_function_takes_what_it_is_called_with() { + assert_eq!( + analyse_last_type_with_globals( + "let apply = fn(g, v) => g(v); apply(fn(z) => z + 1, 2.5)", + ambiguous_plus() + ), + StaticType::Float + ); + // Each later call goes through the type the first one settled. + assert_eq!( + analyse_last_type_with_globals( + "let apply = fn(g, v) { g(v); g(v) }; apply(fn(z) => z + 1, 2.5)", + ambiguous_plus() + ), + StaticType::Float + ); + assert_eq!( + errors("let apply = fn(g) { g(1); g(\"s\") };", ambiguous_plus()), + vec!["The variable 'g' holds a function that doesn't accept the arguments 'String'"] + ); + // A function that would have to take itself stays unknown, and the + // call is checked as the program runs. + assert_eq!( + errors("let f = fn(g) => g(g);", vec![]), + Vec::::new() + ); +} + +#[test] +fn iterating_waits_until_the_kind_of_sequence_is_known() { + let globals = [natives(&[("push", "(List, T)")]), ambiguous_plus()].concat(); + assert_eq!( + analyse_last_type_with_globals( + "let g = fn(rows) { let out = []; for r in rows { out.push(r + 1) }; out }; g([2.5])", + globals + ), + StaticType::List(Box::new(StaticType::Float)) + ); +} + +#[test] +fn unpacking_waits_until_the_kind_of_value_is_known() { + assert_eq!( + analyse_last_type("let swap = fn(p) { let (a, b) = p; (b, a) }; swap((1, \"s\"))"), + StaticType::Tuple(vec![StaticType::String, StaticType::Int]) + ); + assert_eq!( + errors("let f = fn(p) { let (a, b) = p; a }; f((1, 2, 3));", vec![]), + vec!["mismatched tuple arity: found a len=2 identifier and a len=3 annotation."] + ); +} + +#[test] +fn what_iterating_or_unpacking_gives_must_fit_how_it_was_used_meanwhile() { + // `plus(r, 1)` and `plus(a, 1)` make `r` and `a` Ints before the call + // settles what they are. + let globals = natives(&[ + ("plus", "(Int, Int) -> Int"), + ("plus", "(String, String) -> String"), + ]); + assert_eq!( + errors( + "let g = fn(rows) { for r in rows { plus(r, 1); } }; g([\"s\"]);", + globals.clone() + ), + vec!["mismatched types: found String but expected Int"] + ); + assert_eq!( + errors( + "let f = fn(p) { let (a, b) = p; plus(a, 1) }; f((\"s\", 2));", + globals + ), + vec!["mismatched types: found String but expected Int"] + ); +} + +#[test] +fn an_op_assignment_waits_for_its_operands_like_any_call() { + // `t += 1` doesn't fix `t` as an `Int` before the call says what `x` is. + assert_eq!( + analyse_last_type_with_globals( + "let f = fn(x) { let t = x; t += 1; t }; f(2.5)", + ambiguous_plus() + ), + StaticType::Float + ); + // The in-place overload is picked along with the operator once they're + // known, as `++=` is here: the natives' global slots are in this order. + let globals = natives(&[ + ("++", "(List, List) -> List"), + ("++", "(String, String) -> String"), + ("++=", "(List, List) -> List"), + ]); + let (_, result) = analyse_with_globals("let f = fn(xs) { xs ++= [1]; }; f([2]);", globals); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert_eq!( + result + .analysis + .augmented_assignments + .values() + .collect::>(), + vec![&Binding::Resolved(Candidate::Scalar( + ndc_analysis::ResolvedVar::Global { slot: 2 } + ))] + ); + // A target nothing else settles keeps the type of what's added to it. + assert_eq!( + analyse_last_type_with_globals("let f = fn(x) { x += 1; x }; f", ambiguous_plus()), + function(vec![StaticType::Int], StaticType::Int) + ); +} + +#[test] +fn a_call_nothing_else_settles_takes_its_narrowest_overload() { + // `[](Any, Any)` would take the map too, but loses to the map's own `[]`, + // which settles the key type. + let globals = [natives(&[("[]", "(Any, Any) -> Any")]), index_natives()].concat(); + assert_eq!( + analyse_last_type_with_globals("let m = %{}; m[\"k\"]; m", globals), + StaticType::Map { + key: Box::new(StaticType::String), + value: Box::new(StaticType::unit()), + } + ); +} + +#[test] +fn a_call_nothing_else_settles_takes_no_narrowest_overload_when_unsure() { + let function = |parameter, returns| StaticType::Function { + parameters: Some(vec![parameter]), + return_type: Box::new(returns), + }; + // Nothing is known about `x`, so it's `Any`, which `pick(Any)` takes. + assert_eq!( + analyse_last_type( + "fn pick(x: Int) => 1; fn pick(x: Any) => \"any\"; let f = fn(x) => pick(x); f" + ), + function(StaticType::Any, StaticType::String) + ); + // Overloads in two scopes fit, so none settles `x`: it's `Any`, and the + // call resolves as it does for any `Any` argument. + assert_eq!( + analyse_last_type( + "fn pick(xs: List) => \"outer\"; { fn pick(xs: List) => 1; let f = fn(x) => pick([x]); f }" + ), + function(StaticType::Any, StaticType::Int) + ); +} + +#[test] +fn a_waiting_call_does_not_commit_to_a_narrower_overload_too_early() { + // `pick(x)` waits: `x` may still turn out to be what only `pick(Any)` takes. + assert_eq!( + analyse_last_type( + "fn pick(x: Int) => 1; fn pick(x: Any) => \"any\"; let f = fn(x) => pick(x); f(\"s\")" + ), + StaticType::String + ); + // The nearest scope's `pick` takes the call, however narrow the outer one is. + assert_eq!( + analyse_last_type( + "fn pick(x: Int) => 1; { fn pick(x: Any) => \"inner\"; let f = fn(x) => pick(x); f(1) }" + ), + StaticType::String + ); +} + +#[test] +fn a_failed_op_assignment_is_reported_once() { + let globals = [ + natives(&[("++", "(String, String) -> String")]), + index_natives(), + ] + .concat(); + assert_eq!( + errors("let g = fn(xs) { xs[0] ++= \"a\"; }; g([1]);", globals), + vec!["No function called '++' found that matches the arguments 'Int, String'"] + ); +} + +#[test] +fn a_field_write_settles_the_receiver_like_a_field_read() { + let structs = "struct P { score: Int } struct Q { score: String } "; + assert_eq!( + errors( + &format!("{structs}let f = fn(p) {{ p.score = 3; p }}; f(Q(\"a\"));"), + vec![] + ), + vec!["The variable 'f' holds a function that doesn't accept the arguments 'Q'"] + ); + // A value that doesn't fit the field is reported once, against the field. + assert_eq!( + errors( + &format!("{structs}let f = fn(p) {{ p.score = 1.5; }}; f(P(1));"), + vec![] + ), + vec!["mismatched types: found Float but expected Int"] + ); +} + +#[test] +fn an_error_shows_what_isnt_known_yet_as_any() { + assert_eq!( + errors("let f = fn(x) -> Int { [x] };", vec![]), + vec!["mismatched types: found List but expected Int"] + ); +} + +#[test] +fn a_cast_settles_what_isnt_known_about_its_value() { + let (_, result) = analyse_with_globals("(fn(x) => x) as Fn(Int) -> Int", vec![]); + assert!(result.errors.is_empty(), "{:?}", result.errors); + // `x` is an Int, so the cast holds without a check when the program runs. + assert_eq!( + result.analysis.cast_checks.values().collect::>(), + vec![&None] + ); +} + +#[test] +fn a_waiting_call_does_not_commit_to_an_overload_taking_any_instead() { + // Only the catch-all `[]=` takes a list of unknown elements as an Int + // list's element, but once the elements are known the element-wise + // `[]=` is the one that fits, and it doesn't take a list. + let globals = [ + natives(&[("[]=", "(List, Any, Any)")]), + index_natives(), + ] + .concat(); + assert_eq!( + errors("let xs = [1]; xs[0] = [];", globals), + vec!["mismatched types: found List but expected Int"] + ); +} + +#[test] +fn an_operator_waits_while_an_operand_could_still_be_a_tuple() { + // `x ++ "!"` could also apply to each string of a tuple. + let globals = natives(&[("++", "(String, String) -> String")]); + assert_eq!( + analyse_last_type_with_globals( + "let suffix = fn(x) => x ++ \"!\"; suffix((\"a\", \"b\"))", + globals + ), + StaticType::Tuple(vec![StaticType::String, StaticType::String]) + ); +} + +#[test] +fn a_write_passes_its_value_like_an_argument() { + // Any sequence goes where a `Sequence` is expected, and settles its + // elements as Ints. + assert_eq!( + analyse_last_type( + "let xs: Sequence = []; let ys = []; let zs: Sequence = ys; ys" + ), + StaticType::List(Box::new(StaticType::Int)) + ); +} + +#[test] +fn a_map_default_waits_until_it_is_known_whether_it_makes_the_values() { + let globals = [natives(&[("+", "(Int, Int) -> Int")]), index_natives()].concat(); + assert_eq!( + analyse_last_type_with_globals( + "let make = fn(factory) => %{:factory}; let m = make(fn() => 1); m[\"x\"] + 1", + globals + ), + StaticType::Int + ); +} + +#[test] +fn a_join_waits_until_both_branches_are_known() { + // `x` isn't fixed as an `Int` by the branch next to it: the branches join + // once the call says what `x` is. + assert_eq!( + analyse_last_type("let f = fn(x) => if true { x } else { 1 }; f(2)"), + StaticType::Int + ); + assert_eq!( + analyse_last_type("let f = fn(x) => if true { x } else { 1 }; f(2.5)"), + StaticType::Any + ); + // What both branches share is known meanwhile: two lists join as a list + // that `push` can take. + assert_eq!( + analyse_last_type_with_globals( + "let xs = if true { [] } else { [] }; xs.push(1); xs", + natives(&[("push", "(List, T)"), ("push", "(MinHeap, T)")]) + ), + StaticType::List(Box::new(StaticType::Int)) + ); +} + +#[test] +fn a_call_nothing_picks_falls_back_to_picking_at_runtime() { + let (last, result) = analyse_with_globals("let f = fn(x) => x + 1; f", ambiguous_plus()); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert_eq!(last, function(vec![StaticType::Any], StaticType::Any)); +} + /// `len`, a native taking a `String`, for the test below. fn len_native() -> Vec<(String, StaticType)> { vec![( diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 5f4b33e6..25ace005 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -2,6 +2,7 @@ use crate::types::{TypeTable, TypeVars}; use ndc_analysis::{Binding, Candidate}; use ndc_core::StaticType; use ndc_resolver::{BindingKind, Candidates, SymbolId, SymbolTable}; +use std::borrow::Cow; /// Whether a call site can fall back to element-wise tuple broadcast when no /// scalar overload matches the argument types directly. Set by the parser: @@ -87,7 +88,7 @@ fn could_pass(param: &StaticType, arg: &StaticType) -> bool { /// not bidirectionally equivalent. Used by [`Overloads::most_specific_match`] to order /// matching overloads by specificity: an overload with `more` parameters /// accepts strictly fewer call signatures than one with `less` parameters. -fn is_strictly_more_specific(more: &[StaticType], less: &[StaticType]) -> bool { +pub(crate) fn is_strictly_more_specific(more: &[StaticType], less: &[StaticType]) -> bool { if more.len() != less.len() { return false; } @@ -170,6 +171,29 @@ impl<'a> Overloads<'a> { }) } + /// The type of `symbol` as far as it's known: a parameter `g` whose type + /// a call such as `g(1)` settled is a function from then on. + fn type_of(self, symbol: SymbolId) -> Cow<'a, StaticType> { + let typ = self.types.get_type(symbol); + if typ.has_vars() { + Cow::Owned(self.vars.resolve(typ)) + } else { + Cow::Borrowed(typ) + } + } + + /// The candidates a call can reach, grouped by scope, nearest first. + pub(crate) fn reachable_groups(self, candidates: &Candidates) -> Vec> { + self.reachable(candidates) + .map(<[SymbolId]>::to_vec) + .collect() + } + + /// Every candidate a call can reach, nearest scope first. + pub(crate) fn reachable_symbols(self, candidates: &Candidates) -> Vec { + self.reachable(candidates).flatten().copied().collect() + } + /// The variable that ends the search, if one does: in /// `fn foo(a) { … } let foo = fn(a, b) { … };`, a call of `foo` ends at the `let`. pub(crate) fn variable_ending_search(self, candidates: &Candidates) -> Option { @@ -204,10 +228,11 @@ impl<'a> Overloads<'a> { /// misleading advice. pub(crate) fn a_cast_could_resolve(self, candidates: &Candidates, sig: &[StaticType]) -> bool { self.reachable(candidates).flatten().any(|&symbol| { + let typ = self.type_of(symbol); let StaticType::Function { parameters: Some(parameters), .. - } = self.types.get_type(symbol) + } = typ.as_ref() else { return false; }; @@ -394,7 +419,7 @@ impl<'a> Overloads<'a> { .iter() .rev() .copied() - .filter(|&symbol| self.types.get_type(symbol).could_be_callable()), + .filter(|&symbol| self.type_of(symbol).could_be_callable()), ); } @@ -427,15 +452,15 @@ impl<'a> Overloads<'a> { let matches = group .iter() .copied() - .filter(|&symbol| self.types.get_type(symbol).is_fn_and_matches(sig)) + .filter(|&symbol| self.type_of(symbol).is_fn_and_matches(sig)) .collect::>(); - let params_of = |symbol: SymbolId| -> Option<&[StaticType]> { - match self.types.get_type(symbol) { + let params_of = |symbol: SymbolId| -> Option> { + match self.type_of(symbol).into_owned() { StaticType::Function { parameters: Some(p), .. - } => Some(p.as_slice()), + } => Some(p), // Variadic / non-function bindings have no concrete shape. _ => None, } @@ -455,7 +480,7 @@ impl<'a> Overloads<'a> { // Variadic never dominates a concrete overload. return true; }; - !is_strictly_more_specific(pj, pi) + !is_strictly_more_specific(&pj, &pi) }) }) } @@ -463,7 +488,8 @@ impl<'a> Overloads<'a> { /// Whether `symbol` is a function whose parameters don't provably conflict /// with `sig`, so it might accept the call at runtime. fn is_compatible(self, symbol: SymbolId, sig: &[StaticType]) -> bool { - let StaticType::Function { parameters, .. } = self.types.get_type(symbol) else { + let typ = self.type_of(symbol); + let StaticType::Function { parameters, .. } = typ.as_ref() else { return false; }; let Some(parameters) = parameters else { @@ -556,7 +582,7 @@ impl<'a> Overloads<'a> { /// runtime: its type is `Any`, a variadic function, or a function whose /// parameters don't provably conflict with `sig`. fn could_accept_call(self, symbol: SymbolId, sig: &[StaticType]) -> bool { - match self.types.get_type(symbol) { + match self.type_of(symbol).as_ref() { StaticType::Function { parameters: Some(parameters), .. @@ -582,7 +608,7 @@ impl<'a> Overloads<'a> { /// here: only a call that commits to the overload knows what it stands /// for, and one picked at runtime never does. fn scalar_return_type(self, symbol: SymbolId) -> StaticType { - match self.types.get_type(symbol) { + match self.type_of(symbol).as_ref() { StaticType::Function { return_type, .. } => self .vars .resolve(return_type) diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index b51caa76..9ff92fca 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -42,6 +42,18 @@ pub(crate) struct TypeVars { /// What each variable becomes if nothing settles it: `Any` for the /// elements of `[]`, which is what `[]` was typed as before. fallbacks: Vec, + /// The variables in the order they were settled, so a [`Snapshot`] can + /// unsettle the ones settled after it, and the solver can tell which + /// ones were settled since it last looked. + trail: Vec, +} + +/// The state of the variables at one point, which [`TypeVars::rollback`] +/// returns to: overload resolution tries each candidate this way. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Snapshot { + vars: usize, + trail: usize, } impl TypeVars { @@ -64,6 +76,86 @@ impl TypeVars { self.solutions[var.0 as usize].as_ref() } + /// Settles `var` as `typ`. + fn settle(&mut self, var: TypeVar, typ: StaticType) { + debug_assert!(self.solution(var).is_none(), "{var:?} is settled twice"); + self.solutions[var.0 as usize] = Some(typ); + self.trail.push(var); + } + + /// How many settlings there have been, to pass to + /// [`Self::settled_since`] later. + pub(crate) fn settled_count(&self) -> usize { + self.trail.len() + } + + /// The variables settled since there were `count` settlings. A rollback + /// only undoes settlings made after the snapshot it returns to, so + /// `count` from before a snapshot stays valid. + pub(crate) fn settled_since(&self, count: usize) -> &[TypeVar] { + &self.trail[count.min(self.trail.len())..] + } + + /// The current state, to [`Self::rollback`] to later. + pub(crate) fn snapshot(&self) -> Snapshot { + Snapshot { + vars: self.solutions.len(), + trail: self.trail.len(), + } + } + + /// Unsettles what was settled since `snapshot`, and forgets the variables + /// made since. + pub(crate) fn rollback(&mut self, snapshot: Snapshot) { + for var in self.trail.drain(snapshot.trail..) { + self.solutions[var.0 as usize] = None; + } + self.solutions.truncate(snapshot.vars); + self.fallbacks.truncate(snapshot.vars); + } + + /// Unifies `a` and `b` if they can be made the same type, and otherwise + /// leaves every variable as it was. + pub(crate) fn try_unify(&mut self, a: &StaticType, b: &StaticType) -> bool { + let snapshot = self.snapshot(); + let unified = self.unify(a, b); + if !unified { + self.rollback(snapshot); + } + unified + } + + /// The unsettled variables in `typ`, each once, outermost first. + pub(crate) fn unsettled(&self, typ: &StaticType) -> Vec { + let unsettled = std::cell::RefCell::new(Vec::new()); + self.resolve(typ).contains(&|part| { + if let StaticType::Var(var) = part + && !unsettled.borrow().contains(var) + { + unsettled.borrow_mut().push(*var); + } + false + }); + unsettled.into_inner() + } + + /// `typ` as an error shows it: the parts not known yet could still be + /// anything, so they're shown as `Any`. + pub(crate) fn shown(&self, typ: &StaticType) -> StaticType { + self.resolve(typ) + .replace(&|part| matches!(part, StaticType::Var(_)).then_some(StaticType::Any)) + } + + /// Settles each of `vars` that is still unsettled with its fallback. + pub(crate) fn settle_with_fallbacks(&mut self, vars: &[TypeVar]) { + for &var in vars { + if self.solution(var).is_none() { + let fallback = self.fallbacks[var.0 as usize].clone(); + self.settle(var, fallback); + } + } + } + /// `typ` with every settled variable replaced by what it stands for. pub(crate) fn resolve(&self, typ: &StaticType) -> StaticType { typ.replace(&|part| match part { @@ -102,7 +194,7 @@ impl TypeVars { { return false; } - self.solutions[var.0 as usize] = Some(other.clone()); + self.settle(*var, other.clone()); true } (StaticType::Option(x), StaticType::Option(y)) @@ -196,9 +288,9 @@ impl TypeVars { | (StaticType::Sequence(p), StaticType::Sequence(a)) => self.bind_argument(p, a), // A function passed as an argument gets the parameter types the // function type passes it, and returns what the function type - // returns: `map(xs, fn(x: Int) => x + 1)` passes `xs`'s elements - // to `x` and settles `U`. A function parameter whose type isn't - // known takes anything, so it settles nothing. + // returns: `map(xs, fn(x) => x + 1)` passes `xs`'s elements to + // `x` and settles `U`. A parameter of type `Any` takes anything, + // so it settles nothing. ( StaticType::Function { parameters: expected, @@ -215,7 +307,7 @@ impl TypeVars { // called the way the function type says. fits = expected.len() == takes.len(); for (expected, takes) in expected.iter().zip(takes) { - if !takes.is_unknown() { + if self.shallow(takes) != StaticType::Any { fits &= self.bind_argument(takes, expected); } } @@ -246,26 +338,17 @@ impl TypeVars { /// Settles every unsettled variable in `typ` as `to`. pub(crate) fn settle_unknowns(&mut self, typ: &StaticType, to: &StaticType) { - let unsettled = std::cell::RefCell::new(Vec::new()); - self.resolve(typ).contains(&|part| { - if let StaticType::Var(var) = part { - unsettled.borrow_mut().push(*var); - } - false - }); - for var in unsettled.into_inner() { - self.solutions[var.0 as usize].get_or_insert_with(|| to.clone()); + for var in self.unsettled(typ) { + self.settle(var, to.clone()); } } /// Settles every variable made since `first` that nothing settled with /// its fallback. pub(crate) fn settle_from(&mut self, first: usize) { - for (solution, fallback) in self.solutions[first..] - .iter_mut() - .zip(&self.fallbacks[first..]) - { - solution.get_or_insert_with(|| fallback.clone()); + for index in first..self.solutions.len() { + let var = TypeVar(u32::try_from(index).expect("fewer than 2^32 type variables")); + self.settle_with_fallbacks(&[var]); } } } diff --git a/tests/functional/programs/004_basic/074_an_index_write_of_a_value_not_yet_known_keeps_the_element_type.ndc b/tests/functional/programs/004_basic/074_an_index_write_of_a_value_not_yet_known_keeps_the_element_type.ndc new file mode 100644 index 00000000..1d05f5cf --- /dev/null +++ b/tests/functional/programs/004_basic/074_an_index_write_of_a_value_not_yet_known_keeps_the_element_type.ndc @@ -0,0 +1,5 @@ +// The empty list's elements aren't known when it's written, but it's still +// not an Int, which is what the list holds. +// expect-error: mismatched types: found List but expected Int +let values = [1]; +values[0] = []; diff --git a/tests/functional/programs/005_functions/064_a_waiting_call_is_picked_as_soon_as_its_arguments_are_known.ndc b/tests/functional/programs/005_functions/064_a_waiting_call_is_picked_as_soon_as_its_arguments_are_known.ndc new file mode 100644 index 00000000..d482215d --- /dev/null +++ b/tests/functional/programs/005_functions/064_a_waiting_call_is_picked_as_soon_as_its_arguments_are_known.ndc @@ -0,0 +1,9 @@ +// `xs[0] + 1` waits until the push settles what `xs` holds, and is picked +// then, so `y` is a Float before `y += 1` is checked. +// expect-output: 4.5 +let xs = []; +let g = fn() => xs[0] + 1; +xs.push(2.5); +let y = g(); +y += 1; +print(y); diff --git a/tests/functional/programs/005_functions/065_a_waiting_call_must_return_what_was_expected_of_it.ndc b/tests/functional/programs/005_functions/065_a_waiting_call_must_return_what_was_expected_of_it.ndc new file mode 100644 index 00000000..be8ab404 --- /dev/null +++ b/tests/functional/programs/005_functions/065_a_waiting_call_must_return_what_was_expected_of_it.ndc @@ -0,0 +1,5 @@ +// `x + 1` waits for `x`, and `r`'s annotation decides its type meanwhile. +// Once the call picks `+(Float, Int)`, its Float result must fit that. +// expect-error: mismatched types: found Float but expected String +let g = fn(x) { let r: String = x + 1; r }; +g(2.5); diff --git a/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc b/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc new file mode 100644 index 00000000..43a7e367 --- /dev/null +++ b/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc @@ -0,0 +1,6 @@ +// A lambda's parameters are settled by its uses like any variable's type, +// so it can't be called with an Int and then with a Float. +// expect-error: doesn't accept the arguments 'Float' +let twice = fn(x) => x * 2; +print(twice(3)); +print(twice(2.5)); diff --git a/tests/functional/programs/016_casts/025_a_cast_gives_a_lambda_its_parameter_types.ndc b/tests/functional/programs/016_casts/025_a_cast_gives_a_lambda_its_parameter_types.ndc new file mode 100644 index 00000000..20807bb1 --- /dev/null +++ b/tests/functional/programs/016_casts/025_a_cast_gives_a_lambda_its_parameter_types.ndc @@ -0,0 +1,5 @@ +// Casting a lambda to a function type settles the parameters nothing else +// settled, so the cast holds when the program runs. +// expect-output: 1 +let f = (fn(x) => x) as Fn(Int) -> Int; +print(f(1)); diff --git a/tests/functional/programs/603_stdlib_seq/023_a_lambda_takes_its_parameter_types_from_the_call.ndc b/tests/functional/programs/603_stdlib_seq/023_a_lambda_takes_its_parameter_types_from_the_call.ndc new file mode 100644 index 00000000..6c593863 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/023_a_lambda_takes_its_parameter_types_from_the_call.ndc @@ -0,0 +1,8 @@ +// `sort_by` passes the list's elements to the lambda, so `a.score` reads +// the field of `P`, and `map` makes a List that can't take a String. +// expect-error: No function called 'push' found that matches the arguments 'List, String' +struct P { score: Int } +let ps = [P(3), P(1)]; +ps.sort_by(fn(a, b) => a.score - b.score); +let scores = ps.map(fn(p) => p.score); +scores.push("a"); From 87ade7c9aad576466e9ebf6e4717b745e756e314 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 22:03:36 +0100 Subject: [PATCH 07/39] =?UTF-8?q?feat(analyser):=20let=20the=20expected=20?= =?UTF-8?q?type=20pick=20an=20overloaded=20function=20used=20as=20a=20valu?= =?UTF-8?q?e=20=F0=9F=8E=AF=20(#300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3b of RFC #281: an overloaded function used as a value is the overload the type expected of it picks. This is what #246 and #217 asked for. Until now a function's name used as a value was its newest overload, whatever the code around it expected. `["a b"].map(split)` passed `split(String, String)` and failed, and `[-1.5, 2.5].map(abs)` passed `abs(Int)`. Now the name waits, like a call, until the type expected of it is known well enough: - When one overload fits what's known, it's the one meant. `map` passes each element, so `split` is `split(String)`, and over Floats `abs` is `abs(Float)`. - A function type annotation picks one too, as do a variable's uses: after `let f = split; f("a b")`, `f` is `split(String)`. - Once the expected parameter types are known, the value is the overload a call with arguments of those types would pick, so the nearest scope, the most specific overload and `Any` losing to an exact type work as for calls; it must also return what's expected. `fn f(x: Int)` next to `fn f(x)` gives `f(Int)` for an `Fn(Int) -> String`. While the parameter types aren't fully known, it commits by the same rule a waiting call does (one function shared by both), so the return type never picks an overload early. - The type expected of an `if` or a list is expected of each branch or element, so `let f: Fn(Int) -> Int = if c { abs } else { floor }` works. - A return annotation settles what the body left unknown before the body's waiting constraints are settled, so `fn f() -> Fn(String, String) -> List { split }` picks the pattern overload. - When nothing says which overload is meant, it's an error that asks for a function type: `'split' has several overloads, and nothing here says which one is meant`, with an example annotation of an overload that fits, type parameters shown as `Any`. When no overload fits, it's `No overload of 'split' is a Fn(Int) -> Int`. - Overloads that redefine each other (same parameters) count as one, the newest, since the redefinition is already reported. Behaviour changes: - A position that expects `Any` doesn't pick an overload, so `print(abs)` is now an error, and so is passing an overloaded function to a named function's parameter, as in `fn apply(g, x) => g(x); apply(abs, -1)`. Named functions' parameters stop being `Any` in 3c, after which that call picks `abs(Int)` from the `-1`. - `015_struct/006` now annotates the getter it means once two structs have an `x` field. The early-commit check from #292 covers function values too: one chosen before its expected type is fully known must be the one chosen once it is. The manual's function page has a new section on overloaded functions as values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --- manual/src/reference/types/function.md | 24 ++ ndc_analyser/src/analyser/declarations.rs | 37 ++- ndc_analyser/src/analyser/function_values.rs | 286 ++++++++++++++++++ ndc_analyser/src/analyser/mod.rs | 17 +- ndc_analyser/src/analyser/solver.rs | 204 +++++++++---- ndc_analyser/src/analyser/tests.rs | 139 +++++++++ ndc_analyser/src/error.rs | 32 ++ ...lue_takes_the_overload_its_use_expects.ndc | 22 ++ ...nction_value_nothing_picks_is_an_error.ndc | 4 + ...d_lists_take_the_type_expected_of_them.ndc | 11 + ...the_most_specific_overload_like_a_call.ndc | 7 + .../015_struct/006_accessor_as_map_key.ndc | 5 +- 12 files changed, 701 insertions(+), 87 deletions(-) create mode 100644 ndc_analyser/src/analyser/function_values.rs create mode 100644 tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc create mode 100644 tests/functional/programs/005_functions/068_an_overloaded_function_value_nothing_picks_is_an_error.ndc create mode 100644 tests/functional/programs/005_functions/069_function_values_in_branches_and_lists_take_the_type_expected_of_them.ndc create mode 100644 tests/functional/programs/005_functions/070_a_function_value_picks_the_most_specific_overload_like_a_call.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index e1c62c46..cfa16699 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -223,6 +223,30 @@ fn foo(a) { a + 1 } fn foo(b) { b + 2 } // ERROR: Illegal redefinition of function 'foo' with parameters (Any) ``` +## An overloaded function as a value + +A function's name can be used as a value, as in `xs.map(abs)`. When the name has several overloads, the type expected where it's used picks one. `map` passes each element, so over a list of Floats `abs` is `abs(Float)`, and over strings `split` is `split(String)` rather than `split(String, String)`. A function type annotation picks one too, and so do a variable's uses: + +```ndc +assert_eq([-1.5, 2.5].map(abs), [1.5, 2.5]); +assert_eq(["a b"].map(split), [["a", "b"]]); + +let by_pattern: Fn(String, String) -> List = split; +assert_eq(by_pattern("a:b", ":"), ["a", "b"]); + +let f = split; +assert_eq(f("a b"), ["a", "b"]); // f is split(String), as it's called with one String +``` + +When nothing says which overload is meant, it's an error that asks for a function type: + +```ndc +let g = split; // ERROR: 'split' has several overloads, and nothing here says which one is meant +print(abs); // ERROR: the same: print takes any value +``` + +Like a call, a value uses the nearest scope with an overload that fits. + ## How a call finds its function A call such as `foo(x)` looks for `foo` in the scope it's written in first, then in each enclosing scope in turn, and finally among the built-in functions. diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 01be9afb..2a160e33 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -110,6 +110,18 @@ impl Analyser { None => implicit_return, }; + // An annotated return type is what the body returns, which can settle + // what the body left waiting: in `fn f() -> Fn(String) -> List + // { split }`, it picks `split(String)`. + // What isn't known about the body's value yet is settled from the + // annotation, like a write: `-> Sequence` takes a `List` and + // settles `?T`. + if let Some(annotated) = &annotated_return + && self.vars.resolve(&inferred_return).has_vars() + { + self.accepts_write(annotated, &inferred_return); + } + // Nothing outside a named function's body can settle the type // variables made in it, as its parameters have their types. A // lambda's variables are settled by how the code around it uses it. @@ -118,23 +130,26 @@ impl Analyser { } let inferred_return = self.vars.resolve(&inferred_return); - // If there is an annotated return type, validate it; - // otherwise record the inferred type in the analysis. - if let Some(annotated) = &annotated_return { - let fits = self.vars.try_unify(annotated, &inferred_return) - || self.vars.resolve(&inferred_return).is_subtype(annotated); - if !fits { + // If there is an annotated return type, report a body that doesn't + // return it; otherwise record the inferred type in the analysis. + match &annotated_return { + Some(annotated) + if !self.vars.try_unify(annotated, &inferred_return) + && !inferred_return.is_subtype(annotated) => + { self.emit(AnalysisError::mismatched_types( &self.vars.shown(&inferred_return), annotated, span, )); } - } else { - self.result - .analysis - .inferred_return_types - .insert(id, inferred_return.clone()); + Some(_) => {} + None => { + self.result + .analysis + .inferred_return_types + .insert(id, inferred_return.clone()); + } } let effective_return = annotated_return.unwrap_or(inferred_return); diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs new file mode 100644 index 00000000..e5a1592d --- /dev/null +++ b/ndc_analyser/src/analyser/function_values.rs @@ -0,0 +1,286 @@ +//! A function's name used as a value, as `split` in `xs.map(split)`. When +//! the name has several overloads, the type expected where it's used picks +//! one: `map` passes one `String`, so `split(String)` is meant, not +//! `split(String, String)`. + +use super::Analyser; +use super::solver::{Constraint, EarlyOverload}; +use crate::error::AnalysisError; +use crate::overloads::CallKind; +use ndc_analysis::{Binding, Candidate}; +use ndc_core::{NodeId, StaticType}; +use ndc_lexer::Span; +use ndc_resolver::{BindingKind, Candidates, SymbolId}; + +/// An overloaded function's name used as a value, waiting for the type +/// expected of it to say which overload is meant. +#[derive(Debug, Clone)] +pub(super) struct FunctionValue { + id: NodeId, + name: String, + /// What the name can mean, as for a call of it. + candidates: Candidates, + /// The functions among them, grouped by scope, nearest first. + groups: Vec>, + /// The type expected of the value, which its uses settle. + expected: StaticType, + span: Span, +} + +impl FunctionValue { + pub(super) fn expected(&self) -> &StaticType { + &self.expected + } +} + +/// Which overload a function value means, given what's known of the type +/// expected of it. +#[derive(Debug, PartialEq)] +pub(super) enum Choice { + /// This overload. + One(SymbolId), + /// Several fit what's known so far. + Undecided, + /// None fits. + None, +} + +impl Analyser { + /// Analyses an identifier read as a value and returns its type. A name + /// with several overloads waits until the type expected of it picks one. + pub(super) fn analyse_value_reference( + &mut self, + id: NodeId, + name: &str, + span: Span, + ) -> StaticType { + let symbol = self.resolver.innermost_reference(id); + let groups = self.overloaded_function_groups(id, symbol); + if groups.iter().map(Vec::len).sum::() > 1 { + let expected = self.vars.fresh(StaticType::Any); + let value = FunctionValue { + id, + name: name.to_string(), + candidates: self.resolver.reference_candidates(id).clone(), + groups, + expected: expected.clone(), + span, + }; + if !self.try_function_value(&value) { + self.wait(Constraint::FunctionValue(value)); + } + return self.vars.resolve(&expected); + } + self.picks + .bindings + .insert(id, Binding::Resolved(Candidate::Scalar(symbol))); + // A generic function used as a value, as in `let p = push`, has one + // type there, which its uses settle. + self.instantiated_type(symbol) + } + + /// The functions the name at `id` can mean, grouped by scope, nearest + /// first, if the nearest thing it can mean, `innermost`, is a function. + fn overloaded_function_groups(&self, id: NodeId, innermost: SymbolId) -> Vec> { + let symbols = self.resolver.symbols(); + if symbols.kind(innermost) != BindingKind::Function { + return Vec::new(); + } + self.overloads() + .reachable_groups(self.resolver.reference_candidates(id)) + .into_iter() + .map(|group| { + group + .into_iter() + .filter(|&symbol| symbols.kind(symbol) == BindingKind::Function) + .collect::>() + }) + .filter(|group| !group.is_empty()) + .collect() + } + + /// `symbol`'s type with fresh variables for its type parameters. + fn instantiated_type(&mut self, symbol: SymbolId) -> StaticType { + let typ = self.vars.resolve(self.types.get_type(symbol)); + if typ.contains(&|part| matches!(part, StaticType::Generic(_))) { + self.vars.instantiate(&typ) + } else { + typ + } + } + + /// Which overload `value` means where a value of type `expected` is + /// expected. Once that's a function type, the value is called with + /// arguments of its parameter types, so it means the overload a call + /// with those arguments means, decided by the same rules: while they + /// aren't fully known, only an overload that's the only one able to take + /// them, and not through an `Any` parameter, can be committed to; once + /// they are, the nearest scope, the most specific overload and `Any` + /// losing to an exact type pick it. The return type doesn't pick, as it + /// doesn't for a call: the overload must return what's expected, or none + /// fits. Before the expected type is a function type, an overload is + /// only chosen when it's the only one there is to choose. + pub(super) fn choose_function_value( + &mut self, + value: &FunctionValue, + expected: &StaticType, + ) -> Choice { + if let StaticType::Function { + parameters: Some(parameters), + .. + } = expected + { + let picked = if parameters.iter().any(StaticType::has_vars) { + match self.early_overload(&value.candidates, parameters, CallKind::Regular) { + EarlyOverload::Only(only) => Some(only), + EarlyOverload::NoneFits => return Choice::None, + EarlyOverload::Undecided => return Choice::Undecided, + } + } else { + match self + .overloads() + .resolve_call(&value.candidates, parameters, CallKind::Regular) + .binding + { + Binding::Resolved(Candidate::Scalar(symbol)) => Some(symbol), + Binding::Dynamic(_) => return Choice::Undecided, + _ => None, + } + }; + return match picked { + Some(symbol) if self.fits_expected(symbol, expected) => Choice::One(symbol), + _ => Choice::None, + }; + } + let fitting = self.fitting_groups(value, expected); + let nearest = if expected.has_vars() { + match &fitting[..] { + [only] => only.clone(), + [] => return Choice::None, + _ => return Choice::Undecided, + } + } else { + match fitting.first() { + Some(nearest) => nearest.clone(), + None => return Choice::None, + } + }; + match nearest[..] { + [only] => Choice::One(only), + // Overloads with the same parameters are a redefinition, which + // is reported where it's declared. The newest one counts. + [newest, ..] if self.all_take_the_same(&nearest) => Choice::One(newest), + _ => Choice::Undecided, + } + } + + /// The overloads of `value` that a value of type `expected` can be, + /// grouped by scope, nearest first, leaving out scopes with none. + fn fitting_groups( + &mut self, + value: &FunctionValue, + expected: &StaticType, + ) -> Vec> { + value + .groups + .iter() + .map(|group| { + group + .iter() + .copied() + .filter(|&symbol| self.fits_expected(symbol, expected)) + .collect::>() + }) + .filter(|group| !group.is_empty()) + .collect() + } + + /// Whether the functions `symbols` all take the same parameter types. + fn all_take_the_same(&self, symbols: &[SymbolId]) -> bool { + let parameters = |symbol: SymbolId| match self.types.get_type(symbol) { + StaticType::Function { parameters, .. } => parameters.clone(), + _ => None, + }; + symbols + .iter() + .all(|&symbol| parameters(symbol) == parameters(symbols[0])) + } + + /// Whether a value of `symbol`'s type can be passed where `expected` is. + fn fits_expected(&mut self, symbol: SymbolId, expected: &StaticType) -> bool { + let snapshot = self.vars.snapshot(); + let typ = self.instantiated_type(symbol); + let fits = self.vars.bind_argument(expected, &typ); + self.vars.rollback(snapshot); + fits + } + + /// Picks the overload `value` means if what's known of the type expected + /// of it decides it, and returns whether it did. + pub(super) fn try_function_value(&mut self, value: &FunctionValue) -> bool { + let expected = self.vars.resolve(&value.expected); + match self.choose_function_value(value, &expected) { + Choice::One(symbol) => { + self.commit_function_value(value, symbol, expected.has_vars()); + true + } + Choice::Undecided if expected.has_vars() => false, + Choice::Undecided => { + let example = self.overload_example(value, &expected); + self.emit(AnalysisError::ambiguous_function_value( + &value.name, + &self.vars.shown(&expected), + &example, + value.span, + )); + self.give_up_on_function_value(value); + true + } + Choice::None => { + self.emit(AnalysisError::no_overload_fits( + &value.name, + &self.vars.shown(&expected), + value.span, + )); + self.give_up_on_function_value(value); + true + } + } + } + + /// The type of an overload that fits `expected`, nearest first, as an + /// example of an annotation that picks one. A type parameter can't be + /// written in an annotation, so it's shown as `Any`. + fn overload_example(&mut self, value: &FunctionValue, expected: &StaticType) -> StaticType { + let symbol = self + .fitting_groups(value, expected) + .first() + .and_then(|group| group.first().copied()) + .unwrap_or_else(|| self.resolver.innermost_reference(value.id)); + self.vars + .shown(self.types.get_type(symbol)) + .replace(&|part| matches!(part, StaticType::Generic(_)).then_some(StaticType::Any)) + } + + fn commit_function_value(&mut self, value: &FunctionValue, symbol: SymbolId, early: bool) { + let typ = self.instantiated_type(symbol); + self.vars.bind_argument(&value.expected, &typ); + if early && cfg!(debug_assertions) { + self.remember_early_function_value(value, symbol); + } + self.picks + .bindings + .insert(value.id, Binding::Resolved(Candidate::Scalar(symbol))); + } + + /// Picks the nearest overload after an error, so analysis carries on + /// without reporting the same value again. + fn give_up_on_function_value(&mut self, value: &FunctionValue) { + let symbol = self.resolver.innermost_reference(value.id); + let typ = self.instantiated_type(symbol); + self.vars.try_unify(&value.expected, &typ); + self.picks + .bindings + .insert(value.id, Binding::Resolved(Candidate::Scalar(symbol))); + } +} diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 8581d050..fd91871e 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -2,7 +2,7 @@ use crate::error::AnalysisError; use crate::overloads::CallKind; use crate::types::{TypeTable, TypeVars}; use ahash::{AHashMap, AHashSet}; -use ndc_analysis::{Analysis, Binding, Candidate}; +use ndc_analysis::Analysis; use ndc_core::r#struct::{StructInfo, StructRegistry}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_layout::{Layout, Picks}; @@ -16,6 +16,7 @@ use std::rc::Rc; mod assignments; mod calls; mod declarations; +mod function_values; mod solver; use declarations::FunctionDeclaration; @@ -277,19 +278,7 @@ impl Analyser { } StaticType::Never } - Expression::Identifier { .. } => { - let symbol = self.resolver.innermost_reference(*id); - let binding = Binding::Resolved(Candidate::Scalar(symbol)); - self.picks.bindings.insert(*id, binding); - // A generic built-in used as a value, as in `let p = push`, - // has one type there, which its uses settle. - let typ = self.vars.resolve(self.types.get_type(symbol)); - if typ.contains(&|part| matches!(part, StaticType::Generic(_))) { - self.vars.instantiate(&typ) - } else { - typ - } - } + Expression::Identifier { name, .. } => self.analyse_value_reference(*id, name, *span), Expression::Statement(inner) => { let typ = self.analyse_expr(inner); // Diverging statements (return/break/continue) propagate Never diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 69522f56..cf0aab9d 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -3,6 +3,7 @@ //! picking `map` settles from the type of `xs`. use super::Analyser; +use super::function_values::{Choice, FunctionValue}; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall, is_strictly_more_specific}; use ndc_analysis::{Binding, Candidate}; @@ -11,6 +12,16 @@ use ndc_lexer::Span; use ndc_resolver::{Candidates, SymbolId}; use std::collections::BTreeSet; +/// What a call can commit to before its arguments are fully known. +pub(super) enum EarlyOverload { + /// This overload, whatever the arguments turn out to be. + Only(SymbolId), + /// More than one could still be picked. + Undecided, + /// None can take the arguments. + NoneFits, +} + /// Where a call's candidates come from and where its pick goes. #[derive(Debug, Clone, Copy)] pub(super) enum CallSite { @@ -31,6 +42,9 @@ pub(super) enum CallSite { pub(super) enum Constraint { /// A call whose overload can't be picked yet. Call(CallConstraint), + /// An overloaded function's name used as a value, waiting for the type + /// expected of it to pick an overload. + FunctionValue(FunctionValue), /// `for x in s`: what iterating over `sequence` gives. Iterate { sequence: StaticType, @@ -50,12 +64,9 @@ pub(super) enum Constraint { value: StaticType, span: Span, }, - /// The one type the two branches of an `if` have, once both are known. - Join { - sides: [StaticType; 2], - result: StaticType, - span: Span, - }, + /// The one type the two branches of an `if` have, once both are known: + /// the branches' types and then the result's. + Join { types: [StaticType; 3], span: Span }, } impl Constraint { @@ -63,10 +74,11 @@ impl Constraint { fn inputs(&self) -> &[StaticType] { match self { Self::Call(call) => &call.arguments, + Self::FunctionValue(value) => std::slice::from_ref(value.expected()), Self::Iterate { sequence, .. } => std::slice::from_ref(sequence), Self::Unpack { value, .. } => std::slice::from_ref(value), Self::MapDefault { default, .. } => std::slice::from_ref(default), - Self::Join { sides, .. } => sides, + Self::Join { types, .. } => types, } } } @@ -74,12 +86,18 @@ impl Constraint { /// An overload a call committed to before its arguments were known, which /// [`Analyser::check_early_picks`] checks once they are. #[derive(Debug)] -pub(super) struct EarlyPick { - candidates: Candidates, - arguments: Vec, - kind: CallKind, - name: String, - binding: Binding, +pub(super) enum EarlyPick { + Call { + candidates: Candidates, + arguments: Vec, + kind: CallKind, + name: String, + binding: Binding, + }, + FunctionValue { + value: FunctionValue, + picked: SymbolId, + }, } #[derive(Debug)] @@ -208,8 +226,7 @@ impl Analyser { _ => { let result = self.vars.fresh(StaticType::Any); self.wait(Constraint::Join { - sides: [a, b], - result: result.clone(), + types: [a, b, result.clone()], span, }); result @@ -248,7 +265,7 @@ impl Analyser { Binding::Dynamic(vec![Candidate::Scalar(picked)]) }; if cfg!(debug_assertions) { - self.early_picks.push(EarlyPick { + self.early_picks.push(EarlyPick::Call { candidates: call.candidates.clone(), arguments: call.arguments.clone(), kind: call.kind, @@ -272,29 +289,60 @@ impl Analyser { return; } for pick in early_picks { - let arguments = pick - .arguments - .iter() - .map(|argument| self.vars.resolve(argument)) - .collect::>(); - let binding = self - .overloads() - .resolve_call(&pick.candidates, &arguments, pick.kind) - .binding; - assert!( - binding == pick.binding, - "internal error: `{}` committed to {:?} before its arguments were known, \ - but with ({}) it means {binding:?}", - pick.name, - pick.binding, - arguments - .iter() - .map(ToString::to_string) - .collect::>() - .join(", "), - ); + match pick { + EarlyPick::Call { + candidates, + arguments, + kind, + name, + binding: picked, + } => { + let arguments = arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + let binding = self + .overloads() + .resolve_call(&candidates, &arguments, kind) + .binding; + assert!( + binding == picked, + "internal error: `{name}` committed to {picked:?} before its arguments \ + were known, but with ({}) it means {binding:?}", + arguments + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "), + ); + } + EarlyPick::FunctionValue { value, picked } => { + let expected = self.vars.resolve(value.expected()); + let choice = self.choose_function_value(&value, &expected); + assert!( + choice == Choice::One(picked), + "internal error: the function value {value:?} committed to {picked:?} \ + before its type was known, but with {} it means {choice:?}", + self.vars.resolve(value.expected()), + ); + } + } } } + + /// Remembers that `value` committed to `picked` before the type expected + /// of it was known, for [`Self::check_early_picks`]. + pub(super) fn remember_early_function_value( + &mut self, + value: &FunctionValue, + picked: SymbolId, + ) { + self.early_picks.push(EarlyPick::FunctionValue { + value: value.clone(), + picked, + }); + } + /// The values a map default of type `default` makes, which waits until /// it's known whether the default is a function: in `fn(f) => %{:f}`, it /// depends on what's passed for `f`. @@ -319,7 +367,7 @@ impl Analyser { /// Makes `constraint` wait, in the order it started waiting, until one /// of the variables it waits on is settled. - fn wait(&mut self, constraint: Constraint) { + pub(super) fn wait(&mut self, constraint: Constraint) { let serial = self.next_serial; self.next_serial += 1; self.wait_again(serial, constraint); @@ -483,6 +531,7 @@ impl Analyser { } true } + Constraint::FunctionValue(value) => self.try_function_value(value), Constraint::Iterate { sequence, element, @@ -541,15 +590,27 @@ impl Analyser { true } Constraint::Join { - sides: [a, b], - result, + types: [a, b, result], span, } => { - let (a, b) = (self.vars.resolve(a), self.vars.resolve(b)); + let (a, b, result) = (a.clone(), b.clone(), result.clone()); + // A result known from outside is what's expected of each + // branch: in `let f: Fn(Int) -> Int = if c { abs } else + // { floor }`, it says which `abs` and `floor` are meant. + if !matches!(self.vars.resolve(&result), StaticType::Var(_)) { + for side in [&a, &b] { + if self.vars.resolve(side).has_vars() { + let snapshot = self.vars.snapshot(); + if !self.vars.bind_argument(&result, side) { + self.vars.rollback(snapshot); + } + } + } + } + let (a, b) = (self.vars.resolve(&a), self.vars.resolve(&b)); if a.has_vars() || b.has_vars() { return false; } - let result = result.clone(); self.settle_waited_for(&result, &a.lub(&b), *span); true } @@ -605,33 +666,56 @@ impl Analyser { return Some(self.resolve_call_now(call, &arguments)); } - let viable = reachable - .iter() - .copied() - .filter(|&symbol| self.could_accept(symbol, &arguments)) - .collect::>(); - match viable[..] { + match self.early_overload(&call.candidates, &arguments, call.kind) { // A write that nothing takes may still fit once its target's // type is known, which also says better what's wrong. - [] if matches!(call.site, CallSite::Setter(_) | CallSite::FieldSetter(_)) => None, - [] => Some(self.resolve_call_now(call, &arguments)), - // An operator may also apply to each element of a tuple, so one - // overload left doesn't decide it while an operand could still - // be a tuple: `fn(x) => x ++ "!"` can take `("a", "b")`. - [only] - if self.fits_without_any(only, &arguments) - && !(call.kind == CallKind::Operator - && arguments - .iter() - .any(|argument| matches!(argument, StaticType::Var(_)))) => + EarlyOverload::NoneFits + if matches!(call.site, CallSite::Setter(_) | CallSite::FieldSetter(_)) => { + None + } + EarlyOverload::NoneFits => Some(self.resolve_call_now(call, &arguments)), + EarlyOverload::Only(only) => { let Some(typ) = self.commit_early(call, &arguments, only) else { self.report_failed_call(call, &arguments); return Some(StaticType::Any); }; Some(typ) } - _ => None, + EarlyOverload::Undecided => None, + } + } + + /// The overload a call of `candidates` with arguments of these types + /// commits to while the arguments aren't fully known: the only one that + /// can take them, once nothing learned later could make another one the + /// pick. One that takes them only through an `Any` parameter would lose + /// to an exact type once they're known, and an operator may also apply + /// to each element of a tuple: `fn(x) => x ++ "!"` can take `("a", "b")`. + /// Function values are picked by the same rule, as calls with the + /// expected parameter types. + pub(super) fn early_overload( + &mut self, + candidates: &Candidates, + arguments: &[StaticType], + kind: CallKind, + ) -> EarlyOverload { + let viable = self + .overloads() + .reachable_symbols(candidates) + .into_iter() + .filter(|&symbol| self.could_accept(symbol, arguments)) + .collect::>(); + let operand_could_be_a_tuple = kind == CallKind::Operator + && arguments + .iter() + .any(|argument| matches!(argument, StaticType::Var(_))); + match viable[..] { + [] => EarlyOverload::NoneFits, + [only] if self.fits_without_any(only, arguments) && !operand_could_be_a_tuple => { + EarlyOverload::Only(only) + } + _ => EarlyOverload::Undecided, } } diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index ee483c32..ad7c1723 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1,5 +1,6 @@ use super::*; use itertools::Itertools; +use ndc_analysis::{Binding, Candidate}; use ndc_lexer::{Lexer, SourceId}; use ndc_parser::Parser; @@ -1092,6 +1093,144 @@ fn a_map_default_waits_until_it_is_known_whether_it_makes_the_values() { ); } +/// `split` as the stdlib has it: one overload splits on whitespace, the +/// other on a pattern. +fn split_natives() -> Vec<(String, StaticType)> { + natives(&[ + ("split", "(String) -> List"), + ("split", "(String, String) -> List"), + ("map", "(List, Fn(T) -> U) -> List"), + ]) +} + +#[test] +fn an_overloaded_function_value_is_the_overload_its_use_expects() { + let list_of_lists = StaticType::List(Box::new(StaticType::List(Box::new(StaticType::String)))); + assert_eq!( + analyse_last_type_with_globals("[\"a b\"].map(split)", split_natives()), + list_of_lists + ); + // A variable's uses say what it is. + assert_eq!( + analyse_last_type_with_globals("let f = split; f(\"a b\"); f", split_natives()), + StaticType::Function { + parameters: Some(vec![StaticType::String]), + return_type: Box::new(StaticType::List(Box::new(StaticType::String))), + } + ); +} + +#[test] +fn a_return_annotation_picks_the_overloaded_function_value_returned() { + assert_eq!( + errors( + "fn by_pattern() -> Fn(String, String) -> List { split }", + split_natives() + ), + Vec::::new() + ); +} + +#[test] +fn a_return_annotation_settles_what_the_body_left_unknown() { + assert_eq!( + errors("fn f() -> Sequence { let xs = []; xs }", vec![]), + Vec::::new() + ); + // Returns that join at `Any` still don't fit an `Int`. + assert_eq!( + errors("fn f(x) -> Int { if x { return \"s\"; } 1 }", vec![]), + vec!["mismatched types: found Any but expected Int"] + ); +} + +#[test] +fn the_help_for_an_ambiguous_function_value_names_an_overload_that_fits() { + let help = |source: &str, globals| { + let (_, result) = analyse_with_globals(source, globals); + result.errors[0].help_text().map(str::to_string) + }; + // A type parameter can't be written in an annotation. + assert_eq!( + help( + "let f = push;", + natives(&[("push", "(List, T)"), ("push", "(MinHeap, T)")]) + ), + Some( + "Say which one with a function type, as in `let f: Fn(MinHeap, Any) -> () = push`" + .to_string() + ) + ); + // The inner `pick` takes two arguments, so the example is an outer one. + assert_eq!( + help( + "fn pick(x: Int) => 1; fn pick(x: Float) => 1.0; { fn pick(a, b) => 0; let f: Fn(Any) -> Any = pick; }", + vec![] + ), + Some( + "Say which one with a function type, as in `let f: Fn(Float) -> Float = pick`" + .to_string() + ) + ); +} + +#[test] +fn a_function_value_is_not_picked_by_its_return_type_before_its_parameters_are_known() { + // Only `pick(Any)` returns an Int, but once `xs` holds Ints a call with + // an Int means `pick(Int)`, so that's the one meant, and it doesn't fit. + let globals = natives(&[ + ("map", "(List, Fn(T) -> U) -> List"), + ("push", "(List, T)"), + ]); + assert_eq!( + errors( + "fn pick(x) => 0; fn pick(x: Int) => \"s\"; let xs = []; let ys: List = xs.map(pick); xs.push(1);", + globals + ), + vec!["No overload of 'pick' is a Fn(Int) -> Int"] + ); +} + +#[test] +fn a_function_value_commits_when_only_one_overload_can_take_the_arguments() { + // `map` calls `split` with one argument, which only `split(String)` + // takes, so it's meant even though nothing says what `xs` holds. + assert_eq!( + errors("let xs = []; xs.map(split);", split_natives()), + Vec::::new() + ); +} + +#[test] +fn an_overloaded_function_value_nothing_picks_is_an_error() { + assert_eq!( + errors("let f = split;", split_natives()), + vec!["'split' has several overloads, and nothing here says which one is meant"] + ); + assert_eq!( + errors("let f: Fn(Int) -> Int = split;", split_natives()), + vec!["No overload of 'split' is a Fn(Int) -> Int"] + ); + // `split(String)` takes the String, but doesn't return an Int. + assert_eq!( + errors("let f: Fn(String) -> Int = split;", split_natives()), + vec!["No overload of 'split' is a Fn(String) -> Int"] + ); +} + +#[test] +fn an_overloaded_function_value_uses_the_nearest_scope_with_an_overload_that_fits() { + // The inner `pick` doesn't take an Int, so the outer one is meant. + assert_eq!( + analyse_last_type( + "fn pick(x: Int) => 1; { fn pick(x: String) => \"s\"; let f: Fn(Int) -> Int = pick; f(1) }" + ), + StaticType::Int + ); + // When both fit, the nearer one is meant, which + // 005_functions/067 shows by what the program prints. +} + #[test] fn a_join_waits_until_both_branches_are_known() { // `x` isn't fixed as an `Int` by the branch next to it: the branches join diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index b6c3ccc4..d289647b 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -171,6 +171,38 @@ impl AnalysisError { } } + /// `example` is one of the overloads' types, to show how to say which + /// one is meant. + pub(crate) fn ambiguous_function_value( + name: &str, + expected: &StaticType, + example: &StaticType, + span: Span, + ) -> Self { + let text = if *expected == StaticType::Any { + format!("'{name}' has several overloads, and nothing here says which one is meant") + } else { + format!( + "'{name}' has several overloads that are a {expected}, and nothing here says which one is meant" + ) + }; + Self { + text, + span, + help_text: Some(format!( + "Say which one with a function type, as in `let f: {example} = {name}`" + )), + } + } + + pub(crate) fn no_overload_fits(name: &str, expected: &StaticType, span: Span) -> Self { + Self { + text: format!("No overload of '{name}' is a {expected}"), + span, + help_text: None, + } + } + pub(crate) fn break_outside_loop(span: Span) -> Self { Self { text: "unexpected break statement outside of loop".to_string(), diff --git a/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc b/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc new file mode 100644 index 00000000..b723521f --- /dev/null +++ b/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc @@ -0,0 +1,22 @@ +// An overloaded function's name used as a value is the overload the type +// expected there picks: `map` passes each element. +// expect-output: [1.5,2.5] +// expect-output: [1,2] +// expect-output: [["a","b"]] +// expect-output: ["string"] ["int"] +// expect-output: ["a","b"] +// expect-output: inner +fn f(x: Int) { "int" } +fn f(x: String) { "string" } +print([-1.5, 2.5].map(abs)); +print([-1, 2].map(abs)); +print(["a b"].map(split)); +print(["a"].map(f), [1].map(f)); +let g = split; +print(g("a b")); +fn pick(x: Int) { 1 } +{ + fn pick(x: Any) { "inner" } + let h: Fn(Int) -> Any = pick; + print(h(1)); +} diff --git a/tests/functional/programs/005_functions/068_an_overloaded_function_value_nothing_picks_is_an_error.ndc b/tests/functional/programs/005_functions/068_an_overloaded_function_value_nothing_picks_is_an_error.ndc new file mode 100644 index 00000000..d9911013 --- /dev/null +++ b/tests/functional/programs/005_functions/068_an_overloaded_function_value_nothing_picks_is_an_error.ndc @@ -0,0 +1,4 @@ +// Nothing says which `split` is meant, so the program has to say it with a +// function type. +// expect-error: 'split' has several overloads, and nothing here says which one is meant +let g = split; diff --git a/tests/functional/programs/005_functions/069_function_values_in_branches_and_lists_take_the_type_expected_of_them.ndc b/tests/functional/programs/005_functions/069_function_values_in_branches_and_lists_take_the_type_expected_of_them.ndc new file mode 100644 index 00000000..b04e7b2a --- /dev/null +++ b/tests/functional/programs/005_functions/069_function_values_in_branches_and_lists_take_the_type_expected_of_them.ndc @@ -0,0 +1,11 @@ +// The type expected of an `if` or a list is expected of each function value +// in it, which picks its overload. +// expect-output: 1 +// expect-output: 2 +// expect-output: 3 +let f: Fn(Int) -> Int = if true { abs } else { floor }; +print(f(-1)); +let fs: List Int> = [abs, floor]; +print(fs[0](-2)); +let x = if true { abs } else { abs }; +print(x(-3)); diff --git a/tests/functional/programs/005_functions/070_a_function_value_picks_the_most_specific_overload_like_a_call.ndc b/tests/functional/programs/005_functions/070_a_function_value_picks_the_most_specific_overload_like_a_call.ndc new file mode 100644 index 00000000..0f1d379e --- /dev/null +++ b/tests/functional/programs/005_functions/070_a_function_value_picks_the_most_specific_overload_like_a_call.ndc @@ -0,0 +1,7 @@ +// With the parameter types known, a function value is the overload a call +// with arguments of those types would pick: `f(Int)` over `f(Any)`. +// expect-output: int ["int"] int +fn f(x: Int) { "int" } +fn f(x) { "any" } +let g: Fn(Int) -> String = f; +print(g(1), [1].map(f), f(1)); diff --git a/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc b/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc index 26d260c5..22cbde87 100644 --- a/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc +++ b/tests/functional/programs/015_struct/006_accessor_as_map_key.ndc @@ -19,12 +19,13 @@ assert(x != y); let by_ctor = %{Point: "the Point constructor"}; assert_eq(by_ctor[Point], "the Point constructor"); -// A same-named field on another struct is a distinct getter. +// A same-named field on another struct is a distinct getter. Once both +// exist, `x` used as a value needs a type to say which one it is. let point_x = x; struct Other { x: Int, } -let other_x = x; +let other_x: Fn(Other) -> Int = x; assert(point_x != other_x); // The two getters have different types, so the keys are typed `Any`. From e56251032b7560dfd002caaa3680542251c2c0ee Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Wed, 11 Nov 2026 22:27:50 +0100 Subject: [PATCH 08/39] =?UTF-8?q?test(proptest):=20fuzz=20the=20analyser's?= =?UTF-8?q?=20early=20overload=20choices=20=F0=9F=94=8D=20(#301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fuzz harness for the analyser's overload choices, and fixes the first bug it found. The analyser commits a call or a function value to an overload before the types involved are fully known, so that information can flow into lambdas and empty literals. Debug builds check that each such early commit is the overload it would get once the types are known (#292). Codex found four cases of disagreement in review, one at a time. This harness looks for them systematically. `tests/proptest/tests/overloads.rs` generates well-formed programs from the pieces that make the analyser commit early: - overloaded functions taking `Int`, `Float`, `String`, lists or anything - two structs sharing a field name - empty literals settled by later uses - lambdas, function values, `map`, `fold` and `if` - index and field writes, `op=`, casts and annotations Each program is analysed in a debug build, and a panic is a failure; the minimal failing program is printed as source. About three quarters of the generated programs make at least one early commit. It runs like the token fuzzer: seeds in `overloads.regressions` replay in every `cargo test`, and fresh fuzzing is opt-in with `--ignored`. CONTRIBUTING.md describes it. The early-commit check now also runs on programs with type errors. Those are most of what the generator produces, and the whole test suite, error tests included, passes with it on. Bug fix: an operator with an `Any` operand committed early, although the operand could be a tuple the operator applies to element-wise. With `fn(a: Any) => a ++ []`, a tuple of lists failed at runtime instead of being concatenated element-wise as before #292, and debug builds panicked. An `Any` operand now holds an operator back like an unknown one does, both while waiting and in the default at the end of a function. After the fix, 50,000 generated programs ran without a failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG Co-authored-by: Claude Opus 5.5 --- CONTRIBUTING.md | 23 +- ndc_analyser/src/analyser/solver.rs | 26 +- ndc_analyser/src/analyser/tests.rs | 14 + tests/proptest/tests/overloads.regressions | 6 + tests/proptest/tests/overloads.rs | 301 +++++++++++++++++++++ 5 files changed, 359 insertions(+), 11 deletions(-) create mode 100644 tests/proptest/tests/overloads.regressions create mode 100644 tests/proptest/tests/overloads.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a4d35c2..aff3985b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ The workspace has three test crates, each living under `tests/`: |---|---|---| | `functional_tests` | `tests/functional/` | End-to-end `.ndc` programs and REPL integration tests. The crate's `build.rs` auto-generates one Rust `#[test]` per `.ndc` file under `tests/functional/programs/`. | | `compiler_tests` | `tests/compiler/` | Asserts exact bytecode `OpCode` sequences for given source. | -| `proptest_tests` | `tests/proptest/` | Property-based fuzz harness that drives random token streams through the parser → analyser → compiler → VM and looks for panics. | +| `proptest_tests` | `tests/proptest/` | Property-based fuzz harnesses: random token streams through the parser → analyser → compiler → VM, and generated programs that stress the analyser's overload choices. Both look for panics. | ### Run everything @@ -79,6 +79,27 @@ the seed to `tests/proptest/tests/panic.regressions`. **Commit the updated `.regressions` file** — every subsequent `cargo test` will guard against that input. +### Run the overload fuzz harness + +`tests/proptest/tests/overloads.rs` generates well-formed programs built +from what makes the analyser commit to an overload before the types are +fully known: overloaded functions, empty literals, lambdas, function +values, `map`, `if`, writes and annotations. In debug builds the analyser +checks that each such early commit is the overload the call or value gets +once the types are known, so any disagreement panics. The minimal +failing program is printed as source. + +```bash +# Fuzz only (1024 cases by default) +cargo test -p proptest_tests --test overloads -- --ignored + +# Long fuzz run +PROPTEST_CASES=100000 cargo test -p proptest_tests --test overloads -- --ignored +``` + +Its seeds go to `tests/proptest/tests/overloads.regressions`, which +`known_overload_regressions_do_not_panic` replays in every `cargo test`. + ## Writing new tests ### Functional `.ndc` tests diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index cf0aab9d..bc6400ce 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -284,10 +284,6 @@ impl Analyser { /// every early commit the tests make. pub(super) fn check_early_picks(&mut self) { let early_picks = std::mem::take(&mut self.early_picks); - // A program with errors may have committed to what was reported. - if !self.result.errors.is_empty() { - return; - } for pick in early_picks { match pick { EarlyPick::Call { @@ -686,12 +682,22 @@ impl Analyser { } } + /// Whether an operator might still apply to each element of a tuple: an + /// operand that isn't known yet, or is `Any`, can be one. + fn operand_could_be_a_tuple(kind: CallKind, arguments: &[StaticType]) -> bool { + kind == CallKind::Operator + && arguments + .iter() + .any(|argument| matches!(argument, StaticType::Var(_) | StaticType::Any)) + } + /// The overload a call of `candidates` with arguments of these types /// commits to while the arguments aren't fully known: the only one that /// can take them, once nothing learned later could make another one the /// pick. One that takes them only through an `Any` parameter would lose /// to an exact type once they're known, and an operator may also apply - /// to each element of a tuple: `fn(x) => x ++ "!"` can take `("a", "b")`. + /// to each element of a tuple, which an unknown or `Any` operand can be: + /// `fn(x) => x ++ "!"` can take `("a", "b")`. /// Function values are picked by the same rule, as calls with the /// expected parameter types. pub(super) fn early_overload( @@ -706,13 +712,12 @@ impl Analyser { .into_iter() .filter(|&symbol| self.could_accept(symbol, arguments)) .collect::>(); - let operand_could_be_a_tuple = kind == CallKind::Operator - && arguments - .iter() - .any(|argument| matches!(argument, StaticType::Var(_))); match viable[..] { [] => EarlyOverload::NoneFits, - [only] if self.fits_without_any(only, arguments) && !operand_could_be_a_tuple => { + [only] + if self.fits_without_any(only, arguments) + && !Self::operand_could_be_a_tuple(kind, arguments) => + { EarlyOverload::Only(only) } _ => EarlyOverload::Undecided, @@ -894,6 +899,7 @@ impl Analyser { if arguments .iter() .any(|argument| matches!(argument, StaticType::Var(_))) + || Self::operand_could_be_a_tuple(call.kind, &arguments) { return false; } diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index ad7c1723..95c845ea 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1069,6 +1069,20 @@ fn an_operator_waits_while_an_operand_could_still_be_a_tuple() { ); } +#[test] +fn an_operator_doesnt_commit_early_while_an_operand_is_any() { + // `a` is `Any`, so it may be a tuple that `++` applies to element-wise: + // the call is picked when the program runs, among all the overloads + // that could do that, as it would be with the types as they end up. + let globals = natives(&[("++", "(List, List) -> List")]); + let (_, result) = analyse_with_globals("let g = fn(a: Any) => a ++ [];", globals); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert!(matches!( + result.analysis.bindings.values().find(|binding| matches!(binding, Binding::Dynamic(_))), + Some(Binding::Dynamic(candidates)) if candidates.iter().any(|c| matches!(c, Candidate::Vec(_))) + )); +} + #[test] fn a_write_passes_its_value_like_an_argument() { // Any sequence goes where a `Sequence` is expected, and settles its diff --git a/tests/proptest/tests/overloads.regressions b/tests/proptest/tests/overloads.regressions new file mode 100644 index 00000000..341ba3ff --- /dev/null +++ b/tests/proptest/tests/overloads.regressions @@ -0,0 +1,6 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. diff --git a/tests/proptest/tests/overloads.rs b/tests/proptest/tests/overloads.rs new file mode 100644 index 00000000..f9e552bf --- /dev/null +++ b/tests/proptest/tests/overloads.rs @@ -0,0 +1,301 @@ +//! Property-based test for the analyser's overload choices. +//! +//! The analyser commits a call or a function value to an overload before +//! the types involved are fully known, so that information can flow into +//! lambdas and empty literals. That's only right if it's the overload the +//! call or value would get once the types are known, and in debug builds +//! the analyser asserts this for every early commit (`check_early_picks`). +//! It also asserts that no type variable is left unsettled. +//! +//! The token fuzzer in `panic.rs` rarely gets past the parser, so this one +//! generates well-formed programs instead, from the pieces that make the +//! analyser commit early: overloaded functions taking `Int`, `Float`, +//! `String`, lists and anything, empty literals settled by later uses, +//! lambdas, function values, `map`, `if`, index writes, `op=` and +//! annotations. Analysing each one must not panic. Type errors are +//! expected and ignored. +//! +//! * `known_overload_regressions_do_not_panic` replays the seeds saved in +//! `overloads.regressions` and runs as part of `cargo test`. +//! * `fuzz_overload_choices` generates fresh programs and is `#[ignore]`d: +//! +//! ```sh +//! cargo test -p proptest_tests --test overloads -- --ignored +//! PROPTEST_CASES=100000 cargo test -p proptest_tests --test overloads -- --ignored +//! ``` + +use ndc_analyser::Analyser; +use ndc_core::FunctionRegistry; +use ndc_lexer::{Lexer, SourceId}; +use ndc_parser::Parser; +use proptest::prelude::*; +use proptest::test_runner::FileFailurePersistence; +use std::fmt; +use std::panic; +use std::sync::Once; +use std::thread; +// The other harnesses in this crate run programs; this one only analyses. +use ndc_vm as _; +use num as _; + +/// A generated program, printed as its source so a failure can be pasted +/// into a file and run. +#[derive(Clone)] +struct Program(String); + +impl fmt::Debug for Program { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "\n{}\n", self.0) + } +} + +/// The parameter types an overload can declare, `""` being none (`Any`). +const PARAMETER_TYPES: [&str; 5] = ["Int", "Float", "String", "List", ""]; + +/// What an overload can return, by the name of its parameter `x`. +const BODIES: [&str; 6] = ["1", "2.5", "\"s\"", "x", "[x]", "true"]; + +/// Overloads of `name` taking one parameter each, of different types so they +/// aren't redefinitions, and maybe one taking two. +fn arb_overloads(name: &'static str) -> impl Strategy { + ( + prop::sample::subsequence(PARAMETER_TYPES.to_vec(), 1..=3), + prop::collection::vec(prop::sample::select(BODIES.to_vec()), 3), + prop::option::of(prop::sample::select(BODIES.to_vec())), + ) + .prop_map(move |(types, bodies, two)| { + let mut source = String::new(); + for (typ, body) in types.iter().zip(bodies) { + let annotation = if typ.is_empty() { + String::new() + } else { + format!(": {typ}") + }; + source.push_str(&format!("fn {name}(x{annotation}) => {body};\n")); + } + if let Some(body) = two { + source.push_str(&format!("fn {name}(x, y: Int) => {body};\n")); + } + source + }) +} + +/// The names a value can be: variables, overloaded functions declared by +/// the program, and overloaded or generic built-ins. +const VALUES: [&str; 15] = [ + "v0", "v1", "v2", "v3", "p", "q", "abs", "split", "push", "len", "max", "first", "sum", "n", + "S", +]; + +const LITERALS: [&str; 13] = [ + "1", + "2.5", + "\"s\"", + "[]", + "[1]", + "[\"s\"]", + "%{}", + "%{\"k\": 1}", + "None", + "(1, 2)", + "S(1)", + "T(\"s\")", + "[S(1)]", +]; + +/// A lambda taking `a`, whose body uses `a` in ways that need its type. +fn arb_lambda() -> impl Strategy { + prop::sample::select(vec![ + "a", + "p(a)", + "q(a)", + "a + 1", + "a ++ \"!\"", + "[a]", + "a.len()", + "if true { a } else { 1 }", + "if true { a } else { [] }", + "p(a, 1)", + "a[0]", + "a.n", + "a.n + 1", + "a as Int", + "a.first()", + ]) + .prop_map(|body| format!("fn(a) => {body}")) +} + +/// An expression, nesting a few levels deep. +fn arb_expression() -> impl Strategy { + let leaf = prop_oneof![ + 3 => prop::sample::select(LITERALS.to_vec()).prop_map(str::to_string), + 3 => prop::sample::select(VALUES.to_vec()).prop_map(str::to_string), + 2 => arb_lambda(), + ]; + leaf.prop_recursive(3, 16, 2, |inner| { + prop_oneof![ + ( + prop::sample::select(vec!["p", "q", "abs", "len"]), + inner.clone() + ) + .prop_map(|(f, e)| format!("{f}({e})")), + (inner.clone(), inner.clone()).prop_map(|(a, b)| format!("p({a}, {b})")), + (inner.clone(), inner.clone()).prop_map(|(a, b)| format!("({a} + {b})")), + (inner.clone(), inner.clone()).prop_map(|(a, b)| format!("[{a}, {b}]")), + (inner.clone(), inner.clone()) + .prop_map(|(a, b)| format!("(if true {{ {a} }} else {{ {b} }})")), + ( + inner.clone(), + prop_oneof![ + arb_lambda(), + prop::sample::select(VALUES.to_vec()).prop_map(str::to_string) + ] + ) + .prop_map(|(xs, f)| format!("{xs}.map({f})")), + inner.clone().prop_map(|e| format!("{e}[0]")), + inner.clone().prop_map(|e| format!("{e}.n")), + inner + .clone() + .prop_map(|e| format!("{e}.fold(0, fn(acc, a) => acc + a)")), + inner.clone().prop_map(|e| format!("({e} as List)")), + inner.clone().prop_map(|e| format!("{e}[\"k\"]")), + (inner.clone(), inner).prop_map(|(f, e)| format!("({f})({e})")), + ] + }) +} + +/// Annotations a `let` can have. +const ANNOTATIONS: [&str; 8] = [ + "Int", + "String", + "List", + "List", + "Fn(Int) -> Int", + "Fn(String) -> List", + "List Int>", + "Any", +]; + +const VARIABLES: [&str; 4] = ["v0", "v1", "v2", "v3"]; + +/// A statement on the variables `v0` to `v3`. +fn arb_statement() -> impl Strategy { + let variable = || prop::sample::select(VARIABLES.to_vec()); + prop_oneof![ + 3 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v}.push({e});")), + 2 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v}[0] = {e};")), + 1 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v}[\"k\"] = {e};")), + 1 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v}.n = {e};")), + 1 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v}[0] += {e};")), + 2 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v} += {e};")), + 2 => (variable(), arb_expression()).prop_map(|(v, e)| format!("{v} = {e};")), + 2 => (prop::sample::select(ANNOTATIONS.to_vec()), arb_expression()) + .prop_map(|(t, e)| format!("let w: {t} = {e};")), + 2 => arb_expression().prop_map(|e| format!("{e};")), + 1 => (variable(), arb_expression()).prop_map(|(v, e)| format!("for e in {v} {{ {e}; }}")), + ] +} + +fn arb_program() -> impl Strategy { + ( + arb_overloads("p"), + arb_overloads("q"), + prop::collection::vec(prop::sample::select(LITERALS.to_vec()), 4), + prop::collection::vec(arb_statement(), 1..=6), + ) + .prop_map(|(p, q, initial, statements)| { + // Two structs with a field of the same name, so `x.n` and the + // getter `n` have overloads to choose from. + let mut source = format!("struct S {{ n: Int }}\nstruct T {{ n: String }}\n{p}{q}"); + for (variable, value) in VARIABLES.iter().zip(initial) { + source.push_str(&format!("let {variable} = {value};\n")); + } + for statement in statements { + source.push_str(&statement); + source.push('\n'); + } + Program(source) + }) +} + +const WORKER_THREAD_NAME: &str = "proptest-overload-worker"; + +/// Silences panics on the worker thread, so shrinking doesn't print each +/// attempt's panic; the final one is resumed on the test thread. +fn install_quiet_panic_hook() { + static HOOK: Once = Once::new(); + HOOK.call_once(|| { + let default_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + if thread::current().name() != Some(WORKER_THREAD_NAME) { + default_hook(info); + } + })); + }); +} + +/// Analyses `program` on a worker thread, so its panic can be silenced and +/// resumed here for proptest to shrink. +fn analyse_quietly(program: Program) { + install_quiet_panic_hook(); + let result = thread::Builder::new() + .name(WORKER_THREAD_NAME.into()) + .spawn(move || analyse(&program)) + .expect("spawn worker thread") + .join(); + if let Err(payload) = result { + panic::resume_unwind(payload); + } +} + +/// Analyses `program` with the standard library's functions, as the +/// interpreter would. Analysis panics on a failed debug assertion. +fn analyse(program: &Program) { + let Ok(tokens) = Lexer::new(&program.0, SourceId::SYNTHETIC).collect::, _>>() + else { + return; + }; + let Ok(expressions) = Parser::from_tokens(tokens).parse() else { + return; + }; + let mut registry = FunctionRegistry::default(); + ndc_stdlib::register(&mut registry); + let natives = registry + .iter() + .map(|function| (function.name.clone(), function.static_type.clone())) + .collect(); + let mut analyser = Analyser::new(natives); + for expression in &expressions { + analyser.analyse(expression); + } + analyser.take_result(); +} + +fn persistence_config(cases: u32) -> ProptestConfig { + ProptestConfig { + cases, + max_shrink_iters: 4096, + failure_persistence: Some(Box::new(FileFailurePersistence::WithSource("regressions"))), + ..ProptestConfig::default() + } +} + +proptest! { + // cases=0 only replays the seeds in `overloads.regressions`. + #![proptest_config(persistence_config(0))] + + #[test] + fn known_overload_regressions_do_not_panic(program in arb_program()) { + analyse_quietly(program); + } +} + +proptest! { + #![proptest_config(persistence_config(1024))] + + #[test] + #[ignore = "fuzz test; opt-in via `cargo test ... -- --ignored`"] + fn fuzz_overload_choices(program in arb_program()) { + analyse_quietly(program); + } +} From 107fe8cebd25521e9d556f08d56878d7f108cd22 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 12 Nov 2026 07:28:52 +0100 Subject: [PATCH 09/39] =?UTF-8?q?feat(analyser):=20make=20functions=20decl?= =?UTF-8?q?ared=20in=20the=20program=20generic=20=F0=9F=A7=AC=20(#304)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3c of the type system RFC (#281): a `fn` declared in the program is generic. Its unannotated parameters are type variables, the calls its body can't decide yet become its constraints, and each call checks them with its own argument types: ```ndc fn inc(x) => x + 1; let a: Int = inc(1); let b: Float = inc(2.5); fn apply(f, x) => f(x); apply(abs, -1); // was an error since 3b: abs(Int) is the one that takes -1 fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; let i: Int = fib(10); // was Any ``` Language changes: - A call whose arguments the body can't use is an error at the call, with notes pointing into the body through each generic call it went through. Before, such a call failed as the program ran. - A `fn` declared in a function or block isn't generic. Its parameters are typed like a lambda's, so its first call decides them. Before, they were `Any`. - A variable from outside keeps one type: in `let seen = []; fn remember(n) { seen.push(n) }`, the first call decides what `remember` takes. - Overloads in one scope may not tie: fit some call with neither more specific. `fn f(x: Int, y)` next to `fn f(x, y: Int)` is an error. `fn show(x)` next to `fn show(x: Int)` is fine, as a typed fallback. A stdlib test checks that no built-ins tie, and none do. The RFC's "Overloads may not overlap" section is updated to match. How it works: - Variables have levels (Rémy). The body of a top-level `fn` is level 1, and settling a level-0 variable lowers the variables in its new type. When the body ends, each unsettled level-1 variable in the signature or in a waiting constraint is settled as a named type parameter (`A`, `B`, …). Hover shows these names. Everything else the body made gets its fallback. - Ambiguous variables: a variable that a waiting constraint depends on, but that the parameters don't decide, is settled in the body first, as at the end of the program. This covers default rules, then fallbacks. Two rules are new: - A function passed to a call counts as decided by the call, as `fn(a, x) => a + x` is by `fold`. - An `if` branch that is only ambiguous takes the other branch's type. This gives `fib` its type `fib(A) -> A`. - An overloaded function value in a generic body must be decided there, since the body is compiled once. - The scheme is the constraints still waiting on type parameters. A call that commits to the function copies them with fresh variables and tries them at once. Copies made through a generic call become the caller's constraints. Copies of the same call with the same arguments are merged, so a function that calls another twice doesn't double its constraints. - Each copy carries the chain of calls it came through. Its errors are reported at the outermost call, with secondary labels for each step: "`twice` calls `inc` here" and "`inc` needs this". The CLI renders them. The LSP shows only the primary span. - A generic body is still compiled once. Its waiting calls are picked as for `Any` arguments, which means at runtime, and copies record no picks. Callers still get precise types. Instances are 3d. Fixes found on the way: - A call left to the program as it runs lists its candidates most specific first, and the VM takes the first that accepts the arguments. Because overloads in one scope can't tie, that's the most specific one. An `Any` or type-parameter argument no longer counts as an exact match for a fallback while a more specific overload in the same scope could take it, and that scope keeps the call. Without this, `fn via(v) => show(v)` called `show(x)` for an Int. The cost: such calls, like `a[r][k]` on an untyped `a`, are decided at runtime instead of binding to a built-in's `Any` fallback. That makes `matrix_mul` about 30% slower, until 3d compiles generic bodies per instance. - An early commit through a `Sequence` parameter no longer settles a bare variable as `Sequence`. Example: `fn mut_map(list, f) { list.len; list[i] = … }` would otherwise make `list` a `Sequence` and reject the index write. - A waiting `a op= b` whose operands become known is decided by the same rule as one known at the start, including the in-place overload. Before, `remove_keys(a, b) { a -= b }` on maps found no `-`, and an `Any` `xs ++= ys` lost the in-place append. - A function that returns itself (`fn f() { f }`) returns `Any` instead of failing the occurs check. Tests: - Updated tests that relied on unannotated parameters being `Any`. Each now writes `: Any` where it tests runtime behaviour: 004/066, 016/017, bug0009, bug0011. One unit test expected an `Any` argument to pick `pick(Any)` statically; it now dispatches at runtime. - Added functional tests 005/071–080 and unit tests covering each rule above. Mutation testing breaks each rule in turn and checks that a test fails. - The benchmark programs run unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + manual/src/reference/types.md | 2 +- manual/src/reference/types/function.md | 75 ++- ndc_analyser/src/analyser/assignments.rs | 45 +- ndc_analyser/src/analyser/declarations.rs | 196 +++++--- ndc_analyser/src/analyser/function_values.rs | 7 +- ndc_analyser/src/analyser/generics.rs | 473 ++++++++++++++++++ ndc_analyser/src/analyser/mod.rs | 29 +- ndc_analyser/src/analyser/solver.rs | 391 ++++++++++++--- ndc_analyser/src/analyser/tests.rs | 235 ++++++++- ndc_analyser/src/error.rs | 75 +++ ndc_analyser/src/lib.rs | 2 +- ndc_analyser/src/overloads.rs | 144 +++++- ndc_analyser/src/types.rs | 79 ++- ndc_bin/src/diagnostic.rs | 11 +- ndc_lsp/src/features/inlay_hints.rs | 5 +- ndc_resolver/src/name_resolution.rs | 2 +- ndc_stdlib/Cargo.toml | 3 + ndc_stdlib/src/lib.rs | 18 + .../004_basic/066_op_assignment_any_rhs.ndc | 2 +- ...takes_each_type_its_body_can_work_with.ndc | 10 + ...h_what_its_body_cannot_use_is_an_error.ndc | 7 + ...unction_returns_what_it_is_called_with.ndc | 7 + ...es_its_argument_to_a_function_argument.ndc | 7 + ...75_a_generic_function_appends_in_place.ndc | 9 + ...on_declared_in_a_function_has_one_type.ndc | 9 + ...verloads_a_call_could_fit_must_not_tie.ndc | 5 + ..._is_a_fallback_next_to_exact_overloads.ndc | 8 + ...ble_has_one_type_in_a_generic_function.ndc | 11 + ..._body_calls_the_most_specific_overload.ndc | 8 + ...y_types_is_checked_as_the_program_runs.ndc | 8 + ...the_body_cannot_decide_is_checked_once.ndc | 6 + .../017_cast_op_assignment_error.ndc | 2 +- .../bug0009_upvalue_in_overload_set.ndc | 2 +- .../900_bugs/bug0011_unpack_non_sequence.ndc | 2 +- 35 files changed, 1705 insertions(+), 191 deletions(-) create mode 100644 ndc_analyser/src/analyser/generics.rs create mode 100644 tests/functional/programs/005_functions/071_a_generic_function_takes_each_type_its_body_can_work_with.ndc create mode 100644 tests/functional/programs/005_functions/072_a_generic_function_called_with_what_its_body_cannot_use_is_an_error.ndc create mode 100644 tests/functional/programs/005_functions/073_a_recursive_generic_function_returns_what_it_is_called_with.ndc create mode 100644 tests/functional/programs/005_functions/074_a_generic_function_passes_its_argument_to_a_function_argument.ndc create mode 100644 tests/functional/programs/005_functions/075_a_generic_function_appends_in_place.ndc create mode 100644 tests/functional/programs/005_functions/076_a_function_declared_in_a_function_has_one_type.ndc create mode 100644 tests/functional/programs/005_functions/077_overloads_a_call_could_fit_must_not_tie.ndc create mode 100644 tests/functional/programs/005_functions/078_an_unannotated_parameter_is_a_fallback_next_to_exact_overloads.ndc create mode 100644 tests/functional/programs/005_functions/079_a_captured_variable_has_one_type_in_a_generic_function.ndc create mode 100644 tests/functional/programs/005_functions/080_a_generic_body_calls_the_most_specific_overload.ndc create mode 100644 tests/functional/programs/005_functions/081_a_parameter_the_body_types_is_checked_as_the_program_runs.ndc create mode 100644 tests/functional/programs/005_functions/082_a_recursive_call_the_body_cannot_decide_is_checked_once.ndc diff --git a/Cargo.lock b/Cargo.lock index 064cc820..a16733f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1281,6 +1281,7 @@ dependencies = [ "factorial", "itertools 0.15.0", "md5", + "ndc_analyser", "ndc_core", "ndc_macros", "ndc_vm", diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index bc2b455e..6adc947c 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -52,6 +52,6 @@ used before (or inside) its own declaration, and it goes out of scope together w block or function that declared it. See [Struct](./types/struct.md) for the scoping rules. -> **Note:** `Any` is the base type for every other type, so an `Any`-annotated binding will accept anything. When a parameter or value has no annotation and the analyser can't infer a type, it falls back to `Any`. There is also a `Never` type used internally for things like `break` that don't produce a value — you'll rarely need to write it by hand. +> **Note:** `Any` is the base type for every other type, so an `Any`-annotated binding will accept anything. When a value has no annotation and the analyser can't infer a type, it falls back to `Any`. A parameter of a function declared in the program gets the type of each call's argument instead (see [Generic functions](./types/function.md#generic-functions)). There is also a `Never` type used internally for things like `break` that don't produce a value — you'll rarely need to write it by hand. To go the other way — recover a precise type from an `Any`-typed value — use a [cast](../features/casts.md): `value as List`. diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index cfa16699..f0c35ca2 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -134,7 +134,7 @@ fn add(x: Int, y: Int) -> Int { } ``` -Annotations are optional. A named function's parameter without one is treated as `Any`; a lambda's gets its type from how the lambda is used (see below). Mix and match as you like: +Annotations are optional. A parameter without one gets its type from how the function is used: each call of a function declared in the program gives it the types of that call's arguments (see [Generic functions](#generic-functions)), and a lambda gets the types of how it's used (see below). Mix and match as you like: ```ndc fn first(xs: List) => xs[0]; // params annotated, return inferred @@ -147,7 +147,7 @@ If the body produces a value that doesn't fit the declared return type, the anal fn bad() -> Int { "hello" } // ERROR: mismatched types ``` -A return-type annotation also helps the analyser understand recursive calls — without it, a recursive call resolves against an unknown return type and you can lose precision. +A recursive call returns what the function returns, so `fib` below returns an `Int` when it's called with one, without an annotation. ### Function types @@ -190,7 +190,57 @@ twice(3); twice(2.5); // ERROR: twice takes an Int ``` -A named function such as `fn twice(x) => x * 2` can be called with both. A lambda parameter that nothing gives a type is `Any`, and is checked when the program runs. +A function declared with `fn` in the program, such as `fn twice(x) => x * 2`, can be called with both (see [Generic functions](#generic-functions)). A lambda parameter that nothing gives a type is `Any`, and is checked when the program runs. + +## Generic functions + +A function declared with `fn` in the program takes any argument its body can work with. Each call gets the types its body makes of that call's arguments: + +```ndc +fn inc(x) => x + 1; + +let a: Int = inc(1); // + on an Int and an Int is an Int +let b: Float = inc(2.5); // + on a Float and an Int is a Float + +fn apply(f, x) => f(x); +assert_eq(apply(abs, -1), 1); +assert_eq(apply(fn(s) => s ++ "!", "hi"), "hi!"); + +fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; +let i: Int = fib(10); +``` + +A call with an argument the body can't use is an error at the call, with a note on the line in the body that can't take it: + +```ndc +fn inc(x) => x + 1; +fn shout(s) => inc(s) ++ "!"; +shout("a"); // ERROR: No function called '+' found that matches the arguments 'String, Int' + // `shout` calls `inc` here, and `inc` needs `x + 1` +``` + +What the body can do with a parameter can make its type known right away: `xs.push(1)` only works on a list of Ints, so `fn f(xs) { xs.push(1) }` takes a `List`. + +Only a function declared in the program itself is generic. One declared in another function or in a block has one type, like a lambda in a variable, and its first call decides it: + +```ndc +fn outer() { + fn same(v) => v; + same(1); + same("a"); // ERROR: same takes an Int +} +``` + +A variable declared outside a function has one type too, even when the function is generic, so the first call decides what it holds: + +```ndc +let seen = []; +fn remember(n) { seen.push(n); n } +remember(1); +remember("a"); // ERROR: seen holds Ints, so remember takes an Int +``` + +To take any value whatever its type, annotate the parameter with `Any`: the body then works with it as the program runs. ## Function overloading @@ -216,13 +266,30 @@ assert_eq("string", describe("a")); assert_eq("something else", describe([1])); ``` -Two overloads in the same scope can't have the same parameter types. Parameter names don't count, and a parameter without a type annotation has type `Any`: +Two overloads in the same scope can't have the same parameter types. Parameter names don't count: ```ndc fn foo(a) { a + 1 } fn foo(b) { b + 2 } // ERROR: Illegal redefinition of function 'foo' with parameters (Any) ``` +A parameter without a type annotation takes any argument the body can work with, so an overload with one is a fallback, like one taking `Any`, but with a typed body. A call uses the most specific overload that fits it: + +```ndc +fn show(x: Int) { "int" } +fn show(x) { "other" } + +assert_eq(show(1), "int"); +assert_eq(show("a"), "other"); +``` + +Two overloads in one scope can't both fit a call with neither more specific than the other, as nothing would say which one the call means: + +```ndc +fn f(x: Int, y) { "first" } +fn f(x, y: Int) { "second" } // ERROR: A call could fit both f(_, Int) and f(Int, _), declared earlier in the same scope, and neither is more specific +``` + ## An overloaded function as a value A function's name can be used as a value, as in `xs.map(abs)`. When the name has several overloads, the type expected where it's used picks one. `map` passes each element, so over a list of Floats `abs` is `abs(Float)`, and over strings `split` is `split(String)` rather than `split(String, String)`. A function type annotation picks one too, and so do a variable's uses: diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 11db4603..6c93b863 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -45,6 +45,23 @@ impl Analyser { self.validate_assignment_write(l_value, &left_type, &result, span); return StaticType::unit(); } + let written = self.op_assignment_now(id, &left_type, &right_type, operation, span); + self.validate_assignment_write(l_value, &left_type, &written, span); + StaticType::unit() + } + + /// Picks what `a op= b` with operands of these types does and returns + /// the type of the value written back to `a`. An in-place overload + /// such as `++=` is used where it keeps `a`'s type, and otherwise `op` + /// makes a new value. + pub(super) fn op_assignment_now( + &mut self, + id: NodeId, + left_type: &StaticType, + right_type: &StaticType, + operation: &str, + span: Span, + ) -> StaticType { let arg_types = vec![left_type.clone(), right_type.clone()]; let ResolvedCall { @@ -64,13 +81,11 @@ impl Analyser { CallKind::Operator, ); let has_op_binding = !matches!(op_binding, Binding::None); - let assign_is_eligible = Self::augmented_rhs_is_compatible(&left_type, &right_type); + let assign_is_eligible = Self::augmented_rhs_is_compatible(left_type, right_type); let writeback_type = match assign_binding { Binding::Resolved(candidate) if assign_is_eligible => { - self.picks - .augmented_assignments - .insert(id, Binding::Resolved(candidate)); + self.record_augmented_pick(id, Binding::Resolved(candidate)); None } Binding::Dynamic(mut assign_candidates) if assign_is_eligible => { @@ -95,9 +110,7 @@ impl Analyser { } else { assign_return }; - self.picks - .augmented_assignments - .insert(id, Binding::Dynamic(assign_candidates)); + self.record_augmented_pick(id, Binding::Dynamic(assign_candidates)); Some(result_type) } Binding::Resolved(_) | Binding::Dynamic(_) => { @@ -105,30 +118,22 @@ 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( - &right_type, - &left_type, - span, - )); - None + self.emit(AnalysisError::mismatched_types(right_type, left_type, span)); + Some(StaticType::Any) } Binding::None if has_op_binding => { - self.picks.augmented_assignments.insert(id, op_binding); + self.record_augmented_pick(id, op_binding); Some(op_return) } Binding::None => { self.emit(AnalysisError::function_not_found( operation, &arg_types, span, )); - None + Some(StaticType::Any) } }; - if let Some(result_type) = writeback_type { - self.validate_assignment_write(l_value, &left_type, &result_type, span); - } - - StaticType::unit() + writeback_type.unwrap_or_else(|| left_type.clone()) } /// Records where an assignment stores its value, and returns the type of diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 2a160e33..75676b21 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -8,7 +8,6 @@ use ndc_parser::{ BindingPattern, BindingPatternLocation, ExpressionLocation, FunctionParameter, StructField, TypeExpr, }; -use ndc_resolver::SymbolId; use std::rc::Rc; /// What a function declaration consists of, named like `fn f(x) { … }` or @@ -24,10 +23,12 @@ pub(super) struct FunctionDeclaration<'a> { } impl Analyser { - /// Analyses a function declaration and returns its type. A named - /// function's unannotated parameters are `Any`. A lambda's are type - /// variables that its uses settle: in `xs.map(fn(x) => x + 1)`, `x` gets - /// the type of `xs`'s elements. + /// Analyses a function declaration and returns its type. Its + /// unannotated parameters are type variables that what the body does + /// with them constrains. A lambda's, and a nested `fn`'s, are settled by + /// how the code around it uses it: in `xs.map(fn(x) => x + 1)`, `x` gets + /// the type of `xs`'s elements. A `fn` in the program scope is generic: + /// each call settles them anew (see [`Self::generalize`]). pub(super) fn analyse_function_declaration( &mut self, function: FunctionDeclaration<'_>, @@ -40,51 +41,70 @@ impl Analyser { return_annotation, span, } = function; - let first_var = self.vars.count(); + let declared = name.map(|name| (name, self.resolver.declared_symbol(id))); + let generic = declared.is_some_and(|(_, symbol)| self.is_generic_function(symbol)); + if generic { + self.vars.enter_level(); + } + let annotations = parameters + .iter() + .map(|param| self.lower_annotation(param.annotation.as_ref())) + .collect::>(); let type_signature = TypeSignature::from_annotated_bindings( parameters .iter() - .map(|param| { - let annotation = match self.lower_annotation(param.annotation.as_ref()) { - Some(annotation) => Some(annotation), - None if name.is_none() => Some(self.vars.fresh(StaticType::Any)), - None => None, - }; - (param.name().to_string(), annotation) + .zip(&annotations) + .map(|(param, annotation)| { + let typ = annotation + .clone() + .unwrap_or_else(|| self.vars.fresh(StaticType::Any)); + (param.name().to_string(), Some(typ)) }) .collect(), ); let annotated_return = self.lower_annotation(return_annotation); + // Until the body says more, a named function's value takes what its + // annotations say, as its body is compiled once for every type it's + // called with. + let runtime_parameters = match declared { + Some(_) => Some( + annotations + .iter() + .map(|annotation| annotation.clone().unwrap_or(StaticType::Any)) + .collect(), + ), + None => type_signature.types(), + }; self.result.analysis.function_types.insert( id, StaticType::Function { - parameters: type_signature.types(), + parameters: runtime_parameters, return_type: Box::new(annotated_return.clone().unwrap_or_default()), }, ); - // Pre-register the function before analyzing its body so recursive calls can - // resolve the name. The return type is unknown at this point so we use Any. - // A redefinition is reported but still bound, since the resolver declared it. - let declared = if let Some(name) = name { - let symbol = self.resolver.declared_symbol(id); + // The function is declared before its body is analysed, so a + // recursive call can call it. Its return type is what the body + // returns, which the recursive calls share. A redefinition is + // reported but still bound, since the resolver declared it. + let recursive_return = match (&declared, &annotated_return) { + (Some(_), None) => Some(self.vars.fresh(StaticType::Any)), + _ => None, + }; + if let Some((name, symbol)) = declared { let parameters = type_signature.types(); - if self.redefines_function(symbol, parameters.as_deref()) { - self.emit(AnalysisError::function_redefinition( - name, - parameters.as_deref(), - span, - )); - } + self.check_overlap(symbol, name, parameters.as_deref(), span); let placeholder = StaticType::Function { - parameters: type_signature.types(), - return_type: Box::new(annotated_return.clone().unwrap_or_default()), + parameters, + return_type: Box::new( + annotated_return + .clone() + .or_else(|| recursive_return.clone()) + .unwrap_or_default(), + ), }; self.types.declare(symbol, placeholder); - Some(symbol) - } else { - None - }; + } self.return_type_stack.push(None); // A function body starts outside of any loop: `break` can't @@ -121,13 +141,37 @@ impl Analyser { { self.accepts_write(annotated, &inferred_return); } - - // Nothing outside a named function's body can settle the type - // variables made in it, as its parameters have their types. A - // lambda's variables are settled by how the code around it uses it. - if declared.is_some() { - self.settle_waiting(first_var); + if let Some(returns) = &recursive_return + && !self.vars.try_unify(returns, &inferred_return) + { + // A function that returns itself, like `fn f() { f }`, would + // have a type that contains itself, so it returns `Any`. + if matches!(self.vars.resolve(returns), StaticType::Var(_)) { + self.vars.unify(returns, &StaticType::Any); + } else { + self.emit(AnalysisError::mismatched_types( + &self.vars.shown(&inferred_return), + &self.vars.shown(returns), + span, + )); + } } + + let signature = StaticType::Function { + parameters: Some(param_types.clone()), + return_type: Box::new( + annotated_return + .clone() + .unwrap_or_else(|| inferred_return.clone()), + ), + }; + let function_type = match declared { + Some((_, symbol)) if generic => { + self.vars.leave_level(); + self.generalize(symbol, &signature) + } + _ => signature, + }; let inferred_return = self.vars.resolve(&inferred_return); // If there is an annotated return type, report a body that doesn't @@ -152,17 +196,32 @@ impl Analyser { } } - let effective_return = annotated_return.unwrap_or(inferred_return); - - let function_type = StaticType::Function { - parameters: Some(param_types.clone()), - return_type: Box::new(effective_return), - }; + // A named function's value is checked as the program runs against + // what it takes, as far as that can be checked: its body may have + // made an unannotated parameter an Int. + if declared.is_some() + && let StaticType::Function { + parameters: Some(parameters), + .. + } = self.vars.resolve(&function_type) + && let Some(StaticType::Function { + parameters: Some(runtime), + .. + }) = self.result.analysis.function_types.get_mut(&id) + { + for ((runtime, parameter), annotation) in + runtime.iter_mut().zip(parameters).zip(&annotations) + { + if annotation.is_none() { + *runtime = checkable_as_the_program_runs(¶meter); + } + } + } // A named declaration stores the function under its name and, like // `let`, evaluates to unit. An anonymous one evaluates to the function. match declared { - Some(symbol) => { + Some((_, symbol)) => { self.types.declare(symbol, function_type); self.picks.variables.insert(id, symbol); StaticType::unit() @@ -212,25 +271,6 @@ impl Analyser { StaticType::unit() } - /// Whether a function declared earlier in the same scope as `symbol` has - /// its name and the same parameter types, which makes `symbol` an illegal - /// redefinition. `fn f(x: Int)` and `fn f(x: String)` are two overloads, - /// but `fn f(x)` and `fn f(y)` both take `Any`. `None` is variadic. - fn redefines_function(&self, symbol: SymbolId, parameters: Option<&[StaticType]>) -> bool { - let name = self.resolver.symbol_name(symbol); - let scope = self.resolver.scope(self.resolver.scope_of(symbol)); - scope.bindings()[..self.resolver.binding_position(symbol)] - .iter() - .filter(|&&earlier| self.resolver.symbol_name(earlier) == name) - .any(|&earlier| match self.types.get_type(earlier) { - StaticType::Function { - parameters: earlier, - .. - } => earlier.as_deref() == parameters, - _ => false, - }) - } - /// Declares a function's parameters and returns their types. fn declare_parameters( &mut self, @@ -360,3 +400,31 @@ impl Analyser { } } } + +/// What the program can check about a value of type `typ` as it runs: the +/// kind of value, but not what a container holds, and nothing about a type +/// parameter, which takes anything. `List` is checked as a list. +fn checkable_as_the_program_runs(typ: &StaticType) -> StaticType { + let any = || Box::new(StaticType::Any); + match typ { + StaticType::Var(_) | StaticType::Generic(_) | StaticType::Tuple(_) => StaticType::Any, + StaticType::List(_) => StaticType::List(any()), + StaticType::Deque(_) => StaticType::Deque(any()), + StaticType::Sequence(_) => StaticType::Sequence(any()), + StaticType::Iterator(_) => StaticType::Iterator(any()), + StaticType::MinHeap(_) => StaticType::MinHeap(any()), + StaticType::MaxHeap(_) => StaticType::MaxHeap(any()), + StaticType::Option(_) => StaticType::Option(any()), + StaticType::Map { .. } => StaticType::Map { + key: any(), + value: any(), + }, + StaticType::Function { parameters, .. } => StaticType::Function { + parameters: parameters + .as_ref() + .map(|parameters| vec![StaticType::Any; parameters.len()]), + return_type: any(), + }, + other => other.clone(), + } +} diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index e5a1592d..093589df 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -76,7 +76,9 @@ impl Analyser { .insert(id, Binding::Resolved(Candidate::Scalar(symbol))); // A generic function used as a value, as in `let p = push`, has one // type there, which its uses settle. - self.instantiated_type(symbol) + let typ = self.instantiate_function(symbol); + self.flush_copies(span); + typ } /// The functions the name at `id` can mean, grouped by scope, nearest @@ -263,8 +265,9 @@ impl Analyser { } fn commit_function_value(&mut self, value: &FunctionValue, symbol: SymbolId, early: bool) { - let typ = self.instantiated_type(symbol); + let typ = self.instantiate_function(symbol); self.vars.bind_argument(&value.expected, &typ); + self.flush_copies(value.span); if early && cfg!(debug_assertions) { self.remember_early_function_value(value, symbol); } diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs new file mode 100644 index 00000000..cf11e12f --- /dev/null +++ b/ndc_analyser/src/analyser/generics.rs @@ -0,0 +1,473 @@ +//! Generic functions. A `fn` declared in the program scope takes any +//! arguments its body can work with: its unannotated parameters are type +//! variables, and the calls its body makes on them wait as its constraints. +//! `fn inc(x) => x + 1` is `inc(A) -> B where +(A, Int) -> B`, and a call +//! `inc(2.5)` copies the constraint as `+(Float, Int) -> ?`, which settles +//! the call's type as `Float`. + +use super::Analyser; +use super::solver::{CallConstraint, Constraint, Waiting}; +use crate::error::AnalysisError; +use crate::overloads::is_strictly_more_specific; +use ahash::AHashSet; +use ndc_core::{StaticType, TypeVar}; +use ndc_lexer::Span; +use ndc_resolver::{ScopeKind, SymbolId}; + +impl Analyser { + /// Whether the function declared as `symbol` is generic, which a `fn` + /// declared in the program scope is. One declared in a function or a + /// block has one type, like a lambda. + pub(super) fn is_generic_function(&self, symbol: SymbolId) -> bool { + self.resolver.scope(self.resolver.scope_of(symbol)).kind() == ScopeKind::Program + } + + /// Ends the body of the generic function `symbol`, whose type is + /// `signature`, and returns its type with its type parameters: each + /// variable its body made that nothing outside it refers to becomes + /// one, and the constraints still waiting on them become the function's. + /// A variable those constraints wait on but that the parameters don't + /// decide, like the element type of `[]` in `fn f(x) { [].push(x) }`, + /// can't be decided by a call either, so it's settled here first, as at + /// the end of the program. + pub(super) fn generalize(&mut self, symbol: SymbolId, signature: &StaticType) -> StaticType { + self.solve_waiting(); + self.settle_ambiguous(signature); + self.merge_copies(); + + let scheme = self + .generic_constraints() + .into_iter() + .map(|serial| { + self.waiting + .remove(&serial) + .expect("the constraint is waiting") + }) + .collect::>(); + + // Name the type parameters in the order they appear. + let mut parameters = self.vars.unsettled(signature); + for waiting in &scheme { + for typ in waiting + .constraint + .inputs() + .iter() + .chain(waiting.constraint.outputs()) + { + for var in self.vars.unsettled(typ) { + if !parameters.contains(&var) { + parameters.push(var); + } + } + } + } + let mut named = 0; + for var in parameters { + if self.vars.is_generalizable(var) { + self.vars.generalize(var, &type_parameter_name(named)); + named += 1; + } + } + self.watched_settlings = self.vars.settled_count(); + + let scheme = scheme + .into_iter() + .map(|waiting| Waiting { + constraint: waiting + .constraint + .map_types(&mut |typ| self.vars.resolve(typ)), + origin: waiting.origin, + }) + .collect::>(); + for waiting in &scheme { + if waiting.origin.is_none() + && let Constraint::Call(call) = &waiting.constraint + { + self.pick_for_any_arguments(call); + } + } + self.schemes.insert(symbol, scheme.into()); + self.vars.resolve(signature) + } + + /// The waiting constraints that wait on or settle a variable of the + /// generic function whose body just ended, oldest first. + fn generic_constraints(&self) -> Vec { + self.waiting + .iter() + .filter(|(_, waiting)| { + waiting + .constraint + .inputs() + .iter() + .chain(waiting.constraint.outputs()) + .flat_map(|typ| self.vars.unsettled(typ)) + .any(|var| self.vars.is_generalizable(var)) + }) + .map(|(&serial, _)| serial) + .collect() + } + + /// The generic function's variables that `constraint` waits on, leaving + /// out what it settles itself. + fn generic_inputs(&self, constraint: &Constraint) -> Vec { + let outputs = self.generic_outputs(constraint); + constraint + .inputs() + .iter() + .flat_map(|typ| self.vars.unsettled(typ)) + .filter(|&var| self.vars.is_generalizable(var) && !outputs.contains(&var)) + .collect() + } + + /// The generic function's variables that `constraint` settles once it's + /// solved. A function passed to a call gets the types the call passes + /// it, as `fn(a, x) => a + x` in `xs.fold(0, fn(a, x) => a + x)`, so + /// they count as settled by the call too. + fn generic_outputs(&self, constraint: &Constraint) -> Vec { + let passed_functions = match constraint { + Constraint::Call(call) => call + .arguments() + .iter() + .map(|argument| self.vars.resolve(argument)) + .filter(|argument| matches!(argument, StaticType::Function { .. })) + .collect(), + _ => Vec::new(), + }; + constraint + .outputs() + .iter() + .chain(&passed_functions) + .flat_map(|typ| self.vars.unsettled(typ)) + .filter(|&var| self.vars.is_generalizable(var)) + .collect() + } + + /// The generic function's variables that its constraints wait on but + /// that its parameters don't decide, by constraint, oldest first. What + /// a constraint settles is decided when what it waits on is. An + /// overloaded function's name used as a value has to be decided in the + /// body, which is compiled once, so what it waits on counts too. + fn ambiguous_vars(&self, parameters: &[StaticType]) -> Vec<(u64, Vec)> { + let serials = self.generic_constraints(); + let mut decided = parameters + .iter() + .flat_map(|typ| self.vars.unsettled(typ)) + .filter(|&var| self.vars.is_generalizable(var)) + .collect::>(); + loop { + let mut changed = false; + for serial in &serials { + let constraint = &self.waiting[serial].constraint; + if matches!(constraint, Constraint::FunctionValue(_)) + || !self + .generic_inputs(constraint) + .iter() + .all(|var| decided.contains(var)) + { + continue; + } + for var in self.generic_outputs(constraint) { + changed |= decided.insert(var); + } + } + if !changed { + break; + } + } + serials + .into_iter() + .filter_map(|serial| { + let constraint = &self.waiting[&serial].constraint; + let ambiguous = match constraint { + Constraint::FunctionValue(_) => self.generic_inputs(constraint), + _ => self + .generic_inputs(constraint) + .into_iter() + .filter(|var| !decided.contains(var)) + .collect(), + }; + (!ambiguous.is_empty()).then_some((serial, ambiguous)) + }) + .collect() + } + + /// Settles the variables of the generic function with type `signature` + /// that its parameters don't decide, like the end of the program + /// settles its own: a call that a default rule decides is decided by it, + /// and what's left gets its fallbacks, oldest constraint first. + fn settle_ambiguous(&mut self, signature: &StaticType) { + let parameters = match signature { + StaticType::Function { + parameters: Some(parameters), + .. + } => parameters.clone(), + _ => Vec::new(), + }; + loop { + let ambiguous = self.ambiguous_vars(¶meters); + let Some((_, vars)) = ambiguous.first().cloned() else { + return; + }; + let decided_by_default = ambiguous.iter().any(|(serial, vars)| { + let only_ambiguous = self.waiting.get(serial).is_some_and(|waiting| { + self.generic_inputs(&waiting.constraint) + .iter() + .all(|var| vars.contains(var)) + }); + (only_ambiguous + && (self.default_to_narrowest(*serial) || self.default_to_operand(*serial))) + || self.default_to_branch(*serial, vars) + }); + if !decided_by_default { + self.vars.settle_with_fallbacks(&vars); + } + self.solve_waiting(); + } + } + + /// Settles a branch of the waiting `if` `serial` whose type is one of + /// `ambiguous` as the other branch's type, and returns whether it did. + /// In `fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }`, + /// what `+` returns is what `fib` returns, which is what the `if` + /// returns: nothing but the other branch can say what it is. + fn default_to_branch(&mut self, serial: u64, ambiguous: &[TypeVar]) -> bool { + let Some(Waiting { + constraint: Constraint::Join { + types: [a, b, _], .. + }, + .. + }) = self.waiting.get(&serial) + else { + return false; + }; + let (a, b) = (self.vars.resolve(a), self.vars.resolve(b)); + for (branch, other) in [(&a, &b), (&b, &a)] { + if let StaticType::Var(var) = branch + && ambiguous.contains(var) + && self.vars.try_unify(branch, other) + { + return true; + } + } + false + } + + /// Merges each copy of a generic function's constraint into an earlier + /// constraint that's the same call with the same arguments, which + /// returns the same type. In `fn g(x) => h(x) + h(x)`, both calls of + /// `h` copy `h`'s constraints, and `g` keeps one of each. + fn merge_copies(&mut self) { + let serials = self.generic_constraints(); + let mut kept: Vec = Vec::new(); + for serial in serials { + let duplicate = { + let waiting = &self.waiting[&serial]; + let Constraint::Call(call) = &waiting.constraint else { + continue; + }; + if waiting.origin.is_none() { + kept.push(serial); + continue; + } + kept.iter().copied().find(|earlier| { + let Constraint::Call(earlier) = &self.waiting[earlier].constraint else { + return false; + }; + earlier.is_same_call(call, &self.vars) + }) + }; + match duplicate { + Some(earlier) => { + let (Constraint::Call(earlier), Constraint::Call(call)) = ( + &self.waiting[&earlier].constraint, + &self.waiting[&serial].constraint, + ) else { + unreachable!("both are calls"); + }; + let (earlier, later) = (earlier.result().clone(), call.result().clone()); + if self.vars.try_unify(&earlier, &later) { + self.waiting.remove(&serial); + } else { + kept.push(serial); + } + } + None => kept.push(serial), + } + } + self.solve_waiting(); + } + + /// Records what the body of a generic function calls at `call`, which + /// is compiled once for every type it's called with: the overloads that + /// can take the arguments, picked as the program runs. + fn pick_for_any_arguments(&mut self, call: &CallConstraint) { + let arguments = call + .arguments() + .iter() + .map(|argument| { + argument.replace(&|part| { + matches!(part, StaticType::Generic(_)).then_some(StaticType::Any) + }) + }) + .collect::>(); + let binding = self + .overloads() + .resolve_call(call.candidates(), &arguments, call.kind()) + .binding; + self.record_pick(call.site(), binding, &arguments); + } + + /// Reports the function `symbol`, about to be declared with parameters + /// of these types, if an earlier function with its name in its scope + /// takes the same parameters, or if a call could fit both and neither + /// is more specific, so nothing says which one it means. A parameter + /// without an annotation takes any argument the body can work with, so + /// `fn f(x: Int, y)` and `fn f(x, y: Int)` both fit `f(1, 2)`. But + /// `fn show(x)` next to `fn show(x: Int)` is a fallback that a call + /// with an Int doesn't pick, like `fn show(x: Any)`. + pub(super) fn check_overlap( + &mut self, + symbol: SymbolId, + name: &str, + parameters: Option<&[StaticType]>, + span: Span, + ) { + let scope = self.resolver.scope(self.resolver.scope_of(symbol)); + let earlier = scope.bindings()[..self.resolver.binding_position(symbol)] + .iter() + .filter(|&&earlier| self.resolver.symbol_name(earlier) == name) + .filter_map( + |&earlier| match self.vars.resolve(self.types.get_type(earlier)) { + StaticType::Function { parameters, .. } => Some(parameters), + _ => None, + }, + ) + .collect::>(); + let shown = |parameters: Option<&[StaticType]>| { + parameters.map(|parameters| { + parameters + .iter() + .map(|parameter| { + parameter.replace(&|part| { + matches!(part, StaticType::Var(_) | StaticType::Generic(_)) + .then_some(StaticType::Any) + }) + }) + .collect::>() + }) + }; + for earlier in earlier { + if shown(earlier.as_deref()) == shown(parameters) { + self.emit(AnalysisError::function_redefinition( + name, + shown(parameters).as_deref(), + span, + )); + return; + } + if let (Some(earlier), Some(parameters)) = (&earlier, parameters) + && is_a_tie(earlier, parameters) + { + self.emit(AnalysisError::tied_overloads( + name, + &shown_with_blanks(parameters), + &shown_with_blanks(earlier), + span, + )); + return; + } + } + } +} + +/// The name of a generic function's `index`th type parameter: `A`, `B`, …, +/// `Z`, `A1`, …. +fn type_parameter_name(index: usize) -> String { + let letter = char::from(b'A' + u8::try_from(index % 26).expect("less than 26")); + match index / 26 { + 0 => letter.to_string(), + round => format!("{letter}{round}"), + } +} + +/// Parameter types as an error shows them, with `_` for a parameter whose +/// type the body decides. +fn shown_with_blanks(parameters: &[StaticType]) -> Vec { + parameters + .iter() + .map(|parameter| match parameter { + StaticType::Var(_) | StaticType::Generic(_) => "_".to_string(), + other => other.to_string(), + }) + .collect() +} + +/// Whether some call could fit overloads taking `a` and `b`, but neither +/// is more specific than the other, so nothing says which one it means. +fn is_a_tie(a: &[StaticType], b: &[StaticType]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(a, b)| may_take_the_same(a, b)) + && !is_strictly_more_specific(a, b) + && !is_strictly_more_specific(b, a) +} + +/// Whether some argument could be passed both where `a` and where `b` is +/// expected and not lose to either: a type parameter takes anything, and +/// `Any` only another `Any` or a type parameter, as it loses to every +/// other type. +fn may_take_the_same(a: &StaticType, b: &StaticType) -> bool { + let wildcard = |typ: &StaticType| matches!(typ, StaticType::Var(_) | StaticType::Generic(_)); + match (a, b) { + _ if wildcard(a) || wildcard(b) => true, + (StaticType::Option(x), StaticType::Option(y)) + | (StaticType::Sequence(x), StaticType::Sequence(y)) + | (StaticType::List(x), StaticType::List(y)) + | (StaticType::Iterator(x), StaticType::Iterator(y)) + | (StaticType::MinHeap(x), StaticType::MinHeap(y)) + | (StaticType::MaxHeap(x), StaticType::MaxHeap(y)) + | (StaticType::Deque(x), StaticType::Deque(y)) => may_take_the_same(x, y), + (StaticType::Tuple(xs), StaticType::Tuple(ys)) => { + xs.len() == ys.len() && xs.iter().zip(ys).all(|(x, y)| may_take_the_same(x, y)) + } + (StaticType::Map { key: k1, value: v1 }, StaticType::Map { key: k2, value: v2 }) => { + may_take_the_same(k1, k2) && may_take_the_same(v1, v2) + } + // `Any` takes only what another `Any` does. + _ => a == b, + } +} + +/// The pairs of `natives` with one name that a call could fit with +/// neither more specific than the other, which overloads in one scope may +/// not be, each described as `name(A) and name(B)`. +pub fn tied_natives(natives: &[(String, StaticType)]) -> Vec { + let mut tied = Vec::new(); + for (i, (name, a)) in natives.iter().enumerate() { + for (other, b) in &natives[i + 1..] { + if let ( + true, + StaticType::Function { + parameters: Some(a), + .. + }, + StaticType::Function { + parameters: Some(b), + .. + }, + ) = (name == other, a, b) + && is_a_tie(a, b) + { + let shown = |parameters: &[StaticType]| { + parameters + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + }; + tied.push(format!("{name}({}) and {name}({})", shown(a), shown(b))); + } + } + } + tied +} diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index fd91871e..6c602af1 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -17,10 +17,12 @@ mod assignments; mod calls; mod declarations; mod function_values; +pub(crate) mod generics; mod solver; use declarations::FunctionDeclaration; -use solver::{Constraint, EarlyPick}; +pub use generics::tied_natives; +use solver::{Constraint, EarlyPick, Origin, Waiting}; #[cfg(test)] mod tests; @@ -47,7 +49,17 @@ pub struct Analyser { vars: TypeVars, /// The calls and other uses that wait for types that aren't known yet, /// by the order they started waiting in. - waiting: BTreeMap, + waiting: BTreeMap, + /// The constraints of each generic function declared in the program, + /// such as `+(A, Int) -> B` for `fn inc(x) => x + 1`, which a call of it + /// copies with the types it's called with. + schemes: AHashMap>, + /// The constraints a call just copied from a generic function, with the + /// function, until the call is known to commit to it. + pending_copies: Vec<(SymbolId, Constraint, Option)>, + /// The calls the constraint being solved was copied through, when it's + /// a copy of a generic function's constraint. + copied_through: Option, /// The number the next waiting constraint gets. next_serial: u64, /// The waiting constraints that each unsettled variable would let make @@ -99,6 +111,9 @@ impl Analyser { types, vars: TypeVars::default(), waiting: BTreeMap::new(), + schemes: AHashMap::new(), + pending_copies: Vec::new(), + copied_through: None, next_serial: 0, watchers: AHashMap::new(), watched_settlings: 0, @@ -152,8 +167,16 @@ impl Analyser { } /// Records an analysis error. The analyser continues with a fallback type - /// (usually `Any`) so that subsequent code is still checked. + /// (usually `Any`) so that subsequent code is still checked. An error in + /// a copy of a generic function's constraint is the call's: it's + /// reported there, with notes pointing into the function. fn emit(&mut self, err: AnalysisError) { + let err = match &self.copied_through { + Some(frames) => { + err.through_calls(frames.iter().map(|frame| (frame.name.as_str(), frame.call))) + } + None => err, + }; self.result.errors.push(err); } diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index bc6400ce..4bd0c7b7 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -6,11 +6,13 @@ use super::Analyser; use super::function_values::{Choice, FunctionValue}; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall, is_strictly_more_specific}; +use crate::types::TypeVars; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; use ndc_resolver::{Candidates, SymbolId}; use std::collections::BTreeSet; +use std::rc::Rc; /// What a call can commit to before its arguments are fully known. pub(super) enum EarlyOverload { @@ -38,7 +40,7 @@ pub(super) enum CallSite { } /// Something that waits until the types it depends on are known. -#[derive(Debug)] +#[derive(Debug, Clone)] pub(super) enum Constraint { /// A call whose overload can't be picked yet. Call(CallConstraint), @@ -71,16 +73,94 @@ pub(super) enum Constraint { impl Constraint { /// The types whose variables the constraint waits on. - fn inputs(&self) -> &[StaticType] { + pub(super) fn inputs(&self) -> &[StaticType] { match self { Self::Call(call) => &call.arguments, Self::FunctionValue(value) => std::slice::from_ref(value.expected()), Self::Iterate { sequence, .. } => std::slice::from_ref(sequence), Self::Unpack { value, .. } => std::slice::from_ref(value), Self::MapDefault { default, .. } => std::slice::from_ref(default), + // A result known from outside says what each branch is. Self::Join { types, .. } => types, } } + + /// The types the constraint settles once it's solved, which are known + /// once its inputs are. + pub(super) fn outputs(&self) -> &[StaticType] { + match self { + Self::Call(call) => std::slice::from_ref(&call.result), + Self::FunctionValue(_) => &[], + Self::Iterate { element, .. } => std::slice::from_ref(element), + Self::Unpack { parts, .. } => parts, + Self::MapDefault { value, .. } => std::slice::from_ref(value), + Self::Join { types, .. } => &types[2..], + } + } + + /// The constraint with `replace` applied to each of its types. + pub(super) fn map_types(&self, replace: &mut impl FnMut(&StaticType) -> StaticType) -> Self { + match self { + Self::Call(call) => Self::Call(CallConstraint { + arguments: call.arguments.iter().map(&mut *replace).collect(), + result: replace(&call.result), + ..call.clone() + }), + Self::FunctionValue(value) => Self::FunctionValue(value.clone()), + Self::Iterate { + sequence, + element, + span, + } => Self::Iterate { + sequence: replace(sequence), + element: replace(element), + span: *span, + }, + Self::Unpack { value, parts, span } => Self::Unpack { + value: replace(value), + parts: parts.iter().map(&mut *replace).collect(), + span: *span, + }, + Self::MapDefault { + default, + value, + span, + } => Self::MapDefault { + default: replace(default), + value: replace(value), + span: *span, + }, + Self::Join { types, span } => Self::Join { + types: types.clone().map(|typ| replace(&typ)), + span: *span, + }, + } + } +} + +/// A call to a generic function, through which a constraint of the +/// function's body was copied to the code calling it. +#[derive(Debug, Clone)] +pub(super) struct Frame { + /// The generic function called. + pub(super) function: SymbolId, + /// Its name, for errors. + pub(super) name: String, + /// The call. + pub(super) call: Span, +} + +/// The calls a constraint was copied through, outermost first: the call in +/// the code being analysed, then the call in that function's body that +/// copied it there, and so on. Its own span is in the last function's body. +pub(super) type Origin = Rc<[Frame]>; + +/// A constraint that waits, and the calls it was copied through if it's a +/// copy of a generic function's constraint. +#[derive(Debug, Clone)] +pub(super) struct Waiting { + pub(super) constraint: Constraint, + pub(super) origin: Option, } /// An overload a call committed to before its arguments were known, which @@ -100,7 +180,7 @@ pub(super) enum EarlyPick { }, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(super) struct CallConstraint { site: CallSite, candidates: Candidates, @@ -112,6 +192,42 @@ pub(super) struct CallConstraint { span: Span, } +impl CallConstraint { + pub(super) fn site(&self) -> CallSite { + self.site + } + + pub(super) fn candidates(&self) -> &Candidates { + &self.candidates + } + + pub(super) fn arguments(&self) -> &[StaticType] { + &self.arguments + } + + pub(super) fn result(&self) -> &StaticType { + &self.result + } + + pub(super) fn kind(&self) -> CallKind { + self.kind + } + + /// Whether `other` calls what this calls, the same way, with arguments + /// of the same types, so it returns the same type. + pub(super) fn is_same_call(&self, other: &Self, vars: &TypeVars) -> bool { + std::mem::discriminant(&self.site) == std::mem::discriminant(&other.site) + && self.kind == other.kind + && self.candidates == other.candidates + && self.arguments.len() == other.arguments.len() + && self + .arguments + .iter() + .zip(&other.arguments) + .all(|(a, b)| vars.resolve(a) == vars.resolve(b)) + } +} + impl Analyser { /// Picks the overload a call means and returns the call's type. When the /// arguments aren't known well enough to pick one, the call waits, and @@ -246,11 +362,14 @@ impl Analyser { arguments: &[StaticType], picked: SymbolId, ) -> Option { - let typ = self.commit_call( + let Some(typ) = self.commit_call( &Binding::Resolved(Candidate::Scalar(picked)), arguments, StaticType::Any, - )?; + ) else { + self.pending_copies.clear(); + return None; + }; let arguments = arguments .iter() .map(|argument| self.vars.resolve(argument)) @@ -274,6 +393,7 @@ impl Analyser { }); } self.record_pick(call.site, binding, &arguments); + self.flush_copies(call.span); Some(typ) } @@ -364,20 +484,39 @@ impl Analyser { /// Makes `constraint` wait, in the order it started waiting, until one /// of the variables it waits on is settled. pub(super) fn wait(&mut self, constraint: Constraint) { + let origin = self.copied_through.clone(); + self.wait_as(Waiting { constraint, origin }); + } + + /// Makes `waiting` wait, as [`Self::wait`]. + pub(super) fn wait_as(&mut self, waiting: Waiting) { let serial = self.next_serial; self.next_serial += 1; - self.wait_again(serial, constraint); + self.wait_again(serial, waiting); } - fn wait_again(&mut self, serial: u64, constraint: Constraint) { - for var in constraint + fn wait_again(&mut self, serial: u64, waiting: Waiting) { + for var in waiting + .constraint .inputs() .iter() .flat_map(|input| self.vars.unsettled(input)) { self.watchers.entry(var).or_default().push(serial); } - self.waiting.insert(serial, constraint); + self.waiting.insert(serial, waiting); + } + + /// Solves `waiting` if what it waits on is known, as + /// [`Self::try_constraint`], and returns whether it did. A copy of a + /// generic function's constraint reports its errors at the call it was + /// copied through, and records no pick: the function's body was + /// compiled with its own. + pub(super) fn try_waiting(&mut self, waiting: &mut Waiting) -> bool { + let outer = std::mem::replace(&mut self.copied_through, waiting.origin.clone()); + let solved = self.try_constraint(&mut waiting.constraint); + self.copied_through = outer; + solved } /// Tries again each waiting constraint that waits on a variable settled @@ -403,13 +542,13 @@ impl Analyser { self.watched_settlings = self.vars.settled_count(); for serial in woken { // A constraint woken by an earlier variable may be solved already. - let Some(mut constraint) = self.waiting.remove(&serial) else { + let Some(mut waiting) = self.waiting.remove(&serial) else { continue; }; - if self.try_constraint(&mut constraint) { + if self.try_waiting(&mut waiting) { still_waiting.remove(&serial); } else { - self.wait_again(serial, constraint); + self.wait_again(serial, waiting); still_waiting.insert(serial); } } @@ -461,36 +600,46 @@ impl Analyser { // type might have settled since, which nothing watches. let serials = self.waiting.keys().copied().collect::>(); for serial in serials { - let Some(mut constraint) = self.waiting.remove(&serial) else { - continue; - }; - if !self.try_constraint(&mut constraint) { - self.wait_again(serial, constraint); - } + self.retry(serial); } self.solve_waiting(); } + /// Tries the waiting constraint `serial` again, if it's still waiting. + /// A call can wait although its arguments are known: a variable's type + /// might have settled since, which nothing watches. + pub(super) fn retry(&mut self, serial: u64) { + let Some(mut waiting) = self.waiting.remove(&serial) else { + return; + }; + if !self.try_waiting(&mut waiting) { + self.wait_again(serial, waiting); + } + } + /// The unsettled variables made since `first` that the waiting /// constraint `serial` waits on, if it's still waiting. fn waits_on_vars_since(&self, serial: u64, first: usize) -> Vec { - self.waiting - .get(&serial) - .map_or_else(Vec::new, |constraint| { - constraint - .inputs() - .iter() - .flat_map(|input| self.vars.unsettled(input)) - .filter(|var| var.0 as usize >= first) - .collect() - }) + self.waiting.get(&serial).map_or_else(Vec::new, |waiting| { + waiting + .constraint + .inputs() + .iter() + .flat_map(|input| self.vars.unsettled(input)) + .filter(|var| var.0 as usize >= first) + .collect() + }) } /// What nothing else settled in the waiting `a op= b` keeps the type it /// already had, so it's taken from `b`: in `m["k"] += 3` on an empty /// map, the values are Ints. Returns whether that settled anything. - fn default_to_operand(&mut self, serial: u64) -> bool { - let Some(Constraint::Call(call)) = self.waiting.get(&serial) else { + pub(super) fn default_to_operand(&mut self, serial: u64) -> bool { + let Some(Waiting { + constraint: Constraint::Call(call), + .. + }) = self.waiting.get(&serial) + else { return false; }; let (CallSite::OpAssignment(_), [target, operand]) = (call.site, &call.arguments[..]) @@ -659,6 +808,9 @@ impl Analyser { } if arguments.iter().all(|argument| !argument.has_vars()) { + if let (CallSite::OpAssignment(id), [left, right]) = (call.site, &arguments[..]) { + return Some(self.op_assignment_now(id, left, right, &call.name, call.span)); + } return Some(self.resolve_call_now(call, &arguments)); } @@ -740,10 +892,14 @@ impl Analyser { } let typ = self.commit_call(&binding, arguments, return_type); self.record_pick(call.site, binding, arguments); - typ.unwrap_or_else(|| { + if let Some(typ) = typ { + self.flush_copies(call.span); + typ + } else { + self.pending_copies.clear(); self.report_failed_call(call, arguments); StaticType::Any - }) + } } /// Settles the types a call's arguments leave unknown from the overload @@ -788,10 +944,8 @@ impl Analyser { } Binding::Dynamic(_) | Binding::None => None, }; - let signature = picked.map(|symbol| { - let declared = self.vars.resolve(self.types.get_type(symbol)); - self.vars.instantiate(&declared) - }); + let pending = self.pending_copies.len(); + let signature = picked.map(|symbol| self.instantiate_function(symbol)); let Some(StaticType::Function { parameters: Some(parameters), return_type: declared_return, @@ -803,13 +957,75 @@ impl Analyser { return Some(return_type); }; - self.bind_signature( + let typ = self.bind_signature( &StaticType::Function { parameters: Some(parameters), return_type: declared_return, }, arguments, - ) + ); + if typ.is_none() { + self.pending_copies.truncate(pending); + } + typ + } + + /// `symbol`'s type with new variables for its type parameters. For a + /// generic function declared in the program, its constraints get the + /// same variables, and wait in [`Self::pending_copies`] until + /// [`Self::flush_copies`] knows which call they're for. + pub(super) fn instantiate_function(&mut self, symbol: SymbolId) -> StaticType { + let declared = self.vars.resolve(self.types.get_type(symbol)); + let mut made = Vec::new(); + let typ = self.vars.instantiate_with(&declared, &mut made); + // A copy of a function's own constraint that calls the function + // again, as a recursive call its body couldn't decide does, is + // checked by the copies the outer call already made: recursion + // doesn't change the types. + let recursive = self + .copied_through + .iter() + .flat_map(|frames| frames.iter()) + .any(|frame| frame.function == symbol); + if let Some(scheme) = self.schemes.get(&symbol).cloned() + && !recursive + { + for waiting in scheme.iter() { + let constraint = waiting + .constraint + .map_types(&mut |typ| self.vars.instantiate_with(typ, &mut made)); + self.pending_copies + .push((symbol, constraint, waiting.origin.clone())); + } + } + typ + } + + /// Adds the constraints [`Self::instantiate_function`] copied for a call + /// at `call`, and solves the ones it can. + pub(super) fn flush_copies(&mut self, call: Span) { + let copies = std::mem::take(&mut self.pending_copies); + for (function, constraint, origin) in copies { + let name = self.resolver.symbol_name(function).to_string(); + let frames = self + .copied_through + .iter() + .flat_map(|outer| outer.iter().cloned()) + .chain(std::iter::once(Frame { + function, + name, + call, + })) + .chain(origin.iter().flat_map(|inner| inner.iter().cloned())) + .collect::>(); + let mut waiting = Waiting { + constraint, + origin: Some(frames.into()), + }; + if !self.try_waiting(&mut waiting) { + self.wait_as(waiting); + } + } } /// Passes each argument where `signature` expects its parameter, which @@ -874,6 +1090,7 @@ impl Analyser { let fits = !matches!(binding, Binding::None) && self.commit_call(&binding, arguments, return_type).is_some(); self.vars.rollback(snapshot); + self.pending_copies.clear(); fits } @@ -887,8 +1104,12 @@ impl Analyser { /// otherwise win, and when each unknown is inside an argument whose kind /// is known: an argument nothing at all is known about is `Any`, which /// the overloads taking `Any` take. - fn default_to_narrowest(&mut self, serial: u64) -> bool { - let Some(Constraint::Call(call)) = self.waiting.get(&serial) else { + pub(super) fn default_to_narrowest(&mut self, serial: u64) -> bool { + let Some(Waiting { + constraint: Constraint::Call(call), + .. + }) = self.waiting.get(&serial) + else { return false; }; let arguments = call @@ -923,15 +1144,30 @@ impl Analyser { else { return false; }; - let Some(Constraint::Call(call)) = self.waiting.remove(&serial) else { + let Some(Waiting { + constraint: Constraint::Call(call), + origin, + }) = self.waiting.remove(&serial) + else { unreachable!("the call was waiting a moment ago"); }; - let Some(typ) = self.commit_early(&call, &arguments, narrowest) else { - self.wait_again(serial, Constraint::Call(call)); + let outer = std::mem::replace(&mut self.copied_through, origin.clone()); + let committed = self.commit_early(&call, &arguments, narrowest); + if let Some(typ) = &committed { + let result = call.result.clone(); + self.settle_waited_for(&result, typ, call.span); + } + self.copied_through = outer; + if committed.is_none() { + self.wait_again( + serial, + Waiting { + constraint: Constraint::Call(call), + origin, + }, + ); return false; - }; - let result = call.result.clone(); - self.settle_waited_for(&result, &typ, call.span); + } true } @@ -961,12 +1197,15 @@ impl Analyser { } /// Whether `symbol` is a function whose parameters take these arguments - /// without an `Any` parameter standing in for an argument of another - /// type. One that does is a fallback that only wins over the overloads + /// as the types they are. An `Any` parameter standing in for an argument + /// of another type is a fallback that only wins over the overloads /// taking exact types once the arguments are known, so a waiting call /// doesn't commit to it: `xs[0] = []` on a `List` leaves only /// `[]=(List, Any, Any)` taking the empty list, but it's still an - /// error once the list's type is known. + /// error once the list's type is known. A `Sequence` parameter takes + /// lists, strings, maps and more, so committing to it would make an + /// argument nothing is known about a `Sequence` rather than what it is: + /// in `fn f(xs) { xs.len(); xs[0] = 1 }`, `xs` is a list. fn fits_without_any(&self, symbol: SymbolId, arguments: &[StaticType]) -> bool { match self.vars.resolve(self.types.get_type(symbol)) { StaticType::Function { @@ -975,8 +1214,10 @@ impl Analyser { } => parameters .iter() .zip(arguments) - .all(|(parameter, argument)| { - *parameter != StaticType::Any || *argument == StaticType::Any + .all(|(parameter, argument)| match (parameter, argument) { + (StaticType::Any, argument) => *argument == StaticType::Any, + (StaticType::Sequence(_), StaticType::Var(_)) => false, + _ => true, }), _ => false, } @@ -1025,17 +1266,24 @@ impl Analyser { } } - fn record_pick( + pub(super) fn record_pick( &mut self, site: CallSite, binding: Binding, arguments: &[StaticType], ) { + if self.copied_through.is_some() { + return; + } match site { - CallSite::Call(id) => self.picks.bindings.insert(id, binding), - CallSite::Getter(id) => self.picks.getters.insert(id, binding), + CallSite::Call(id) => { + self.picks.bindings.insert(id, binding); + } + CallSite::Getter(id) => { + self.picks.getters.insert(id, binding); + } CallSite::Setter(id) | CallSite::FieldSetter(id) => { - self.picks.setters.insert(id, binding) + self.picks.setters.insert(id, binding); } // An in-place overload such as `++=` does the same thing faster, // so it's used where one takes the operands as they are. @@ -1049,19 +1297,38 @@ impl Analyser { &arguments, CallKind::Operator, ); - let binding = match in_place.binding { - Binding::Resolved(candidate) - if Self::augmented_rhs_is_compatible(&arguments[0], &arguments[1]) => - { - Binding::Resolved(candidate) + let compatible = Self::augmented_rhs_is_compatible(&arguments[0], &arguments[1]); + let binding = match (in_place.binding, binding) { + (Binding::Resolved(candidate), _) if compatible => Binding::Resolved(candidate), + // Which one applies is left to the program as it runs, + // which mutates in place where it can. + (Binding::Dynamic(mut candidates), operators) if compatible => { + let operators = match operators { + Binding::Resolved(operator) => vec![operator], + Binding::Dynamic(operators) => operators, + Binding::None => Vec::new(), + }; + for operator in operators { + if !candidates.contains(&operator) { + candidates.push(operator); + } + } + Binding::Dynamic(candidates) } - _ => binding, + (_, binding) => binding, }; - self.picks.augmented_assignments.insert(id, binding) + self.record_augmented_pick(id, binding); } }; } + /// Records what `a op= b` at `id` does, as [`Self::record_pick`]. + pub(super) fn record_augmented_pick(&mut self, id: NodeId, binding: Binding) { + if self.copied_through.is_none() { + self.picks.augmented_assignments.insert(id, binding); + } + } + /// Reports that no overload accepts `call`. Writing `xs[i] += 1` reads /// and writes `xs[i]`, and when the read fails, the write fails the same /// way, which is reported once. diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 95c845ea..703f3816 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -271,7 +271,7 @@ fn annotated_declaration_rejects_a_mismatched_container_literal() { // `f` might hold any function, so which one runs is only known at runtime. #[test] fn a_call_of_a_value_of_unknown_type_dispatches_at_runtime() { - let (_, result) = analyse_with_globals("fn call(f) { f(\"x\") }", vec![]); + let (_, result) = analyse_with_globals("fn call(f: Any) { f(\"x\") }", vec![]); assert!(result.errors.is_empty(), "errors: {:?}", result.errors); let callee = result .analysis @@ -536,7 +536,7 @@ fn a_call_picked_at_runtime_has_no_type_parameters_in_its_type() { // and its `T` is never known. let globals = natives(&[("reversed", "(List) -> List")]); assert_eq!( - analyse_last_type_with_globals("fn f(xs) { reversed(xs) } f", globals), + analyse_last_type_with_globals("fn f(xs: Any) { reversed(xs) } f", globals), StaticType::Function { parameters: Some(vec![StaticType::Any]), return_type: Box::new(StaticType::List(Box::new(StaticType::Any))), @@ -953,12 +953,13 @@ fn a_call_nothing_else_settles_takes_no_narrowest_overload_when_unsure() { parameters: Some(vec![parameter]), return_type: Box::new(returns), }; - // Nothing is known about `x`, so it's `Any`, which `pick(Any)` takes. + // Nothing is known about `x`, so it's `Any`, and the program picks + // `pick(Int)` or `pick(Any)` as it runs. assert_eq!( analyse_last_type( "fn pick(x: Int) => 1; fn pick(x: Any) => \"any\"; let f = fn(x) => pick(x); f" ), - function(StaticType::Any, StaticType::String) + function(StaticType::Any, StaticType::Any) ); // Overloads in two scopes fit, so none settles `x`: it's `Any`, and the // call resolves as it does for any `Any` argument. @@ -1338,3 +1339,229 @@ fn plain_assignment_to_a_field_is_not_ambiguous_with_a_value_named_like_the_fiel Vec::::new() ); } + +#[test] +fn a_generic_function_has_the_type_its_body_makes_of_each_call() { + for (call, typ) in [("inc(1)", StaticType::Int), ("inc(2.5)", StaticType::Float)] { + assert_eq!( + analyse_last_type_with_globals( + &format!("fn inc(x) => x + 1; inc(1); inc(2.5); {call}"), + ambiguous_plus() + ), + typ, + "{call}" + ); + } +} + +#[test] +fn a_generic_function_calls_what_its_body_calls_as_the_program_runs() { + // `inc`'s body is compiled once, for Ints and Floats alike. + let (_, result) = analyse_with_globals("fn inc(x) => x + 1; inc(1);", ambiguous_plus()); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert!( + result + .analysis + .bindings + .values() + .any(|binding| matches!(binding, Binding::Dynamic(_))), + "{:?}", + result.analysis.bindings + ); +} + +#[test] +fn a_generic_function_calling_one_has_its_constraints_too() { + assert_eq!( + analyse_last_type_with_globals( + "fn inc(x) => x + 1; fn twice(x) => inc(inc(x)); twice(2.5)", + ambiguous_plus() + ), + StaticType::Float + ); +} + +#[test] +fn an_error_in_a_generic_body_is_the_outermost_calls() { + let source = "fn inc(x) => x + 1; fn twice(x) => inc(inc(x)); twice(\"a\");"; + let (_, result) = analyse_with_globals(source, ambiguous_plus()); + let [error] = &result.errors[..] else { + panic!("one error expected: {:?}", result.errors); + }; + let text = |span: Span| &source[span.range()]; + assert_eq!(text(error.span()), "twice(\"a\")"); + assert_eq!( + error + .notes() + .iter() + .map(|(span, note)| (text(*span), note.as_str())) + .collect::>(), + vec![ + ("inc(x)", "`twice` calls `inc` here"), + ("x + 1", "`inc` needs this"), + ] + ); +} + +#[test] +fn a_generic_function_keeps_one_copy_of_each_call_it_copies() { + // Each function calls the one before twice. With a copy of each call's + // constraints, the last would have 2^40. + let mut source = "fn f0(x) => x + 1;".to_string(); + for i in 1..40 { + source.push_str(&format!("fn f{i}(x) => f{}(x) + f{}(x);", i - 1, i - 1)); + } + source.push_str("f39(2.5)"); + assert_eq!( + analyse_last_type_with_globals( + &source, + natives(&[ + ("+", "(Int, Int) -> Int"), + ("+", "(Float, Int) -> Float"), + ("+", "(Float, Float) -> Float"), + ]) + ), + StaticType::Float + ); +} + +#[test] +fn a_variable_from_outside_has_one_type_in_a_generic_function() { + // `n` is stored in `xs`, which the first call settles. + let globals = natives(&[("push", "(List, T)")]); + assert_eq!( + analyse_last_type_with_globals( + "let xs = []; fn f(n) { xs.push(n) } f(1); xs", + globals.clone() + ), + StaticType::List(Box::new(StaticType::Int)) + ); + assert_eq!( + errors( + "let xs = []; fn f(n) { xs.push(n) } f(1); f(\"a\");", + globals + ), + vec!["No function called 'f' found that matches the arguments 'String'"] + ); +} + +#[test] +fn what_no_call_can_decide_is_decided_in_the_body() { + // The map's types can't depend on how `count` is called, so they're + // settled where it's declared, as at the end of the program. + let mut globals = index_natives(); + globals.extend(natives(&[("+", "(Int, Int) -> Int")])); + assert_eq!( + analyse_last_type_with_globals( + "fn count() { let m = %{}; m[\"k\"] = 1; m } count()", + globals + ), + StaticType::Map { + key: Box::new(StaticType::String), + value: Box::new(StaticType::Int), + } + ); +} + +#[test] +fn a_recursive_generic_function_returns_what_its_branches_join_to() { + let globals = natives(&[ + ("<", "(Int, Int) -> Bool"), + ("<", "(Float, Int) -> Bool"), + ("-", "(Int, Int) -> Int"), + ("-", "(Float, Int) -> Float"), + ("+", "(Int, Int) -> Int"), + ("+", "(Float, Float) -> Float"), + ]); + for (argument, typ) in [("10", StaticType::Int), ("10.0", StaticType::Float)] { + assert_eq!( + analyse_last_type_with_globals( + &format!( + "fn fib(n) => if n < 2 {{ n }} else {{ fib(n - 1) + fib(n - 2) }}; fib({argument})" + ), + globals.clone() + ), + typ, + ); + } +} + +#[test] +fn overloads_tie_when_a_call_could_fit_both_and_neither_is_more_specific() { + for (source, ties) in [ + ("fn f(x: Int, y) {} fn f(x, y: Int) {}", true), + ("fn f(x: Int, y: Any) {} fn f(x: Any, y: Int) {}", false), + ("fn f(x: Int) {} fn f(x) {}", false), + ("fn f(x: List) {} fn f(x) {}", false), + ("fn f(x: Int) {} fn f(x: Any) {}", false), + ("fn f(x: List) {} fn f(x: List) {}", false), + ("fn f(x) {} fn f(x, y) {}", false), + ] { + let errors = errors(source, vec![]); + assert_eq!( + errors + .iter() + .any(|error| error.contains("neither is more specific")), + ties, + "{source}: {errors:?}" + ); + } + // Two unannotated parameters are a redefinition, as two `Any`s are. + assert_eq!( + errors("fn f(x) {} fn f(y) {}", vec![]), + vec!["Illegal redefinition of function 'f' with parameters (Any) in the same scope"] + ); +} + +#[test] +fn a_lambda_a_generic_function_passes_on_takes_what_each_call_gives_it() { + // `map` waits for what `xs` is, so its lambda's types are only known + // once `incs` is called. + let mut globals = natives(&[ + ("map", "(List, Fn(T) -> U) -> List"), + ("map", "(Deque, Fn(T) -> U) -> List"), + ]); + globals.extend(ambiguous_plus()); + assert_eq!( + analyse_last_type_with_globals( + "fn incs(xs) => xs.map(fn(x) => x + 1); incs([1]); incs([2.5])", + globals + ), + StaticType::List(Box::new(StaticType::Float)) + ); +} + +#[test] +fn an_overloaded_function_value_in_a_generic_function_is_decided_by_its_body() { + // The body is compiled once, so `g` has to be one `abs`, whatever `f` + // is called with. + let globals = natives(&[("abs", "(Int) -> Int"), ("abs", "(Float) -> Float")]); + assert_eq!( + errors("fn f(x) { let g = abs; g(x) } f(1);", globals.clone()), + vec![ + "'abs' has several overloads that are a Fn(Any) -> Any, and nothing here says which one is meant" + ] + ); + assert_eq!( + errors( + "fn f(x) { let g: Fn(Int) -> Int = abs; g(x) } f(1);", + globals + ), + Vec::::new() + ); +} + +#[test] +fn a_generic_function_stored_in_a_variable_keeps_its_constraints() { + assert_eq!( + analyse_last_type_with_globals("fn inc(x) => x + 1; let h = inc; h(2.5)", ambiguous_plus()), + StaticType::Float + ); + assert_eq!( + errors( + "fn inc(x) => x + 1; let h = inc; h(\"a\");", + ambiguous_plus() + ), + vec!["No function called '+' found that matches the arguments 'String, Int'"] + ); +} diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index d289647b..f61add91 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -10,6 +10,8 @@ pub struct AnalysisError { text: String, span: Span, help_text: Option, + /// Other places that explain the error, each with what it says there. + notes: Vec<(Span, String)>, } impl From for AnalysisError { @@ -18,6 +20,7 @@ impl From for AnalysisError { text: error.to_string(), span: error.span(), help_text: error.help_text(), + notes: Vec::new(), } } } @@ -33,11 +36,47 @@ impl AnalysisError { self.help_text.as_deref() } + /// Other places that explain the error, each with what it says there. + pub fn notes(&self) -> &[(Span, String)] { + &self.notes + } + + /// This error, found in a generic function's body while checking a call + /// of it, as the call's error. `calls` are the calls it came through, + /// outermost first, each with the function it calls: in + /// `fn yy(l) => l.len(); yy(5)`, the call `yy(5)` gets the error that + /// there's no `len(Int)`, with a note at `l.len()`. + pub(crate) fn through_calls<'a>( + mut self, + calls: impl IntoIterator, + ) -> Self { + let calls = calls.into_iter().collect::>(); + let Some(&(_, outermost)) = calls.first() else { + return self; + }; + let mut notes = calls + .windows(2) + .map(|pair| { + let [(caller, _), (callee, call)] = pair else { + unreachable!("windows of two"); + }; + (*call, format!("`{caller}` calls `{callee}` here")) + }) + .collect::>(); + let (innermost, _) = calls[calls.len() - 1]; + notes.push((self.span, format!("`{innermost}` needs this"))); + notes.append(&mut self.notes); + self.notes = notes; + self.span = outermost; + self + } + pub(crate) fn invalid_type_annotation(err: &StaticTypeConstructionError, span: Span) -> Self { Self { text: err.to_string(), span, help_text: Some(err.help_text().to_string()), + notes: Vec::new(), } } @@ -46,6 +85,7 @@ impl AnalysisError { text: format!("type `{name}` does not take generic arguments"), span, help_text: None, + notes: Vec::new(), } } @@ -60,6 +100,7 @@ impl AnalysisError { ), span, help_text: None, + notes: Vec::new(), } } @@ -68,6 +109,7 @@ impl AnalysisError { text: format!("mismatched types: found {found} but expected {expected}"), span, help_text: None, + notes: Vec::new(), } } @@ -76,6 +118,7 @@ impl AnalysisError { text: format!("invalid cast: {found} can never be {target}"), span, help_text: None, + notes: Vec::new(), } } @@ -96,6 +139,27 @@ impl AnalysisError { help_text: Some( "Overloads in one scope need different parameter types, so annotate a parameter's type to tell them apart".to_string(), ), + notes: Vec::new(), + } + } + + pub(crate) fn tied_overloads( + name: &str, + parameters: &[String], + earlier: &[String], + span: Span, + ) -> Self { + Self { + text: format!( + "A call could fit both {name}({}) and {name}({}), declared earlier in the same scope, and neither is more specific", + parameters.join(", "), + earlier.join(", "), + ), + span, + help_text: Some( + "A parameter without a type annotation (`_` here) takes any argument the body can work with. Annotate the parameters so that one overload is more specific, or so that they take different arguments".to_string(), + ), + notes: Vec::new(), } } @@ -104,6 +168,7 @@ impl AnalysisError { text: format!("Unable to index into {typ}"), span, help_text: None, + notes: Vec::new(), } } pub(crate) fn unable_to_unpack_type(typ: &StaticType, span: Span) -> Self { @@ -111,6 +176,7 @@ impl AnalysisError { text: format!("Invalid unpacking of {typ}"), span, help_text: None, + notes: Vec::new(), } } @@ -138,6 +204,7 @@ impl AnalysisError { "A variable hides the functions named '{name}' further out: it doesn't join their overloads" ) }), + notes: Vec::new(), } } @@ -149,6 +216,7 @@ impl AnalysisError { ), span, help_text: None, + notes: Vec::new(), } } @@ -192,6 +260,7 @@ impl AnalysisError { help_text: Some(format!( "Say which one with a function type, as in `let f: {example} = {name}`" )), + notes: Vec::new(), } } @@ -200,6 +269,7 @@ impl AnalysisError { text: format!("No overload of '{name}' is a {expected}"), span, help_text: None, + notes: Vec::new(), } } @@ -208,6 +278,7 @@ impl AnalysisError { text: "unexpected break statement outside of loop".to_string(), span, help_text: None, + notes: Vec::new(), } } @@ -216,6 +287,7 @@ impl AnalysisError { text: "unexpected continue statement outside of loop".to_string(), span, help_text: None, + notes: Vec::new(), } } @@ -224,6 +296,7 @@ impl AnalysisError { text: "unexpected return statement outside of function body".to_string(), span, help_text: None, + notes: Vec::new(), } } @@ -239,6 +312,7 @@ impl AnalysisError { ), span, help_text: None, + notes: Vec::new(), } } @@ -247,6 +321,7 @@ impl AnalysisError { text: format!("Unable to invoke {typ} as a function."), span, help_text: None, + notes: Vec::new(), } } } diff --git a/ndc_analyser/src/lib.rs b/ndc_analyser/src/lib.rs index 50a1051f..f9f6ff9b 100644 --- a/ndc_analyser/src/lib.rs +++ b/ndc_analyser/src/lib.rs @@ -3,5 +3,5 @@ mod error; mod overloads; mod types; -pub use analyser::{Analyser, AnalysisResult}; +pub use analyser::{Analyser, AnalysisResult, tied_natives}; pub use error::AnalysisError; diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 25ace005..953be744 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -394,20 +394,32 @@ impl<'a> Overloads<'a> { let mut all_by_name = Vec::new(); for group in self.reachable(candidates) { + let compatible = || { + let compatible = group + .iter() + .copied() + .filter(|&symbol| self.is_compatible(symbol, sig)) + .collect::>(); + self.most_specific_first(compatible) + }; if let Some(exact) = self.most_specific_match(group, sig) { + // An overload in this scope takes the call, so it's this + // scope's, but a more specific one here may take it as the + // program runs, which then picks among them. + let (exact, loose) = if self.a_more_specific_one_could_fit(group, exact, sig) { + (None, Some(compatible())) + } else { + (Some(exact), None) + }; return ScalarWalk { - exact: Some(exact), - loose: None, + exact, + loose, all_by_name: Vec::new(), }; } if loose.is_none() { - let compatible = group - .iter() - .copied() - .filter(|&symbol| self.is_compatible(symbol, sig)) - .collect::>(); + let compatible = compatible(); if !compatible.is_empty() { loose = Some(compatible); } @@ -485,6 +497,68 @@ impl<'a> Overloads<'a> { }) } + /// The parameter types of `symbol`, if it's a function that isn't + /// variadic. + fn parameters_of(self, symbol: SymbolId) -> Option> { + match self.type_of(symbol).into_owned() { + StaticType::Function { + parameters: Some(parameters), + .. + } => Some(parameters), + _ => None, + } + } + + /// Whether an overload in `group` more specific than `exact`, which + /// accepts `sig`, might accept the call when the program runs. An + /// argument of type `Any` fits `show(x: Any)`, but if it's an Int when + /// the program runs, `show(x: Int)` is the overload that takes it. + fn a_more_specific_one_could_fit( + self, + group: &[SymbolId], + exact: SymbolId, + sig: &[StaticType], + ) -> bool { + let Some(exact) = self.parameters_of(exact) else { + return false; + }; + group.iter().any(|&other| { + self.parameters_of(other).is_some_and(|other_parameters| { + is_strictly_more_specific(&other_parameters, &exact) + && self.is_compatible(other, sig) + }) + }) + } + + /// `symbols` with each one before every one it's more specific than, + /// and otherwise in the order given. The program tries the overloads it + /// picks from as it runs in this order, and takes the first that + /// accepts the arguments. Overloads in one scope don't tie, so of those + /// that accept them, one is more specific than the others, and it's the + /// first. + fn most_specific_first(self, mut symbols: Vec) -> Vec { + let mut ordered = Vec::with_capacity(symbols.len()); + while !symbols.is_empty() { + let next = symbols + .iter() + .position(|&symbol| { + let Some(parameters) = self.parameters_of(symbol) else { + // A variadic function is the least specific. + return symbols + .iter() + .all(|&other| self.parameters_of(other).is_none()); + }; + symbols.iter().all(|&other| { + self.parameters_of(other) + .is_none_or(|other| !is_strictly_more_specific(&other, ¶meters)) + }) + }) + .unwrap_or(0); + ordered.push(symbols.remove(next)); + } + ordered + } + /// Whether `symbol` is a function whose parameters don't provably conflict /// with `sig`, so it might accept the call at runtime. fn is_compatible(self, symbol: SymbolId, sig: &[StaticType]) -> bool { @@ -492,10 +566,9 @@ impl<'a> Overloads<'a> { let StaticType::Function { parameters, .. } = typ.as_ref() else { return false; }; + // A variadic function accepts any arguments. let Some(parameters) = parameters else { - unreachable!( - "a variadic function always matches exactly before compatibility is checked" - ); + return true; }; parameters.len() == sig.len() && parameters @@ -855,6 +928,57 @@ mod tests { assert_eq!(resolved.return_type, list_of(StaticType::Any)); } + #[test] + fn an_unknown_argument_leaves_a_fallback_to_the_program_with_the_more_specific_first() { + // An `Any` argument fits `show(Any)`, but when it's an Int as the + // program runs, `show(Int)` takes it. The program takes the first + // overload that accepts the arguments, so that one comes first, + // whichever was declared first. + let (types, shows) = table(vec![ + fn_type(vec![StaticType::Int], StaticType::String), + fn_type(vec![StaticType::Any], StaticType::String), + ]); + for group in [[shows[0], shows[1]], [shows[1], shows[0]]] { + let resolved = resolve( + &types, + &candidates(&[&group]), + &[StaticType::Any], + CallKind::Regular, + ); + assert_eq!( + resolved.binding, + Binding::Dynamic(vec![ + Candidate::Scalar(shows[0]), + Candidate::Scalar(shows[1]) + ]), + "declared in the order {group:?}" + ); + } + } + + #[test] + fn a_fallback_left_to_the_program_still_hides_the_overloads_further_out() { + // The inner `show(Any)` takes every call, so the outer one never does. + let (types, shows) = table(vec![ + fn_type(vec![StaticType::Any], StaticType::String), + fn_type(vec![StaticType::Int], StaticType::String), + fn_type(vec![StaticType::Any], StaticType::String), + ]); + let resolved = resolve( + &types, + &candidates(&[&shows[1..], &shows[..1]]), + &[StaticType::Any], + CallKind::Regular, + ); + assert_eq!( + resolved.binding, + Binding::Dynamic(vec![ + Candidate::Scalar(shows[1]), + Candidate::Scalar(shows[2]) + ]), + ); + } + #[test] fn a_call_every_overload_rejects_binds_nothing() { let (types, foos) = table(vec![ diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 9ff92fca..061cf66a 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -46,6 +46,16 @@ pub(crate) struct TypeVars { /// unsettle the ones settled after it, and the solver can tell which /// ones were settled since it last looked. trail: Vec, + /// How deep in generic functions each variable belongs: 0 for the + /// program's top level, 1 for the body of a top-level `fn`. A variable + /// that a function's body made but that something outside it now refers + /// to, like `n` in `let xs = []; fn f(n) { xs.push(n) }`, belongs outside. + levels: Vec, + /// The level new variables get. + level: u32, + /// The levels lowered so far, with what each was before, so a + /// [`Snapshot`] can raise them again. + lowered: Vec<(TypeVar, u32)>, } /// The state of the variables at one point, which [`TypeVars::rollback`] @@ -54,6 +64,7 @@ pub(crate) struct TypeVars { pub(crate) struct Snapshot { vars: usize, trail: usize, + lowered: usize, } impl TypeVars { @@ -63,13 +74,33 @@ impl TypeVars { TypeVar(u32::try_from(self.solutions.len()).expect("fewer than 2^32 type variables")); self.solutions.push(None); self.fallbacks.push(fallback); + self.levels.push(self.level); StaticType::Var(var) } - /// How many variables there are, so [`Self::settle_from`] can settle - /// the ones made after this point. - pub(crate) fn count(&self) -> usize { - self.solutions.len() + /// Starts the body of a generic function: the variables made until + /// [`Self::leave_level`] belong to it. + pub(crate) fn enter_level(&mut self) { + self.level += 1; + } + + /// Ends the body [`Self::enter_level`] started. The variables it made + /// that nothing outside refers to can then be generalized. + pub(crate) fn leave_level(&mut self) { + self.level -= 1; + } + + /// Whether `var` is unsettled and belongs to a generic function's body + /// that has ended, so it can become one of the function's type + /// parameters. + pub(crate) fn is_generalizable(&self, var: TypeVar) -> bool { + self.solution(var).is_none() && self.levels[var.0 as usize] > self.level + } + + /// Settles `var` as the type parameter `name` of the generic function + /// whose body made it. + pub(crate) fn generalize(&mut self, var: TypeVar, name: &str) { + self.settle(var, StaticType::Generic(name.into())); } fn solution(&self, var: TypeVar) -> Option<&StaticType> { @@ -79,6 +110,17 @@ impl TypeVars { /// Settles `var` as `typ`. fn settle(&mut self, var: TypeVar, typ: StaticType) { debug_assert!(self.solution(var).is_none(), "{var:?} is settled twice"); + // What `var` stands for now belongs where `var` does. + let level = self.levels[var.0 as usize]; + if level < self.level { + for inner in self.unsettled(&typ) { + let inner_level = self.levels[inner.0 as usize]; + if inner_level > level { + self.lowered.push((inner, inner_level)); + self.levels[inner.0 as usize] = level; + } + } + } self.solutions[var.0 as usize] = Some(typ); self.trail.push(var); } @@ -101,6 +143,7 @@ impl TypeVars { Snapshot { vars: self.solutions.len(), trail: self.trail.len(), + lowered: self.lowered.len(), } } @@ -110,8 +153,12 @@ impl TypeVars { for var in self.trail.drain(snapshot.trail..) { self.solutions[var.0 as usize] = None; } + for (var, level) in self.lowered.drain(snapshot.lowered..).rev() { + self.levels[var.0 as usize] = level; + } self.solutions.truncate(snapshot.vars); self.fallbacks.truncate(snapshot.vars); + self.levels.truncate(snapshot.vars); } /// Unifies `a` and `b` if they can be made the same type, and otherwise @@ -241,6 +288,17 @@ impl TypeVars { /// signature of the overload it picked, so its arguments settle what `T` /// stands for at that call. pub(crate) fn instantiate(&mut self, typ: &StaticType) -> StaticType { + self.instantiate_with(typ, &mut Vec::new()) + } + + /// [`Self::instantiate`], with the variables already made for some type + /// parameters in `made`, which gets the ones made for the others. A + /// generic function's constraints are instantiated with its signature. + pub(crate) fn instantiate_with( + &mut self, + typ: &StaticType, + made: &mut Vec<(Box, StaticType)>, + ) -> StaticType { let names = std::cell::RefCell::new(Vec::>::new()); typ.contains(&|part| { if let StaticType::Generic(name) = part @@ -250,13 +308,14 @@ impl TypeVars { } false }); - let vars = names - .into_inner() - .into_iter() - .map(|name| (name, self.fresh(StaticType::Any))) - .collect::>(); + for name in names.into_inner() { + if !made.iter().any(|(generic, _)| *generic == name) { + let var = self.fresh(StaticType::Any); + made.push((name, var)); + } + } typ.replace(&|part| match part { - StaticType::Generic(name) => vars + StaticType::Generic(name) => made .iter() .find(|(generic, _)| generic == name) .map(|(_, var)| var.clone()), diff --git a/ndc_bin/src/diagnostic.rs b/ndc_bin/src/diagnostic.rs index eb658015..89df4b3c 100644 --- a/ndc_bin/src/diagnostic.rs +++ b/ndc_bin/src/diagnostic.rs @@ -84,13 +84,16 @@ 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("related to this"), + ]; + labels.extend(cause.notes().iter().map(|(span, note)| { + Label::secondary(span.source_id(), span.range()).with_message(note) + })); let mut d = Diagnostic::error() .with_code("analysis") .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()]); } diff --git a/ndc_lsp/src/features/inlay_hints.rs b/ndc_lsp/src/features/inlay_hints.rs index ef5f90a4..1717d732 100644 --- a/ndc_lsp/src/features/inlay_hints.rs +++ b/ndc_lsp/src/features/inlay_hints.rs @@ -142,8 +142,11 @@ mod tests { #[test] fn unannotated_param_gets_inlay() { + // `foo` takes any argument, so its parameter's type is `foo`'s type + // parameter, which it returns. let hints = collect_hints("fn foo(x) { x }"); - assert!(has_label(&hints, |l| l == ": Any")); + assert!(has_label(&hints, |l| l == ": A"), "{hints:?}"); + assert!(has_label(&hints, |l| l == " -> A"), "{hints:?}"); } #[test] diff --git a/ndc_resolver/src/name_resolution.rs b/ndc_resolver/src/name_resolution.rs index ea3a39fc..a2fa67e5 100644 --- a/ndc_resolver/src/name_resolution.rs +++ b/ndc_resolver/src/name_resolution.rs @@ -151,7 +151,7 @@ impl NameResolutionError { /// within a scope. For `x` in `let x = 1; { let x = 2; x }`: `[[x2], [x1]]`. /// Identifiers always have at least one candidate, augmented operators may /// not. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Candidates(Vec>); /// The two operations an augmented assignment can use. For `xs ++= ys`: diff --git a/ndc_stdlib/Cargo.toml b/ndc_stdlib/Cargo.toml index 12cbf99d..f95d0aed 100644 --- a/ndc_stdlib/Cargo.toml +++ b/ndc_stdlib/Cargo.toml @@ -23,6 +23,9 @@ serde_json = { workspace = true, optional = true } md5 = { version = "0.8.0", optional = true } sha1 = { version = "0.11.0", optional = true } +[dev-dependencies] +ndc_analyser.workspace = true + [features] default = ["crypto", "rand", "regex", "serde"] crypto = ["dep:md5", "dep:sha1"] diff --git a/ndc_stdlib/src/lib.rs b/ndc_stdlib/src/lib.rs index ef1f503a..b3e35324 100644 --- a/ndc_stdlib/src/lib.rs +++ b/ndc_stdlib/src/lib.rs @@ -49,3 +49,21 @@ pub fn register(env: &mut FunctionRegistry>) { string::register(env); value::register(env); } + +#[cfg(test)] +mod tests { + use super::*; + + /// A call that fits two overloads in one scope must find one of them + /// more specific, so it never has to choose between generic overloads. + #[test] + fn no_two_overloads_tie() { + let mut registry = FunctionRegistry::default(); + register(&mut registry); + let natives = registry + .iter() + .map(|function| (function.name.clone(), function.static_type.clone())) + .collect::>(); + assert_eq!(ndc_analyser::tied_natives(&natives), Vec::::new()); + } +} diff --git a/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc b/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc index 2137c005..c19f68a6 100644 --- a/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc +++ b/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc @@ -3,7 +3,7 @@ // nothing downstream would check the elements. Cast the operand to state what // it holds. // expect-error: mismatched types: found Any but expected List -fn opaque(x) => x; +fn opaque(x) -> Any => x; let values = [1]; values ++= opaque([2, 3]); print(values) diff --git a/tests/functional/programs/005_functions/071_a_generic_function_takes_each_type_its_body_can_work_with.ndc b/tests/functional/programs/005_functions/071_a_generic_function_takes_each_type_its_body_can_work_with.ndc new file mode 100644 index 00000000..796f0f53 --- /dev/null +++ b/tests/functional/programs/005_functions/071_a_generic_function_takes_each_type_its_body_can_work_with.ndc @@ -0,0 +1,10 @@ +// A function declared in the program takes any argument its body can work +// with, and each call gets the type the body makes of its own arguments: +// `inc(1)` is an Int and `inc(2.5)` a Float. +// expect-output: 2 3.5 Int Float +fn inc(x) => x + 1; +let a = inc(1); +let b = inc(2.5); +let c: Int = a; +let d: Float = b; +print(a, b, "Int", "Float"); diff --git a/tests/functional/programs/005_functions/072_a_generic_function_called_with_what_its_body_cannot_use_is_an_error.ndc b/tests/functional/programs/005_functions/072_a_generic_function_called_with_what_its_body_cannot_use_is_an_error.ndc new file mode 100644 index 00000000..9d9389dc --- /dev/null +++ b/tests/functional/programs/005_functions/072_a_generic_function_called_with_what_its_body_cannot_use_is_an_error.ndc @@ -0,0 +1,7 @@ +// A call of a generic function is checked against what its body does with +// the arguments. The error is reported at the call, with a note on the line +// in the body that can't take them. +// expect-error: No function called '+' found that matches the arguments 'String, Int' +fn inc(x) => x + 1; +fn shout(s) => inc(s) ++ "!"; +shout("a"); diff --git a/tests/functional/programs/005_functions/073_a_recursive_generic_function_returns_what_it_is_called_with.ndc b/tests/functional/programs/005_functions/073_a_recursive_generic_function_returns_what_it_is_called_with.ndc new file mode 100644 index 00000000..2951d805 --- /dev/null +++ b/tests/functional/programs/005_functions/073_a_recursive_generic_function_returns_what_it_is_called_with.ndc @@ -0,0 +1,7 @@ +// A recursive call has the types of the call it's in, so `fib` returns an +// Int for an Int and a Float for a Float, as the annotations check. +// expect-output: 55 55 +fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; +let i: Int = fib(10); +let f: Float = fib(10.0); +print(i, f); diff --git a/tests/functional/programs/005_functions/074_a_generic_function_passes_its_argument_to_a_function_argument.ndc b/tests/functional/programs/005_functions/074_a_generic_function_passes_its_argument_to_a_function_argument.ndc new file mode 100644 index 00000000..19ff8675 --- /dev/null +++ b/tests/functional/programs/005_functions/074_a_generic_function_passes_its_argument_to_a_function_argument.ndc @@ -0,0 +1,7 @@ +// A parameter called as a function takes what the call passes it, so +// `apply` takes an overloaded function too: `abs(Int)` is the one that +// takes `-1`. +// expect-output: 1 hi! [2,3] +fn apply(f, x) => f(x); +fn inc(x) => x + 1; +print(apply(abs, -1), apply(fn(s) => s ++ "!", "hi"), [1, 2].map(inc)); diff --git a/tests/functional/programs/005_functions/075_a_generic_function_appends_in_place.ndc b/tests/functional/programs/005_functions/075_a_generic_function_appends_in_place.ndc new file mode 100644 index 00000000..37740b00 --- /dev/null +++ b/tests/functional/programs/005_functions/075_a_generic_function_appends_in_place.ndc @@ -0,0 +1,9 @@ +// `xs ++= ys` in a generic function appends to the caller's list, as it +// does where the list's type is known. +// expect-output: [1,2,3] +fn append(xs, ys) { + xs ++= ys; +} +let list = [1]; +append(list, [2, 3]); +print(list); diff --git a/tests/functional/programs/005_functions/076_a_function_declared_in_a_function_has_one_type.ndc b/tests/functional/programs/005_functions/076_a_function_declared_in_a_function_has_one_type.ndc new file mode 100644 index 00000000..7a9eade5 --- /dev/null +++ b/tests/functional/programs/005_functions/076_a_function_declared_in_a_function_has_one_type.ndc @@ -0,0 +1,9 @@ +// Only a function declared in the program is generic. One declared in a +// function or a block has one type, like a lambda in a variable: its first +// call decides it. +// expect-error: No function called 'same' found that matches the arguments 'String' +fn outer() { + fn same(v) => v; + same(1); + same("a"); +} diff --git a/tests/functional/programs/005_functions/077_overloads_a_call_could_fit_must_not_tie.ndc b/tests/functional/programs/005_functions/077_overloads_a_call_could_fit_must_not_tie.ndc new file mode 100644 index 00000000..69855b6a --- /dev/null +++ b/tests/functional/programs/005_functions/077_overloads_a_call_could_fit_must_not_tie.ndc @@ -0,0 +1,5 @@ +// `f(1, 2)` fits both overloads, and neither is more specific, so nothing +// would say which one it means. +// expect-error: A call could fit both f(_, Int) and f(Int, _) +fn f(x: Int, y) { "first" } +fn f(x, y: Int) { "second" } diff --git a/tests/functional/programs/005_functions/078_an_unannotated_parameter_is_a_fallback_next_to_exact_overloads.ndc b/tests/functional/programs/005_functions/078_an_unannotated_parameter_is_a_fallback_next_to_exact_overloads.ndc new file mode 100644 index 00000000..5e393802 --- /dev/null +++ b/tests/functional/programs/005_functions/078_an_unannotated_parameter_is_a_fallback_next_to_exact_overloads.ndc @@ -0,0 +1,8 @@ +// A call uses the most specific overload that fits it, so `show(x)` takes +// what `show(Int)` doesn't, like `show(x: Any)` would, but with a typed body. +// expect-output: int other other +fn show(x: Int) { "int" } +fn show(x) { "other" } +fn show_any(x: Int) { "int" } +fn show_any(x: Any) { "other" } +print(show(1), show("a"), show_any([1])); diff --git a/tests/functional/programs/005_functions/079_a_captured_variable_has_one_type_in_a_generic_function.ndc b/tests/functional/programs/005_functions/079_a_captured_variable_has_one_type_in_a_generic_function.ndc new file mode 100644 index 00000000..2eaceed7 --- /dev/null +++ b/tests/functional/programs/005_functions/079_a_captured_variable_has_one_type_in_a_generic_function.ndc @@ -0,0 +1,11 @@ +// A variable declared outside a function has one type, though the function +// is generic: the first call decides what `seen` holds, and so what +// `remember` takes. +// expect-error: No function called 'remember' found that matches the arguments 'String' +let seen = []; +fn remember(n) { + seen.push(n); + n +} +remember(1); +remember("a"); diff --git a/tests/functional/programs/005_functions/080_a_generic_body_calls_the_most_specific_overload.ndc b/tests/functional/programs/005_functions/080_a_generic_body_calls_the_most_specific_overload.ndc new file mode 100644 index 00000000..110d752a --- /dev/null +++ b/tests/functional/programs/005_functions/080_a_generic_body_calls_the_most_specific_overload.ndc @@ -0,0 +1,8 @@ +// A generic function's body is compiled once, so a call on its parameter +// picks the overload as the program runs: the most specific one that takes +// the argument, `show(Int)` for an Int. +// expect-output: int other +fn show(x: Int) { "int" } +fn show(x) { "other" } +fn via(v) => show(v); +print(via(1), via("a")); diff --git a/tests/functional/programs/005_functions/081_a_parameter_the_body_types_is_checked_as_the_program_runs.ndc b/tests/functional/programs/005_functions/081_a_parameter_the_body_types_is_checked_as_the_program_runs.ndc new file mode 100644 index 00000000..cebae648 --- /dev/null +++ b/tests/functional/programs/005_functions/081_a_parameter_the_body_types_is_checked_as_the_program_runs.ndc @@ -0,0 +1,8 @@ +// `pick`'s body makes `x` an Int, so a call decided as the program runs +// tells it apart from the `Any` fallback, as a call with known types does. +// expect-output: 1 fallback 1 +fn need_int(x: Int) => x; +fn pick(x) => need_int(x); +fn pick(x: Any) => "fallback"; +fn via(x: Any) => pick(x); +print(via(1), via("a"), pick(1)); diff --git a/tests/functional/programs/005_functions/082_a_recursive_call_the_body_cannot_decide_is_checked_once.ndc b/tests/functional/programs/005_functions/082_a_recursive_call_the_body_cannot_decide_is_checked_once.ndc new file mode 100644 index 00000000..b716b60e --- /dev/null +++ b/tests/functional/programs/005_functions/082_a_recursive_call_the_body_cannot_decide_is_checked_once.ndc @@ -0,0 +1,6 @@ +// The recursive call could mean either `f`, so it waits until `f` is +// called. Checking it with the call's types doesn't check `f` again. +// expect-output: a 1 +fn f(x: Int) => x; +fn f(x) => if true { x } else { f(x) }; +print(f("a"), f(1)); diff --git a/tests/functional/programs/016_casts/017_cast_op_assignment_error.ndc b/tests/functional/programs/016_casts/017_cast_op_assignment_error.ndc index f065834e..d3772c81 100644 --- a/tests/functional/programs/016_casts/017_cast_op_assignment_error.ndc +++ b/tests/functional/programs/016_casts/017_cast_op_assignment_error.ndc @@ -1,7 +1,7 @@ // The cast checks the elements, so a bad operand fails at the cast site // instead of silently landing in the target. // expect-error: cannot cast List to List -fn opaque(x) => x; +fn opaque(x) -> Any => x; let values = [1]; values ++= opaque([0.5]) as List; print(values) diff --git a/tests/functional/programs/900_bugs/bug0009_upvalue_in_overload_set.ndc b/tests/functional/programs/900_bugs/bug0009_upvalue_in_overload_set.ndc index 715e022f..70c2f88a 100644 --- a/tests/functional/programs/900_bugs/bug0009_upvalue_in_overload_set.ndc +++ b/tests/functional/programs/900_bugs/bug0009_upvalue_in_overload_set.ndc @@ -7,7 +7,7 @@ fn outer() { fn describe(x: Int) { "int" } fn describe(x: String) { "string" } fn middle() { - fn inner(v) { describe(v) } + fn inner(v: Any) { describe(v) } inner(1) ++ " " ++ inner("a") } middle() diff --git a/tests/functional/programs/900_bugs/bug0011_unpack_non_sequence.ndc b/tests/functional/programs/900_bugs/bug0011_unpack_non_sequence.ndc index b9a342d1..2e5ecb1d 100644 --- a/tests/functional/programs/900_bugs/bug0011_unpack_non_sequence.ndc +++ b/tests/functional/programs/900_bugs/bug0011_unpack_non_sequence.ndc @@ -1,6 +1,6 @@ // B3: exec_unpack panicked on non-sequence values instead of returning VmError. // expect-error: cannot unpack -fn foo(n) { +fn foo(n: Any) { let a, b = n; } From 526795167871dc843353d9b1ba532c8fc397d82e Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 12 Nov 2026 09:16:12 +0100 Subject: [PATCH 10/39] =?UTF-8?q?feat(compiler):=20compile=20a=20generic?= =?UTF-8?q?=20function=20once=20for=20each=20set=20of=20types=20its=20call?= =?UTF-8?q?s=20give=20it=20=F0=9F=A7=AC=20(#306)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3d of the type system RFC (#281): a generic function's body is compiled once for each set of types its calls give it, and each copy picks its calls' overloads from those types. Before, it was compiled once and decided those calls as the program ran. ```ndc fn inc(x) => x + 1; print(inc(1), inc(2.5)); ``` ```text inc(1) calls inc, whose + is +(Int, Int), called directly inc(2.5) calls inc, whose + is +(Float, Int) ``` How it works: - Key: an instance is for the types of the function's key. The key is the type parameters that its waiting constraints, and the generic functions its body calls, depend on. `fn wrap(x) => [x]` has an empty key, so its one body serves every call, as before. A call whose key types are all `Any` uses the function's own body, which still decides its calls at runtime. So does runtime dispatch. A function's name used as a value is always the function itself, so two values of it stay equal and work as the same map key. - Uses: when a call commits to a generic function, the analyser records the types it gives the key. A use inside a generic body whose types depend on that body's type parameters becomes part of the body's scheme. Uses made while solving a copy of a constraint aren't recorded, as their picks belong to the function they were copied from. - Instance pass: at the end of a batch, each use gets the instance for its now-known types. A new instance is a hidden top-level symbol named like `inc`, which no identifier can spell, so the layout gives it the next global slot. The pass solves the function's constraints again for the instance's types and records the picks in that instance's own tables. It also follows the generic calls inside, until no new instances appear. Errors are suppressed during the pass, because they were reported at the calls. - `pure fn`: a generic `pure fn`'s instances share its memo cache, through a new `MemoizeLike` opcode, so a call through a value of the function finds what a direct call to an instance computed. - Recursion: a call in a generic body to the function itself has the body's own types, since recursion is monomorphic. So in an instance, it calls that instance. - Compiler: each instance is compiled at its function's declaration. Its picks are overlaid on the function's own, and nested functions inherit them. It's stored in its own global. A top-level function captures nothing, so an instance is a plain constant. Calls point at the instance's global. - Functions declared inside: a function or lambda declared in a generic body is compiled with each instance. Where that uses none of the instance's picks, it reuses the prototype from the function's own body, so it stays the same function: `make(1) == make(1.5)` when `make` returns one. - REPL: a call on a later line, to a function an earlier line declared, uses the function's own body. The earlier line's code isn't kept, so no new instance can be compiled for it. That's a performance limit, not a correctness one, and keeping generic bodies to compile later instances is follow-up work. Performance against master: - `matrix_mul`: 29% faster (3c had made it about 30% slower) - `fibonacci`: 10% faster - `string_concat` and `closures`: about 10% faster - `perlin`, `ackermann` and `quicksort`: 4–5% faster - the others: unchanged within noise Tests: - Analyser unit tests cover: one instance per set of key types; no instance for an empty key or `Any` arguments; an instance calling the instances its types need. - Functional tests: 005/083 covers an instance calling another generic function; 005/084 covers a generic function used as a value staying equal to itself. - A unit test covers an instance calling itself where its body recurses. 010_memoization/003 covers the shared cache, and 005/085 plus two compiler tests cover the nested functions. - REPL tests cover instances within one line, and a later line calling an earlier function with new types. - The overload fuzzer now generates generic functions that call each other. Debug builds assert that no use outside a generic body has a type parameter in its key. With that, the fuzzer found a bug where a body's uses were sorted after its type parameters were named; its seed is kept in `overloads.regressions`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/function.md | 2 + ndc_analyser/src/analyser/declarations.rs | 6 +- ndc_analyser/src/analyser/function_values.rs | 4 +- ndc_analyser/src/analyser/generics.rs | 82 ++++- ndc_analyser/src/analyser/instances.rs | 289 ++++++++++++++++++ ndc_analyser/src/analyser/mod.rs | 43 ++- ndc_analyser/src/analyser/solver.rs | 75 +++-- ndc_analyser/src/analyser/tests.rs | 118 +++++++ ndc_analyser/src/types.rs | 6 + ndc_analysis/src/lib.rs | 23 ++ ndc_layout/src/lib.rs | 48 ++- ndc_resolver/src/name_resolution.rs | 32 +- ndc_vm/src/chunk.rs | 8 + ndc_vm/src/compiler.rs | 237 +++++++++++--- ndc_vm/src/vm.rs | 19 ++ tests/compiler/tests/compiler.rs | 58 ++++ ...nce_calls_the_instances_its_types_need.ndc | 10 + ...used_as_a_value_is_the_function_itself.ndc | 7 + ...d_in_a_generic_body_keeps_its_identity.ndc | 10 + ...fn_shares_its_cache_with_its_instances.ndc | 14 + tests/functional/tests/repl.rs | 18 ++ tests/proptest/tests/overloads.regressions | 1 + tests/proptest/tests/overloads.rs | 73 +++-- 23 files changed, 1067 insertions(+), 116 deletions(-) create mode 100644 ndc_analyser/src/analyser/instances.rs create mode 100644 tests/functional/programs/005_functions/083_each_instance_calls_the_instances_its_types_need.ndc create mode 100644 tests/functional/programs/005_functions/084_a_generic_function_used_as_a_value_is_the_function_itself.ndc create mode 100644 tests/functional/programs/005_functions/085_a_function_declared_in_a_generic_body_keeps_its_identity.ndc create mode 100644 tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index f0c35ca2..7022f725 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -210,6 +210,8 @@ fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; let i: Int = fib(10); ``` +The body is compiled for each set of types it's called with, so the calls in it are picked when the program is compiled, not as it runs. Called with a value of type `Any`, the function picks them as it runs. + A call with an argument the body can't use is an error at the call, with a note on the line in the body that can't take it: ```ndc diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 75676b21..25686bbf 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -45,7 +45,9 @@ impl Analyser { let generic = declared.is_some_and(|(_, symbol)| self.is_generic_function(symbol)); if generic { self.vars.enter_level(); + self.declaring = declared.map(|(_, symbol)| symbol); } + let first_use = self.uses.len(); let annotations = parameters .iter() .map(|param| self.lower_annotation(param.annotation.as_ref())) @@ -168,7 +170,9 @@ impl Analyser { let function_type = match declared { Some((_, symbol)) if generic => { self.vars.leave_level(); - self.generalize(symbol, &signature) + self.declaring = None; + self.declared_in_batch.insert(symbol); + self.generalize(symbol, &signature, id, first_use) } _ => signature, }; diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index 093589df..c4d80fc0 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -77,7 +77,7 @@ impl Analyser { // A generic function used as a value, as in `let p = push`, has one // type there, which its uses settle. let typ = self.instantiate_function(symbol); - self.flush_copies(span); + self.flush_copies(None, span); typ } @@ -267,7 +267,7 @@ impl Analyser { fn commit_function_value(&mut self, value: &FunctionValue, symbol: SymbolId, early: bool) { let typ = self.instantiate_function(symbol); self.vars.bind_argument(&value.expected, &typ); - self.flush_copies(value.span); + self.flush_copies(None, value.span); if early && cfg!(debug_assertions) { self.remember_early_function_value(value, symbol); } diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index cf11e12f..2e3cfda2 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -6,11 +6,12 @@ //! the call's type as `Float`. use super::Analyser; +use super::instances::Scheme; use super::solver::{CallConstraint, Constraint, Waiting}; use crate::error::AnalysisError; use crate::overloads::is_strictly_more_specific; use ahash::AHashSet; -use ndc_core::{StaticType, TypeVar}; +use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; use ndc_resolver::{ScopeKind, SymbolId}; @@ -30,7 +31,13 @@ impl Analyser { /// decide, like the element type of `[]` in `fn f(x) { [].push(x) }`, /// can't be decided by a call either, so it's settled here first, as at /// the end of the program. - pub(super) fn generalize(&mut self, symbol: SymbolId, signature: &StaticType) -> StaticType { + pub(super) fn generalize( + &mut self, + symbol: SymbolId, + signature: &StaticType, + declaration: NodeId, + first_use: usize, + ) -> StaticType { self.solve_waiting(); self.settle_ambiguous(signature); self.merge_copies(); @@ -45,6 +52,22 @@ impl Analyser { }) .collect::>(); + // The generic functions the body calls with types that depend on + // its type parameters are the instance's to pick. + let (uses, outside): (Vec<_>, Vec<_>) = self + .uses + .split_off(first_use) + .into_iter() + .partition(|generic_use| { + generic_use.key.iter().any(|typ| { + self.vars + .unsettled(typ) + .into_iter() + .any(|var| self.vars.is_generalizable(var)) + }) + }); + self.uses.extend(outside); + // Name the type parameters in the order they appear. let mut parameters = self.vars.unsettled(signature); for waiting in &scheme { @@ -68,6 +91,24 @@ impl Analyser { named += 1; } } + + // A type only the body makes, as of a list nothing else uses, gets + // its fallback. + let uses = uses + .into_iter() + .map(|mut generic_use| { + for typ in &generic_use.key { + let unnamed = self.vars.unsettled(typ); + self.vars.settle_with_fallbacks(&unnamed); + } + generic_use.key = generic_use + .key + .iter() + .map(|typ| self.vars.resolve(typ)) + .collect(); + generic_use + }) + .collect::>(); self.watched_settlings = self.vars.settled_count(); let scheme = scheme @@ -86,7 +127,26 @@ impl Analyser { self.pick_for_any_arguments(call); } } - self.schemes.insert(symbol, scheme.into()); + // An instance is for the types of the type parameters the + // constraints and uses depend on. + let mut key: Vec> = Vec::new(); + for typ in scheme + .iter() + .flat_map(|waiting| waiting.constraint.inputs()) + .chain(uses.iter().flat_map(|generic_use| &generic_use.key)) + { + collect_type_parameters(typ, &mut key); + } + self.schemes.insert( + symbol, + Scheme { + constraints: scheme.into(), + key: key.into(), + uses: uses.into(), + declaration, + recursive_calls: std::mem::take(&mut self.recursive_calls).into(), + }, + ); self.vars.resolve(signature) } @@ -381,6 +441,22 @@ impl Analyser { } } +/// Adds the type parameters in `typ` that `parameters` doesn't have yet. +fn collect_type_parameters(typ: &StaticType, parameters: &mut Vec>) { + let found = std::cell::RefCell::new(Vec::new()); + typ.contains(&|part| { + if let StaticType::Generic(name) = part { + found.borrow_mut().push(name.clone()); + } + false + }); + for name in found.into_inner() { + if !parameters.contains(&name) { + parameters.push(name); + } + } +} + /// The name of a generic function's `index`th type parameter: `A`, `B`, …, /// `Z`, `A1`, …. fn type_parameter_name(index: usize) -> String { diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs new file mode 100644 index 00000000..8987967d --- /dev/null +++ b/ndc_analyser/src/analyser/instances.rs @@ -0,0 +1,289 @@ +//! Instances of generic functions. A generic function's body is compiled +//! once for each set of types its calls give the type parameters its +//! constraints depend on, its *key*, and each copy picks its calls' overloads +//! from those types. `fn inc(x) => x + 1` has key `A`: `inc(1)` calls the +//! instance for `Int`, whose `+` is `+(Int, Int)`, and `inc(2.5)` the one +//! for `Float`. `fn wrap(x) => [x]` has an empty key, so its own body serves +//! every call. + +use super::Analyser; +use super::solver::{CallSite, Constraint, Origin, Waiting}; +use ahash::AHashMap; +use ndc_analysis::{Binding, Candidate}; +use ndc_core::{NodeId, StaticType}; +use ndc_layout::InstancePicks; +use ndc_resolver::SymbolId; +use std::rc::Rc; + +/// A generic function's type beyond its signature: what its body still +/// waits on, and which types an instance is for. +#[derive(Debug, Clone)] +pub(super) struct Scheme { + /// The constraints its body left waiting on its type parameters. + pub(super) constraints: Rc<[Waiting]>, + /// The type parameters the constraints and `uses` depend on, which an + /// instance is for. + pub(super) key: Rc<[Box]>, + /// The generic functions its body calls with types that depend on its + /// type parameters. + pub(super) uses: Rc<[GenericUse]>, + /// The function's declaration. + pub(super) declaration: NodeId, + /// The calls its body makes of itself, which have its own types. + pub(super) recursive_calls: Rc<[NodeId]>, +} + +/// A call of a generic function, or its name used as a value, which needs +/// the instance for the types it gives the function's key. +#[derive(Debug, Clone)] +pub(super) struct GenericUse { + /// Where the use's pick is recorded. + pub(super) site: CallSite, + pub(super) function: SymbolId, + /// The types it gives the function's key, in the key's order. + pub(super) key: Vec, + /// The instance whose body the use is in, `None` for code that's compiled + /// once, like the program's top level. + pub(super) instance: Option, +} + +/// What a call that's about to commit to a generic function copied from it, +/// until it's known whether the call commits. +#[derive(Debug, Default)] +pub(super) struct Pending { + /// Its constraints, with the function they're copied from and the calls + /// they were copied through before. + pub(super) copies: Vec<(SymbolId, Constraint, Option)>, + /// The function and the types the call gives its key. + pub(super) uses: Vec<(SymbolId, Vec)>, +} + +impl Pending { + /// How much is pending, to [`Self::truncate`] to later. + pub(super) fn len(&self) -> (usize, usize) { + (self.copies.len(), self.uses.len()) + } + + pub(super) fn truncate(&mut self, (copies, uses): (usize, usize)) { + self.copies.truncate(copies); + self.uses.truncate(uses); + } + + pub(super) fn clear(&mut self) { + self.truncate((0, 0)); + } +} + +impl Analyser { + /// Records that the call at `site` committed to the generic functions + /// [`Pending::uses`] holds, unless it's in a copy of a constraint, whose + /// pick belongs to the function it was copied from. A tuple operator's + /// call commits to one overload per element, which needs no instance. + pub(super) fn record_uses(&mut self, site: CallSite) { + let uses = std::mem::take(&mut self.pending.uses); + if self.copied_through.is_some() { + return; + } + if let [(function, key)] = &uses[..] { + self.uses.push(GenericUse { + site, + function: *function, + key: key.clone(), + instance: self.recording, + }); + } + } + + /// Finds the instance each use of a generic function in the batch needs, + /// and points the use at it. A new instance's constraints are solved for + /// its types, which records its picks and the instances its own uses + /// need, until there are no new ones. Every error was reported where the + /// constraints were copied to the calls, so solving them again reports + /// nothing. + pub(super) fn instantiate_uses(&mut self) { + let mut new = Vec::new(); + loop { + let uses = std::mem::take(&mut self.uses); + for generic_use in uses { + self.point_at_instance(&generic_use, &mut new); + } + let Some((instance, function, key)) = new.pop() else { + break; + }; + self.solve_instance(instance, function, &key); + } + } + + /// Points `generic_use` at the instance it needs, adding it to `new` if + /// it's new. A use that left the pick to the program as it runs keeps + /// it. + fn point_at_instance( + &mut self, + generic_use: &GenericUse, + new: &mut Vec<(SymbolId, SymbolId, Vec)>, + ) { + let key = generic_use + .key + .iter() + .map(|typ| self.vars.shown(typ)) + .collect::>(); + debug_assert!( + !key.iter() + .any(|typ| typ.contains(&|part| matches!(part, StaticType::Generic(_)))), + "internal error: a use outside of generic bodies has a type parameter in {key:?}" + ); + let instance = self.instance_for(generic_use.function, key, new); + if instance == generic_use.function { + return; + } + // An instance's body picks what the function's own body does, + // except where its types say otherwise. + let function = Binding::Resolved(Candidate::Scalar(generic_use.function)); + let picked = generic_use + .instance + .and_then(|of| self.pick_tables(Some(of)).at(generic_use.site).cloned()) + .or_else(|| self.pick_tables(None).at(generic_use.site).cloned()); + if picked != Some(function) { + return; + } + self.pick_tables(generic_use.instance).insert( + generic_use.site, + Binding::Resolved(Candidate::Scalar(instance)), + ); + } + + /// The tables a pick in the body of `instance` goes in, or the shared + /// ones for `None`. + pub(super) fn pick_tables(&mut self, instance: Option) -> PickTables<'_> { + let picks = &mut self.picks; + match instance.and_then(|instance| picks.instances.get_mut(&instance)) { + Some(picks) => PickTables { + bindings: &mut picks.bindings, + getters: &mut picks.getters, + setters: &mut picks.setters, + augmented_assignments: &mut picks.augmented_assignments, + }, + None => PickTables { + bindings: &mut picks.bindings, + getters: &mut picks.getters, + setters: &mut picks.setters, + augmented_assignments: &mut picks.augmented_assignments, + }, + } + } + + /// The instance of `function` for these types of its key: the function + /// itself when they're all `Any`, as its own body picks its calls as the + /// program runs, or when it was declared by an earlier batch, which + /// compiled it already. + fn instance_for( + &mut self, + function: SymbolId, + key: Vec, + new: &mut Vec<(SymbolId, SymbolId, Vec)>, + ) -> SymbolId { + if key.iter().all(|typ| *typ == StaticType::Any) + || !self.declared_in_batch.contains(&function) + { + return function; + } + if let Some(&instance) = self.instances.get(&(function, key.clone())) { + return instance; + } + let name = format!( + "{}<{}>", + self.resolver.symbol_name(function), + key.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + ); + let instance = self.resolver.declare_instance(name); + self.types + .declare(instance, self.types.get_type(function).clone()); + let declaration = self.schemes[&function].declaration; + self.picks + .instances + .insert(instance, InstancePicks::new(declaration)); + self.instances.insert((function, key.clone()), instance); + new.push((instance, function, key)); + instance + } + + /// Solves the constraints of `function` for the instance whose key has + /// these types, recording the picks they make in the instance's picks. + fn solve_instance(&mut self, instance: SymbolId, function: SymbolId, key: &[StaticType]) { + let scheme = self.schemes[&function].clone(); + let mut made = scheme + .key + .iter() + .cloned() + .zip(key.iter().cloned()) + .collect::>(); + let outer = self.recording.replace(instance); + let first = self.vars.count(); + for waiting in scheme.constraints.iter() { + let mut copy = Waiting { + constraint: waiting + .constraint + .map_types(&mut |typ| self.vars.instantiate_with(typ, &mut made)), + origin: waiting.origin.clone(), + }; + if !self.try_waiting(&mut copy) { + self.wait_as(copy); + } + } + for &call in scheme.recursive_calls.iter() { + self.pick_tables(Some(instance)).insert( + CallSite::Call(call), + Binding::Resolved(Candidate::Scalar(instance)), + ); + } + for generic_use in scheme.uses.iter() { + let key = generic_use + .key + .iter() + .map(|typ| self.vars.instantiate_with(typ, &mut made)) + .collect(); + self.uses.push(GenericUse { + site: generic_use.site, + function: generic_use.function, + key, + instance: Some(instance), + }); + } + self.settle_waiting(first); + self.recording = outer; + } +} + +/// The tables of one body's picks, by the kind of site. +pub(super) struct PickTables<'a> { + bindings: &'a mut AHashMap>, + getters: &'a mut AHashMap>, + setters: &'a mut AHashMap>, + augmented_assignments: &'a mut AHashMap>, +} + +impl<'a> PickTables<'a> { + fn table(self, site: CallSite) -> (&'a mut AHashMap>, NodeId) { + match site { + CallSite::Call(id) => (self.bindings, id), + CallSite::Getter(id) => (self.getters, id), + CallSite::Setter(id) | CallSite::FieldSetter(id) => (self.setters, id), + CallSite::OpAssignment(id) => (self.augmented_assignments, id), + } + } + + /// The pick at `site`. + pub(super) fn at(self, site: CallSite) -> Option<&'a Binding> { + let (table, id) = self.table(site); + table.get(&id) + } + + /// Records `binding` as the pick at `site`. + pub(super) fn insert(self, site: CallSite, binding: Binding) { + let (table, id) = self.table(site); + table.insert(id, binding); + } +} diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 6c602af1..56a4de33 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -18,11 +18,13 @@ mod calls; mod declarations; mod function_values; pub(crate) mod generics; +mod instances; mod solver; use declarations::FunctionDeclaration; pub use generics::tied_natives; -use solver::{Constraint, EarlyPick, Origin, Waiting}; +use instances::{GenericUse, Pending, Scheme}; +use solver::{EarlyPick, Origin, Waiting}; #[cfg(test)] mod tests; @@ -53,10 +55,26 @@ pub struct Analyser { /// The constraints of each generic function declared in the program, /// such as `+(A, Int) -> B` for `fn inc(x) => x + 1`, which a call of it /// copies with the types it's called with. - schemes: AHashMap>, - /// The constraints a call just copied from a generic function, with the - /// function, until the call is known to commit to it. - pending_copies: Vec<(SymbolId, Constraint, Option)>, + schemes: AHashMap, + /// What a call just copied from a generic function, until the call is + /// known to commit to it. + pending: Pending, + /// The uses of generic functions in the batch whose instances aren't + /// known yet. + uses: Vec, + /// The instance of each generic function for each set of types of its + /// key that a call gave it. + instances: AHashMap<(SymbolId, Vec), SymbolId>, + /// The instance whose constraints are being solved, which gets the + /// picks they make. + recording: Option, + /// The generic functions the batch declares, which the batch compiles + /// instances of. + declared_in_batch: AHashSet, + /// The generic function whose body is being analysed. + declaring: Option, + /// The calls its body makes of itself. + recursive_calls: Vec, /// The calls the constraint being solved was copied through, when it's /// a copy of a generic function's constraint. copied_through: Option, @@ -112,7 +130,13 @@ impl Analyser { vars: TypeVars::default(), waiting: BTreeMap::new(), schemes: AHashMap::new(), - pending_copies: Vec::new(), + pending: Pending::default(), + uses: Vec::new(), + instances: AHashMap::new(), + recording: None, + declared_in_batch: AHashSet::new(), + declaring: None, + recursive_calls: Vec::new(), copied_through: None, next_serial: 0, watchers: AHashMap::new(), @@ -136,6 +160,8 @@ impl Analyser { // so the ones left unsettled get their fallbacks, what waited on them // is solved, and no variable leaves the analyser. self.settle_waiting(0); + self.instantiate_uses(); + self.declared_in_batch.clear(); debug_assert!(self.waiting.is_empty(), "unsolved: {:?}", self.waiting); self.watchers.clear(); if cfg!(debug_assertions) { @@ -171,6 +197,11 @@ impl Analyser { /// a copy of a generic function's constraint is the call's: it's /// reported there, with notes pointing into the function. fn emit(&mut self, err: AnalysisError) { + // An instance's constraints were checked where they were copied to + // its calls. + if self.recording.is_some() { + return; + } let err = match &self.copied_through { Some(frames) => { err.through_calls(frames.iter().map(|frame| (frame.name.as_str(), frame.call))) diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 4bd0c7b7..92f8a990 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -367,7 +367,7 @@ impl Analyser { arguments, StaticType::Any, ) else { - self.pending_copies.clear(); + self.pending.clear(); return None; }; let arguments = arguments @@ -393,7 +393,7 @@ impl Analyser { }); } self.record_pick(call.site, binding, &arguments); - self.flush_copies(call.span); + self.flush_copies(Some(call.site), call.span); Some(typ) } @@ -893,10 +893,10 @@ impl Analyser { let typ = self.commit_call(&binding, arguments, return_type); self.record_pick(call.site, binding, arguments); if let Some(typ) = typ { - self.flush_copies(call.span); + self.flush_copies(Some(call.site), call.span); typ } else { - self.pending_copies.clear(); + self.pending.clear(); self.report_failed_call(call, arguments); StaticType::Any } @@ -944,7 +944,7 @@ impl Analyser { } Binding::Dynamic(_) | Binding::None => None, }; - let pending = self.pending_copies.len(); + let pending = self.pending.len(); let signature = picked.map(|symbol| self.instantiate_function(symbol)); let Some(StaticType::Function { parameters: Some(parameters), @@ -965,14 +965,14 @@ impl Analyser { arguments, ); if typ.is_none() { - self.pending_copies.truncate(pending); + self.pending.truncate(pending); } typ } /// `symbol`'s type with new variables for its type parameters. For a /// generic function declared in the program, its constraints get the - /// same variables, and wait in [`Self::pending_copies`] until + /// same variables, and wait in [`Self::pending`] until /// [`Self::flush_copies`] knows which call they're for. pub(super) fn instantiate_function(&mut self, symbol: SymbolId) -> StaticType { let declared = self.vars.resolve(self.types.get_type(symbol)); @@ -987,24 +987,43 @@ impl Analyser { .iter() .flat_map(|frames| frames.iter()) .any(|frame| frame.function == symbol); - if let Some(scheme) = self.schemes.get(&symbol).cloned() - && !recursive - { - for waiting in scheme.iter() { + let Some(scheme) = self.schemes.get(&symbol).cloned() else { + return typ; + }; + if !recursive { + for waiting in scheme.constraints.iter() { let constraint = waiting .constraint .map_types(&mut |typ| self.vars.instantiate_with(typ, &mut made)); - self.pending_copies + self.pending + .copies .push((symbol, constraint, waiting.origin.clone())); } } + if !scheme.key.is_empty() { + let key = scheme + .key + .iter() + .map(|name| { + self.vars + .instantiate_with(&StaticType::Generic(name.clone()), &mut made) + }) + .collect(); + self.pending.uses.push((symbol, key)); + } typ } /// Adds the constraints [`Self::instantiate_function`] copied for a call - /// at `call`, and solves the ones it can. - pub(super) fn flush_copies(&mut self, call: Span) { - let copies = std::mem::take(&mut self.pending_copies); + /// at `call`, and solves the ones it can. A call at `site` gets the + /// instance its types need; a function's name used as a value, with no + /// `site`, stays the function itself, so two values of it are equal. + pub(super) fn flush_copies(&mut self, site: Option, call: Span) { + match site { + Some(site) => self.record_uses(site), + None => self.pending.uses.clear(), + } + let copies = std::mem::take(&mut self.pending.copies); for (function, constraint, origin) in copies { let name = self.resolver.symbol_name(function).to_string(); let frames = self @@ -1090,7 +1109,7 @@ impl Analyser { let fits = !matches!(binding, Binding::None) && self.commit_call(&binding, arguments, return_type).is_some(); self.vars.rollback(snapshot); - self.pending_copies.clear(); + self.pending.clear(); fits } @@ -1275,15 +1294,20 @@ impl Analyser { if self.copied_through.is_some() { return; } + // A generic function's body calls itself with its own types, so an + // instance's recursive call is a call of the instance. + if let (CallSite::Call(id), Binding::Resolved(Candidate::Scalar(callee)), None) = + (site, &binding, self.recording) + && self.declaring == Some(*callee) + { + self.recursive_calls.push(id); + } match site { - CallSite::Call(id) => { - self.picks.bindings.insert(id, binding); - } - CallSite::Getter(id) => { - self.picks.getters.insert(id, binding); - } - CallSite::Setter(id) | CallSite::FieldSetter(id) => { - self.picks.setters.insert(id, binding); + CallSite::Call(_) + | CallSite::Getter(_) + | CallSite::Setter(_) + | CallSite::FieldSetter(_) => { + self.pick_tables(self.recording).insert(site, binding); } // An in-place overload such as `++=` does the same thing faster, // so it's used where one takes the operands as they are. @@ -1325,7 +1349,8 @@ impl Analyser { /// Records what `a op= b` at `id` does, as [`Self::record_pick`]. pub(super) fn record_augmented_pick(&mut self, id: NodeId, binding: Binding) { if self.copied_through.is_none() { - self.picks.augmented_assignments.insert(id, binding); + self.pick_tables(self.recording) + .insert(CallSite::OpAssignment(id), binding); } } diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 703f3816..a540f342 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1565,3 +1565,121 @@ fn a_generic_function_stored_in_a_variable_keeps_its_constraints() { vec!["No function called '+' found that matches the arguments 'String, Int'"] ); } + +/// How many instances of generic functions analysing `source` makes. +fn instance_count(source: &str, globals: Vec<(String, StaticType)>) -> usize { + let (_, result) = analyse_with_globals(source, globals); + assert!(result.errors.is_empty(), "{:?}", result.errors); + result.analysis.instances.values().map(Vec::len).sum() +} + +#[test] +fn each_set_of_key_types_gets_one_instance() { + let (_, result) = analyse_with_globals( + "fn inc(x) => x + 1; inc(1); inc(2.5); inc(3);", + ambiguous_plus(), + ); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let instances = result + .analysis + .instances + .values() + .flatten() + .collect::>(); + assert_eq!(instances.len(), 2); + // Each picks its `+` from its types. + for instance in instances { + assert!( + instance + .bindings + .values() + .any(|binding| matches!(binding, Binding::Resolved(_))), + "{instance:?}" + ); + } +} + +#[test] +fn a_function_whose_calls_dont_depend_on_its_types_has_no_instances() { + assert_eq!( + instance_count("fn wrap(x) => [x]; wrap(1); wrap(\"a\");", vec![]), + 0 + ); +} + +#[test] +fn a_call_with_any_arguments_uses_the_function_itself() { + assert_eq!( + instance_count( + "fn inc(x) => x + 1; let a: Any = 1; inc(a);", + ambiguous_plus() + ), + 0 + ); +} + +#[test] +fn an_instance_calls_the_instances_its_types_need() { + // `twice(2.5)` needs `twice` for Floats, which needs `inc` for Floats. + let (_, result) = analyse_with_globals( + "fn inc(x) => x + 1; fn twice(x) => inc(inc(x)); twice(2.5);", + ambiguous_plus(), + ); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert_eq!( + result.analysis.instances.len(), + 2, + "{:?}", + result.analysis.instances + ); + let twice = result + .analysis + .instances + .values() + .flatten() + .find(|instance| instance.bindings.len() == 2) + .expect("twice's instance picks its two calls of inc"); + let inc = result + .analysis + .instances + .values() + .flatten() + .find(|instance| instance.bindings.len() == 1) + .expect("inc's instance picks its +"); + assert!( + twice + .bindings + .values() + .all(|binding| *binding == Binding::Resolved(Candidate::Scalar(inc.variable))), + "{twice:?}" + ); +} + +#[test] +fn an_instance_calls_itself_where_its_body_recurses() { + let globals = natives(&[ + ("<", "(Int, Int) -> Bool"), + ("-", "(Int, Int) -> Int"), + ("+", "(Int, Int) -> Int"), + ]); + let (_, result) = analyse_with_globals( + "fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; fib(10);", + globals, + ); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let [instance] = &result + .analysis + .instances + .values() + .flatten() + .collect::>()[..] + else { + panic!("one instance expected: {:?}", result.analysis.instances); + }; + let calls_itself = instance + .bindings + .values() + .filter(|binding| **binding == Binding::Resolved(Candidate::Scalar(instance.variable))) + .count(); + assert_eq!(calls_itself, 2, "{instance:?}"); +} diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 061cf66a..cd1bc7bf 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -103,6 +103,12 @@ impl TypeVars { self.settle(var, StaticType::Generic(name.into())); } + /// How many variables there are, so [`Self::settle_from`] can settle + /// the ones made after this point. + pub(crate) fn count(&self) -> usize { + self.solutions.len() + } + fn solution(&self, var: TypeVar) -> Option<&StaticType> { self.solutions[var.0 as usize].as_ref() } diff --git a/ndc_analysis/src/lib.rs b/ndc_analysis/src/lib.rs index 91942184..88493fb9 100644 --- a/ndc_analysis/src/lib.rs +++ b/ndc_analysis/src/lib.rs @@ -62,6 +62,29 @@ pub struct Analysis { /// How many global slots the program needs: the natives and every /// top-level declaration, counting earlier REPL lines. pub global_count: usize, + /// The instances of each generic function the batch declares, keyed by + /// the declaration. An instance is the function's body for the types one + /// set of calls gives it, which picks the overloads its calls use from + /// those types: `fn inc(x) => x + 1` has one for Ints, whose `+` is + /// `+(Int, Int)`. The declaration itself is the instance for arguments + /// of any type, whose calls are picked as the program runs. + pub instances: AHashMap>, +} + +/// One instance of a generic function: where it's stored, and the picks in +/// its body that differ from the function's own. +#[derive(Debug, Clone)] +pub struct Instance { + /// The global the instance is stored in. + pub variable: ResolvedVar, + /// As [`Analysis::bindings`], for the instance's body. + pub bindings: AHashMap, + /// As [`Analysis::getters`], for the instance's body. + pub getters: AHashMap, + /// As [`Analysis::setters`], for the instance's body. + pub setters: AHashMap, + /// As [`Analysis::augmented_assignments`], for the instance's body. + pub augmented_assignments: AHashMap, } impl Analysis { diff --git a/ndc_layout/src/lib.rs b/ndc_layout/src/lib.rs index b91eb915..1e88ba85 100644 --- a/ndc_layout/src/lib.rs +++ b/ndc_layout/src/lib.rs @@ -8,7 +8,9 @@ //! them lives, once per batch. use ahash::AHashMap; -use ndc_analysis::{Analysis, Binding, CaptureSource, DeclaredStruct, FieldAccessors, ResolvedVar}; +use ndc_analysis::{ + Analysis, Binding, CaptureSource, DeclaredStruct, FieldAccessors, Instance, ResolvedVar, +}; use ndc_core::NodeId; use ndc_core::r#struct::StructInfo; use ndc_resolver::{NameResolver, ScopeId, ScopeKind, SymbolId}; @@ -33,6 +35,32 @@ pub struct Picks { /// Each struct declaration's struct, for [`Analysis::structs`], which also /// says where its field accessors live. pub structs: AHashMap, + /// For [`Analysis::instances`], by the symbol each instance is stored as. + pub instances: AHashMap, +} + +/// What one instance of a generic function picks in its body, where that +/// differs from the function's own picks. +#[derive(Debug)] +pub struct InstancePicks { + /// The generic function's declaration. + pub declaration: NodeId, + pub bindings: AHashMap>, + pub getters: AHashMap>, + pub setters: AHashMap>, + pub augmented_assignments: AHashMap>, +} + +impl InstancePicks { + pub fn new(declaration: NodeId) -> Self { + Self { + declaration, + bindings: AHashMap::new(), + getters: AHashMap::new(), + setters: AHashMap::new(), + augmented_assignments: AHashMap::new(), + } + } } /// Where the resolver's symbols live at runtime. @@ -119,6 +147,24 @@ impl<'a> Layout<'a> { let locate_binding = |(node, binding): (NodeId, Binding)| { (node, binding.map(|symbol| locate_use(node, symbol))) }; + for (symbol, picks) in picks.instances { + let instance = Instance { + variable: self.location(symbol, None), + bindings: picks.bindings.into_iter().map(locate_binding).collect(), + getters: picks.getters.into_iter().map(locate_binding).collect(), + setters: picks.setters.into_iter().map(locate_binding).collect(), + augmented_assignments: picks + .augmented_assignments + .into_iter() + .map(locate_binding) + .collect(), + }; + analysis + .instances + .entry(picks.declaration) + .or_default() + .push(instance); + } analysis .bindings .extend(picks.bindings.into_iter().map(locate_binding)); diff --git a/ndc_resolver/src/name_resolution.rs b/ndc_resolver/src/name_resolution.rs index a2fa67e5..bbdab23b 100644 --- a/ndc_resolver/src/name_resolution.rs +++ b/ndc_resolver/src/name_resolution.rs @@ -263,9 +263,24 @@ impl ScopeTree { } pub(crate) fn bind(&mut self, symbol_id: SymbolId) { - let position = self.scopes[self.current.0].bindings.len(); - self.scopes[self.current.0].bind(symbol_id); - self.bound_in.insert(symbol_id, (self.current, position)); + self.bind_in(self.current, symbol_id); + } + + /// Binds a value in `scope`, which may not be the current one. + fn bind_in(&mut self, scope: ScopeId, symbol_id: SymbolId) { + let position = self.scopes[scope.0].bindings.len(); + self.scopes[scope.0].bind(symbol_id); + self.bound_in.insert(symbol_id, (scope, position)); + } + + /// The program's top-level scope. + fn program(&self) -> ScopeId { + let position = self + .scopes + .iter() + .position(|scope| scope.kind == ScopeKind::Program) + .expect("internal error: the resolver starts in the program scope"); + ScopeId(position) } /// Binds a type name in the current scope. Types aren't runtime values, so @@ -462,6 +477,17 @@ impl NameResolver { /// The symbol a resolved declaration introduced: a binding pattern identifier's /// or a named function's, keyed by its node. + /// Declares a top-level value named `name` that no code refers to by + /// name, as no identifier can be spelled like it: an instance of a generic + /// function, which the analyser picks for the calls that use it. Being + /// top-level, it's a global. + pub fn declare_instance(&mut self, name: String) -> SymbolId { + let symbol = self.symbols.declare(name, BindingKind::Function); + let program = self.scopes.program(); + self.scopes.bind_in(program, symbol); + symbol + } + pub fn declared_symbol(&self, node: NodeId) -> SymbolId { self.declarations[&node] } diff --git a/ndc_vm/src/chunk.rs b/ndc_vm/src/chunk.rs index 6a2731f0..16ec470b 100644 --- a/ndc_vm/src/chunk.rs +++ b/ndc_vm/src/chunk.rs @@ -74,6 +74,7 @@ impl std::fmt::Debug for JumpTarget { /// | `Unpack` | `[… compound → … v1…vn]` | +(n−1) (pops 1, pushes n) | /// | `NewCell` | `[…]` | 0 (puts a fresh cell in a slot) | /// | `Memoize` | `[… fn → … memoized_fn]` | 0 (pops and pushes) | +/// | `MemoizeLike` | `[… fn memoized → … memoized_fn]` | −1 (shares memoized's cache) | // NOTE: OpCode cannot be Copy because the Closure variant holds Rc<[CaptureSource]>. // The dispatch loop accesses opcodes by reference to avoid cloning the 32-byte enum on // every iteration; see Vm::run_to_depth. @@ -147,6 +148,10 @@ pub enum OpCode { NewCell(usize), /// Pops function, pushes memoized wrapper. `[… fn → … memoized_fn]` Memoize, + /// Pops a memoized function and a function, and pushes the function + /// memoized with the same cache, as an instance of a generic `pure fn` + /// shares the function's. `[… fn memoized → … memoized_fn]` + MemoizeLike, /// Peeks top; errors if the value does not conform to the type. `[… value → … value]` CheckType(Rc), } @@ -184,6 +189,7 @@ impl OpCode { Self::Return => "Return", Self::NewCell(_) => "NewCell", Self::Memoize => "Memoize", + Self::MemoizeLike => "MemoizeLike", Self::CheckType(_) => "CheckType", } } @@ -239,6 +245,7 @@ impl OpCode { Self::Return => -1, Self::NewCell(_) => 0, Self::Memoize => 0, + Self::MemoizeLike => -1, // Peeks the value without popping it. Self::CheckType(_) => 0, } @@ -293,6 +300,7 @@ impl std::fmt::Debug for OpCode { Self::Return => write!(f, "Return"), Self::Unpack(n) => write!(f, "Unpack({n})"), Self::Memoize => write!(f, "Memoize"), + Self::MemoizeLike => write!(f, "MemoizeLike"), Self::CheckType(typ) => write!(f, "CheckType({typ})"), } } diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 6d22611f..960fec9a 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -1,7 +1,8 @@ use crate::chunk::{JumpTarget, LabelId, OpCode, OptimizerIr}; use crate::value::{CompiledFunction, Function}; use crate::{Object, Value}; -use ndc_analysis::{Analysis, Binding, Candidate, ResolvedVar}; +use ahash::AHashMap; +use ndc_analysis::{Analysis, Binding, Candidate, Instance, ResolvedVar}; use ndc_core::NodeId; use ndc_core::StaticType; use ndc_lexer::{NumericLiteral, Span}; @@ -9,6 +10,7 @@ use ndc_parser::{ AssignmentTarget, AssignmentTargetLocation, BindingPattern, BindingPatternLocation, Expression, ExpressionLocation, ForBody, ForIteration, LogicalOperator, }; +use std::cell::{Cell, RefCell}; use std::rc::Rc; pub struct Compiler { @@ -21,6 +23,17 @@ pub struct Compiler { optimize: bool, /// What the analyser worked out about the batch being compiled. analysis: Rc, + /// The instance of a generic function whose body is being compiled, + /// whose picks take the place of the function's own. + instance: Option>, + /// Whether the code compiled so far used a pick of `instance` that + /// differs from the function's own, so it differs from what the + /// function's own body compiles to. + used_instance: Cell, + /// The functions compiled outside of any instance, by declaration. A + /// function an instance's body declares is the same function when it + /// compiles the same, so it keeps its identity. + prototypes: Rc>>>, } impl Compiler { @@ -34,6 +47,9 @@ impl Compiler { allow_return: false, optimize: true, analysis: Rc::default(), + instance: None, + used_instance: Cell::new(false), + prototypes: Rc::default(), } } @@ -47,6 +63,9 @@ impl Compiler { allow_return: false, optimize: false, analysis: Rc::default(), + instance: None, + used_instance: Cell::new(false), + prototypes: Rc::default(), } } /// Compiles a program, or one REPL line, into a function the VM runs at @@ -200,8 +219,7 @@ impl Compiler { self.ir.write(OpCode::Constant(idx), span); } Expression::Identifier { .. } => { - let analysis = Rc::clone(&self.analysis); - self.compile_binding(analysis.binding(id), span)?; + self.compile_binding(&self.binding(id), span)?; } Expression::Cast { value, .. } => { self.compile_expr(*value)?; @@ -257,8 +275,7 @@ impl Compiler { value: container, index, } => { - let analysis = Rc::clone(&self.analysis); - self.compile_binding(analysis.setter(l_value.id), span)?; + self.compile_binding(&self.setter(l_value.id), span)?; self.compile_expr(*container)?; self.compile_expr(*index)?; self.compile_expr(*value)?; @@ -282,8 +299,7 @@ impl Compiler { member_span, .. } => { - let analysis = Rc::clone(&self.analysis); - self.compile_binding(analysis.setter(l_value.id), member_span)?; + self.compile_binding(&self.setter(l_value.id), member_span)?; self.compile_expr(*receiver)?; self.compile_expr(*value)?; self.ir.write(OpCode::Call(2), member_span); @@ -293,13 +309,12 @@ impl Compiler { l_value, r_value, .. } => { let target = PreparedAssignmentTarget::prepare(self, l_value)?; - let analysis = Rc::clone(&self.analysis); - let Some(binding) = analysis.augmented_assignments.get(&id) else { + let Some(binding) = self.augmented_assignment(id) else { return Err(CompileError::unresolved_binding(span)); }; - let opcode = Self::call_opcode_for(binding, 2); - self.compile_binding(binding, span)?; + let opcode = Self::call_opcode_for(&binding, 2); + self.compile_binding(&binding, span)?; target.emit_read(self)?; self.compile_expr(*r_value)?; self.ir.write(opcode, span); @@ -393,7 +408,7 @@ impl Compiler { let function_span = function.span; let opcode = match &function.expression { Expression::Identifier { .. } => { - Self::call_opcode_for(self.analysis.binding(function.id), arguments.len()) + Self::call_opcode_for(&self.binding(function.id), arguments.len()) } _ => OpCode::Call(arguments.len()), }; @@ -410,11 +425,10 @@ impl Compiler { member_span, .. } => { - let analysis = Rc::clone(&self.analysis); - let getter = analysis + let getter = self .getter(id) .expect("internal error: every member access is analysed before it's compiled"); - self.compile_binding(getter, member_span)?; + self.compile_binding(&getter, member_span)?; self.compile_expr(*receiver)?; self.ir.write(OpCode::Call(1), member_span); } @@ -519,8 +533,7 @@ impl Compiler { let tmp_value = self.allocate_temp(); self.write_temp(tmp_value, span, OpCode::SetLocal); - let analysis = Rc::clone(&self.analysis); - self.compile_binding(analysis.setter(l_value.id), span)?; + self.compile_binding(&self.setter(l_value.id), span)?; self.compile_expr(*value)?; self.compile_expr(*index)?; self.write_temp(tmp_value, span, OpCode::GetLocal); @@ -572,12 +585,54 @@ impl Compiler { /// The function an augmented assignment reads the target at `target` with. fn target_getter(&self, target: NodeId) -> Binding { - self.analysis - .getter(target) + self.getter(target) .expect("internal error: an augmented assignment's target has a getter") + } + + /// What the identifier `id` refers to, in the instance being compiled. + fn binding(&self, id: NodeId) -> Binding { + self.instance_pick(|instance| instance.bindings.get(&id)) + .unwrap_or_else(|| self.analysis.binding(id)) .clone() } + /// The pick `pick` finds in the instance being compiled, if any, noting + /// that the code depends on the instance. + fn instance_pick<'a>( + &'a self, + pick: impl FnOnce(&'a Instance) -> Option<&'a Binding>, + ) -> Option<&'a Binding> { + let found = self.instance.as_deref().and_then(pick); + if found.is_some() { + self.used_instance.set(true); + } + found + } + + /// The function that reads through the target at `id`, if it reads, in + /// the instance being compiled. + fn getter(&self, id: NodeId) -> Option { + self.instance_pick(|instance| instance.getters.get(&id)) + .or_else(|| self.analysis.getter(id)) + .cloned() + } + + /// The function that writes through the target at `id`, in the instance + /// being compiled. + fn setter(&self, id: NodeId) -> Binding { + self.instance_pick(|instance| instance.setters.get(&id)) + .unwrap_or_else(|| self.analysis.setter(id)) + .clone() + } + + /// The function the augmented assignment `id` calls, in the instance + /// being compiled. + fn augmented_assignment(&self, id: NodeId) -> Option { + self.instance_pick(|instance| instance.augmented_assignments.get(&id)) + .or_else(|| self.analysis.augmented_assignments.get(&id)) + .cloned() + } + /// Emit code that puts the resolved callee on top of the stack. /// /// * `Resolved(Scalar(var))` and `Resolved(Vec(scalar))` both emit a @@ -785,36 +840,26 @@ impl Compiler { span: Span, ) -> Result<(), CompileError> { let name_variable = self.analysis.variable(id); - let static_type = self.analysis.function_type(id).clone(); - let mut fn_compiler = Self { - ir: Default::default(), - source_locals: self.analysis.frame_size(id), - temp_count: 0, - temp_uses: Vec::new(), - loop_stack: vec![], - allow_return: true, - optimize: self.optimize, - analysis: Rc::clone(&self.analysis), - }; - fn_compiler.compile_expr(body)?; - fn_compiler.ir.write(OpCode::Return, Span::synthetic()); - fn_compiler.layout_temporaries(); - - if fn_compiler.optimize { - fn_compiler.ir.peephole(); - } - - let num_locals = fn_compiler.num_locals(); - let compiled = CompiledFunction { - name, - static_type, - body: fn_compiler.ir.into_chunk(), - num_locals, - num_globals: 0, + let analysis = Rc::clone(&self.analysis); + let instances = analysis.instances.get(&id).map_or(&[][..], Vec::as_slice); + // The function's own body comes first, so the functions it declares + // are there for its instances to reuse. + let (compiled, instance_bodies) = if instances.is_empty() { + (self.prototype(id, name, body)?, Vec::new()) + } else { + let compiled = self.prototype(id, name.clone(), body.clone())?; + let instance_bodies = instances + .iter() + .map(|instance| { + self.compile_function_body(id, name.clone(), body.clone(), Some(instance)) + .map(|(compiled, _)| compiled) + }) + .collect::, _>>()?; + (compiled, instance_bodies) }; let idx = self .ir - .add_constant(Value::function(Function::Compiled(Rc::new(compiled)))); + .add_constant(Value::function(Function::Compiled(compiled))); // A captured function may capture itself, before its value exists, so // its cell must be in place before the closure is made. @@ -846,11 +891,9 @@ impl Compiler { match name_variable { Some(var @ (ResolvedVar::Local { .. } | ResolvedVar::Global { .. })) => { self.emit_declare(var, span); - self.emit_unit(); } Some(ResolvedVar::Cell { slot }) => { self.ir.write(OpCode::SetCell(slot), span); - self.emit_unit(); } Some(ResolvedVar::Upvalue { .. }) => { unreachable!("the analyser never assigns a declaration to an upvalue") @@ -858,9 +901,103 @@ impl Compiler { None => {} } + // Each instance of a generic function is its body compiled with the + // instance's picks. A generic function is declared at the top level, + // so it captures nothing and is a global, and its instances are + // stored in globals of their own. A `pure fn`'s instances share its + // cache, as they compute what it does. + for (instance, instance_body) in instances.iter().zip(instance_bodies) { + let idx = self + .ir + .add_constant(Value::function(Function::Compiled(Rc::new(instance_body)))); + self.ir.write(OpCode::Constant(idx), span); + if pure && let Some(function) = name_variable { + self.emit_get_var(function, span); + self.ir.write(OpCode::MemoizeLike, span); + } + self.emit_declare(instance.variable, span); + } + + if name_variable.is_some() { + self.emit_unit(); + } + Ok(()) } + /// The compiled function declared at `id`. Inside an instance's body, a + /// function that compiles as it does outside of it is the same one. + fn prototype( + &self, + id: NodeId, + name: Option, + body: ExpressionLocation, + ) -> Result, CompileError> { + let (compiled, used_instance) = self.compile_function_body(id, name, body, None)?; + if self.instance.is_none() { + let compiled = Rc::new(compiled); + self.prototypes + .borrow_mut() + .insert(id, Rc::clone(&compiled)); + return Ok(compiled); + } + if used_instance { + self.used_instance.set(true); + } else if let Some(prototype) = self.prototypes.borrow().get(&id) { + return Ok(Rc::clone(prototype)); + } + Ok(Rc::new(compiled)) + } + + /// Compiles the body of the function declared at `id`, with the picks + /// of `instance` if it's an instance of a generic function. The + /// functions it declares are compiled with them too. Also returns + /// whether the body used a pick of the instance it's compiled in. + fn compile_function_body( + &self, + id: NodeId, + name: Option, + body: ExpressionLocation, + instance: Option<&Instance>, + ) -> Result<(CompiledFunction, bool), CompileError> { + let static_type = self.analysis.function_type(id).clone(); + let mut fn_compiler = Self { + ir: Default::default(), + source_locals: self.analysis.frame_size(id), + temp_count: 0, + temp_uses: Vec::new(), + loop_stack: vec![], + allow_return: true, + optimize: self.optimize, + analysis: Rc::clone(&self.analysis), + instance: instance + .map(|instance| Rc::new(instance.clone())) + .or_else(|| self.instance.clone()), + used_instance: Cell::new(false), + prototypes: Rc::clone(&self.prototypes), + }; + fn_compiler.compile_expr(body)?; + fn_compiler.ir.write(OpCode::Return, Span::synthetic()); + fn_compiler.layout_temporaries(); + + if fn_compiler.optimize { + fn_compiler.ir.peephole(); + } + + let num_locals = fn_compiler.num_locals(); + let used_instance = fn_compiler.used_instance.get(); + Ok(( + CompiledFunction { + name, + static_type, + body: fn_compiler.ir.into_chunk(), + num_locals, + num_globals: 0, + }, + used_instance, + )) + } + fn compile_for( &mut self, iterations: &[ForIteration], @@ -1079,7 +1216,7 @@ impl PreparedAssignmentTarget { container_span, index_span, getter: compiler.target_getter(l_value.id), - setter: compiler.analysis.setter(l_value.id).clone(), + setter: compiler.setter(l_value.id), }) } AssignmentTarget::Member { @@ -1098,7 +1235,7 @@ impl PreparedAssignmentTarget { receiver_span, member_span, getter: compiler.target_getter(l_value.id), - setter: compiler.analysis.setter(l_value.id).clone(), + setter: compiler.setter(l_value.id), }) } AssignmentTarget::Sequence(_) => unreachable!( diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index f3954904..c4c10ebf 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -603,6 +603,25 @@ impl Vm { function: Rc::new(func), })); } + OpCode::MemoizeLike => { + let memoized = self.stack.pop().expect("stack underflow"); + let function = self.stack.pop().expect("stack underflow"); + let (Value::Object(memoized), Value::Object(function)) = (memoized, function) + else { + panic!("MemoizeLike: expected two functions on the stack"); + }; + let ( + Object::Function(Function::Memoized { cache, .. }), + Object::Function(function), + ) = (memoized.as_ref(), function.as_ref()) + else { + panic!("MemoizeLike: expected a function and a memoized function"); + }; + self.stack.push(Value::function(Function::Memoized { + cache: Rc::clone(cache), + function: Rc::new(function.clone()), + })); + } } }; diff --git a/tests/compiler/tests/compiler.rs b/tests/compiler/tests/compiler.rs index 600788e6..9058fe0c 100644 --- a/tests/compiler/tests/compiler.rs +++ b/tests/compiler/tests/compiler.rs @@ -675,3 +675,61 @@ fn temporaries_in_a_function_come_after_its_frames_own_locals() { written.dedup(); assert_eq!(written, [0, 1, 2, 3, 4], "{ops:?}"); } + +/// The functions `function`'s code puts on the stack, in order. +fn nested_functions(function: &CompiledFunction) -> Vec> { + function + .opcodes() + .iter() + .filter_map(|opcode| match opcode { + Constant(index) + | Closure { + constant_idx: index, + .. + } => Some(*index), + _ => None, + }) + .filter_map(|index| match function.body().constant(index) { + ndc_vm::Value::Object(object) => match object.as_ref() { + ndc_vm::Object::Function(ndc_vm::Function::Compiled(function)) => { + Some(Rc::clone(function)) + } + _ => None, + }, + _ => None, + }) + .collect() +} + +/// The compiled top-level code of `input`, with the standard library. +fn compile_program_with_stdlib(input: &str) -> CompiledFunction { + let mut interp = ndc_interpreter::Interpreter::capturing(); + interp.configure(ndc_stdlib::register); + interp + .compile_str_unoptimized(input) + .expect("compile failed") +} + +// A generic function's declaration compiles its own body (constant 0), then +// each instance (constant 1 for `adder` with Ints). A lambda in the body is +// compiled with the instance: its `+` is picked for Ints there, and as the +// program runs in the function's own body, so the two differ. +#[test] +fn a_function_declared_in_an_instance_uses_the_instances_picks() { + let program = + compile_program_with_stdlib("fn adder(x) { let g = fn(y) => y + 1; g(x) } adder(1);"); + let own = nested_functions(&function_constant(&program, 0))[0].clone(); + let instance = nested_functions(&function_constant(&program, 1))[0].clone(); + assert!(!Rc::ptr_eq(&own, &instance)); + assert_ne!(own.opcodes(), instance.opcodes()); +} + +// A function declared in an instance that compiles as it does in the +// function's own body is the same function, so it keeps its identity. +#[test] +fn a_function_declared_in_an_instance_that_compiles_the_same_is_the_same() { + let program = compile_program_with_stdlib("fn make(x) { x + 1; fn f() => 42; f } make(1);"); + let own = nested_functions(&function_constant(&program, 0))[0].clone(); + let instance = nested_functions(&function_constant(&program, 1))[0].clone(); + assert!(Rc::ptr_eq(&own, &instance)); +} diff --git a/tests/functional/programs/005_functions/083_each_instance_calls_the_instances_its_types_need.ndc b/tests/functional/programs/005_functions/083_each_instance_calls_the_instances_its_types_need.ndc new file mode 100644 index 00000000..a8b3886a --- /dev/null +++ b/tests/functional/programs/005_functions/083_each_instance_calls_the_instances_its_types_need.ndc @@ -0,0 +1,10 @@ +// A generic function's body is compiled for each type it's called with, and +// the generic functions it calls are the ones for the types they get there: +// `perlin(0.5)` calls `fade` for Floats. +// expect-output: 0.25 1 0.25 +fn fade(t) => t * t; +fn perlin(x) { + let xf = x - int(x); + fade(xf) +} +print(perlin(1.5), fade(1), fade(0.5)); diff --git a/tests/functional/programs/005_functions/084_a_generic_function_used_as_a_value_is_the_function_itself.ndc b/tests/functional/programs/005_functions/084_a_generic_function_used_as_a_value_is_the_function_itself.ndc new file mode 100644 index 00000000..03130c72 --- /dev/null +++ b/tests/functional/programs/005_functions/084_a_generic_function_used_as_a_value_is_the_function_itself.ndc @@ -0,0 +1,7 @@ +// A generic function's name used as a value is the function, whatever type +// it's used at, so two values of it are equal. +// expect-output: true 2 2.5 +fn inc(x) => x + 1; +let a: Fn(Int) -> Int = inc; +let b: Fn(Float) -> Float = inc; +print(a == b, a(1), b(1.5)); diff --git a/tests/functional/programs/005_functions/085_a_function_declared_in_a_generic_body_keeps_its_identity.ndc b/tests/functional/programs/005_functions/085_a_function_declared_in_a_generic_body_keeps_its_identity.ndc new file mode 100644 index 00000000..c787145e --- /dev/null +++ b/tests/functional/programs/005_functions/085_a_function_declared_in_a_generic_body_keeps_its_identity.ndc @@ -0,0 +1,10 @@ +// A function declared in a generic function's body is compiled again for +// each type the generic function is called with. Where it compiles the same, +// it's the same function, so `make(1)` and `make(1.5)` return equal values. +// expect-output: true 42 +fn make(x) { + x + 1; + fn f() => 42; + f +} +print(make(1) == make(1.5), make(1)()); diff --git a/tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc b/tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc new file mode 100644 index 00000000..463dc452 --- /dev/null +++ b/tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc @@ -0,0 +1,14 @@ +// A generic `pure fn` is compiled once for each type it's called with, and +// all of them share its cache: a call through a value of it, which is the +// function itself, finds what a direct call computed. +// expect-output: 10 10 2.5 2 +let calls = 0; +pure fn add(x, y) { + calls += 1; + x + y +} +let first = add(5, 5); +let f = add; +let second = f(5, 5); +let third = add(1.0, 1.5); +print(first, second, third, calls); diff --git a/tests/functional/tests/repl.rs b/tests/functional/tests/repl.rs index 595eed4e..b0f9c7b6 100644 --- a/tests/functional/tests/repl.rs +++ b/tests/functional/tests/repl.rs @@ -218,3 +218,21 @@ fn configure_after_a_line_ran_updates_the_globals() { let out = String::from_utf8(interp.get_output().unwrap()).unwrap(); assert_eq!(out.trim(), "1"); } + +#[test] +fn a_generic_function_from_an_earlier_line_takes_a_call_with_new_types() { + // The line that declared `inc` compiled its instance for Ints. A later + // line calls it with a Float, which its own body takes. + let out = repl_output(&[ + "fn inc(x) => x + 1;", + "print(inc(1));", + "print(inc(2.5), inc(1));", + ]); + assert_eq!(out.trim(), "2\n3.5 2"); +} + +#[test] +fn a_generic_function_on_one_line_uses_its_instances() { + let out = repl_output(&["fn inc(x) => x + 1; print(inc(1), inc(2.5));"]); + assert_eq!(out.trim(), "2 3.5"); +} diff --git a/tests/proptest/tests/overloads.regressions b/tests/proptest/tests/overloads.regressions index 341ba3ff..b6399d79 100644 --- a/tests/proptest/tests/overloads.regressions +++ b/tests/proptest/tests/overloads.regressions @@ -4,3 +4,4 @@ # # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. +cc ccf1065cc5b6785de2f3e60fab2f91a07dbf049d640de1fd8e4cd69e87a31628 # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: List) => 1; fn q(x: List) => 1; fn g(a) => a.len(); fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0[0] += (if true { q(push).fold(0, fn(acc, a) => acc + a) } else { fn(a) => a ++ "!" }); diff --git a/tests/proptest/tests/overloads.rs b/tests/proptest/tests/overloads.rs index f9e552bf..84000d80 100644 --- a/tests/proptest/tests/overloads.rs +++ b/tests/proptest/tests/overloads.rs @@ -11,8 +11,9 @@ //! generates well-formed programs instead, from the pieces that make the //! analyser commit early: overloaded functions taking `Int`, `Float`, //! `String`, lists and anything, empty literals settled by later uses, -//! lambdas, function values, `map`, `if`, index writes, `op=` and -//! annotations. Analysing each one must not panic. Type errors are +//! lambdas, function values, `map`, `if`, index writes, `op=`, +//! annotations, and generic functions calling each other, whose instances +//! the analyser works out after the program. Analysing each one must not panic. Type errors are //! expected and ignored. //! //! * `known_overload_regressions_do_not_panic` replays the seeds saved in @@ -82,9 +83,9 @@ fn arb_overloads(name: &'static str) -> impl Strategy { /// The names a value can be: variables, overloaded functions declared by /// the program, and overloaded or generic built-ins. -const VALUES: [&str; 15] = [ +const VALUES: [&str; 17] = [ "v0", "v1", "v2", "v3", "p", "q", "abs", "split", "push", "len", "max", "first", "sum", "n", - "S", + "S", "g", "h", ]; const LITERALS: [&str; 13] = [ @@ -103,26 +104,46 @@ const LITERALS: [&str; 13] = [ "[S(1)]", ]; +/// What a function taking `a` can do with it, in ways that need its type. +const BODIES_USING_A: [&str; 15] = [ + "a", + "p(a)", + "q(a)", + "a + 1", + "a ++ \"!\"", + "[a]", + "a.len()", + "if true { a } else { 1 }", + "if true { a } else { [] }", + "p(a, 1)", + "a[0]", + "a.n", + "a.n + 1", + "a as Int", + "a.first()", +]; + /// A lambda taking `a`, whose body uses `a` in ways that need its type. fn arb_lambda() -> impl Strategy { - prop::sample::select(vec![ - "a", - "p(a)", - "q(a)", - "a + 1", - "a ++ \"!\"", - "[a]", - "a.len()", - "if true { a } else { 1 }", - "if true { a } else { [] }", - "p(a, 1)", - "a[0]", - "a.n", - "a.n + 1", - "a as Int", - "a.first()", - ]) - .prop_map(|body| format!("fn(a) => {body}")) + prop::sample::select(BODIES_USING_A.to_vec()).prop_map(|body| format!("fn(a) => {body}")) +} + +/// Two generic functions, `g` and `h`, where `h` calls `g`, so that each +/// call needs instances of both. +fn arb_generic_functions() -> impl Strategy { + ( + prop::sample::select(BODIES_USING_A.to_vec()), + prop::sample::select(vec![ + "g(a)", + "g(a) + 1", + "[g(a)]", + "g([a])", + "p(g(a))", + "if true { g(a) } else { a }", + "g(a).len()", + ]), + ) + .prop_map(|(g, h)| format!("fn g(a) => {g};\nfn h(a) => {h};\n")) } /// An expression, nesting a few levels deep. @@ -135,7 +156,7 @@ fn arb_expression() -> impl Strategy { leaf.prop_recursive(3, 16, 2, |inner| { prop_oneof![ ( - prop::sample::select(vec!["p", "q", "abs", "len"]), + prop::sample::select(vec!["p", "q", "abs", "len", "g", "h"]), inner.clone() ) .prop_map(|(f, e)| format!("{f}({e})")), @@ -200,13 +221,15 @@ fn arb_program() -> impl Strategy { ( arb_overloads("p"), arb_overloads("q"), + arb_generic_functions(), prop::collection::vec(prop::sample::select(LITERALS.to_vec()), 4), prop::collection::vec(arb_statement(), 1..=6), ) - .prop_map(|(p, q, initial, statements)| { + .prop_map(|(p, q, generic, initial, statements)| { // Two structs with a field of the same name, so `x.n` and the // getter `n` have overloads to choose from. - let mut source = format!("struct S {{ n: Int }}\nstruct T {{ n: String }}\n{p}{q}"); + let mut source = + format!("struct S {{ n: Int }}\nstruct T {{ n: String }}\n{p}{q}{generic}"); for (variable, value) in VARIABLES.iter().zip(initial) { source.push_str(&format!("let {variable} = {value};\n")); } From d14b1ac60cbd145396281208d5e57858b45ab4bf Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 12 Nov 2026 13:41:34 +0100 Subject: [PATCH 11/39] =?UTF-8?q?feat(analyser):=20check=20a=20value=20of?= =?UTF-8?q?=20type=20Any=20where=20code=20expects=20another=20type=20?= =?UTF-8?q?=F0=9F=9B=82=20(#308)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4a, part 1, of the type system RFC (#281): `Any` becomes the dynamic type. A value of type `Any` goes where code expects another type, and is checked to have that type when the program gets there. ```ndc let a: Any = "five"; let n: Int = a; // error: expected Int but found String ``` Before, an `Any` value went into a typed variable unchecked, and a function with an annotated return type couldn't return one at all. Where a value of type `Any` is checked: - Storing it in an annotated variable, or a name a pattern declares. - Assigning it to a variable, an element or a field, including through `op=` and destructuring. - Passing it for a parameter whose type the call knows. `xs.push(a)` on a `List` checks for an Int. - Returning it from a function with an annotated return type. Each value returned is now checked against the annotation on its own, so a mismatch is reported at that value, not at the function. - A waiting call that turns out to return `Any` after other code had already settled its type. How it works: - The analyser records each check in a new `Analysis::type_checks` table, keyed by where it happens: an expression, checked once it's computed, or an assignment target or a declared name, checked before the value is stored. - The compiler emits a new `ExpectType` opcode there. It works like `CheckType` (containers are scanned, as `as` scans them), but its error reads `expected Int but found String`. - Checks in a generic function's body depend on the types each instance is for. In `fn put(xs, v) { xs.push(v) }`, the instance for `List` checks `v` for an Int and the one for `List` for a String. So each instance keeps its own checks next to its picks, and the function's own body checks its type parameters as `Any`. A copy of a constraint in a caller records nothing, because the instance for that call does the check. - Each instance of a generic `pure fn` now has a memo cache of its own, and the `MemoizeLike` opcode from #306, which made them share one, is gone. Instances can fail where the others don't for the same arguments, directly or through what they call: two empty lists of different element types look the same as the program runs. So a call through a value of the function, which runs its own body, no longer finds what a direct call to an instance computed. None of the benchmarks gets a check, so their performance is unchanged. Not in this PR: containers are still covariant, so `[a]` with `a: Any` still passes for a `List` without a check. Invariance is the next part of 4a. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types.md | 17 +++- ndc_analyser/src/analyser/assignments.rs | 39 ++++++-- ndc_analyser/src/analyser/calls.rs | 53 +++++++--- ndc_analyser/src/analyser/declarations.rs | 58 ++++------- ndc_analyser/src/analyser/instances.rs | 16 +-- ndc_analyser/src/analyser/mod.rs | 73 ++++++++++++-- ndc_analyser/src/analyser/solver.rs | 98 +++++++++++++++---- ndc_analyser/src/analyser/tests.rs | 4 +- ndc_analysis/src/lib.rs | 8 ++ ndc_layout/src/lib.rs | 6 +- ndc_vm/src/chunk.rs | 15 ++- ndc_vm/src/compiler.rs | 41 ++++++-- ndc_vm/src/vm.rs | 28 ++---- tests/compiler/tests/compiler.rs | 25 +++++ ...fn_shares_its_cache_with_its_instances.ndc | 14 --- ...of_a_generic_pure_fn_has_its_own_cache.ndc | 15 +++ ...ance_that_checks_any_has_its_own_cache.ndc | 10 ++ ...se_callee_checks_any_has_its_own_cache.ndc | 10 ++ ...annotated_let_takes_an_any_of_its_type.ndc | 6 ++ .../002_an_annotated_let_checks_an_any.ndc | 4 + .../003_an_assignment_checks_an_any.ndc | 5 + .../004_a_destructuring_checks_each_any.ndc | 6 ++ ...rn_annotation_takes_an_any_of_its_type.ndc | 5 + .../006_a_return_annotation_checks_an_any.ndc | 8 ++ .../007_a_generic_argument_checks_an_any.ndc | 7 ++ .../008_an_index_write_checks_an_any.ndc | 5 + .../009_a_field_write_checks_an_any.ndc | 6 ++ .../010_a_container_from_any_is_scanned.ndc | 4 + ...stance_checks_an_any_against_its_types.ndc | 11 +++ 29 files changed, 447 insertions(+), 150 deletions(-) delete mode 100644 tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc create mode 100644 tests/functional/programs/010_memoization/003_each_copy_of_a_generic_pure_fn_has_its_own_cache.ndc create mode 100644 tests/functional/programs/010_memoization/004_an_instance_that_checks_any_has_its_own_cache.ndc create mode 100644 tests/functional/programs/010_memoization/005_an_instance_whose_callee_checks_any_has_its_own_cache.ndc create mode 100644 tests/functional/programs/017_dynamic_any/001_an_annotated_let_takes_an_any_of_its_type.ndc create mode 100644 tests/functional/programs/017_dynamic_any/002_an_annotated_let_checks_an_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/003_an_assignment_checks_an_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/004_a_destructuring_checks_each_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/005_a_return_annotation_takes_an_any_of_its_type.ndc create mode 100644 tests/functional/programs/017_dynamic_any/006_a_return_annotation_checks_an_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/007_a_generic_argument_checks_an_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/008_an_index_write_checks_an_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/009_a_field_write_checks_an_any.ndc create mode 100644 tests/functional/programs/017_dynamic_any/010_a_container_from_any_is_scanned.ndc create mode 100644 tests/functional/programs/017_dynamic_any/011_each_instance_checks_an_any_against_its_types.ndc diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index 6adc947c..54473268 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -54,4 +54,19 @@ rules. > **Note:** `Any` is the base type for every other type, so an `Any`-annotated binding will accept anything. When a value has no annotation and the analyser can't infer a type, it falls back to `Any`. A parameter of a function declared in the program gets the type of each call's argument instead (see [Generic functions](./types/function.md#generic-functions)). There is also a `Never` type used internally for things like `break` that don't produce a value — you'll rarely need to write it by hand. -To go the other way — recover a precise type from an `Any`-typed value — use a [cast](../features/casts.md): `value as List`. +### Using an `Any` value where a type is expected + +You can use an `Any` value where a specific type is expected. The program checks the value when it gets there: + +```ndc,fails +let a: Any = 5; +let n: Int = a; // fine: a holds an Int +print(n + 1); // 6 + +let b: Any = "five"; +let m: Int = b; // error: expected Int but found String +``` + +The same goes for assigning it, passing it to a function, or returning it from one. For a list or a map, every element is checked. + +You can also check it yourself with a [cast](../features/casts.md): `value as List`. diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 6c93b863..fd7176a3 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -1,5 +1,5 @@ use super::Analyser; -use super::solver::CallSite; +use super::solver::{CallNodes, CallSite}; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall}; use itertools::izip; @@ -38,6 +38,10 @@ impl Analyser { let result = self.constrain_call( CallSite::OpAssignment(id), vec![left_type.clone(), right_type], + CallNodes { + arguments: vec![None, Some(r_value.id)], + result: None, + }, CallKind::Operator, operation, span, @@ -160,6 +164,10 @@ impl Analyser { Some(_) => self.constrain_call( CallSite::Getter(lvalue.id), vec![container.clone(), index_type.clone()], + CallNodes { + arguments: vec![Some(value.id), Some(index.id)], + result: None, + }, CallKind::Regular, "[]", span, @@ -171,6 +179,10 @@ impl Analyser { self.constrain_call( CallSite::Setter(lvalue.id), vec![container, index_type, stored.clone()], + CallNodes { + arguments: vec![Some(value.id), Some(index.id), Some(lvalue.id)], + result: None, + }, CallKind::Regular, "[]=", span, @@ -194,6 +206,10 @@ impl Analyser { let stored = self.constrain_call( CallSite::Getter(lvalue.id), vec![receiver_type.clone()], + CallNodes { + arguments: vec![Some(receiver.id)], + result: None, + }, CallKind::Regular, member, *member_span, @@ -201,6 +217,10 @@ impl Analyser { self.constrain_call( CallSite::FieldSetter(lvalue.id), vec![receiver_type, stored.clone()], + CallNodes { + arguments: vec![Some(receiver.id), Some(lvalue.id)], + result: None, + }, CallKind::Regular, &format!("{member}="), *member_span, @@ -245,17 +265,24 @@ impl Analyser { /// `stored_type` are kept, settling type variables on the way: in /// `let xs = []; xs[0] = 1`, the write settles the element type of `xs` /// as `Int`. The stored type never widens to fit the value. An `Any` - /// value fits anywhere, as `Any` is the dynamic type. + /// value fits anywhere, as `Any` is the dynamic type, and is checked at + /// `node` as the program runs. pub(super) fn accepts_write( &mut self, stored_type: &StaticType, value_type: &StaticType, + node: Option, ) -> bool { // A write passes the value where a value of the stored type is // expected, as a call passes an argument: `let xs: Sequence = // []` settles the list's elements as Ints. let snapshot = self.vars.snapshot(); if self.vars.bind_argument(stored_type, value_type) { + if let Some(node) = node + && self.vars.resolve(value_type) == StaticType::Any + { + self.expect(node, stored_type.clone()); + } return true; } self.vars.rollback(snapshot); @@ -293,17 +320,17 @@ impl Analyser { AssignmentTarget::Identifier { .. } if self.picks.variables.contains_key(&lvalue.id) => { - if !self.accepts_write(stored_type, value_type) { + if !self.accepts_write(stored_type, value_type, Some(lvalue.id)) { self.emit_rejected_write(stored_type, value_type, span); } } AssignmentTarget::Member { .. } => { - if !self.accepts_write(stored_type, value_type) { + if !self.accepts_write(stored_type, value_type, Some(lvalue.id)) { self.emit_rejected_write(stored_type, value_type, span); } } AssignmentTarget::Index { index, .. } => { - if self.accepts_write(stored_type, value_type) { + if self.accepts_write(stored_type, value_type, Some(lvalue.id)) { return; } // `xs[0..2] = (1, 2)` stores the tuple's elements. @@ -317,7 +344,7 @@ impl Analyser { && let Some(stored_element) = self.vars.resolve(stored_type).index_element_type() && let Some(value_element) = value_type.sequence_element_type() - && self.accepts_write(&stored_element, &value_element) + && self.accepts_write(&stored_element, &value_element, None) { return; } diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index eb7ba954..33e70bb0 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -1,5 +1,5 @@ use super::Analyser; -use super::solver::CallSite; +use super::solver::{CallNodes, CallSite}; use crate::error::AnalysisError; use crate::overloads::{CallKind, Overloads}; use ndc_core::{NodeId, StaticType}; @@ -53,6 +53,10 @@ impl Analyser { self.constrain_call( CallSite::Getter(id), vec![receiver_type], + CallNodes { + arguments: vec![Some(receiver.id)], + result: Some(id), + }, CallKind::Regular, member, member_span, @@ -63,6 +67,7 @@ impl Analyser { /// Only operator-form calls are eligible for vec dispatch. pub(super) fn analyse_call( &mut self, + id: NodeId, function: &ExpressionLocation, arguments: &[ExpressionLocation], kind: CallKind, @@ -72,6 +77,7 @@ impl Analyser { // function position, so the callee is analysed as a value. The callee // comes before the arguments, in source order like the resolver, so // declarations in it get their slots first. + let nodes = arguments.iter().map(|arg| Some(arg.id)).collect::>(); let Expression::Identifier { name, .. } = &function.expression else { let callee_type = self.analyse_expr(function); let arguments = arguments @@ -83,18 +89,27 @@ impl Analyser { callee @ StaticType::Function { parameters: Some(_), .. - } => self.bind_signature(&callee, &arguments).unwrap_or_else(|| { - let arguments = arguments - .iter() - .map(|argument| self.vars.shown(argument)) - .collect::>(); - self.emit(AnalysisError::callee_rejects_arguments( - &self.vars.shown(&callee), - &arguments, - span, - )); - StaticType::Any - }), + } => { + if let Some((typ, parameters)) = self.bind_signature(&callee, &arguments) { + for (node, expected) in + self.expected_of_any(¶meters, &arguments, &nodes) + { + self.expect(node, expected); + } + typ + } else { + let arguments = arguments + .iter() + .map(|argument| self.vars.shown(argument)) + .collect::>(); + self.emit(AnalysisError::callee_rejects_arguments( + &self.vars.shown(&callee), + &arguments, + span, + )); + StaticType::Any + } + } callee @ StaticType::Var(_) => self.call_unknown_function(&callee, arguments), // Nothing tells what the callee does with its arguments. callee if callee.could_be_callable() => { @@ -119,6 +134,16 @@ impl Analyser { .iter() .map(|arg| self.analyse_expr(arg)) .collect::>(); - self.constrain_call(CallSite::Call(function.id), arguments, kind, name, span) + self.constrain_call( + CallSite::Call(function.id), + arguments, + CallNodes { + arguments: nodes, + result: Some(id), + }, + kind, + name, + span, + ) } } diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 25686bbf..efc89212 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -1,4 +1,4 @@ -use super::Analyser; +use super::{Analyser, Returns}; use crate::error::AnalysisError; use itertools::{Itertools, izip}; use ndc_core::r#struct::StructInfo; @@ -108,7 +108,10 @@ impl Analyser { self.types.declare(symbol, placeholder); } - self.return_type_stack.push(None); + self.return_type_stack.push(Returns { + annotated: annotated_return.clone(), + joined: None, + }); // A function body starts outside of any loop: `break` can't // leave the function to end a loop around its declaration. let outer_loop_depth = std::mem::take(&mut self.loop_depth); @@ -123,26 +126,16 @@ impl Analyser { } let implicit_return = self.analyse_expr(body); - let explicit_return = self.return_type_stack.pop().unwrap(); + let mut returns = self.return_type_stack.pop().unwrap(); self.loop_depth = outer_loop_depth; - // Combine explicit `return` types with the block's implicit return type. - let inferred_return = match explicit_return { - Some(ret) => self.join(&ret, &implicit_return, span), - None => implicit_return, - }; + // The body's value is returned like the values of `return`. An + // annotated return type is what each of them returns, which can + // settle what the body left waiting: in `fn f() -> Fn(String) -> + // List { split }`, it picks `split(String)`. + self.add_return(&mut returns, body, implicit_return); + let inferred_return = returns.joined.unwrap_or(StaticType::Never); - // An annotated return type is what the body returns, which can settle - // what the body left waiting: in `fn f() -> Fn(String) -> List - // { split }`, it picks `split(String)`. - // What isn't known about the body's value yet is settled from the - // annotation, like a write: `-> Sequence` takes a `List` and - // settles `?T`. - if let Some(annotated) = &annotated_return - && self.vars.resolve(&inferred_return).has_vars() - { - self.accepts_write(annotated, &inferred_return); - } if let Some(returns) = &recursive_return && !self.vars.try_unify(returns, &inferred_return) { @@ -178,26 +171,11 @@ impl Analyser { }; let inferred_return = self.vars.resolve(&inferred_return); - // If there is an annotated return type, report a body that doesn't - // return it; otherwise record the inferred type in the analysis. - match &annotated_return { - Some(annotated) - if !self.vars.try_unify(annotated, &inferred_return) - && !inferred_return.is_subtype(annotated) => - { - self.emit(AnalysisError::mismatched_types( - &self.vars.shown(&inferred_return), - annotated, - span, - )); - } - Some(_) => {} - None => { - self.result - .analysis - .inferred_return_types - .insert(id, inferred_return.clone()); - } + if annotated_return.is_none() { + self.result + .analysis + .inferred_return_types + .insert(id, inferred_return.clone()); } // A named function's value is checked as the program runs against @@ -311,7 +289,7 @@ impl Analyser { // The value is checked like an assignment to the annotated // variable, so `let o: Option = None` settles `None`. if let Some(expected_type) = &expected_type - && !self.accepts_write(expected_type, &found_type) + && !self.accepts_write(expected_type, &found_type, Some(lvalue.id)) { self.emit_rejected_write(expected_type, &found_type, *span); } diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs index 8987967d..417a122a 100644 --- a/ndc_analyser/src/analyser/instances.rs +++ b/ndc_analyser/src/analyser/instances.rs @@ -47,8 +47,9 @@ pub(super) struct GenericUse { pub(super) instance: Option, } -/// What a call that's about to commit to a generic function copied from it, -/// until it's known whether the call commits. +/// What a call that's about to commit copied from the generic function it +/// commits to, and the checks its arguments need, until it's known whether +/// the call commits. #[derive(Debug, Default)] pub(super) struct Pending { /// Its constraints, with the function they're copied from and the calls @@ -56,21 +57,24 @@ pub(super) struct Pending { pub(super) copies: Vec<(SymbolId, Constraint, Option)>, /// The function and the types the call gives its key. pub(super) uses: Vec<(SymbolId, Vec)>, + /// Where an argument of type `Any` is checked, and against what. + pub(super) checks: Vec<(NodeId, StaticType)>, } impl Pending { /// How much is pending, to [`Self::truncate`] to later. - pub(super) fn len(&self) -> (usize, usize) { - (self.copies.len(), self.uses.len()) + pub(super) fn len(&self) -> (usize, usize, usize) { + (self.copies.len(), self.uses.len(), self.checks.len()) } - pub(super) fn truncate(&mut self, (copies, uses): (usize, usize)) { + pub(super) fn truncate(&mut self, (copies, uses, checks): (usize, usize, usize)) { self.copies.truncate(copies); self.uses.truncate(uses); + self.checks.truncate(checks); } pub(super) fn clear(&mut self) { - self.truncate((0, 0)); + self.truncate((0, 0, 0)); } } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 56a4de33..93714db9 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -78,6 +78,10 @@ pub struct Analyser { /// The calls the constraint being solved was copied through, when it's /// a copy of a generic function's constraint. copied_through: Option, + /// Where a value of type `Any` is checked as the program runs, and the + /// type it's checked against, which may still have variables in it, by + /// the instance whose body checks it, or `None` for code compiled once. + checks: Vec<(Option, NodeId, StaticType)>, /// The number the next waiting constraint gets. next_serial: u64, /// The waiting constraints that each unsettled variable would let make @@ -95,10 +99,9 @@ pub struct Analyser { /// What each struct's type symbol stands for, so `p: Point` lowers to /// `Point`'s struct type. Every other type name is built in. struct_types: AHashMap>, - /// Stack of explicit `return` types for each enclosing function scope. - /// Pushed on function entry, popped on exit. The value accumulates the - /// lub of all `return ` types seen so far. - return_type_stack: Vec>, + /// What each enclosing function returns with `return`, innermost last. + /// Pushed on function entry, popped on exit. + return_type_stack: Vec, /// How many loops enclose the expression being analysed in the current /// function, so `break` and `continue` outside of one are reported. loop_depth: usize, @@ -112,6 +115,15 @@ pub struct Analyser { laid_out_scopes: usize, } +/// What a function returns. +#[derive(Debug)] +pub(super) struct Returns { + /// Its annotated return type. + annotated: Option, + /// The type its values have in common, without an annotation. + joined: Option, +} + impl Analyser { /// An analyser for programs that can call `natives`, given as name and /// type in registry order: the first is the native in global slot 0. @@ -138,6 +150,7 @@ impl Analyser { declaring: None, recursive_calls: Vec::new(), copied_through: None, + checks: Vec::new(), next_serial: 0, watchers: AHashMap::new(), watched_settlings: 0, @@ -169,6 +182,21 @@ impl Analyser { } self.failed_reads.clear(); self.types.resolve_all(&self.vars); + for (instance, node, expected) in std::mem::take(&mut self.checks) { + let expected = self + .vars + .resolve(&expected) + .replace(&|part| matches!(part, StaticType::Generic(_)).then_some(StaticType::Any)); + if expected == StaticType::Any { + continue; + } + let checks = match instance.and_then(|instance| self.picks.instances.get_mut(&instance)) + { + Some(picks) => &mut picks.type_checks, + None => &mut self.result.analysis.type_checks, + }; + checks.insert(node, expected); + } let analysis = &mut self.result.analysis; for table in [ &mut analysis.expr_types, @@ -192,6 +220,18 @@ impl Analyser { result } + /// Records that the value of type `Any` at `node` is checked to be an + /// `expected` as the program runs, in the instance whose constraints are + /// being solved, if any. A function's own body checks its type + /// parameters as `Any`. A copy of a constraint checks nothing, as its + /// node is in the body of the function it was copied from, which the + /// instance for the call checks. + pub(super) fn expect(&mut self, node: NodeId, expected: StaticType) { + if self.copied_through.is_none() { + self.checks.push((self.recording, node, expected)); + } + } + /// Records an analysis error. The analyser continues with a fallback type /// (usually `Any`) so that subsequent code is still checked. An error in /// a copy of a generic function's constraint is the call's: it's @@ -463,11 +503,11 @@ impl Analyser { | Expression::MethodCall { function, arguments, - } => self.analyse_call(function, arguments, CallKind::Regular, *span), + } => self.analyse_call(*id, function, arguments, CallKind::Regular, *span), Expression::OperatorCall { function, arguments, - } => self.analyse_call(function, arguments, CallKind::Operator, *span), + } => self.analyse_call(*id, function, arguments, CallKind::Operator, *span), Expression::MemberAccess { receiver, member, @@ -526,9 +566,9 @@ impl Analyser { Expression::Return { value } => { let typ = self.analyse_expr(value); match self.return_type_stack.pop() { - Some(mut returned) => { - self.fold_join(&mut returned, typ, *span); - self.return_type_stack.push(returned); + Some(mut returns) => { + self.add_return(&mut returns, value, typ); + self.return_type_stack.push(returns); } None => self.emit(AnalysisError::return_outside_function(*span)), } @@ -632,6 +672,21 @@ impl Analyser { } /// Joins a new type into an accumulator, as with [`Self::join`]. + /// Adds the value `value` of type `typ` that a function returns to + /// `returns`. A function with an annotated return type returns that + /// type, and each value it returns has to fit it. + fn add_return(&mut self, returns: &mut Returns, value: &ExpressionLocation, typ: StaticType) { + match &returns.annotated { + Some(annotated) => { + let annotated = annotated.clone(); + if !self.accepts_write(&annotated, &typ, Some(value.id)) { + self.emit_rejected_write(&annotated, &typ, value.span); + } + } + None => self.fold_join(&mut returns.joined, typ, value.span), + } + } + fn fold_join(&mut self, acc: &mut Option, new_type: StaticType, span: Span) { *acc = Some(match acc.take() { Some(prev) => self.join(&prev, &new_type, span), diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 92f8a990..0d54d47d 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -180,11 +180,26 @@ pub(super) enum EarlyPick { }, } +/// The expressions a call's arguments and value come from, where a value +/// of type `Any` is checked against the type the call expects of it as the +/// program runs: in `xs.push(a)` on a `List`, `a` is checked to be an +/// Int. The value an assignment stores is checked at the assignment's +/// target. +#[derive(Debug, Clone, Default)] +pub(super) struct CallNodes { + /// Where each argument comes from. + pub(super) arguments: Vec>, + /// The call itself, whose value is checked when it's `Any` but code + /// meanwhile settled the type it has. + pub(super) result: Option, +} + #[derive(Debug, Clone)] pub(super) struct CallConstraint { site: CallSite, candidates: Candidates, arguments: Vec, + nodes: CallNodes, /// The call's type, which the picked overload's return type settles. result: StaticType, kind: CallKind, @@ -236,6 +251,7 @@ impl Analyser { &mut self, site: CallSite, arguments: Vec, + nodes: CallNodes, kind: CallKind, name: &str, span: Span, @@ -244,6 +260,7 @@ impl Analyser { candidates: self.site_candidates(site).clone(), site, arguments, + nodes, result: StaticType::Never, kind, name: name.to_string(), @@ -365,6 +382,7 @@ impl Analyser { let Some(typ) = self.commit_call( &Binding::Resolved(Candidate::Scalar(picked)), arguments, + &call.nodes.arguments, StaticType::Any, ) else { self.pending.clear(); @@ -665,13 +683,15 @@ impl Analyser { // the read said what the field holds, so it's the value // that doesn't fit. The write fails the same way and // isn't reported again. - CallSite::Getter(id) if !self.accepts_write(&result, &typ) => { + CallSite::Getter(id) + if !self.accepts_write(&result, &typ, call.nodes.result) => + { self.emit_rejected_write(&typ, &result, call.span); self.failed_reads.insert(id); } CallSite::Getter(_) => {} _ => { - self.settle_waited_for(&result, &typ, call.span); + self.settle_waited_for(&result, &typ, call.span, call.nodes.result); } } true @@ -688,7 +708,7 @@ impl Analyser { } let found = sequence.sequence_element_type().unwrap_or(StaticType::Any); let element = element.clone(); - self.settle_waited_for(&element, &found, *span); + self.settle_waited_for(&element, &found, *span, None); true } Constraint::Unpack { value, parts, span } => { @@ -717,7 +737,7 @@ impl Analyser { }; let found = found.unwrap_or_else(|| vec![StaticType::Any; parts.len()]); for (part, found) in parts.iter().zip(found) { - self.settle_waited_for(part, &found, *span); + self.settle_waited_for(part, &found, *span, None); } true } @@ -731,7 +751,7 @@ impl Analyser { return false; } let value = value.clone(); - self.settle_waited_for(&value, &Self::default_value_type(default), *span); + self.settle_waited_for(&value, &Self::default_value_type(default), *span, None); true } Constraint::Join { @@ -756,7 +776,7 @@ impl Analyser { if a.has_vars() || b.has_vars() { return false; } - self.settle_waited_for(&result, &a.lub(&b), *span); + self.settle_waited_for(&result, &a.lub(&b), *span, None); true } } @@ -764,9 +784,16 @@ impl Analyser { /// Settles `waited`, the type a constraint gave out while it waited, as /// `found`, the type it turned out to be. Code analysed meanwhile may - /// have settled `waited` already, and then `found` has to fit it. - fn settle_waited_for(&mut self, waited: &StaticType, found: &StaticType, span: Span) { - if !self.accepts_write(waited, found) { + /// have settled `waited` already, and then `found` has to fit it: a + /// value of type `Any` is checked at `node` as the program runs. + fn settle_waited_for( + &mut self, + waited: &StaticType, + found: &StaticType, + span: Span, + node: Option, + ) { + if !self.accepts_write(waited, found, node) { self.emit_rejected_write(waited, found, span); } } @@ -890,7 +917,7 @@ impl Analyser { self.record_pick(call.site, binding, arguments); return StaticType::Any; } - let typ = self.commit_call(&binding, arguments, return_type); + let typ = self.commit_call(&binding, arguments, &call.nodes.arguments, return_type); self.record_pick(call.site, binding, arguments); if let Some(typ) = typ { self.flush_copies(Some(call.site), call.span); @@ -910,11 +937,13 @@ impl Analyser { /// When the pick is left to the program as it runs, any function may get /// the arguments, so what's unknown in them becomes `Any`. Returns `None` /// when an argument doesn't fit the picked overload after all, which - /// settles nothing. + /// settles nothing. An argument of type `Any` is checked at its node in + /// `nodes` as the program runs, once the call commits. fn commit_call( &mut self, binding: &Binding, arguments: &[StaticType], + nodes: &[Option], return_type: StaticType, ) -> Option { let picked = match binding { @@ -938,7 +967,7 @@ impl Analyser { scalar => scalar.clone(), }) .collect::>(); - returns.push(self.commit_call(&scalar, &arguments, StaticType::Any)?); + returns.push(self.commit_call(&scalar, &arguments, &[], StaticType::Any)?); } return Some(StaticType::Tuple(returns)); } @@ -964,10 +993,13 @@ impl Analyser { }, arguments, ); - if typ.is_none() { + let Some((typ, parameters)) = typ else { self.pending.truncate(pending); - } - typ + return None; + }; + let checks = self.expected_of_any(¶meters, arguments, nodes); + self.pending.checks.extend(checks); + Some(typ) } /// `symbol`'s type with new variables for its type parameters. For a @@ -1023,6 +1055,9 @@ impl Analyser { Some(site) => self.record_uses(site), None => self.pending.uses.clear(), } + for (node, expected) in std::mem::take(&mut self.pending.checks) { + self.expect(node, expected); + } let copies = std::mem::take(&mut self.pending.copies); for (function, constraint, origin) in copies { let name = self.resolver.symbol_name(function).to_string(); @@ -1049,13 +1084,13 @@ impl Analyser { /// Passes each argument where `signature` expects its parameter, which /// settles what's unknown in either, and returns what the signature - /// returns. Returns `None` and settles nothing when an argument doesn't - /// fit, or the number of arguments is wrong. + /// returns, and its parameters. Returns `None` and settles nothing when + /// an argument doesn't fit, or the number of arguments is wrong. pub(super) fn bind_signature( &mut self, signature: &StaticType, arguments: &[StaticType], - ) -> Option { + ) -> Option<(StaticType, Vec)> { let StaticType::Function { parameters: Some(parameters), return_type, @@ -1073,7 +1108,26 @@ impl Analyser { self.vars.rollback(snapshot); return None; } - Some(self.vars.resolve(return_type)) + Some((self.vars.resolve(return_type), parameters.clone())) + } + + /// Where each argument of type `Any` that `nodes` has a node for is + /// checked against the parameter it was passed for. + pub(super) fn expected_of_any( + &self, + parameters: &[StaticType], + arguments: &[StaticType], + nodes: &[Option], + ) -> Vec<(NodeId, StaticType)> { + parameters + .iter() + .zip(arguments) + .zip(nodes) + .filter_map(|((parameter, argument), node)| { + let node = (*node)?; + (self.vars.resolve(argument) == StaticType::Any).then(|| (node, parameter.clone())) + }) + .collect() } /// The type of calling a value whose type isn't known yet, like `g` in @@ -1107,7 +1161,9 @@ impl Analyser { .overloads() .resolve_call(&call.candidates, arguments, call.kind); let fits = !matches!(binding, Binding::None) - && self.commit_call(&binding, arguments, return_type).is_some(); + && self + .commit_call(&binding, arguments, &call.nodes.arguments, return_type) + .is_some(); self.vars.rollback(snapshot); self.pending.clear(); fits @@ -1174,7 +1230,7 @@ impl Analyser { let committed = self.commit_early(&call, &arguments, narrowest); if let Some(typ) = &committed { let result = call.result.clone(); - self.settle_waited_for(&result, typ, call.span); + self.settle_waited_for(&result, typ, call.span, call.nodes.result); } self.copied_through = outer; if committed.is_none() { diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index a540f342..65989a39 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1152,10 +1152,10 @@ fn a_return_annotation_settles_what_the_body_left_unknown() { errors("fn f() -> Sequence { let xs = []; xs }", vec![]), Vec::::new() ); - // Returns that join at `Any` still don't fit an `Int`. + // Each value returned has to fit the annotation. assert_eq!( errors("fn f(x) -> Int { if x { return \"s\"; } 1 }", vec![]), - vec!["mismatched types: found Any but expected Int"] + vec!["mismatched types: found String but expected Int"] ); } diff --git a/ndc_analysis/src/lib.rs b/ndc_analysis/src/lib.rs index 88493fb9..4dbff040 100644 --- a/ndc_analysis/src/lib.rs +++ b/ndc_analysis/src/lib.rs @@ -52,6 +52,12 @@ pub struct Analysis { /// the cast. `None` when the value is already known to have the type, as /// in `1 as Int`, so there is nothing to check. pub cast_checks: AHashMap>, + /// The type a value of type `Any` is checked against as the program + /// runs, where code that expects that type gets it, keyed by where it's + /// checked: an expression, checked once it's computed, or an assignment + /// target or a name a pattern declares, checked before the value is + /// stored. In `let n: Int = a` with `a: Any`, `n` checks for an Int. + pub type_checks: AHashMap, /// How many local slots each function's own variables use, parameters /// first, keyed by its declaration. The compiler places its temporaries /// after them. @@ -85,6 +91,8 @@ pub struct Instance { pub setters: AHashMap, /// As [`Analysis::augmented_assignments`], for the instance's body. pub augmented_assignments: AHashMap, + /// As [`Analysis::type_checks`], for the instance's body. + pub type_checks: AHashMap, } impl Analysis { diff --git a/ndc_layout/src/lib.rs b/ndc_layout/src/lib.rs index 1e88ba85..d0794941 100644 --- a/ndc_layout/src/lib.rs +++ b/ndc_layout/src/lib.rs @@ -11,8 +11,8 @@ use ahash::AHashMap; use ndc_analysis::{ Analysis, Binding, CaptureSource, DeclaredStruct, FieldAccessors, Instance, ResolvedVar, }; -use ndc_core::NodeId; use ndc_core::r#struct::StructInfo; +use ndc_core::{NodeId, StaticType}; use ndc_resolver::{NameResolver, ScopeId, ScopeKind, SymbolId}; #[cfg(test)] @@ -49,6 +49,8 @@ pub struct InstancePicks { pub getters: AHashMap>, pub setters: AHashMap>, pub augmented_assignments: AHashMap>, + /// As [`Analysis::type_checks`], for the instance's body. + pub type_checks: AHashMap, } impl InstancePicks { @@ -59,6 +61,7 @@ impl InstancePicks { getters: AHashMap::new(), setters: AHashMap::new(), augmented_assignments: AHashMap::new(), + type_checks: AHashMap::new(), } } } @@ -158,6 +161,7 @@ impl<'a> Layout<'a> { .into_iter() .map(locate_binding) .collect(), + type_checks: picks.type_checks, }; analysis .instances diff --git a/ndc_vm/src/chunk.rs b/ndc_vm/src/chunk.rs index 16ec470b..e01e00b2 100644 --- a/ndc_vm/src/chunk.rs +++ b/ndc_vm/src/chunk.rs @@ -74,7 +74,6 @@ impl std::fmt::Debug for JumpTarget { /// | `Unpack` | `[… compound → … v1…vn]` | +(n−1) (pops 1, pushes n) | /// | `NewCell` | `[…]` | 0 (puts a fresh cell in a slot) | /// | `Memoize` | `[… fn → … memoized_fn]` | 0 (pops and pushes) | -/// | `MemoizeLike` | `[… fn memoized → … memoized_fn]` | −1 (shares memoized's cache) | // NOTE: OpCode cannot be Copy because the Closure variant holds Rc<[CaptureSource]>. // The dispatch loop accesses opcodes by reference to avoid cloning the 32-byte enum on // every iteration; see Vm::run_to_depth. @@ -148,12 +147,11 @@ pub enum OpCode { NewCell(usize), /// Pops function, pushes memoized wrapper. `[… fn → … memoized_fn]` Memoize, - /// Pops a memoized function and a function, and pushes the function - /// memoized with the same cache, as an instance of a generic `pure fn` - /// shares the function's. `[… fn memoized → … memoized_fn]` - MemoizeLike, /// Peeks top; errors if the value does not conform to the type. `[… value → … value]` CheckType(Rc), + /// Peeks top, a value of type `Any` where code expects the type; errors + /// if it isn't one. `[… value → … value]` + ExpectType(Rc), } impl OpCode { @@ -189,8 +187,8 @@ impl OpCode { Self::Return => "Return", Self::NewCell(_) => "NewCell", Self::Memoize => "Memoize", - Self::MemoizeLike => "MemoizeLike", Self::CheckType(_) => "CheckType", + Self::ExpectType(_) => "ExpectType", } } @@ -245,9 +243,8 @@ impl OpCode { Self::Return => -1, Self::NewCell(_) => 0, Self::Memoize => 0, - Self::MemoizeLike => -1, // Peeks the value without popping it. - Self::CheckType(_) => 0, + Self::CheckType(_) | Self::ExpectType(_) => 0, } } } @@ -300,8 +297,8 @@ impl std::fmt::Debug for OpCode { Self::Return => write!(f, "Return"), Self::Unpack(n) => write!(f, "Unpack({n})"), Self::Memoize => write!(f, "Memoize"), - Self::MemoizeLike => write!(f, "MemoizeLike"), Self::CheckType(typ) => write!(f, "CheckType({typ})"), + Self::ExpectType(typ) => write!(f, "ExpectType({typ})"), } } } diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 960fec9a..2503040a 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -279,10 +279,12 @@ impl Compiler { self.compile_expr(*container)?; self.compile_expr(*index)?; self.compile_expr(*value)?; + self.expect_type(l_value.id, span); self.ir.write(OpCode::Call(3), span); } AssignmentTarget::Identifier { span: lv_span, .. } => { self.compile_expr(*value)?; + self.expect_type(l_value.id, lv_span); self.emit_set_var(self.assigned_variable(l_value.id), lv_span); self.emit_unit(); } @@ -302,12 +304,14 @@ impl Compiler { self.compile_binding(&self.setter(l_value.id), member_span)?; self.compile_expr(*receiver)?; self.compile_expr(*value)?; + self.expect_type(l_value.id, member_span); self.ir.write(OpCode::Call(2), member_span); } }, Expression::OpAssignment { l_value, r_value, .. } => { + let target_id = l_value.id; let target = PreparedAssignmentTarget::prepare(self, l_value)?; let Some(binding) = self.augmented_assignment(id) else { return Err(CompileError::unresolved_binding(span)); @@ -318,6 +322,7 @@ impl Compiler { target.emit_read(self)?; self.compile_expr(*r_value)?; self.ir.write(opcode, span); + self.expect_type(target_id, span); target.emit_store(self)?; self.emit_unit(); @@ -507,12 +512,27 @@ impl Compiler { } } + if leaves_value { + self.expect_type(id, span); + } // Every expression except a statement leaves exactly one value. self.ir .set_stack_depth(entry_depth + isize::from(leaves_value)); Ok(()) } + /// Checks the value on top of the stack, which comes from `Any`, where + /// the analyser says code at `at` expects a type. + fn expect_type(&mut self, at: NodeId, span: Span) { + if let Some(expected) = self + .instance_pick(|instance| instance.type_checks.get(&at)) + .or_else(|| self.analysis.type_checks.get(&at)) + { + let expected = Rc::new(expected.clone()); + self.ir.write(OpCode::ExpectType(expected), span); + } + } + fn compile_assignment_target( &mut self, l_value: AssignmentTargetLocation, @@ -520,6 +540,7 @@ impl Compiler { ) -> Result<(), CompileError> { match l_value.target { AssignmentTarget::Identifier { span: lv_span, .. } => { + self.expect_type(l_value.id, lv_span); self.emit_set_var(self.assigned_variable(l_value.id), lv_span); } AssignmentTarget::Index { value, index } => { @@ -530,6 +551,7 @@ impl Compiler { // 4. Call []= function // 5. Pop the return value + self.expect_type(l_value.id, span); let tmp_value = self.allocate_temp(); self.write_temp(tmp_value, span, OpCode::SetLocal); @@ -564,6 +586,7 @@ impl Compiler { let variable = self.analysis.variable(pattern.id).expect( "internal error: every binding pattern is analysed before it's compiled", ); + self.expect_type(pattern.id, span); self.emit_declare(variable, span); } BindingPattern::Sequence(seq) => { @@ -598,10 +621,10 @@ impl Compiler { /// The pick `pick` finds in the instance being compiled, if any, noting /// that the code depends on the instance. - fn instance_pick<'a>( + fn instance_pick<'a, T>( &'a self, - pick: impl FnOnce(&'a Instance) -> Option<&'a Binding>, - ) -> Option<&'a Binding> { + pick: impl FnOnce(&'a Instance) -> Option<&'a T>, + ) -> Option<&'a T> { let found = self.instance.as_deref().and_then(pick); if found.is_some() { self.used_instance.set(true); @@ -904,16 +927,18 @@ impl Compiler { // Each instance of a generic function is its body compiled with the // instance's picks. A generic function is declared at the top level, // so it captures nothing and is a global, and its instances are - // stored in globals of their own. A `pure fn`'s instances share its - // cache, as they compute what it does. + // stored in globals of their own. A `pure fn`'s instance has a cache + // of its own, as instances may check values of type `Any` against + // their own types: `pure fn has(xs, v) { xs.contains(v) }` checks `v` + // for an Int in its instance for `List`, but not for + // `List`, and both may be called with an empty list. for (instance, instance_body) in instances.iter().zip(instance_bodies) { let idx = self .ir .add_constant(Value::function(Function::Compiled(Rc::new(instance_body)))); self.ir.write(OpCode::Constant(idx), span); - if pure && let Some(function) = name_variable { - self.emit_get_var(function, span); - self.ir.write(OpCode::MemoizeLike, span); + if pure { + self.ir.write(OpCode::Memoize, span); } self.emit_declare(instance.variable, span); } diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index c4c10ebf..78f63a9d 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -226,6 +226,15 @@ impl Vm { )); } } + OpCode::ExpectType(target) => { + let value = self.stack.last().expect("stack underflow"); + if !value.conforms_to(target) { + return Err(VmError::new( + format!("expected {target} but found {}", value.diagnostic_type()), + span, + )); + } + } OpCode::GetLocal(slot) => { let slot = *slot; self.stack.push(self.stack[frame.slot(slot)].clone()); @@ -603,25 +612,6 @@ impl Vm { function: Rc::new(func), })); } - OpCode::MemoizeLike => { - let memoized = self.stack.pop().expect("stack underflow"); - let function = self.stack.pop().expect("stack underflow"); - let (Value::Object(memoized), Value::Object(function)) = (memoized, function) - else { - panic!("MemoizeLike: expected two functions on the stack"); - }; - let ( - Object::Function(Function::Memoized { cache, .. }), - Object::Function(function), - ) = (memoized.as_ref(), function.as_ref()) - else { - panic!("MemoizeLike: expected a function and a memoized function"); - }; - self.stack.push(Value::function(Function::Memoized { - cache: Rc::clone(cache), - function: Rc::new(function.clone()), - })); - } } }; diff --git a/tests/compiler/tests/compiler.rs b/tests/compiler/tests/compiler.rs index 9058fe0c..709417e5 100644 --- a/tests/compiler/tests/compiler.rs +++ b/tests/compiler/tests/compiler.rs @@ -534,6 +534,31 @@ fn a_proven_cast_needs_no_runtime_check() { ); } +// let a: Any = 1; let n: Int = a; +// +// An `Any` stored where an Int is expected is checked before it's stored. +#[test] +fn an_any_stored_in_a_typed_variable_is_checked() { + let ops = compile_with_analysis("let a: Any = 1; let n: Int = a;"); + assert!( + ops.iter() + .any(|op| matches!(op, ExpectType(t) if **t == ndc_core::StaticType::Int)), + "expected an ExpectType(Int) guard, got: {ops:?}", + ); +} + +// let n: Int = 1; let m = n; +// +// A value whose type is known needs no check. +#[test] +fn a_typed_value_stored_in_a_typed_variable_is_not_checked() { + let ops = compile_with_analysis("let n: Int = 1; let m: Int = n;"); + assert!( + ops.iter().all(|op| !matches!(op, ExpectType(_))), + "a value of a known type must not be checked, got: {ops:?}", + ); +} + // fn outer() { // let x = 1; // let f = fn() { x }; diff --git a/tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc b/tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc deleted file mode 100644 index 463dc452..00000000 --- a/tests/functional/programs/010_memoization/003_a_generic_pure_fn_shares_its_cache_with_its_instances.ndc +++ /dev/null @@ -1,14 +0,0 @@ -// A generic `pure fn` is compiled once for each type it's called with, and -// all of them share its cache: a call through a value of it, which is the -// function itself, finds what a direct call computed. -// expect-output: 10 10 2.5 2 -let calls = 0; -pure fn add(x, y) { - calls += 1; - x + y -} -let first = add(5, 5); -let f = add; -let second = f(5, 5); -let third = add(1.0, 1.5); -print(first, second, third, calls); diff --git a/tests/functional/programs/010_memoization/003_each_copy_of_a_generic_pure_fn_has_its_own_cache.ndc b/tests/functional/programs/010_memoization/003_each_copy_of_a_generic_pure_fn_has_its_own_cache.ndc new file mode 100644 index 00000000..c20fb19c --- /dev/null +++ b/tests/functional/programs/010_memoization/003_each_copy_of_a_generic_pure_fn_has_its_own_cache.ndc @@ -0,0 +1,15 @@ +// A generic `pure fn` is compiled once for each type it's called with, and +// each of those copies has a cache of its own. The second direct call finds +// what the first computed. A call through a value of the function runs the +// function's own body, which computes it again. +// expect-output: 10 10 10 2 +let calls = 0; +pure fn add(x, y) { + calls += 1; + x + y +} +let first = add(5, 5); +let again = add(5, 5); +let f = add; +let through_value = f(5, 5); +print(first, again, through_value, calls); diff --git a/tests/functional/programs/010_memoization/004_an_instance_that_checks_any_has_its_own_cache.ndc b/tests/functional/programs/010_memoization/004_an_instance_that_checks_any_has_its_own_cache.ndc new file mode 100644 index 00000000..96f49143 --- /dev/null +++ b/tests/functional/programs/010_memoization/004_an_instance_that_checks_any_has_its_own_cache.ndc @@ -0,0 +1,10 @@ +// The instance of `has` for a `List` checks that `v` is an Int, which +// the one for a `List` doesn't. Both get an empty list, so a cache +// they shared would hand the second call the first one's result. +// expect-error: expected Int but found String +pure fn has(xs, v) { xs.contains(v) } +let anything: List = []; +let numbers: List = []; +let a: Any = "s"; +print(has(anything, a)); +print(has(numbers, a)); diff --git a/tests/functional/programs/010_memoization/005_an_instance_whose_callee_checks_any_has_its_own_cache.ndc b/tests/functional/programs/010_memoization/005_an_instance_whose_callee_checks_any_has_its_own_cache.ndc new file mode 100644 index 00000000..9ab35501 --- /dev/null +++ b/tests/functional/programs/010_memoization/005_an_instance_whose_callee_checks_any_has_its_own_cache.ndc @@ -0,0 +1,10 @@ +// `outer` doesn't check anything itself, but its instance for a +// `List` calls the instance of `has` that checks `v` for an Int. +// expect-error: expected Int but found String +fn has(xs, v) { xs.contains(v) } +pure fn outer(xs, v) { has(xs, v) } +let anything: List = []; +let numbers: List = []; +let a: Any = "s"; +print(outer(anything, a)); +print(outer(numbers, a)); diff --git a/tests/functional/programs/017_dynamic_any/001_an_annotated_let_takes_an_any_of_its_type.ndc b/tests/functional/programs/017_dynamic_any/001_an_annotated_let_takes_an_any_of_its_type.ndc new file mode 100644 index 00000000..bda05a7a --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/001_an_annotated_let_takes_an_any_of_its_type.ndc @@ -0,0 +1,6 @@ +// A value of type `Any` fits where an Int is expected, and is checked to be +// one as the program runs. +// expect-output: 6 +let a: Any = 5; +let n: Int = a; +print(n + 1); diff --git a/tests/functional/programs/017_dynamic_any/002_an_annotated_let_checks_an_any.ndc b/tests/functional/programs/017_dynamic_any/002_an_annotated_let_checks_an_any.ndc new file mode 100644 index 00000000..9bb57b52 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/002_an_annotated_let_checks_an_any.ndc @@ -0,0 +1,4 @@ +// expect-error: expected Int but found String +let a: Any = "five"; +let n: Int = a; +print(n); diff --git a/tests/functional/programs/017_dynamic_any/003_an_assignment_checks_an_any.ndc b/tests/functional/programs/017_dynamic_any/003_an_assignment_checks_an_any.ndc new file mode 100644 index 00000000..e443bd6c --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/003_an_assignment_checks_an_any.ndc @@ -0,0 +1,5 @@ +// expect-error: expected Int but found String +let a: Any = "five"; +let n = 1; +n = a; +print(n); diff --git a/tests/functional/programs/017_dynamic_any/004_a_destructuring_checks_each_any.ndc b/tests/functional/programs/017_dynamic_any/004_a_destructuring_checks_each_any.ndc new file mode 100644 index 00000000..3ba08af0 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/004_a_destructuring_checks_each_any.ndc @@ -0,0 +1,6 @@ +// expect-error: expected Int but found String +let a: Any = "five"; +let n = 0; +let s = ""; +(n, s) = (a, a); +print(n, s); diff --git a/tests/functional/programs/017_dynamic_any/005_a_return_annotation_takes_an_any_of_its_type.ndc b/tests/functional/programs/017_dynamic_any/005_a_return_annotation_takes_an_any_of_its_type.ndc new file mode 100644 index 00000000..0597ed09 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/005_a_return_annotation_takes_an_any_of_its_type.ndc @@ -0,0 +1,5 @@ +// A function annotated to return an Int can return a value of type `Any`, +// which is checked as the program runs. +// expect-output: 3 +fn first(xs: Any) -> Int { xs[0] } +print(first([3])); diff --git a/tests/functional/programs/017_dynamic_any/006_a_return_annotation_checks_an_any.ndc b/tests/functional/programs/017_dynamic_any/006_a_return_annotation_checks_an_any.ndc new file mode 100644 index 00000000..9613cb48 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/006_a_return_annotation_checks_an_any.ndc @@ -0,0 +1,8 @@ +// expect-error: expected Int but found String +fn first(xs: Any) -> Int { + if xs.len() == 0 { + return 0; + } + xs[0] +} +print(first(["three"])); diff --git a/tests/functional/programs/017_dynamic_any/007_a_generic_argument_checks_an_any.ndc b/tests/functional/programs/017_dynamic_any/007_a_generic_argument_checks_an_any.ndc new file mode 100644 index 00000000..8fb8a955 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/007_a_generic_argument_checks_an_any.ndc @@ -0,0 +1,7 @@ +// `push(List, T)` takes an Int for a `List`, so an argument of type +// `Any` is checked to be one. +// expect-error: expected Int but found String +let xs = [1, 2]; +let a: Any = "three"; +xs.push(a); +print(xs); diff --git a/tests/functional/programs/017_dynamic_any/008_an_index_write_checks_an_any.ndc b/tests/functional/programs/017_dynamic_any/008_an_index_write_checks_an_any.ndc new file mode 100644 index 00000000..692d3e4f --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/008_an_index_write_checks_an_any.ndc @@ -0,0 +1,5 @@ +// expect-error: expected Int but found String +let xs = [1, 2]; +let a: Any = "three"; +xs[0] = a; +print(xs); diff --git a/tests/functional/programs/017_dynamic_any/009_a_field_write_checks_an_any.ndc b/tests/functional/programs/017_dynamic_any/009_a_field_write_checks_an_any.ndc new file mode 100644 index 00000000..f46e83ee --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/009_a_field_write_checks_an_any.ndc @@ -0,0 +1,6 @@ +// expect-error: expected Int but found String +struct Point { x: Int, y: Int } +let p = Point(1, 2); +let a: Any = "three"; +p.x = a; +print(p); diff --git a/tests/functional/programs/017_dynamic_any/010_a_container_from_any_is_scanned.ndc b/tests/functional/programs/017_dynamic_any/010_a_container_from_any_is_scanned.ndc new file mode 100644 index 00000000..c89eba8f --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/010_a_container_from_any_is_scanned.ndc @@ -0,0 +1,4 @@ +// expect-error: expected List but found List +let a: Any = ["one"]; +let xs: List = a; +print(xs); diff --git a/tests/functional/programs/017_dynamic_any/011_each_instance_checks_an_any_against_its_types.ndc b/tests/functional/programs/017_dynamic_any/011_each_instance_checks_an_any_against_its_types.ndc new file mode 100644 index 00000000..0adfcd33 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/011_each_instance_checks_an_any_against_its_types.ndc @@ -0,0 +1,11 @@ +// `put` picks `push` for each set of types it's called with, so each +// instance checks an argument of type `Any` against its own: the String +// goes into the list of Strings, but not into the list of Ints. +// expect-error: expected Int but found String +fn put(xs, v) { xs.push(v) } +let a: Any = "s"; +let words = ["t"]; +put(words, a); +print(words); +let numbers = [1]; +put(numbers, a); From 1650dcce12cfd66a9ae0db129d71ccaf0383dc13 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 12 Nov 2026 15:30:33 +0100 Subject: [PATCH 12/39] =?UTF-8?q?feat(analyser):=20make=20literals=20adapt?= =?UTF-8?q?=20to=20their=20use=20and=20containers=20invariant=20?= =?UTF-8?q?=F0=9F=A7=A9=20(#310)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 4c and 4a-2 of the type system RFC (#281). Containers and function types become invariant, and literals take the type their use needs. Invariance alone would reject `let xs: List = [1, 2]`, so the two land together. Behaviour changes: - An integer literal is an `Int` unless its use says otherwise: `let f: Float = 7` holds `7.0`, and `let total = 0; total += 2.5` makes `total` a Float. - List, map and tuple literals take their element types from their use, as in `let xs: List = [1, 2]`. Otherwise a literal is the type its values share, so `[1.5, 1]` is a `List`, or `Any` if they share none, as in `[1, "a"]`. - Two literals of different types are an error at the value: after `let v = ["s"]`, `v = [1]` reports the `1`. - The first use whose overload pick depends on a literal decides its type. A use that picks the same overload whatever the literal is, like `xs.push(1)`, leaves it open. - A `List` is not a `List`, and the same goes for maps, tuples, `Option` and function types. Converting goes through `Any` or `as`. - Overloads are ranked by "is an instance of", so `f(List)` and `f(List)` are unrelated. - In the standard library, slicing takes an `Iterator` instead of an `Iterator`, and adding to a set is `insert(Map, K)`. How it works: - A literal's type is a type variable with bounds that settling it checks: an integer literal's may be `Int`, `Float`, `Number` or `Any`, and a collection literal's element type any type each of its values fits. Each bound remembers the literal it came from, so mixed values of one literal make it `Any`, while values of several literals that don't fit are reported. - A literal nothing settles takes the first default with which every call waiting on it still fits, before any other default rule applies. In `total += 2.5`, an Int `total` couldn't hold the Float result, so `0` is a Float. - `StaticType::fits` and `may_fit` replace subtyping in overload matching and ranking, and argument binding unifies inside containers. The VM keeps `is_subtype` for its runtime checks, and joins keep `lub` until 4a-3. Programs compile to the same bytecode as before, so they run as fast. Analysis is slower where calls wait for literals: about 1 ms more on the benchmarks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types.md | 19 + manual/src/reference/types/number.md | 18 +- ndc_analyser/src/analyser/assignments.rs | 31 +- ndc_analyser/src/analyser/calls.rs | 10 +- ndc_analyser/src/analyser/declarations.rs | 1 - ndc_analyser/src/analyser/function_values.rs | 34 +- ndc_analyser/src/analyser/generics.rs | 3 + ndc_analyser/src/analyser/mod.rs | 118 +++-- ndc_analyser/src/analyser/solver.rs | 304 ++++++++++- ndc_analyser/src/analyser/tests.rs | 78 ++- ndc_analyser/src/overloads.rs | 61 +-- ndc_analyser/src/types.rs | 490 +++++++++++++++--- ndc_core/src/static_type.rs | 101 +++- ndc_lsp/src/features/completion.rs | 4 +- ndc_stdlib/src/hash_map.rs | 2 +- ndc_stdlib/src/index.rs | 6 +- ndc_vm/src/compiler.rs | 10 +- .../070_a_variable_keeps_its_type.ndc | 2 +- ...66_a_lambda_in_a_variable_has_one_type.ndc | 2 +- ...lue_takes_the_overload_its_use_expects.ndc | 2 +- .../053_push_checks_the_element_type.ndc | 2 +- .../023_numeric_sibling_cast_error.ndc | 6 +- ...an_int_unless_something_says_otherwise.ndc | 2 + ...ger_literal_takes_the_type_a_use_needs.ndc | 4 + ...003_an_integer_literal_can_be_a_number.ndc | 3 + ...lding_a_literal_takes_what_it_is_given.ndc | 5 + ...mulator_takes_the_type_of_the_elements.ndc | 3 + ...t_of_numbers_takes_the_type_they_share.ndc | 4 + ..._list_literal_takes_its_annotated_type.ndc | 4 + ...of_values_that_share_no_type_holds_any.ndc | 4 + ...a_map_literal_takes_its_annotated_type.ndc | 4 + ...tuple_literal_takes_its_annotated_type.ndc | 3 + ...terals_of_different_types_are_an_error.ndc | 4 + ...12_a_list_of_ints_is_not_a_list_of_any.ndc | 5 + ...through_any_to_change_its_element_type.ndc | 6 + .../014_a_function_type_is_invariant.ndc | 5 + ...e_pick_depends_on_a_literal_decides_it.ndc | 9 + ...e_whatever_a_literal_is_leaves_it_open.ndc | 6 + ...eturning_any_is_not_checked_as_a_value.ndc | 6 + .../604_stdlib_math/003_mixed_sum_error.ndc | 5 +- tests/proptest/tests/overloads.regressions | 4 + 41 files changed, 1166 insertions(+), 224 deletions(-) create mode 100644 tests/functional/programs/018_literals/001_an_integer_literal_is_an_int_unless_something_says_otherwise.ndc create mode 100644 tests/functional/programs/018_literals/002_an_integer_literal_takes_the_type_a_use_needs.ndc create mode 100644 tests/functional/programs/018_literals/003_an_integer_literal_can_be_a_number.ndc create mode 100644 tests/functional/programs/018_literals/004_a_variable_holding_a_literal_takes_what_it_is_given.ndc create mode 100644 tests/functional/programs/018_literals/005_an_accumulator_takes_the_type_of_the_elements.ndc create mode 100644 tests/functional/programs/018_literals/006_a_list_of_numbers_takes_the_type_they_share.ndc create mode 100644 tests/functional/programs/018_literals/007_a_list_literal_takes_its_annotated_type.ndc create mode 100644 tests/functional/programs/018_literals/008_a_literal_of_values_that_share_no_type_holds_any.ndc create mode 100644 tests/functional/programs/018_literals/009_a_map_literal_takes_its_annotated_type.ndc create mode 100644 tests/functional/programs/018_literals/010_a_tuple_literal_takes_its_annotated_type.ndc create mode 100644 tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc create mode 100644 tests/functional/programs/018_literals/012_a_list_of_ints_is_not_a_list_of_any.ndc create mode 100644 tests/functional/programs/018_literals/013_a_list_goes_through_any_to_change_its_element_type.ndc create mode 100644 tests/functional/programs/018_literals/014_a_function_type_is_invariant.ndc create mode 100644 tests/functional/programs/018_literals/015_the_first_use_whose_pick_depends_on_a_literal_decides_it.ndc create mode 100644 tests/functional/programs/018_literals/016_a_use_that_picks_the_same_whatever_a_literal_is_leaves_it_open.ndc create mode 100644 tests/functional/programs/018_literals/017_a_map_default_function_returning_any_is_not_checked_as_a_value.ndc diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index 54473268..70bab2af 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -38,6 +38,25 @@ Nested generics work too — the parser handles the `>>` ambiguity for you: let grid: List> = [[1, 2], [3, 4]]; ``` +### Element types are exact + +A `List` is not a `List`: a `List` could be given a String, which the list of Ints would then hold. The same goes for maps, tuples, options, the other containers, and function types. To change a container's element type, go through `Any`: + +```ndc,fails +let words = "a b".split(" "); // List +let any: Any = words; +let anything: List = any; // fine: checked as the program runs +let wrong: List = words; // error: found List but expected List +``` + +A literal takes the type its use needs, so an annotation can widen it directly. A literal whose values share no type is a container of `Any`: + +```ndc +let xs: List = [1, 2]; +xs.push("three"); +let mixed = [1, "a"]; // List +``` + [Struct](./types/struct.md) names are type names like any other, including inside generics: ```ndc diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index 4e52884a..526b6ff5 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -8,7 +8,7 @@ Andy C++ exposes three sibling numeric types: * `Float` stores an IEEE 754 `f64`. * `Number` supports arbitrary-size integers, exact rational values, floats, and complex values. -`Int`, `Float`, and `Number` share `Any` as their nearest common supertype. An `Int` does not satisfy a `Number` annotation. Use an `n` literal or the `Number` constructor when you need the advanced mode: +`Int`, `Float`, and `Number` share `Any` as their nearest common supertype. An `Int` value does not satisfy a `Number` annotation. Use an `n` literal or the `Number` constructor when you need the advanced mode: ```ndc let count: Int = 42; @@ -33,6 +33,22 @@ let hexadecimal = 0x2an; An integer literal without `n` must fit in `i64`. The lexer reports an error and suggests the suffixed form when it does not fit. +An integer literal without a suffix is an `Int`, unless how it's used says otherwise. Then it's a `Float` or a `Number`: + +```ndc +print(7 / 2); // 3 +let f: Float = 7; +print(f / 2); // 3.5 + +let total = 0; +total += 2.5; // total is a Float +print(total); // 2.5 + +print([1.5, 2.25].fold(0, fn(acc, x) => acc + x)); // 3.75 +``` + +A value that's already an `Int`, like the result of `len`, stays one. + Arbitrary-radix literals such as `16r2a` remain `Int` literals and do not accept `n`. The `i` and `j` suffixes create complex `Number` values: ```ndc diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index fd7176a3..84bbbcf1 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -233,7 +233,7 @@ impl Analyser { /// An index expression typed as an integer sequence (e.g. `0..2`) selects /// a slice of the container rather than a single element. pub(super) fn index_type_is_slice(index_type: &StaticType) -> bool { - index_type.is_subtype(&StaticType::Iterator(Box::new(StaticType::Int))) + index_type.fits(&StaticType::Iterator(Box::new(StaticType::Int))) } /// Specialized `op=` implementations mutate the left operand in place and @@ -251,13 +251,10 @@ impl Analyser { match (left_type, right_type) { (StaticType::Tuple(left), StaticType::Tuple(right)) => { left.len() == right.len() - && right - .iter() - .zip(left) - .all(|(right, left)| right.is_subtype(left)) + && right.iter().zip(left).all(|(right, left)| right.fits(left)) } - (StaticType::Tuple(left), right) => left.iter().all(|left| right.is_subtype(left)), - (left, right) => right.is_subtype(left), + (StaticType::Tuple(left), right) => left.iter().all(|left| right.fits(left)), + (left, right) => right.fits(left), } } @@ -277,18 +274,16 @@ impl Analyser { // expected, as a call passes an argument: `let xs: Sequence = // []` settles the list's elements as Ints. let snapshot = self.vars.snapshot(); - if self.vars.bind_argument(stored_type, value_type) { - if let Some(node) = node - && self.vars.resolve(value_type) == StaticType::Any - { - self.expect(node, stored_type.clone()); - } - return true; + if !self.vars.bind_argument(stored_type, value_type) { + self.vars.rollback(snapshot); + return false; + } + if let Some(node) = node + && self.vars.resolve(value_type) == StaticType::Any + { + self.expect(node, stored_type.clone()); } - self.vars.rollback(snapshot); - let stored_type = self.vars.resolve(stored_type); - let value_type = self.vars.resolve(value_type); - value_type == StaticType::Any || value_type.is_subtype(&stored_type) + true } /// Reports that a value of `value_type` can't be stored where values of diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 33e70bb0..6a8a89e7 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -84,6 +84,14 @@ impl Analyser { .iter() .map(|arg| self.analyse_expr(arg)) .collect::>(); + // A call needs to know what it calls now, so what a literal + // holds, like the functions in `[f, g][0](1)`, is settled. + if let StaticType::Var(var) = self.vars.resolve(&callee_type) + && self.vars.is_literal(var) + { + self.vars.settle_with_fallbacks(&[var]); + self.solve_waiting(); + } return match self.vars.resolve(&callee_type) { // The callee's type says what it takes, like a picked overload's. callee @ StaticType::Function { @@ -122,7 +130,7 @@ impl Analyser { } } other => { - self.emit(AnalysisError::not_callable(&other, span)); + self.emit(AnalysisError::not_callable(&self.vars.shown(&other), span)); StaticType::Any } }; diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index efc89212..678c4dde 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -285,7 +285,6 @@ impl Analyser { ) { match &lvalue.pattern { BindingPattern::Identifier { span, .. } => { - // If there is a type annotation and the given type is not a subtype of the annotated type we emit an error // The value is checked like an assignment to the annotated // variable, so `let o: Option = None` settles `None`. if let Some(expected_type) = &expected_type diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index c4d80fc0..ef327b1d 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -145,7 +145,21 @@ impl Analyser { .binding { Binding::Resolved(Candidate::Scalar(symbol)) => Some(symbol), - Binding::Dynamic(_) => return Choice::Undecided, + // The program would pick one as it runs, but a function + // value is one function. When the expected type's `Any` + // could be what's not known yet, as in a generic + // function's own body, more than one might be meant. + Binding::Dynamic(candidates) => { + let might = candidates.into_iter().any(|candidate| match candidate { + Candidate::Scalar(symbol) => self.might_be_expected(symbol, expected), + Candidate::Vec(_) => false, + }); + return if might { + Choice::Undecided + } else { + Choice::None + }; + } _ => None, } }; @@ -208,6 +222,16 @@ impl Analyser { .all(|&symbol| parameters(symbol) == parameters(symbols[0])) } + /// Whether a value of `symbol`'s type might be passed where `expected` + /// is, once what's not known about either is: an `Fn(Int) -> Int` might + /// be the `Fn(Any) -> Any` a generic function's body expects, but an + /// `Fn(Int, Int) -> Int` isn't. + fn might_be_expected(&self, symbol: SymbolId, expected: &StaticType) -> bool { + self.vars + .resolve(self.types.get_type(symbol)) + .may_fit(&self.vars.resolve(expected)) + } + /// Whether a value of `symbol`'s type can be passed where `expected` is. fn fits_expected(&mut self, symbol: SymbolId, expected: &StaticType) -> bool { let snapshot = self.vars.snapshot(); @@ -258,6 +282,14 @@ impl Analyser { .fitting_groups(value, expected) .first() .and_then(|group| group.first().copied()) + .or_else(|| { + value + .groups + .iter() + .flatten() + .copied() + .find(|&symbol| self.might_be_expected(symbol, expected)) + }) .unwrap_or_else(|| self.resolver.innermost_reference(value.id)); self.vars .shown(self.types.get_type(symbol)) diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 2e3cfda2..666fede1 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -10,6 +10,7 @@ use super::instances::Scheme; use super::solver::{CallConstraint, Constraint, Waiting}; use crate::error::AnalysisError; use crate::overloads::is_strictly_more_specific; +use crate::types::TypeVars; use ahash::AHashSet; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; @@ -39,6 +40,8 @@ impl Analyser { first_use: usize, ) -> StaticType { self.solve_waiting(); + // A literal in the body has one type, which a call can't change. + self.default_literals(TypeVars::is_generalizable); self.settle_ambiguous(signature); self.merge_copies(); diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 93714db9..dedc387c 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -78,10 +78,11 @@ pub struct Analyser { /// The calls the constraint being solved was copied through, when it's /// a copy of a generic function's constraint. copied_through: Option, - /// Where a value of type `Any` is checked as the program runs, and the - /// type it's checked against, which may still have variables in it, by - /// the instance whose body checks it, or `None` for code compiled once. - checks: Vec<(Option, NodeId, StaticType)>, + /// Where a value is checked as the program runs if it's of type `Any`, + /// with its type and the type it's checked against, which may both + /// still have variables in them, by the instance whose body checks it, + /// or `None` for code compiled once. + checks: Vec<(Option, NodeId, StaticType, StaticType)>, /// The number the next waiting constraint gets. next_serial: u64, /// The waiting constraints that each unsettled variable would let make @@ -173,6 +174,7 @@ impl Analyser { // so the ones left unsettled get their fallbacks, what waited on them // is solved, and no variable leaves the analyser. self.settle_waiting(0); + self.vars.forget_literals(); self.instantiate_uses(); self.declared_in_batch.clear(); debug_assert!(self.waiting.is_empty(), "unsolved: {:?}", self.waiting); @@ -182,7 +184,10 @@ impl Analyser { } self.failed_reads.clear(); self.types.resolve_all(&self.vars); - for (instance, node, expected) in std::mem::take(&mut self.checks) { + for (instance, node, typ, expected) in std::mem::take(&mut self.checks) { + if self.vars.resolve(&typ) != StaticType::Any { + continue; + } let expected = self .vars .resolve(&expected) @@ -227,8 +232,14 @@ impl Analyser { /// node is in the body of the function it was copied from, which the /// instance for the call checks. pub(super) fn expect(&mut self, node: NodeId, expected: StaticType) { + self.expect_if_any(node, StaticType::Any, expected); + } + + /// As [`Self::expect`], for a value of type `typ`, which is checked if + /// it turns out to be `Any`. + pub(super) fn expect_if_any(&mut self, node: NodeId, typ: StaticType, expected: StaticType) { if self.copied_through.is_none() { - self.checks.push((self.recording, node, expected)); + self.checks.push((self.recording, node, typ, expected)); } } @@ -353,7 +364,7 @@ impl Analyser { StaticType::Option(Box::new(self.vars.fresh(StaticType::Any))) } Expression::StringLiteral(_) => StaticType::String, - Expression::NumericLiteral(NumericLiteral::Int64(_)) => StaticType::Int, + Expression::NumericLiteral(NumericLiteral::Int64(_)) => self.vars.fresh_number(*span), Expression::NumericLiteral(NumericLiteral::Float64(_)) => StaticType::Float, Expression::NumericLiteral( NumericLiteral::NumberInt(_) @@ -394,15 +405,20 @@ impl Analyser { let found = self.analyse_expr(value); // What isn't known about the value yet is what the cast says // it is: `(fn(x) => x) as Fn(Int) -> Int` takes an Int, and - // needs no check as the program runs. Known parts are checked. - if self.vars.resolve(&found).has_vars() { + // needs no check as the program runs, and `1 as Float` is a + // Float. Known parts are checked, and so is a value nothing + // is known about, like what a call that waits returns. + let found = self.vars.resolve(&found); + let unknown = matches!(found, StaticType::Var(var) if !self.vars.is_literal(var)); + if found.has_vars() && !unknown { self.vars.try_unify(&found, &target); } let found = self.vars.resolve(&found); if !found.overlaps(&target) { + let found = self.vars.shown(&found); self.emit(AnalysisError::impossible_cast(&found, &target, *span)); } - let check = (!found.is_subtype(&target)).then(|| target.clone()); + let check = (!found.fits(&target)).then(|| target.clone()); self.result.analysis.cast_checks.insert(*id, check); target } @@ -513,50 +529,66 @@ impl Analyser { member, member_span, } => self.analyse_member_access(*id, receiver, member, *member_span), + // Each element of a tuple literal takes the type its uses settle, + // as a list literal's elements do: `let t: (Any, Int) = ("a", 1)`. + // An element whose type isn't known yet already does. Expression::Tuple { values } => { let mut types = Vec::with_capacity(values.len()); - for v in values { - types.push(self.analyse_expr(v)); + for value in values { + let typ = self.analyse_expr(value); + types.push(match self.vars.resolve(&typ) { + StaticType::Var(_) => typ, + _ => self.holding(vec![(Some(value.id), value.span, typ)]), + }); } StaticType::Tuple(types) } Expression::List { values } => { - let element_type = match self.analyse_multiple_expression_with_same_type(values) { - Some(element_type) => element_type, - None => self.vars.fresh(StaticType::Any), + let element_type = if values.is_empty() { + self.vars.fresh(StaticType::Any) + } else { + let values = values + .iter() + .map(|value| (Some(value.id), value.span, self.analyse_expr(value))) + .collect(); + self.holding(values) }; StaticType::List(Box::new(element_type)) } Expression::Map { values, default } => { - let mut key_type: Option = None; - let mut value_type: Option = None; + let mut keys = Vec::new(); + let mut held = Vec::new(); for (key, value) in values { - let found = self.analyse_expr(key); - self.fold_join(&mut key_type, found, key.span); + keys.push((Some(key.id), key.span, self.analyse_expr(key))); if let Some(value) = value { - let found = self.analyse_expr(value); - self.fold_join(&mut value_type, found, value.span); + held.push((Some(value.id), value.span, self.analyse_expr(value))); } } // Reads can produce the default value, so its type is part of - // the map's value type. + // the map's value type. A default that's a function, as an + // `Any` one may be as the program runs, is called for each + // missing key, so what it makes isn't the default's own + // value, and has no node to be checked at. if let Some(default) = default { let default_type = self.analyse_expr(default); let default_type = self.constrain_default_value(default_type, default.span); - self.fold_join(&mut value_type, default_type, default.span); + held.push((None, default.span, default_type)); } - let key_type = match key_type { - Some(key_type) => key_type, - None => self.vars.fresh(StaticType::Any), + let key_type = if keys.is_empty() { + self.vars.fresh(StaticType::Any) + } else { + self.holding(keys) }; // A literal with keys but no values, like `%{1, 2}`, is a set. - let value_type = match value_type { - Some(value_type) => value_type, - None if values.is_empty() => self.vars.fresh(StaticType::unit()), - None => StaticType::unit(), + let value_type = if !held.is_empty() { + self.holding(held) + } else if values.is_empty() { + self.vars.fresh(StaticType::unit()) + } else { + StaticType::unit() }; StaticType::Map { key: Box::new(key_type), @@ -649,16 +681,24 @@ impl Analyser { typ } - fn analyse_multiple_expression_with_same_type( - &mut self, - expressions: &Vec, - ) -> Option { - let mut element_type: Option = None; - for expression in expressions { - let found = self.analyse_expr(expression); - self.fold_join(&mut element_type, found, expression.span); + /// The type of the elements of a literal holding `values`, each with + /// where it is and its type, which takes the type its uses settle: in + /// `let xs: List = [1, 2]`, `Any`. A value of type `Any` among + /// them is checked against it as the program runs, at its node if it + /// has one. + fn holding(&mut self, values: Vec<(Option, Span, StaticType)>) -> StaticType { + let element = self.vars.fresh_holding( + values + .iter() + .map(|(_, span, typ)| (typ.clone(), *span)) + .collect(), + ); + for (node, _, typ) in values { + if let Some(node) = node { + self.expect_if_any(node, typ, element.clone()); + } } - element_type + element } /// The type of the values a map default of type `default` produces. A diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 0d54d47d..64a665d4 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -590,6 +590,11 @@ impl Analyser { // on no variable made since `first` never needs them. let mut fallen_back = 0; loop { + // A literal takes its default before anything else's is applied, + // as a call its type would settle may be waiting for it. + if self.default_literals(|_, var| var.0 as usize >= first) { + unchecked.extend(self.waiting.keys().copied()); + } while let Some(serial) = unchecked.pop_first() { if !self.waits_on_vars_since(serial, first).is_empty() && (self.default_to_narrowest(serial) || self.default_to_operand(serial)) @@ -623,6 +628,140 @@ impl Analyser { self.solve_waiting(); } + /// Settles the types of the literals that `belongs` says to that nothing + /// settled, and returns whether it settled any. Each takes the first of + /// its defaults that every waiting call it's in can still take: in + /// `let total = 0; total += 2.5`, `+` with a Float can't give an Int + /// back to `total`, so `0` is a Float. + pub(super) fn default_literals( + &mut self, + belongs: impl Fn(&TypeVars, TypeVar) -> bool, + ) -> bool { + let mut settled_any = false; + loop { + let mut progress = false; + for var in self.vars.unsettled_literals(&belongs) { + if !self.vars.is_literal(var) { + continue; + } + let Some(defaults) = self.vars.literal_defaults(var) else { + continue; + }; + let fits = defaults.iter().position(|default| { + let snapshot = self.vars.snapshot(); + let fits = self.vars.settle_literal(var, default) && self.calls_still_fit(var); + self.vars.rollback(snapshot); + fits + }); + // When none fits, the first one says best what's wrong. + let default = &defaults[fits.unwrap_or(0)]; + let snapshot = self.vars.snapshot(); + if !self.vars.settle_literal(var, default) { + self.vars.rollback(snapshot); + if self.vars.holds_several_literals(var) { + self.report_literals_that_differ(var, default); + } else { + self.vars.settle_with_fallbacks(&[var]); + } + } + self.solve_waiting(); + progress = true; + settled_any = true; + } + if !progress { + // What's left holds literals that aren't these to settle, + // like `n` in `let n = 1; fn f() { [n] }`, and is what they + // are: a list of whatever `n` is. + let left = self.vars.unsettled_literals(&belongs); + if left.is_empty() { + return settled_any; + } + for var in left { + match self.vars.held_literal(var) { + Some(held) if self.vars.is_literal(var) => { + self.vars.settle_literal(var, &StaticType::Var(held)); + } + _ => self.vars.settle_with_fallbacks(&[var]), + } + } + self.solve_waiting(); + settled_any = true; + } + } + } + + /// Reports the values of the literals whose element type is `var` that + /// don't fit `default`, and settles it as `default`: after `let v = + /// ["s"]; v = [1]`, `1` isn't a String. + fn report_literals_that_differ(&mut self, var: TypeVar, default: &StaticType) { + for (held, span) in self.vars.held(var) { + let snapshot = self.vars.snapshot(); + let fits = self.vars.bind_argument(default, &held); + self.vars.rollback(snapshot); + if !fits { + let error = AnalysisError::mismatched_types(&self.vars.shown(&held), default, span); + self.emit(error); + } + } + self.vars.settle_literal(var, default); + } + + /// Whether each waiting call that waits on `var` can still take its + /// arguments, and give back what's expected of it. + fn calls_still_fit(&mut self, var: TypeVar) -> bool { + let serials = self.watchers.get(&var).cloned().unwrap_or_default(); + serials.into_iter().all(|serial| { + let Some(Waiting { + constraint: Constraint::Call(call), + .. + }) = self.waiting.get(&serial).cloned() + else { + return true; + }; + self.call_still_fits(&call) + }) + } + + /// Whether some overload of `call` takes its arguments, as far as + /// they're known, and returns what fits its result. + fn call_still_fits(&mut self, call: &CallConstraint) -> bool { + let arguments = call + .arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + if Self::operand_could_be_a_tuple(call.kind, &arguments) { + return true; + } + let reachable = self.overloads().reachable_symbols(&call.candidates); + reachable.into_iter().any(|symbol| { + let function = self.vars.resolve(self.types.get_type(symbol)); + let StaticType::Function { + parameters: Some(_), + .. + } = &function + else { + return true; + }; + let snapshot = self.vars.snapshot(); + let StaticType::Function { + parameters: Some(parameters), + return_type, + } = self.vars.instantiate(&function) + else { + unreachable!("instantiating a function type gives a function type"); + }; + let fits = parameters.len() == arguments.len() + && parameters + .iter() + .zip(&arguments) + .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)) + && self.vars.bind_argument(&call.result, &return_type); + self.vars.rollback(snapshot); + fits + }) + } + /// Tries the waiting constraint `serial` again, if it's still waiting. /// A call can wait although its arguments are known: a variable's type /// might have settled since, which nothing watches. @@ -815,7 +954,8 @@ impl Analyser { // A variable whose type isn't known yet, like the parameter `f` in // `fn(f) => f(1)`, holds a function that takes these arguments. if let [only] = reachable[..] - && let callee @ StaticType::Var(_) = self.vars.resolve(self.types.get_type(only)) + && let callee @ StaticType::Var(var) = self.vars.resolve(self.types.get_type(only)) + && !self.vars.is_literal(var) { let typ = self.call_unknown_function(&callee, arguments); self.record_pick( @@ -826,10 +966,22 @@ impl Analyser { return Some(typ); } + // A variable holding a literal, like `n` in `let n = 1; n(2)`, can't + // be called, which is said best once it's settled. + if reachable.iter().any(|&symbol| { + matches!( + self.vars.resolve(self.types.get_type(symbol)), + StaticType::Var(var) if self.vars.is_literal(var) + ) + }) { + return None; + } + // A write waits for the value it stores, which is analysed after the // target: only then can it tell whether the value is what's wrong. - if let (CallSite::Setter(_) | CallSite::FieldSetter(_), Some(StaticType::Var(_))) = + if let (CallSite::Setter(_) | CallSite::FieldSetter(_), Some(StaticType::Var(var))) = (call.site, arguments.last()) + && !self.vars.is_literal(*var) { return None; } @@ -849,6 +1001,16 @@ impl Analyser { { None } + // A literal takes the type its uses need, or else its default, + // which says better what's wrong: `f(1)` with `f(String)` gets + // an Int. On tuples, an operator may apply to each element. + EarlyOverload::NoneFits + if arguments + .iter() + .any(|argument| self.vars.has_unsettled_literals(argument)) => + { + None + } EarlyOverload::NoneFits => Some(self.resolve_call_now(call, &arguments)), EarlyOverload::Only(only) => { let Some(typ) = self.commit_early(call, &arguments, only) else { @@ -885,24 +1047,152 @@ impl Analyser { arguments: &[StaticType], kind: CallKind, ) -> EarlyOverload { - let viable = self - .overloads() - .reachable_symbols(candidates) - .into_iter() + let reachable = self.overloads().reachable_symbols(candidates); + let literals = arguments + .iter() + .flat_map(|argument| self.vars.unsettled(argument)) + .filter(|&var| self.vars.is_literal(var)) + .collect::>(); + let viable = reachable + .iter() + .copied() .filter(|&symbol| self.could_accept(symbol, arguments)) .collect::>(); match viable[..] { + // A literal may still become `Any`, which an overload that can't + // take it now might take: `[1, "s"]` holds `Any`s, which + // `p(x: String)` may get. + [] if !literals.is_empty() && self.fits_once_any(&reachable, arguments, None) => { + EarlyOverload::Undecided + } [] => EarlyOverload::NoneFits, [only] if self.fits_without_any(only, arguments) - && !Self::operand_could_be_a_tuple(kind, arguments) => + && !Self::operand_could_be_a_tuple(kind, arguments) + && !self.merges_literals(only, arguments) => { + // When the pick depends on what a literal is now, as + // `p(x)` over `p(x: Int)` does for the Strings in + // `["s"].map(p)`, the literal is what it is from here on: + // the first use that depends on it decides it. + if !literals.is_empty() + && self.fits_once_any(&reachable, arguments, Some(only)) + && !self.decide_literals(&literals, only, arguments) + { + return EarlyOverload::Undecided; + } EarlyOverload::Only(only) } _ => EarlyOverload::Undecided, } } + /// Whether one of `reachable` other than `except` takes `arguments` + /// once each literal among them is `Any`. + fn fits_once_any( + &mut self, + reachable: &[SymbolId], + arguments: &[StaticType], + except: Option, + ) -> bool { + let as_any = arguments + .iter() + .map(|argument| { + self.vars.resolve(argument).replace(&|part| match part { + StaticType::Var(var) if self.vars.is_literal(*var) => Some(StaticType::Any), + _ => None, + }) + }) + .collect::>(); + reachable + .iter() + .filter(|&&symbol| Some(symbol) != except) + .any(|&symbol| self.could_accept(symbol, &as_any)) + } + + /// Settles each of `literals` as the first of its defaults with which + /// `only` still takes `arguments`, and returns whether it could. + fn decide_literals( + &mut self, + literals: &[TypeVar], + only: SymbolId, + arguments: &[StaticType], + ) -> bool { + for &var in literals { + // A list of numbers is what the numbers are. + while let Some(held) = self.vars.held_literal(var) { + if !self.decide_literals(&[held], only, arguments) { + return false; + } + } + if !self.vars.is_literal(var) { + continue; + } + let Some(defaults) = self.vars.literal_defaults(var) else { + return false; + }; + let decided = defaults.iter().any(|default| { + let snapshot = self.vars.snapshot(); + let fits = + self.vars.settle_literal(var, default) && self.could_accept(only, arguments); + if !fits { + self.vars.rollback(snapshot); + } + fits + }); + if !decided { + return false; + } + } + true + } + + /// Whether passing these arguments to `symbol` would make the types of + /// two literals one, as `++(List, List)` does with `[1]` and + /// `["a"]`. Each literal first gets its own type, which says better + /// whether they're the same. + fn merges_literals(&mut self, symbol: SymbolId, arguments: &[StaticType]) -> bool { + let literals = arguments + .iter() + .flat_map(|argument| self.vars.unsettled(argument)) + .filter(|&var| self.vars.is_literal(var)) + .collect::>(); + if literals.len() < 2 { + return false; + } + let StaticType::Function { + parameters: Some(_), + .. + } = self.vars.resolve(self.types.get_type(symbol)) + else { + return false; + }; + let snapshot = self.vars.snapshot(); + let StaticType::Function { + parameters: Some(parameters), + .. + } = self + .vars + .instantiate(&self.vars.resolve(self.types.get_type(symbol))) + else { + unreachable!("instantiating a function type gives a function type"); + }; + for (parameter, argument) in parameters.iter().zip(arguments) { + self.vars.bind_argument(parameter, argument); + } + let mut became = Vec::new(); + let merged = literals.iter().any(|&var| { + let StaticType::Var(now) = self.vars.resolve(&StaticType::Var(var)) else { + return false; + }; + let merged = became.contains(&now); + became.push(now); + merged + }); + self.vars.rollback(snapshot); + merged + } + /// Resolves `call` with the arguments as they are, reporting it when no /// overload accepts them. fn resolve_call_now(&mut self, call: &CallConstraint, arguments: &[StaticType]) -> StaticType { diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 65989a39..1aaafc85 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -44,10 +44,10 @@ fn index_natives() -> Vec<(String, StaticType)> { // The stdlib's list and map overloads. [ ("[]", "(List, Int) -> T"), - ("[]", "(List, Iterator) -> List"), + ("[]", "(List, Iterator) -> List"), ("[]", "(Map, K) -> V"), ("[]=", "(List, Int, T)"), - ("[]=", "(List, Iterator, Sequence)"), + ("[]=", "(List, Iterator, Sequence)"), ("[]=", "(Map, K, V)"), ("[]=", "(String, Any, String)"), ] @@ -199,11 +199,8 @@ fn annotated_identifier_augmented_assignment_rejects_widening() { #[test] fn an_in_place_operator_assignment_keeps_the_target_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), - }; + let append = + StaticType::signature("(List, List) -> List").expect("valid signature"); assert_eq!( analyse_last_type_with_globals( @@ -216,11 +213,8 @@ fn an_in_place_operator_assignment_keeps_the_target_type() { #[test] fn an_in_place_operator_assignment_cannot_change_the_target_type() { - 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), - }; + let concat = + StaticType::signature("(List, List) -> List").expect("valid signature"); assert_analysis_error( "let values = [1]; values ++= [\"two\"];", @@ -423,18 +417,31 @@ fn a_variable_keeps_its_inferred_type() { parameters: Some(vec![StaticType::Int, StaticType::Float]), return_type: Box::new(StaticType::Float), }; - let globals = [index_natives(), vec![("+".to_string(), add)]].concat(); + // `one()` is an Int that isn't a literal, which would take the type + // its uses need. + let globals = [ + index_natives(), + vec![("+".to_string(), add)], + natives(&[("one", "() -> Int")]), + ] + .concat(); let scalar = "mismatched types: found Float but expected Int"; let list = "mismatched types: found List but expected List"; for (source, expected) in [ - ("let x = 3; x = 0.5;", scalar), - ("let x = 3; x += 0.5;", scalar), - ("let a = 1; let b = 2; (a, b) = (0.5, 2);", scalar), - ("for x in [1] { x = 0.5; }", scalar), - ("let values = [1]; values[0] = 0.5;", scalar), - ("let values = [1]; values[0..1] = [0.5];", list), - ("let values = [1]; values[0] += 0.5;", scalar), - ("let values = %{\"one\": 1}; values[\"two\"] = 0.5;", scalar), + ("let x = one(); x = 0.5;", scalar), + ("let x = one(); x += 0.5;", scalar), + ( + "let a = one(); let b = one(); (a, b) = (0.5, one());", + scalar, + ), + ("for x in [one()] { x = 0.5; }", scalar), + ("let values = [one()]; values[0] = 0.5;", scalar), + ("let values = [one()]; values[0..1] = [0.5];", list), + ("let values = [one()]; values[0] += 0.5;", scalar), + ( + "let values = %{\"one\": one()}; values[\"two\"] = 0.5;", + scalar, + ), ] { assert_eq!(errors(source, globals.clone()), vec![expected], "{source}"); } @@ -815,7 +822,10 @@ fn a_lambda_passed_to_a_function_takes_its_parameter_types_from_the_call() { #[test] fn a_lambda_in_a_variable_has_one_type() { assert_eq!( - errors("let f = fn(x) => x + 1; f(1); f(2.5);", ambiguous_plus()), + errors( + "let f = fn(x) => x + 1; f(one()); f(2.5);", + [ambiguous_plus(), natives(&[("one", "() -> Int")])].concat() + ), vec!["The variable 'f' holds a function that doesn't accept the arguments 'Float'"] ); } @@ -961,13 +971,14 @@ fn a_call_nothing_else_settles_takes_no_narrowest_overload_when_unsure() { ), function(StaticType::Any, StaticType::Any) ); - // Overloads in two scopes fit, so none settles `x`: it's `Any`, and the - // call resolves as it does for any `Any` argument. + // Overloads in two scopes fit, so none settles `x`: it's `Any`, and + // `[x]` is a `List`, which neither takes. assert_eq!( - analyse_last_type( - "fn pick(xs: List) => \"outer\"; { fn pick(xs: List) => 1; let f = fn(x) => pick([x]); f }" + errors( + "fn pick(xs: List) => \"outer\"; { fn pick(xs: List) => 1; let f = fn(x) => pick([x]); f }", + vec![] ), - function(StaticType::Any, StaticType::Int) + vec!["No function called 'pick' found that matches the arguments 'List'"] ); } @@ -1683,3 +1694,16 @@ fn an_instance_calls_itself_where_its_body_recurses() { .count(); assert_eq!(calls_itself, 2, "{instance:?}"); } + +#[test] +fn a_list_literal_holding_the_list_it_is_pushed_to_is_analysed() { + // `[xs]` holds a list of what it is itself, which used to recurse + // forever while its type was settled. + for source in [ + "let xs = []; xs.push([xs]);", + "let xs = [\"s\"]; xs = [xs];", + ] { + let (_, result) = analyse_with_globals(source, natives(&[("push", "(List, T)")])); + assert!(result.errors.len() <= 1, "{source}: {:?}", result.errors); + } +} diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 953be744..6824c727 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -40,7 +40,7 @@ pub(crate) enum VecResolution { /// Output of a single scalar overload walk across the scope chain. struct ScalarWalk { - /// First exact-subtype match found; short-circuits the walk. + /// First exact match found; short-circuits the walk. exact: Option, /// First-scope-wins loose-compatibility candidates. loose: Option>, @@ -50,54 +50,32 @@ struct ScalarWalk { } /// Whether a value of type `arg` might be accepted where `param` is expected -/// when the program runs. For a function passed for a function parameter, -/// only what both types know has to agree: `fn(x) => x * 2`, typed -/// `Fn(Any) -> Any`, can be passed for an `Fn(Int) -> Int`, but +/// when the program runs: an `Any` might turn out to be anything, and the +/// parts of either type not known yet could be anything. A lambda +/// `fn(x) => x * 2` can be passed for an `Fn(Int) -> Int`, but /// `fn(x: String) => 1` can't, and neither can a function of two arguments. fn could_pass(param: &StaticType, arg: &StaticType) -> bool { - match (param, arg) { - ( - StaticType::Function { - parameters: expected, - return_type: expected_return, - }, - StaticType::Function { - parameters: takes, - return_type: returns, - }, - ) => { - let parameters = match (expected, takes) { - // What the parameter passes has to fit what the function takes. - (Some(expected), Some(takes)) => { - expected.len() == takes.len() - && expected - .iter() - .zip(takes) - .all(|(expected, takes)| could_pass(takes, expected)) - } - _ => true, - }; - parameters && could_pass(expected_return, returns) - } - _ => !param.is_incompatible_with(arg), - } + arg.may_fit(param) } -/// Returns `true` iff `more` is a strict subtype of `less` pointwise — i.e. -/// every position satisfies `more[i] <: less[i]` and at least one position is -/// not bidirectionally equivalent. Used by [`Overloads::most_specific_match`] to order -/// matching overloads by specificity: an overload with `more` parameters -/// accepts strictly fewer call signatures than one with `less` parameters. +/// Returns `true` iff `more` is strictly more specific than `less`: each of +/// its parameter types is the other's, or an instance of it, and at least +/// one isn't the other's. An instance of a type parameter is any type, and +/// every type is an instance of `Any`: `f(Int)` is more specific than +/// `f(x)` and `f(Any)`, but `f(List)` and `f(List)` are +/// unrelated. Used by [`Overloads::most_specific_match`] to order matching +/// overloads by specificity: an overload with `more` parameters accepts +/// strictly fewer call signatures than one with `less` parameters. pub(crate) fn is_strictly_more_specific(more: &[StaticType], less: &[StaticType]) -> bool { if more.len() != less.len() { return false; } let mut any_strict = false; for (m, l) in more.iter().zip(less) { - if !m.is_subtype(l) { + if !m.fits(l) { return false; } - if !l.is_subtype(m) { + if !l.fits(m) { any_strict = true; } } @@ -244,7 +222,7 @@ impl<'a> Overloads<'a> { && parameters .iter() .zip(sig) - .any(|(param, arg)| !arg.is_subtype(param)) + .any(|(param, arg)| !arg.fits(param)) }) } @@ -447,11 +425,10 @@ impl<'a> Overloads<'a> { /// /// Resolution proceeds in two stages: /// 1. Filter to overloads whose declared signature accepts `sig` - /// (the usual subtype check via [`StaticType::is_fn_and_matches`]). + /// (each argument [`StaticType::fits`] its parameter). /// 2. Among the survivors, pick the one whose parameter signature is /// most specific — i.e. not strictly dominated by any other match's - /// parameter signature in the subtype partial order. A signature `b` - /// strictly dominates `a` when `b <: a` and `a` is not `<: b`. + /// parameter signature (see [`is_strictly_more_specific`]). /// /// Variadic overloads (`parameters: None`) match anything but are treated /// as the least specific shape: any concrete overload that also matches @@ -827,7 +804,7 @@ mod tests { #[test] fn more_specific_overload_wins_in_same_scope() { let seq_any = StaticType::Sequence(Box::new(StaticType::Any)); - let fn_any = variadic_fn(StaticType::Any); + let fn_any = variadic_fn(StaticType::Generic("U".into())); let (types, locates) = table(vec![ fn_type(vec![seq_any.clone(), fn_any], StaticType::Any), fn_type(vec![seq_any, StaticType::Any], StaticType::Any), diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index cd1bc7bf..454330ac 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -1,5 +1,6 @@ -use ahash::AHashMap; +use ahash::{AHashMap, AHashSet}; use ndc_core::{StaticType, TypeVar}; +use ndc_lexer::Span; use ndc_resolver::SymbolId; /// The type of every value the name resolver declared, natives included, @@ -56,6 +57,31 @@ pub(crate) struct TypeVars { /// The levels lowered so far, with what each was before, so a /// [`Snapshot`] can raise them again. lowered: Vec<(TypeVar, u32)>, + /// What each variable may stand for beyond what unifying says, which + /// settling it checks: the type of the literal `1` is an `Int`, a + /// `Float` or a `Number`. + bounds: Vec>, + /// The variables a bound was added to after they were made, in order, + /// so a [`Snapshot`] can take the bounds off again. + bounded: Vec, + /// The variables that got a bound, in the order they got it, which are + /// the types of literals. + literals: Vec, +} + +/// What a variable may stand for, beyond what unifying says. +#[derive(Debug, Clone)] +enum Bound { + /// It's the type of an integer literal, like `1`: an `Int`, a `Float` + /// or a `Number`, or `Any`, which takes anything. It's an `Int` unless + /// something says otherwise. With the literal's type as it was made, + /// and where the literal is. + Number(TypeVar, Span), + /// It's the type of the elements of a literal that holds a value of + /// this type, like `[1, 2.5]`, so the value has to fit it. With the + /// literal, as the type of the elements it was made for, and where the + /// value is. + Holds(StaticType, TypeVar, Span), } /// The state of the variables at one point, which [`TypeVars::rollback`] @@ -65,6 +91,7 @@ pub(crate) struct Snapshot { vars: usize, trail: usize, lowered: usize, + bounded: usize, } impl TypeVars { @@ -75,9 +102,225 @@ impl TypeVars { self.solutions.push(None); self.fallbacks.push(fallback); self.levels.push(self.level); + self.bounds.push(Vec::new()); StaticType::Var(var) } + /// The type of an integer literal, like `1`, which its uses settle as an + /// `Int`, a `Float` or a `Number`: `1` in `[1.5, 1]` is a Float. It's an + /// `Int` unless something says otherwise. + pub(crate) fn fresh_number(&mut self, span: Span) -> StaticType { + let literal = self.next_var(); + self.fresh_bounded(StaticType::Int, vec![Bound::Number(literal, span)]) + } + + /// The variable [`Self::fresh`] makes next. + fn next_var(&self) -> TypeVar { + TypeVar(u32::try_from(self.solutions.len()).expect("fewer than 2^32 type variables")) + } + + /// The type of the elements of a literal holding values of the types + /// `held`, which its uses settle as any type each value fits: in + /// `let xs: List = [1, 2]`, `Any`. Unless something says otherwise + /// it's the type the values share, or `Any` if they share none. + pub(crate) fn fresh_holding(&mut self, held: Vec<(StaticType, Span)>) -> StaticType { + let literal = self.next_var(); + self.fresh_bounded( + StaticType::Any, + held.into_iter() + .map(|(typ, span)| Bound::Holds(typ, literal, span)) + .collect(), + ) + } + + /// A literal whose type the literal type `var` holds values of and that + /// isn't settled yet, as `1`'s is in `[1]`. + pub(crate) fn held_literal(&self, var: TypeVar) -> Option { + self.bounds[var.0 as usize] + .iter() + .find_map(|bound| match bound { + Bound::Holds(held, _, _) => match self.resolve(held) { + StaticType::Var(held) if held != var && self.is_literal(held) => Some(held), + _ => None, + }, + Bound::Number(..) => None, + }) + } + + /// The values the literal type `var` holds, with where each is. + pub(crate) fn held(&self, var: TypeVar) -> Vec<(StaticType, Span)> { + self.bounds[var.0 as usize] + .iter() + .map(|bound| match bound { + Bound::Holds(typ, _, span) => (typ.clone(), *span), + Bound::Number(_, span) => (StaticType::Int, *span), + }) + .collect() + } + + /// Whether the literal type `var` holds the values of more than one + /// literal, as it does once `v = [1]` makes it the type of `v`'s + /// `["s"]` too. Values of one literal that share no type are held as + /// `Any`, but two literals of different types are an error. + pub(crate) fn holds_several_literals(&self, var: TypeVar) -> bool { + let mut literals = self.bounds[var.0 as usize].iter().map(|bound| match bound { + Bound::Holds(_, literal, _) | Bound::Number(literal, _) => *literal, + }); + literals + .next() + .is_some_and(|first| literals.any(|literal| literal != first)) + } + + fn fresh_bounded(&mut self, fallback: StaticType, bounds: Vec) -> StaticType { + let typ = self.fresh(fallback); + let StaticType::Var(var) = typ else { + unreachable!("a fresh type is a variable"); + }; + self.bounds[var.0 as usize] = bounds; + self.literals.push(var); + typ + } + + /// The unsettled types of literals that `belongs` says to settle, the + /// ones holding values before the numbers, so a value can still say + /// what a number is: `1` in `[1, 2.5]` is a Float. + pub(crate) fn unsettled_literals( + &self, + belongs: impl Fn(&Self, TypeVar) -> bool, + ) -> Vec { + let mut literals = Vec::new(); + let mut seen = AHashSet::new(); + for &var in &self.literals { + if (var.0 as usize) < self.solutions.len() + && self.solution(var).is_none() + && !self.bounds[var.0 as usize].is_empty() + && belongs(self, var) + && seen.insert(var) + { + literals.push(var); + } + } + literals.sort_by_key(|&var| self.is_number(var)); + literals + } + + /// Forgets the types of literals, once they're all settled at the end + /// of a batch. + pub(crate) fn forget_literals(&mut self) { + debug_assert!( + self.literals + .iter() + .all(|&var| var.0 as usize >= self.solutions.len() || !self.is_literal(var)), + "a literal's type is left unsettled" + ); + self.literals.clear(); + } + + /// Whether `var` is the type of an integer literal. + fn is_number(&self, var: TypeVar) -> bool { + self.bounds[var.0 as usize] + .iter() + .any(|bound| matches!(bound, Bound::Number(..))) + } + + /// What the unsettled literal type `var` could be when nothing says + /// otherwise, best first. `None` if that isn't known yet: a list of + /// numbers is a list of whatever the numbers are, which is up to them. + pub(crate) fn literal_defaults(&self, var: TypeVar) -> Option> { + let mut defaults = Vec::new(); + let mut waits = false; + let mut unknown = None; + // The literal made first says first what the type is: after `let v + // = ["s"]; v = [1]`, it's what `v` was. + let mut bounds = self.bounds[var.0 as usize].iter().collect::>(); + bounds.sort_by_key(|bound| match bound { + Bound::Holds(_, literal, _) | Bound::Number(literal, _) => literal.0, + }); + for bound in bounds { + let held = match bound { + Bound::Number(..) => { + for number in [StaticType::Int, StaticType::Float, StaticType::Number] { + if !defaults.contains(&number) { + defaults.push(number); + } + } + continue; + } + Bound::Holds(held, _, _) => held, + }; + match self.resolve(held) { + StaticType::Var(held) if self.is_literal(held) => waits = true, + // A value whose type isn't known holds what its type is: + // `fn wrap(x) => [x]` returns a list of what `x` is. + StaticType::Var(held) => { + unknown.get_or_insert(StaticType::Var(held)); + } + // A value that never arrives says nothing about the type. + StaticType::Never => {} + held => { + if !defaults.contains(&held) { + defaults.push(held); + } + } + } + } + if defaults.is_empty() && waits { + return None; + } + if defaults.is_empty() { + // Values that share no type are held as `Any`, which settling + // the literal's type with a default it doesn't take falls back + // to. + defaults.push(unknown.unwrap_or(StaticType::Any)); + } + Some(defaults) + } + + /// What the unsettled literal type `var` is shown as: what it would be + /// if nothing said otherwise, as far as that's known. + pub(crate) fn preview(&self, var: TypeVar) -> StaticType { + if self.is_number(var) { + return StaticType::Int; + } + let mut held = Vec::new(); + for bound in &self.bounds[var.0 as usize] { + // A literal that holds itself, like `[xs]` pushed to `xs`, says + // nothing more about what it is. + if let Bound::Holds(typ, _, _) = bound + && !self + .resolve(typ) + .contains(&|part| *part == StaticType::Var(var)) + { + let shown = self.shown(typ); + if !held.contains(&shown) { + held.push(shown); + } + } + } + match &held[..] { + [only] => only.clone(), + _ => StaticType::Any, + } + } + + /// Whether `var` is unsettled and has bounds, as a literal's type does. + pub(crate) fn is_literal(&self, var: TypeVar) -> bool { + self.solution(var).is_none() && !self.bounds[var.0 as usize].is_empty() + } + + /// Whether `typ` has a part that's a literal's type not settled yet. + pub(crate) fn has_unsettled_literals(&self, typ: &StaticType) -> bool { + self.unsettled(typ) + .into_iter() + .any(|var| self.is_literal(var)) + } + + /// Settles `var` as `typ`, and returns whether `typ` is within its + /// bounds, as [`Self::unify`] does. + pub(crate) fn settle_literal(&mut self, var: TypeVar, typ: &StaticType) -> bool { + self.unify(&StaticType::Var(var), typ) + } + /// Starts the body of a generic function: the variables made until /// [`Self::leave_level`] belong to it. pub(crate) fn enter_level(&mut self) { @@ -98,8 +341,13 @@ impl TypeVars { } /// Settles `var` as the type parameter `name` of the generic function - /// whose body made it. + /// whose body made it. A literal's type is settled before, so `var` has + /// no bounds. pub(crate) fn generalize(&mut self, var: TypeVar, name: &str) { + debug_assert!( + self.bounds[var.0 as usize].is_empty(), + "the literal type {var:?} is generalized" + ); self.settle(var, StaticType::Generic(name.into())); } @@ -113,8 +361,10 @@ impl TypeVars { self.solutions[var.0 as usize].as_ref() } - /// Settles `var` as `typ`. - fn settle(&mut self, var: TypeVar, typ: StaticType) { + /// Settles `var` as `typ`, and returns whether `typ` is within the + /// bounds of `var`. When `typ` is another variable, it gets them. On a + /// mismatch, `var` and the variables settled meanwhile stay settled. + fn settle(&mut self, var: TypeVar, typ: StaticType) -> bool { debug_assert!(self.solution(var).is_none(), "{var:?} is settled twice"); // What `var` stands for now belongs where `var` does. let level = self.levels[var.0 as usize]; @@ -127,8 +377,37 @@ impl TypeVars { } } } + let shallow = self.shallow(&typ); self.solutions[var.0 as usize] = Some(typ); self.trail.push(var); + let bounds = self.bounds[var.0 as usize].clone(); + if bounds.is_empty() { + return true; + } + match shallow { + StaticType::Var(other) => { + for bound in bounds { + // A literal holding a value of the type the other + // variable already is says nothing more. + if let Bound::Holds(held, _, _) = &bound + && self.shallow(held) == StaticType::Var(other) + { + continue; + } + self.bounds[other.0 as usize].push(bound); + self.bounded.push(other); + self.literals.push(other); + } + true + } + typ => bounds.iter().all(|bound| match bound { + Bound::Number(..) => matches!( + typ, + StaticType::Int | StaticType::Float | StaticType::Number | StaticType::Any + ), + Bound::Holds(held, _, _) => self.bind_argument(&typ, held), + }), + } } /// How many settlings there have been, to pass to @@ -150,6 +429,7 @@ impl TypeVars { vars: self.solutions.len(), trail: self.trail.len(), lowered: self.lowered.len(), + bounded: self.bounded.len(), } } @@ -162,9 +442,15 @@ impl TypeVars { for (var, level) in self.lowered.drain(snapshot.lowered..).rev() { self.levels[var.0 as usize] = level; } + for var in self.bounded.drain(snapshot.bounded..).rev() { + if (var.0 as usize) < snapshot.vars { + self.bounds[var.0 as usize].pop(); + } + } self.solutions.truncate(snapshot.vars); self.fallbacks.truncate(snapshot.vars); self.levels.truncate(snapshot.vars); + self.bounds.truncate(snapshot.vars); } /// Unifies `a` and `b` if they can be made the same type, and otherwise @@ -193,18 +479,46 @@ impl TypeVars { } /// `typ` as an error shows it: the parts not known yet could still be - /// anything, so they're shown as `Any`. + /// anything, so they're shown as `Any`, except a literal's, which is + /// shown as what it is unless something says otherwise: `[1]` as a + /// `List`. pub(crate) fn shown(&self, typ: &StaticType) -> StaticType { - self.resolve(typ) - .replace(&|part| matches!(part, StaticType::Var(_)).then_some(StaticType::Any)) + self.resolve(typ).replace(&|part| match part { + StaticType::Var(var) if self.is_literal(*var) => Some(self.preview(*var)), + StaticType::Var(_) => Some(StaticType::Any), + _ => None, + }) } - /// Settles each of `vars` that is still unsettled with its fallback. + /// Settles each of `vars` that is still unsettled with its fallback. A + /// literal's type gets the first of its defaults within its bounds. pub(crate) fn settle_with_fallbacks(&mut self, vars: &[TypeVar]) { for &var in vars { - if self.solution(var).is_none() { - let fallback = self.fallbacks[var.0 as usize].clone(); - self.settle(var, fallback); + if self.solution(var).is_some() { + continue; + } + let defaults = match self.literal_defaults(var) { + Some(defaults) if !self.bounds[var.0 as usize].is_empty() => defaults, + _ => vec![self.fallbacks[var.0 as usize].clone()], + }; + let settled = defaults.iter().any(|default| { + // A variable can't stand for a type that contains it. + if self + .resolve(default) + .contains(&|part| *part == StaticType::Var(var)) + { + return false; + } + let snapshot = self.snapshot(); + let settled = self.settle(var, default.clone()); + if !settled { + self.rollback(snapshot); + } + settled + }); + if !settled { + // `Any` takes anything a literal holds. + self.settle(var, StaticType::Any); } } } @@ -247,8 +561,7 @@ impl TypeVars { { return false; } - self.settle(*var, other.clone()); - true + self.settle(*var, other.clone()) } (StaticType::Option(x), StaticType::Option(y)) | (StaticType::Sequence(x), StaticType::Sequence(y)) @@ -332,79 +645,49 @@ impl TypeVars { /// Settles the parts of `argument` that aren't known yet by passing it /// where a `parameter` is expected, and returns whether it fits there. In /// `push(xs, 1)` with `push(List, ?0)` and `xs: List`, both - /// variables become `Int`. A parameter of type `Any` settles what's - /// unknown in the argument as `Any`, because the function may store - /// anything in it. + /// variables become `Int`. Passing makes the two types the same, except + /// that a parameter of type `Any` takes anything, and an argument of + /// type `Any` goes anywhere, as it's checked as the program runs. Inside + /// a container, a function type, an `Option` or a tuple, `Any` is just + /// another type: a `List` doesn't fit a `List`. A + /// `Sequence` takes any kind of sequence, and settles `T` from its + /// elements. pub(crate) fn bind_argument(&mut self, parameter: &StaticType, argument: &StaticType) -> bool { let (parameter, argument) = (self.shallow(parameter), self.shallow(argument)); match (¶meter, &argument) { - (_, StaticType::Never) => true, - (StaticType::Any, _) => { - self.settle_unknowns(&argument, &StaticType::Any); - true - } + (_, StaticType::Never) | (StaticType::Any, _) => true, + // A value of type `Any` is checked where it goes, so it leaves a + // literal's type what the literal says: `n` in `let n = 1; + // n = a` is still an Int. + (StaticType::Var(var), StaticType::Any) if self.is_literal(*var) => true, (StaticType::Var(_), _) | (_, StaticType::Var(_)) => self.unify(¶meter, &argument), - (StaticType::Option(p), StaticType::Option(a)) - | (StaticType::List(p), StaticType::List(a)) - | (StaticType::Iterator(p), StaticType::Iterator(a)) - | (StaticType::MinHeap(p), StaticType::MinHeap(a)) - | (StaticType::MaxHeap(p), StaticType::MaxHeap(a)) - | (StaticType::Deque(p), StaticType::Deque(a)) - | (StaticType::Sequence(p), StaticType::Sequence(a)) => self.bind_argument(p, a), - // A function passed as an argument gets the parameter types the - // function type passes it, and returns what the function type - // returns: `map(xs, fn(x) => x + 1)` passes `xs`'s elements to - // `x` and settles `U`. A parameter of type `Any` takes anything, - // so it settles nothing. + (_, StaticType::Any) => true, + // A function type that says nothing about the parameters takes + // any function that returns what it says. ( StaticType::Function { - parameters: expected, + parameters: None, return_type: p, }, - StaticType::Function { - parameters: takes, - return_type: a, - }, - ) => { - let mut fits = true; - if let (Some(expected), Some(takes)) = (expected, takes) { - // A function taking another number of arguments can't be - // called the way the function type says. - fits = expected.len() == takes.len(); - for (expected, takes) in expected.iter().zip(takes) { - if self.shallow(takes) != StaticType::Any { - fits &= self.bind_argument(takes, expected); - } - } - } - fits && self.bind_argument(p, a) - } - (StaticType::Tuple(ps), StaticType::Tuple(args)) if ps.len() == args.len() => { - ps.iter().zip(args).all(|(p, a)| self.bind_argument(p, a)) - } - (StaticType::Map { key: pk, value: pv }, StaticType::Map { key: ak, value: av }) => { - let keys = self.bind_argument(pk, ak); - let values = self.bind_argument(pv, av); - keys && values - } - // Any sequence passes as a `Sequence` of its elements. + StaticType::Function { return_type: a, .. }, + ) => self.unify(p, a), + (StaticType::Sequence(p), StaticType::Sequence(a)) => self.bind_argument(p, a), (StaticType::Sequence(p), _) if argument.sequence_element_type().is_some() => { let element = argument .sequence_element_type() .expect("checked by the guard"); self.bind_argument(p, &element) } - _ => { - let argument = self.resolve(&argument); - argument.is_unknown() || argument.is_subtype(&self.resolve(¶meter)) - } + _ => self.unify(¶meter, &argument), } } /// Settles every unsettled variable in `typ` as `to`. pub(crate) fn settle_unknowns(&mut self, typ: &StaticType, to: &StaticType) { for var in self.unsettled(typ) { - self.settle(var, to.clone()); + if self.solution(var).is_none() { + self.settle(var, to.clone()); + } } } @@ -417,3 +700,78 @@ impl TypeVars { } } } + +#[cfg(test)] +mod tests { + use super::TypeVars; + use ndc_core::StaticType; + use ndc_lexer::Span; + + fn span() -> Span { + Span::synthetic() + } + + #[test] + fn a_number_literal_is_a_number_or_any() { + let mut vars = TypeVars::default(); + for typ in [ + StaticType::Int, + StaticType::Float, + StaticType::Number, + StaticType::Any, + ] { + let literal = vars.fresh_number(span()); + assert!(vars.try_unify(&literal, &typ), "{typ}"); + } + let literal = vars.fresh_number(span()); + assert!(!vars.try_unify(&literal, &StaticType::String)); + } + + #[test] + fn a_literal_holds_what_each_value_fits() { + let mut vars = TypeVars::default(); + let number = vars.fresh_number(span()); + let element = + vars.fresh_holding(vec![(number.clone(), span()), (StaticType::Float, span())]); + assert!(!vars.try_unify(&element, &StaticType::String)); + assert!(vars.try_unify(&element, &StaticType::Float)); + // Holding a Float made the number a Float too. + assert_eq!(vars.resolve(&number), StaticType::Float); + } + + #[test] + fn a_rollback_takes_back_the_bounds_another_variable_got() { + let mut vars = TypeVars::default(); + let literal = vars.fresh_number(span()); + let other = vars.fresh(StaticType::Any); + let snapshot = vars.snapshot(); + assert!(vars.unify(&literal, &other)); + // `other` is the literal now, so it can't be a String. + assert!(!vars.try_unify(&other, &StaticType::String)); + vars.rollback(snapshot); + assert!(vars.try_unify(&other, &StaticType::String)); + } + + #[test] + fn a_literal_defaults_to_what_its_values_share() { + let mut vars = TypeVars::default(); + let number = vars.fresh_number(span()); + let StaticType::Var(element) = + vars.fresh_holding(vec![(number, span()), (StaticType::Float, span())]) + else { + unreachable!("a literal's type is a variable"); + }; + assert_eq!( + vars.literal_defaults(element), + Some(vec![StaticType::Float]) + ); + let StaticType::Var(mixed) = vars.fresh_holding(vec![ + (StaticType::String, span()), + (StaticType::Int, span()), + ]) else { + unreachable!("a literal's type is a variable"); + }; + vars.settle_with_fallbacks(&[mixed]); + assert_eq!(vars.resolve(&StaticType::Var(mixed)), StaticType::Any); + } +} diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 290ad01d..0fbed6f3 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -540,6 +540,91 @@ impl StaticType { } } + /// Whether a value of this type can be passed where `parameter` is + /// expected as it is, with nothing to check as the program runs. The + /// two types are the same, except that `parameter` can be `Any`, which + /// takes anything, and that its type parameters and the parts of it not + /// known yet stand for any type. Inside a container, a function type, + /// an `Option` or a tuple, `Any` is just another type: a `List` + /// doesn't fit a `List`, as the list could then get a String. A + /// `Sequence` takes any kind of sequence of what fits `T`. + pub fn fits(&self, parameter: &Self) -> bool { + matches!(parameter, Self::Any) || self.matches(parameter, false) + } + + /// Whether a value of this type might be passed where `parameter` is + /// expected, once what isn't known about it is: as [`Self::fits`], + /// except that the parts of this type not known yet, and the whole of + /// it if it's `Any`, stand for any type too. `Any` checked as the + /// program runs can turn out to be an Int, but a `List` is never a + /// `List`. + pub fn may_fit(&self, parameter: &Self) -> bool { + matches!(self, Self::Any) || self.fits(parameter) || self.matches(parameter, true) + } + + /// Whether this type is `parameter`, where what [`Self::fits`] and + /// [`Self::may_fit`] let stand for any type does, and the variables of + /// this type too if `unknowns_match`. + fn matches(&self, parameter: &Self, unknowns_match: bool) -> bool { + let wildcard = |typ: &Self| matches!(typ, Self::Var(_) | Self::Generic(_)); + if wildcard(parameter) || (unknowns_match && wildcard(self)) { + return true; + } + let matches = |a: &Self, p: &Self| a.matches(p, unknowns_match); + match (self, parameter) { + (Self::Never, _) => true, + (Self::Sequence(a), Self::Sequence(p)) => a.fits(p) || matches(a, p), + (argument, Self::Sequence(p)) if Self::is_sequence(argument) => { + let element = argument + .sequence_element_type() + .expect("a sequence has elements"); + element.fits(p) || matches(&element, p) + } + (Self::Option(a), Self::Option(p)) + | (Self::List(a), Self::List(p)) + | (Self::Iterator(a), Self::Iterator(p)) + | (Self::MinHeap(a), Self::MinHeap(p)) + | (Self::MaxHeap(a), Self::MaxHeap(p)) + | (Self::Deque(a), Self::Deque(p)) => matches(a, p), + (Self::Tuple(a), Self::Tuple(p)) => { + a.len() == p.len() && a.iter().zip(p).all(|(a, p)| matches(a, p)) + } + (Self::Map { key: ak, value: av }, Self::Map { key: pk, value: pv }) => { + matches(ak, pk) && matches(av, pv) + } + ( + Self::Function { + parameters: a, + return_type: ar, + }, + Self::Function { + parameters: p, + return_type: pr, + }, + ) => { + // What a function value takes and returns isn't checked as + // the program runs, so whether it might fit only depends on + // what both types know: `fn(x) => x > 1`, typed `Fn(Any) -> + // Any` where nothing says what `x` is, might be a predicate. + let part = |a: &Self, p: &Self| { + matches(a, p) + || (unknowns_match && (matches!(a, Self::Any) || matches!(p, Self::Any))) + }; + let parameters = match (a, p) { + // A function value whose parameters aren't known fits + // a function type that says nothing about them either. + (_, None) => true, + (None, Some(_)) => unknowns_match, + (Some(a), Some(p)) => { + a.len() == p.len() && a.iter().zip(p).all(|(a, p)| part(a, p)) + } + }; + parameters && part(ar, pr) + } + _ => self == parameter, + } + } + /// Whether this type stands for every type: `Any`, a type variable or /// a type parameter, which are all `Any` to the methods here. pub fn is_unknown(&self) -> bool { @@ -785,15 +870,16 @@ impl StaticType { return false; }; - let Some(_) = parameters else { + let Some(parameters) = parameters else { // If this branch happens then the function we're matching against is variadic meaning it's always a match return true; }; - self.is_subtype(&Self::Function { - parameters: Some(types.to_vec()), - return_type: Box::new(Self::Any), - }) + parameters.len() == types.len() + && types + .iter() + .zip(parameters) + .all(|(argument, parameter)| argument.fits(parameter)) } pub fn unpack(&self) -> Option + '_>> { @@ -905,7 +991,10 @@ mod test { StaticType::Int, ]))); - assert!(fun.is_fn_and_matches(&[list_of_two_tuple_int, StaticType::Int])); + // A list of tuples isn't a list of `Any`, which could get a String. + assert!(!fun.is_fn_and_matches(&[list_of_two_tuple_int.clone(), StaticType::Int])); + let generic = StaticType::signature("(List, Int) -> T").expect("valid signature"); + assert!(generic.is_fn_and_matches(&[list_of_two_tuple_int, StaticType::Int])); } #[test] diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index a72a41eb..61bbed29 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -157,7 +157,7 @@ fn group_overloads<'a>( continue; }; if let (Some(recv), Some(params)) = (receiver_type, parameters) - && (params.is_empty() || !recv.is_subtype(¶ms[0])) + && (params.is_empty() || !recv.may_fit(¶ms[0])) { continue; } @@ -199,7 +199,7 @@ fn group_overloads<'a>( fn merged_type_display(candidates: &[&StaticType]) -> String { if let Some(supertype) = candidates .iter() - .find(|t| candidates.iter().all(|c| c.is_subtype(t))) + .find(|t| candidates.iter().all(|c| c.fits(t))) { return supertype.to_string(); } diff --git a/ndc_stdlib/src/hash_map.rs b/ndc_stdlib/src/hash_map.rs index 46abcedf..0ade336c 100644 --- a/ndc_stdlib/src/hash_map.rs +++ b/ndc_stdlib/src/hash_map.rs @@ -47,7 +47,7 @@ mod inner { } /// Inserts a value into a set. - #[function(name = "insert", signature = "(Map, K)")] + #[function(name = "insert", signature = "(Map, K)")] pub fn insert_set(map: &mut HashMap, key: Value) { map.insert(key, Value::unit()); } diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index 5c500161..c42ef0d3 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -58,7 +58,7 @@ fn register_get(env: &mut FunctionRegistry>) { // An element by its index, or a slice by a range of indices. for signature in [ "(List, Int) -> T", - "(List, Iterator) -> List", + "(List, Iterator) -> List", // An index whose type isn't known could be either, and keeps the // allocation-free path rather than falling back to `[](Any, Any)`. "(List, Any) -> Any", @@ -98,7 +98,7 @@ fn register_get(env: &mut FunctionRegistry>) { // A deque is indexed like a list, and a range of it is a list too. for signature in [ "(Deque, Int) -> T", - "(Deque, Iterator) -> List", + "(Deque, Iterator) -> List", "(Deque, Any) -> Any", ] { env.declare_global_fn(Rc::new(NativeFunction { @@ -123,7 +123,7 @@ fn register_set(env: &mut FunctionRegistry>) { // An element by its index, or a slice by a range of indices. for signature in [ "(List, Int, T)", - "(List, Iterator, Sequence)", + "(List, Iterator, Sequence)", // An index whose type isn't known could be either. "(List, Any, Any)", ] { diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 2503040a..200a51ba 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -205,7 +205,15 @@ impl Compiler { } Expression::NumericLiteral(literal) => { let value = match literal { - NumericLiteral::Int64(i) => Value::int(i), + // An integer literal is what its use made it: `1` in + // `[1.5, 1]` is a Float. + NumericLiteral::Int64(i) => match self.analysis.expr_types.get(&id) { + Some(StaticType::Float) => Value::float(i as f64), + Some(StaticType::Number) => { + Value::number(crate::value::AdvancedNumber::Int(i.into())) + } + _ => Value::int(i), + }, NumericLiteral::Float64(f) => Value::float(f), NumericLiteral::NumberInt(i) => { Value::number(crate::value::AdvancedNumber::Int(i)) diff --git a/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc b/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc index 593b0b2f..d69fddec 100644 --- a/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc +++ b/tests/functional/programs/004_basic/070_a_variable_keeps_its_type.ndc @@ -1,6 +1,6 @@ // A variable keeps the type its first value gave it, so a call resolved // against that type, like the `+` in `f`, stays right. // expect-error: mismatched types: found Float but expected Int -let x = 1; +let x = "a".len(); fn f() { x + 1 } x = 2.5; diff --git a/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc b/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc index 43a7e367..12138a88 100644 --- a/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc +++ b/tests/functional/programs/005_functions/066_a_lambda_in_a_variable_has_one_type.ndc @@ -2,5 +2,5 @@ // so it can't be called with an Int and then with a Float. // expect-error: doesn't accept the arguments 'Float' let twice = fn(x) => x * 2; -print(twice(3)); +print(twice("abc".len())); print(twice(2.5)); diff --git a/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc b/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc index b723521f..6e2f8a48 100644 --- a/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc +++ b/tests/functional/programs/005_functions/067_an_overloaded_function_value_takes_the_overload_its_use_expects.ndc @@ -17,6 +17,6 @@ print(g("a b")); fn pick(x: Int) { 1 } { fn pick(x: Any) { "inner" } - let h: Fn(Int) -> Any = pick; + let h: Fn(Any) -> String = pick; print(h(1)); } diff --git a/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc b/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc index 182586c8..5b138f91 100644 --- a/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc +++ b/tests/functional/programs/006_lists/053_push_checks_the_element_type.ndc @@ -1,4 +1,4 @@ // `push` takes a List and a T, so a list of integers only takes integers. // expect-error: No function called 'push' found that matches the arguments 'List, Float' -let x = [1]; +let x = ["a".len()]; x.push(1.0); diff --git a/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc b/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc index da4deb7a..b577512a 100644 --- a/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc +++ b/tests/functional/programs/016_casts/023_numeric_sibling_cast_error.ndc @@ -1,4 +1,6 @@ // A cast asserts, it does not convert, and Int is not a Number under the -// sibling numeric model — `Number(1)` is the conversion. +// sibling numeric model — `Number(1)` is the conversion. A literal like `1` +// would take the type the cast says. // expect-error: invalid cast: Int can never be Number -1 as Number +let n = "a".len(); +n as Number diff --git a/tests/functional/programs/018_literals/001_an_integer_literal_is_an_int_unless_something_says_otherwise.ndc b/tests/functional/programs/018_literals/001_an_integer_literal_is_an_int_unless_something_says_otherwise.ndc new file mode 100644 index 00000000..80ba1670 --- /dev/null +++ b/tests/functional/programs/018_literals/001_an_integer_literal_is_an_int_unless_something_says_otherwise.ndc @@ -0,0 +1,2 @@ +// expect-output: 3 +print(7 / 2); diff --git a/tests/functional/programs/018_literals/002_an_integer_literal_takes_the_type_a_use_needs.ndc b/tests/functional/programs/018_literals/002_an_integer_literal_takes_the_type_a_use_needs.ndc new file mode 100644 index 00000000..0a15b39a --- /dev/null +++ b/tests/functional/programs/018_literals/002_an_integer_literal_takes_the_type_a_use_needs.ndc @@ -0,0 +1,4 @@ +// The annotation makes `7` a Float, so the division is a Float's. +// expect-output: 3.5 +let f: Float = 7; +print(f / 2); diff --git a/tests/functional/programs/018_literals/003_an_integer_literal_can_be_a_number.ndc b/tests/functional/programs/018_literals/003_an_integer_literal_can_be_a_number.ndc new file mode 100644 index 00000000..94db0d13 --- /dev/null +++ b/tests/functional/programs/018_literals/003_an_integer_literal_can_be_a_number.ndc @@ -0,0 +1,3 @@ +// expect-output: 1/3 +let n: Number = 1; +print(n / 3); diff --git a/tests/functional/programs/018_literals/004_a_variable_holding_a_literal_takes_what_it_is_given.ndc b/tests/functional/programs/018_literals/004_a_variable_holding_a_literal_takes_what_it_is_given.ndc new file mode 100644 index 00000000..93275c28 --- /dev/null +++ b/tests/functional/programs/018_literals/004_a_variable_holding_a_literal_takes_what_it_is_given.ndc @@ -0,0 +1,5 @@ +// `+` with a Float can't give `total` an Int back, so `0` is a Float. +// expect-output: 2.5 +let total = 0; +total += 2.5; +print(total); diff --git a/tests/functional/programs/018_literals/005_an_accumulator_takes_the_type_of_the_elements.ndc b/tests/functional/programs/018_literals/005_an_accumulator_takes_the_type_of_the_elements.ndc new file mode 100644 index 00000000..833fd8cc --- /dev/null +++ b/tests/functional/programs/018_literals/005_an_accumulator_takes_the_type_of_the_elements.ndc @@ -0,0 +1,3 @@ +// `fold` passes its result back to the lambda, so `0` is a Float. +// expect-output: 3.75 +print([1.5, 2.25].fold(0, fn(acc, x) => acc + x)); diff --git a/tests/functional/programs/018_literals/006_a_list_of_numbers_takes_the_type_they_share.ndc b/tests/functional/programs/018_literals/006_a_list_of_numbers_takes_the_type_they_share.ndc new file mode 100644 index 00000000..e0e6d901 --- /dev/null +++ b/tests/functional/programs/018_literals/006_a_list_of_numbers_takes_the_type_they_share.ndc @@ -0,0 +1,4 @@ +// `1` is a Float like `1.5`, so dividing it gives a Float. +// expect-output: 0.5 +let xs = [1.5, 1]; +print(xs[1] / 2); diff --git a/tests/functional/programs/018_literals/007_a_list_literal_takes_its_annotated_type.ndc b/tests/functional/programs/018_literals/007_a_list_literal_takes_its_annotated_type.ndc new file mode 100644 index 00000000..c8039c45 --- /dev/null +++ b/tests/functional/programs/018_literals/007_a_list_literal_takes_its_annotated_type.ndc @@ -0,0 +1,4 @@ +// expect-output: [1,2,"three"] +let xs: List = [1, 2]; +xs.push("three"); +print(xs); diff --git a/tests/functional/programs/018_literals/008_a_literal_of_values_that_share_no_type_holds_any.ndc b/tests/functional/programs/018_literals/008_a_literal_of_values_that_share_no_type_holds_any.ndc new file mode 100644 index 00000000..ad69d45c --- /dev/null +++ b/tests/functional/programs/018_literals/008_a_literal_of_values_that_share_no_type_holds_any.ndc @@ -0,0 +1,4 @@ +// expect-output: [1,"a",2.5] +let xs = [1, "a"]; +xs.push(2.5); +print(xs); diff --git a/tests/functional/programs/018_literals/009_a_map_literal_takes_its_annotated_type.ndc b/tests/functional/programs/018_literals/009_a_map_literal_takes_its_annotated_type.ndc new file mode 100644 index 00000000..a0573c42 --- /dev/null +++ b/tests/functional/programs/018_literals/009_a_map_literal_takes_its_annotated_type.ndc @@ -0,0 +1,4 @@ +// expect-output: 1 two +let m: Map = %{"a": 1}; +m["b"] = "two"; +print(m["a"], m["b"]); diff --git a/tests/functional/programs/018_literals/010_a_tuple_literal_takes_its_annotated_type.ndc b/tests/functional/programs/018_literals/010_a_tuple_literal_takes_its_annotated_type.ndc new file mode 100644 index 00000000..73f8a843 --- /dev/null +++ b/tests/functional/programs/018_literals/010_a_tuple_literal_takes_its_annotated_type.ndc @@ -0,0 +1,3 @@ +// expect-output: ("a",1) +let t: (Any, Int) = ("a", 1); +print(t); diff --git a/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc b/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc new file mode 100644 index 00000000..83250312 --- /dev/null +++ b/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc @@ -0,0 +1,4 @@ +// `v` is a list of Strings, so it can't be given a list of Ints. +// expect-error: mismatched types: found Int but expected String +let v = ["s"]; +v = [1]; diff --git a/tests/functional/programs/018_literals/012_a_list_of_ints_is_not_a_list_of_any.ndc b/tests/functional/programs/018_literals/012_a_list_of_ints_is_not_a_list_of_any.ndc new file mode 100644 index 00000000..5a7de16f --- /dev/null +++ b/tests/functional/programs/018_literals/012_a_list_of_ints_is_not_a_list_of_any.ndc @@ -0,0 +1,5 @@ +// A List could get a String, which the List would then hold. +// Converting goes through `Any`. +// expect-error: mismatched types: found List but expected List +let words = "a b".split(" "); +let anything: List = words; diff --git a/tests/functional/programs/018_literals/013_a_list_goes_through_any_to_change_its_element_type.ndc b/tests/functional/programs/018_literals/013_a_list_goes_through_any_to_change_its_element_type.ndc new file mode 100644 index 00000000..9343a5ce --- /dev/null +++ b/tests/functional/programs/018_literals/013_a_list_goes_through_any_to_change_its_element_type.ndc @@ -0,0 +1,6 @@ +// expect-output: ["a","b",1] +let words = "a b".split(" "); +let any: Any = words; +let anything: List = any; +anything.push(1); +print(anything); diff --git a/tests/functional/programs/018_literals/014_a_function_type_is_invariant.ndc b/tests/functional/programs/018_literals/014_a_function_type_is_invariant.ndc new file mode 100644 index 00000000..fe288258 --- /dev/null +++ b/tests/functional/programs/018_literals/014_a_function_type_is_invariant.ndc @@ -0,0 +1,5 @@ +// A function taking Any isn't a function taking an Int: the program +// couldn't check what a call through it returns. +// expect-error: mismatched types: found Fn(Any) -> Any but expected Fn(Int) -> Any +fn f(x: Any) => x; +let g: Fn(Int) -> Any = f; diff --git a/tests/functional/programs/018_literals/015_the_first_use_whose_pick_depends_on_a_literal_decides_it.ndc b/tests/functional/programs/018_literals/015_the_first_use_whose_pick_depends_on_a_literal_decides_it.ndc new file mode 100644 index 00000000..feb122ca --- /dev/null +++ b/tests/functional/programs/018_literals/015_the_first_use_whose_pick_depends_on_a_literal_decides_it.ndc @@ -0,0 +1,9 @@ +// `describe(x)` takes the Strings, but would lose to `describe(x: Int)` if +// the list held `Any`, so `map` decides that the list holds Strings. It +// can't become a List after that. +// expect-error: mismatched types: found List but expected List +fn describe(x: Int) => "int"; +fn describe(x) => "other"; +let words = ["a"]; +print(words.map(describe)); +let anything: List = words; diff --git a/tests/functional/programs/018_literals/016_a_use_that_picks_the_same_whatever_a_literal_is_leaves_it_open.ndc b/tests/functional/programs/018_literals/016_a_use_that_picks_the_same_whatever_a_literal_is_leaves_it_open.ndc new file mode 100644 index 00000000..001f4659 --- /dev/null +++ b/tests/functional/programs/018_literals/016_a_use_that_picks_the_same_whatever_a_literal_is_leaves_it_open.ndc @@ -0,0 +1,6 @@ +// `push` is the same whatever the list holds, so the Float that's pushed +// later decides that `1` is a Float too. +// expect-output: 0.5 +let xs = [1]; +xs.push(1.5); +print(xs[0] / 2); diff --git a/tests/functional/programs/018_literals/017_a_map_default_function_returning_any_is_not_checked_as_a_value.ndc b/tests/functional/programs/018_literals/017_a_map_default_function_returning_any_is_not_checked_as_a_value.ndc new file mode 100644 index 00000000..f0832557 --- /dev/null +++ b/tests/functional/programs/018_literals/017_a_map_default_function_returning_any_is_not_checked_as_a_value.ndc @@ -0,0 +1,6 @@ +// The map calls `fallback` for a missing key, so the function itself isn't +// one of the map's Ints. +// expect-output: 1 +fn fallback() -> Any => 1; +let m: Map = %{:fallback}; +print(m["a"]); diff --git a/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc b/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc index 797c2816..04d35cc6 100644 --- a/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc +++ b/tests/functional/programs/604_stdlib_math/003_mixed_sum_error.ndc @@ -1,3 +1,4 @@ -// Int, Float, and Number are siblings, so a heterogeneous list is List. +// The elements share no type, so the list is a List, which no `sum` +// takes. // expect-error: No function called 'sum' -print([1, 2.0].sum()); +print([1, "two"].sum()); diff --git a/tests/proptest/tests/overloads.regressions b/tests/proptest/tests/overloads.regressions index b6399d79..f2afa9f9 100644 --- a/tests/proptest/tests/overloads.regressions +++ b/tests/proptest/tests/overloads.regressions @@ -5,3 +5,7 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc ccf1065cc5b6785de2f3e60fab2f91a07dbf049d640de1fd8e4cd69e87a31628 # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: List) => 1; fn q(x: List) => 1; fn g(a) => a.len(); fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0[0] += (if true { q(push).fold(0, fn(acc, a) => acc + a) } else { fn(a) => a ++ "!" }); +cc 28b971024cd28c1bd67b10fd47576f0057292aac87059605340f0ab7c835a6de # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: String) => 1; fn p(x) => 1; fn q(x) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0 += [1, "s"].map(fn(a) => p(a)); +cc 4c529d1b26bda369a7a7106e5e40396abfa3a955a96ea68d07756b86ca4f49fa # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: List) => 1; fn p(x) => 1; fn q(x) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0[0] = (1)([fn(a) => p(a), len]); +cc ab11f2151f1074090878c174fcbb8148d9db5640b1b288bb551118afb4a91606 # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: Int) => 1; fn p(x) => x; fn q(x: List) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0 += (q(%{}))(["s"].map(p)); +cc 1a8c5b5e8e0c29aa6847f08a786b42e2c659fcdc43101296a32471c5fad65d8f # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x) => 1; fn q(x: String) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0 = ["s"]["k"][0]; From e3027c5d8261a537dfa09961eb064858e8023da0 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 12 Nov 2026 15:57:43 +0100 Subject: [PATCH 13/39] =?UTF-8?q?feat(analyser):=20join=20types=20without?= =?UTF-8?q?=20subtyping=20and=20delete=20is=5Fsubtype=20=F0=9F=AA=93=20(#3?= =?UTF-8?q?11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4a-3 of the type system RFC (#281): the last of subtyping goes. Values join by unification instead of a least upper bound, and `is_subtype`, `lub` and `glb` are deleted from `StaticType`. Behaviour changes: - Two branches of an `if`, or the values a function returns, are one type when they unify, with literals taking the other side's type, and `Any` when they don't: `if c { 1 } else { 2.5 }` is a Float, `if c { 1 } else { "a" }` is `Any`, and `if c { [] } else { [1] }` is a `List`. - Containers of different types join as `Any`, not as a container of `Any`: a `List` and a `List` join as `Any`, as do two tuples of different types. The same goes for what a call the program decides as it runs returns. - A cast to a function type checks only how many arguments the function takes, as runtime dispatch already did. How it works: - A join waits only while a whole branch is unknown, as `x` in `fn(x) => if c { x } else { 0 }`, so a lambda's parameter isn't fixed by the branch next to it. A branch that nothing else settles takes the other branch's type at the end of a batch too, not only in a generic function. - `StaticType::join` is the type both values have, or `Any`. It replaces `lub` for the results of calls decided at runtime, for op=, and for the element types the VM reports for values. - The VM checks a value against a scalar, struct, function or iterator type with `Value::is_a` instead of comparing types with `is_subtype`, and an `Option` parameter by its value. - `overlaps`, which rejects impossible casts, compares types structurally. Bug fixes from #310: - When a call's pick depended on a literal, the literal's type was decided but the pick made before was kept. If the literal became `Any`, another overload could take the call as the program runs, so the pick is now made again once the literal is decided. - Literals whose values share no type, like `[1, "a"]`, are `Any` lists on their own, but two of them made one type were rejected as literals of different types. Now any literal can be one type with such a literal: `if c { [[1, "a"]] } else { [[2, "b"]] }` is a `List>`, and after `let v = [1, "a"]`, `v = ["b"]` holds `Any`s. Literals of one type each, like `["s"]` and `[1]`, still differ. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types.md | 12 +- manual/src/reference/types/number.md | 2 +- manual/src/reference/variables-and-scopes.md | 2 +- ndc_analyser/src/analyser/assignments.rs | 2 +- ndc_analyser/src/analyser/generics.rs | 2 +- ndc_analyser/src/analyser/solver.rs | 102 +++--- ndc_analyser/src/analyser/tests.rs | 9 +- ndc_analyser/src/overloads.rs | 34 +- ndc_analyser/src/types.rs | 56 +++- ndc_core/src/static_type.rs | 315 ++---------------- ndc_lsp/src/features/completion.rs | 4 +- ndc_vm/src/value/mod.rs | 79 +++-- ...terals_of_different_types_are_an_error.ndc | 2 +- ...ranches_of_different_types_join_as_any.ndc | 6 + ...ranches_of_one_shape_are_made_one_type.ndc | 5 + ...y_branch_takes_the_other_branch_s_type.ndc | 4 + ...terals_that_each_hold_any_are_one_type.ndc | 6 + ...akes_the_any_of_a_variable_holding_any.ndc | 6 + tests/proptest/tests/overloads.regressions | 1 + 19 files changed, 259 insertions(+), 390 deletions(-) create mode 100644 tests/functional/programs/018_literals/018_branches_of_different_types_join_as_any.ndc create mode 100644 tests/functional/programs/018_literals/019_branches_of_one_shape_are_made_one_type.ndc create mode 100644 tests/functional/programs/018_literals/020_an_empty_branch_takes_the_other_branch_s_type.ndc create mode 100644 tests/functional/programs/018_literals/021_branches_of_literals_that_each_hold_any_are_one_type.ndc create mode 100644 tests/functional/programs/018_literals/022_a_literal_takes_the_any_of_a_variable_holding_any.ndc diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index 70bab2af..224edf56 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -2,7 +2,7 @@ Andy C++ runs as a dynamically typed language — values carry their types at runtime and most checking happens then. You can also attach type annotations to variables, function parameters, and return values, and the analyser will use them to flag obvious mismatches before the program runs. -The type system is hierarchical with `Any` at the root: +Every value is an `Any`. The other types are: * `Any` * [`Option`](./types/option.md) @@ -57,6 +57,14 @@ xs.push("three"); let mixed = [1, "a"]; // List ``` +The branches of an `if` work the same way. They're one type when they can be, with literals taking the other branch's type, and `Any` when they can't: + +```ndc +let a = if true { 1 } else { 2.5 }; // Float +let b = if true { 1 } else { "a" }; // Any +let c = if true { [] } else { [1] }; // List +``` + [Struct](./types/struct.md) names are type names like any other, including inside generics: ```ndc @@ -71,7 +79,7 @@ used before (or inside) its own declaration, and it goes out of scope together w block or function that declared it. See [Struct](./types/struct.md) for the scoping rules. -> **Note:** `Any` is the base type for every other type, so an `Any`-annotated binding will accept anything. When a value has no annotation and the analyser can't infer a type, it falls back to `Any`. A parameter of a function declared in the program gets the type of each call's argument instead (see [Generic functions](./types/function.md#generic-functions)). There is also a `Never` type used internally for things like `break` that don't produce a value — you'll rarely need to write it by hand. +> **Note:** every value fits `Any`, so an `Any`-annotated binding will accept anything. When a value has no annotation and the analyser can't infer a type, it falls back to `Any`. A parameter of a function declared in the program gets the type of each call's argument instead (see [Generic functions](./types/function.md#generic-functions)). There is also a `Never` type used internally for things like `break` that don't produce a value — you'll rarely need to write it by hand. ### Using an `Any` value where a type is expected diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index 526b6ff5..22df6295 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -8,7 +8,7 @@ Andy C++ exposes three sibling numeric types: * `Float` stores an IEEE 754 `f64`. * `Number` supports arbitrary-size integers, exact rational values, floats, and complex values. -`Int`, `Float`, and `Number` share `Any` as their nearest common supertype. An `Int` value does not satisfy a `Number` annotation. Use an `n` literal or the `Number` constructor when you need the advanced mode: +`Int`, `Float`, and `Number` are separate types, which have only `Any` in common. An `Int` value does not satisfy a `Number` annotation. Use an `n` literal or the `Number` constructor when you need the advanced mode: ```ndc let count: Int = 42; diff --git a/manual/src/reference/variables-and-scopes.md b/manual/src/reference/variables-and-scopes.md index 62e6663c..c37d6795 100644 --- a/manual/src/reference/variables-and-scopes.md +++ b/manual/src/reference/variables-and-scopes.md @@ -102,7 +102,7 @@ let name: String = "world"; let xs: List = [1, 2, 3]; ``` -A subtype is fine. All concrete types fit where `Any` is requested: +Every value fits where `Any` is requested: ```ndc let n: Number = 3n; diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 84bbbcf1..54721131 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -110,7 +110,7 @@ impl Analyser { } let result_type = if has_op_binding { - assign_return.lub(&op_return) + assign_return.join(&op_return) } else { assign_return }; diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 666fede1..804435fb 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -294,7 +294,7 @@ impl Analyser { /// In `fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }`, /// what `+` returns is what `fib` returns, which is what the `if` /// returns: nothing but the other branch can say what it is. - fn default_to_branch(&mut self, serial: u64, ambiguous: &[TypeVar]) -> bool { + pub(super) fn default_to_branch(&mut self, serial: u64, ambiguous: &[TypeVar]) -> bool { let Some(Waiting { constraint: Constraint::Join { types: [a, b, _], .. diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 64a665d4..8a882e4b 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -66,8 +66,8 @@ pub(super) enum Constraint { value: StaticType, span: Span, }, - /// The one type the two branches of an `if` have, once both are known: - /// the branches' types and then the result's. + /// The one type the two branches of an `if` have, once neither is + /// wholly unknown: the branches' types and then the result's. Join { types: [StaticType; 3], span: Span }, } @@ -312,51 +312,20 @@ impl Analyser { } /// The one type that values of type `a` and `b` both have, as the two - /// branches of an `if`: their least upper bound. Where a part of either - /// isn't known yet, that part waits until it is, so a lambda's parameter + /// branches of an `if`: the type they are once made the same, or `Any` + /// if they can't be. `if c { 1 } else { 2.5 }` is a Float, `if c { [] } + /// else { [1] }` a `List`, and `if c { 1 } else { "a" }` is `Any`. + /// A branch nothing is known about yet waits, so a lambda's parameter /// isn't fixed by the branch next to it: in `fn(x) => if c { x } else - /// { 0 }`, `x` can still be a `Float`. Parts both sides share are joined - /// part by part, so `if c { [] } else { [1] }` is a list either way. + /// { 0 }`, `x` can still be a `String`. pub(super) fn join(&mut self, a: &StaticType, b: &StaticType, span: Span) -> StaticType { let (a, b) = (self.vars.resolve(a), self.vars.resolve(b)); + let unknown = |this: &Self, typ: &StaticType| matches!(typ, StaticType::Var(var) if !this.vars.is_literal(*var)); match (&a, &b) { (StaticType::Never, _) => b, (_, StaticType::Never) => a, _ if a == b => a, - _ if !a.has_vars() && !b.has_vars() => a.lub(&b), - (StaticType::Option(x), StaticType::Option(y)) => { - StaticType::Option(Box::new(self.join(x, y, span))) - } - (StaticType::List(x), StaticType::List(y)) => { - StaticType::List(Box::new(self.join(x, y, span))) - } - (StaticType::Iterator(x), StaticType::Iterator(y)) => { - StaticType::Iterator(Box::new(self.join(x, y, span))) - } - (StaticType::Deque(x), StaticType::Deque(y)) => { - StaticType::Deque(Box::new(self.join(x, y, span))) - } - (StaticType::MinHeap(x), StaticType::MinHeap(y)) => { - StaticType::MinHeap(Box::new(self.join(x, y, span))) - } - (StaticType::MaxHeap(x), StaticType::MaxHeap(y)) => { - StaticType::MaxHeap(Box::new(self.join(x, y, span))) - } - (StaticType::Map { key: k1, value: v1 }, StaticType::Map { key: k2, value: v2 }) => { - StaticType::Map { - key: Box::new(self.join(k1, k2, span)), - value: Box::new(self.join(v1, v2, span)), - } - } - (StaticType::Tuple(xs), StaticType::Tuple(ys)) if xs.len() == ys.len() => { - StaticType::Tuple( - xs.iter() - .zip(ys) - .map(|(x, y)| self.join(x, y, span)) - .collect(), - ) - } - _ => { + _ if unknown(self, &a) || unknown(self, &b) => { let result = self.vars.fresh(StaticType::Any); self.wait(Constraint::Join { types: [a, b, result.clone()], @@ -364,9 +333,21 @@ impl Analyser { }); result } + _ => self.join_known(&a, &b), } } + /// [`Self::join`] of two types that aren't wholly unknown: the type + /// they are once made the same, or `Any`. + fn join_known(&mut self, a: &StaticType, b: &StaticType) -> StaticType { + let snapshot = self.vars.snapshot(); + if self.vars.unify(a, b) { + return self.vars.resolve(a); + } + self.vars.rollback(snapshot); + StaticType::Any + } + /// Commits `call` to `picked` while its arguments still have unknown /// parts, which settles them from `picked`'s parameters, and returns the /// call's type, or `None` when they don't fit after all. The program @@ -576,10 +557,11 @@ impl Analyser { } /// Solves what waits on the variables made since `first`, which nothing - /// outside the code they were made in can settle. A call that a default - /// rule can decide is decided by it first: it commits to its narrowest - /// overload, or `a op= b` keeps the type of `b`. What's left gets its - /// fallbacks, oldest constraint first. Constraints that wait only on + /// outside the code they were made in can settle. Literals take their + /// defaults first. Then what a default rule can decide is decided by it: + /// a call commits to its narrowest overload, `a op= b` keeps the type of + /// `b`, and a branch of an `if` takes the other branch's type. What's + /// left gets its fallbacks, oldest constraint first. Constraints that wait only on /// older variables keep waiting. pub(super) fn settle_waiting(&mut self, first: usize) { // Each constraint is checked for a default rule once, and again only @@ -596,8 +578,11 @@ impl Analyser { unchecked.extend(self.waiting.keys().copied()); } while let Some(serial) = unchecked.pop_first() { - if !self.waits_on_vars_since(serial, first).is_empty() - && (self.default_to_narrowest(serial) || self.default_to_operand(serial)) + let vars = self.waits_on_vars_since(serial, first); + if !vars.is_empty() + && (self.default_to_narrowest(serial) + || self.default_to_operand(serial) + || self.default_to_branch(serial, &vars)) { unchecked.extend(self.solve_waiting_and_report()); } @@ -912,10 +897,15 @@ impl Analyser { } } let (a, b) = (self.vars.resolve(&a), self.vars.resolve(&b)); - if a.has_vars() || b.has_vars() { + let unknown = |typ: &StaticType| matches!(typ, StaticType::Var(var) if !self.vars.is_literal(*var)); + if unknown(&a) || unknown(&b) { return false; } - self.settle_waited_for(&result, &a.lub(&b), *span, None); + let joined = match (&a, &b) { + (StaticType::Never, other) | (other, StaticType::Never) => other.clone(), + _ => self.join_known(&a, &b), + }; + self.settle_waited_for(&result, &joined, *span, None); true } } @@ -1075,11 +1065,17 @@ impl Analyser { // `p(x)` over `p(x: Int)` does for the Strings in // `["s"].map(p)`, the literal is what it is from here on: // the first use that depends on it decides it. - if !literals.is_empty() - && self.fits_once_any(&reachable, arguments, Some(only)) - && !self.decide_literals(&literals, only, arguments) - { - return EarlyOverload::Undecided; + // What they're decided as may still leave the pick open, + // as `Any` would, so it's made again with them settled. + if !literals.is_empty() && self.fits_once_any(&reachable, arguments, Some(only)) { + if !self.decide_literals(&literals, only, arguments) { + return EarlyOverload::Undecided; + } + let arguments = arguments + .iter() + .map(|argument| self.vars.resolve(argument)) + .collect::>(); + return self.early_overload(candidates, &arguments, kind); } EarlyOverload::Only(only) } diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 1aaafc85..5f54d49a 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -172,6 +172,7 @@ fn a_function_returns_the_join_of_its_body_and_every_return() { }", vec![], ); + // Tuples of different types have no type in common but `Any`. assert!(result.errors.is_empty(), "errors: {:?}", result.errors); assert_eq!( result @@ -180,7 +181,7 @@ fn a_function_returns_the_join_of_its_body_and_every_return() { .values() .exactly_one() .ok(), - Some(&StaticType::Tuple(vec![StaticType::Any; 3])), + Some(&StaticType::Any), ); } @@ -1265,8 +1266,14 @@ fn a_join_waits_until_both_branches_are_known() { analyse_last_type("let f = fn(x) => if true { x } else { 1 }; f(2)"), StaticType::Int ); + // The literal `1` becomes a Float like `x`, but a String and an Int join + // as `Any`. assert_eq!( analyse_last_type("let f = fn(x) => if true { x } else { 1 }; f(2.5)"), + StaticType::Float + ); + assert_eq!( + analyse_last_type("let f = fn(x) => if true { x } else { 1 }; f(\"a\")"), StaticType::Any ); // What both branches share is known meanwhile: two lists join as a list diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 6824c727..99031a4b 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -245,9 +245,10 @@ impl<'a> Overloads<'a> { /// position and no looser scalar competes. Returns /// `Binding::Resolved(Vec)`, return type = `Tuple`. /// 3. **Loose scalar or vec candidates** — returns - /// `Binding::Dynamic(candidates)`, return type = LUB of contributions - /// (pure-scalar LUBs declared returns; pure-vec LUBs per position and - /// wraps in `Tuple<…>`; mixed scalar+vec collapses to `Any`). + /// `Binding::Dynamic(candidates)`, return type = the join of the + /// contributions (pure-scalar joins the declared returns; pure-vec + /// joins per position and wraps in `Tuple<…>`; mixed scalar+vec + /// collapses to `Any`). /// 4. **Same-name callables, no signature match** — last-resort /// fallback so `Any`-typed callees still get a candidate list. /// Returns `Binding::Dynamic`, return type = `Any`. @@ -667,14 +668,14 @@ impl<'a> Overloads<'a> { } } - /// LUB the return types of a list of scalar candidates. Used when every + /// Join the return types of a list of scalar candidates. Used when every /// candidate in a Dynamic binding came from the compat-filtered walk and /// is therefore guaranteed to be a function. - fn lub_scalar_returns(self, symbols: &[SymbolId]) -> StaticType { + fn join_scalar_returns(self, symbols: &[SymbolId]) -> StaticType { symbols .iter() .map(|&symbol| self.scalar_return_type(symbol)) - .reduce(|a, b| a.lub(&b)) + .reduce(|a, b| a.join(&b)) .unwrap_or(StaticType::Any) } @@ -687,14 +688,14 @@ impl<'a> Overloads<'a> { ) -> StaticType { let has_scalar = !scalar_loose.is_empty(); - // Pure scalar: LUB the declared returns. This is the precision-recovery - // case PR #140 had to widen to `Any` for soundness; routing it through - // the LUB now is safe because we know none of the candidates can vec. + // Pure scalar: join the declared returns. This is the precision-recovery + // case PR #140 had to widen to `Any` for soundness; joining them now is + // safe because we know none of the candidates can vec. if has_scalar && vec_resolution.is_none() { - return self.lub_scalar_returns(scalar_loose); + return self.join_scalar_returns(scalar_loose); } - // Pure static vec: per-position LUB → Tuple<…> of element returns. + // Pure static vec: per-position join → Tuple<…> of element returns. if !has_scalar && let Some(VecResolution::Static { axis_len, @@ -703,16 +704,16 @@ impl<'a> Overloads<'a> { { let elements: Vec = positions .iter() - .map(|pos| self.lub_scalar_returns(pos)) + .map(|pos| self.join_scalar_returns(pos)) .collect(); debug_assert_eq!(elements.len(), *axis_len); return StaticType::Tuple(elements); } // Mixed scalar+vec, Any-fallback vec, or empty (defensive): the - // result has no useful upper bound — widen to `Any`. `LUB(scalar, - // Tuple<…>)` collapses to `Any` in our lattice anyway, so there's - // nothing more precise to compute for the mixed case. + // result has no type in common — widen to `Any`. A scalar and a + // `Tuple<…>` join as `Any` anyway, so there's nothing more precise to + // compute for the mixed case. StaticType::Any } } @@ -902,7 +903,8 @@ mod tests { foos.iter() .all(|&foo| picked.contains(&Candidate::Scalar(foo))) ); - assert_eq!(resolved.return_type, list_of(StaticType::Any)); + // A List and a List have no type in common but `Any`. + assert_eq!(resolved.return_type, StaticType::Any); } #[test] diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 454330ac..24ab71c0 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -226,7 +226,7 @@ impl TypeVars { /// What the unsettled literal type `var` could be when nothing says /// otherwise, best first. `None` if that isn't known yet: a list of /// numbers is a list of whatever the numbers are, which is up to them. - pub(crate) fn literal_defaults(&self, var: TypeVar) -> Option> { + pub(crate) fn literal_defaults(&mut self, var: TypeVar) -> Option> { let mut defaults = Vec::new(); let mut waits = false; let mut unknown = None; @@ -272,10 +272,60 @@ impl TypeVars { // the literal's type with a default it doesn't take falls back // to. defaults.push(unknown.unwrap_or(StaticType::Any)); + } else if !defaults.contains(&StaticType::Any) && self.a_literal_holds_any(var) { + // A literal whose values share no type is held as `Any`, which + // any literal can be too: after `let v = [1, "a"]`, `v = ["b"]` + // holds `Any`s like `v` does. + defaults.push(StaticType::Any); } Some(defaults) } + /// Whether a literal whose values `var` holds holds values that share no + /// type, as `[1, "a"]` does, and is held as `Any` on its own. + fn a_literal_holds_any(&mut self, var: TypeVar) -> bool { + let mut literals = Vec::new(); + for bound in &self.bounds[var.0 as usize] { + match bound { + Bound::Holds(held, literal, _) => match literals + .iter_mut() + .find(|(found, _): &&mut (TypeVar, Vec)| found == literal) + { + Some((_, held_by_it)) => held_by_it.push(held.clone()), + None => literals.push((*literal, vec![held.clone()])), + }, + // A number literal is a number. + Bound::Number(..) => {} + } + } + literals.into_iter().any(|(_, held)| { + let snapshot = self.snapshot(); + let shared = held[1..].iter().all(|other| self.unify(&held[0], other)); + self.rollback(snapshot); + !shared + }) + } + + /// Whether the unsettled literal type `var` could still be settled as + /// one of its defaults, or waits for the literals it holds to say. + fn some_default_fits(&mut self, var: TypeVar) -> bool { + let Some(defaults) = self.literal_defaults(var) else { + return true; + }; + defaults.iter().any(|default| { + if self + .resolve(default) + .contains(&|part| *part == StaticType::Var(var)) + { + return false; + } + let snapshot = self.snapshot(); + let fits = self.settle(var, default.clone()); + self.rollback(snapshot); + fits + }) + } + /// What the unsettled literal type `var` is shown as: what it would be /// if nothing said otherwise, as far as that's known. pub(crate) fn preview(&self, var: TypeVar) -> StaticType { @@ -398,7 +448,9 @@ impl TypeVars { self.bounded.push(other); self.literals.push(other); } - true + // The values of two literals have to have a type in common: + // `["s"]` and `[1]` can't be one literal's type. + !self.holds_several_literals(other) || self.some_default_fits(other) } typ => bounds.iter().all(|bound| match bound { Bound::Number(..) => matches!( diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 0fbed6f3..96bd26e7 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -68,7 +68,7 @@ pub enum StaticType { /// here treats one as `Any`. Generic(Box), /// Bottom type for diverging expressions (`return`, `break`, `continue`). - /// `Never` is a subtype of every type: `lub(T, Never) = T`. + /// A value of it fits anywhere, and it joins with any type as that type. Never, Bool, Function { @@ -228,275 +228,27 @@ impl StaticType { } } - /// Checks if `self` is a subtype of `other`. - /// - /// A type S is a subtype of T (S <: T) if a value of type S can be safely - /// used wherever a value of type T is expected. - /// - /// # Key Rules - /// - `Any` is the top type (supertype of all types) - /// - `Int`, `Float`, and `Number` are sibling types - /// - `Sequence` > sequence types with element type T - /// - Generic types are covariant in their type parameters - /// - Function parameters are **contravariant**, returns are **covariant** - /// - /// Note: this function probably doesn't handle variadic functions correctly - pub fn is_subtype(&self, other: &Self) -> bool { - // Never is the bottom type: subtype of everything - if matches!(self, Self::Never) { - return true; - } - - // Any is the universal supertype - if other.is_unknown() { - return true; - } - - // Only Any satisfies the above; all other types fail here - if self.is_unknown() { - return false; - } - - #[allow(clippy::match_same_arms)] - #[allow(clippy::unnested_or_patterns)] - match (self, other) { - // Reflexivity: every type is a subtype of itself - _ if self == other => true, - - // Sequence hierarchy: specific sequences are subtypes of Sequence - // where T is their element type (covariant) - (Self::List(s), Self::Sequence(t)) - | (Self::Iterator(s), Self::Sequence(t)) - | (Self::MinHeap(s), Self::Sequence(t)) - | (Self::MaxHeap(s), Self::Sequence(t)) - | (Self::Deque(s), Self::Sequence(t)) => s.is_subtype(t), - - // String is Sequence - (Self::String, Self::Sequence(t)) => Self::String.is_subtype(t.as_ref()), - - // Tuple is Sequence - (Self::Tuple(elems), Self::Sequence(t)) => { - Self::compute_tuple_element_type(elems).is_subtype(t) - } - - // Map is Sequence> - (Self::Map { key, value }, Self::Sequence(t)) => { - Self::Tuple(vec![key.as_ref().clone(), value.as_ref().clone()]) - .is_subtype(t.as_ref()) - } - - // Generic types are covariant: Container <: Container if S <: T - (Self::Option(s), Self::Option(t)) - | (Self::List(s), Self::List(t)) - | (Self::Iterator(s), Self::Iterator(t)) - | (Self::MinHeap(s), Self::MinHeap(t)) - | (Self::MaxHeap(s), Self::MaxHeap(t)) - | (Self::Deque(s), Self::Deque(t)) - | (Self::Sequence(s), Self::Sequence(t)) => s.is_subtype(t), - - // Tuples are covariant pointwise and must have the same arity - (Self::Tuple(s_elems), Self::Tuple(t_elems)) => { - s_elems.len() == t_elems.len() - && s_elems.iter().zip(t_elems).all(|(s, t)| s.is_subtype(t)) - } - - // Maps are covariant in both key and value type parameters - (Self::Map { key: k1, value: v1 }, Self::Map { key: k2, value: v2 }) => { - k1.is_subtype(k2) && v1.is_subtype(v2) - } - - // Functions: contravariant in parameters, covariant in return type - // F1 <: F2 iff params(F2) <: params(F1) AND return(F1) <: return(F2) - ( - Self::Function { - parameters: p1, - return_type: r1, - }, - Self::Function { - parameters: p2, - return_type: r2, - }, - ) => { - // Return type is covariant: must satisfy r1 <: r2 - let return_ok = r1.is_subtype(r2); - - // Parameters are contravariant: must satisfy p2 <: p1 (reversed!) - let params_ok = match (p1, p2) { - (None, None) => true, - // A function with specific params is a subtype of one with generic params - (Some(_), None) => true, - // Cannot substitute generic params with specific ones - (None, Some(_)) => false, - (Some(ps1), Some(ps2)) => { - ps1.len() == ps2.len() && - // Note the reversal: p2 must be subtype of p1! - // A type parameter of a signature, like `T` in - // `Fn(T) -> U`, fits whatever the function takes: - // the call works out what it stands for. An `Any` - // doesn't, as a value of that type is only - // checked when the program runs. - ps1.iter() - .zip(ps2) - .all(|(p1, p2)| matches!(p2, Self::Generic(_)) || p2.is_subtype(p1)) - } - }; - - return_ok && params_ok - } - - _ => false, - } - } - - // - /// Computes the Least Upper Bound (join) of two types. - /// - /// The LUB is the most specific type that is a supertype of both inputs. - /// - /// # Examples - /// - `lub(Int, Float) = Any` - /// - `lub(List, List) = List` - /// - `lub(List, Iterator) = Sequence` - /// - `lub(Int, String) = Any` - pub fn lub(&self, other: &Self) -> Self { - // Never is the bottom type: lub(T, Never) = T - if matches!(self, Self::Never) { - return other.clone(); - } - if matches!(other, Self::Never) { - return self.clone(); - } - - // Any is the top type - if self.is_unknown() || other.is_unknown() { - return Self::Any; - } - - // Reflexivity: lub(T, T) = T - if self == other { - return self.clone(); - } - - // If one is a subtype of the other, return the supertype - if self.is_subtype(other) { - return other.clone(); - } - if other.is_subtype(self) { - return self.clone(); - } - - match (self, other) { - // Covariant generic types: compute LUB pointwise - (Self::Option(s), Self::Option(t)) => Self::Option(Box::new(s.lub(t))), - (Self::List(s), Self::List(t)) => Self::List(Box::new(s.lub(t))), - (Self::Iterator(s), Self::Iterator(t)) => Self::Iterator(Box::new(s.lub(t))), - (Self::MinHeap(s), Self::MinHeap(t)) => Self::MinHeap(Box::new(s.lub(t))), - (Self::MaxHeap(s), Self::MaxHeap(t)) => Self::MaxHeap(Box::new(s.lub(t))), - (Self::Deque(s), Self::Deque(t)) => Self::Deque(Box::new(s.lub(t))), - (Self::Sequence(s), Self::Sequence(t)) => Self::Sequence(Box::new(s.lub(t))), - - // Maps are covariant in both parameters - (Self::Map { key: k1, value: v1 }, Self::Map { key: k2, value: v2 }) => Self::Map { - key: Box::new(k1.lub(k2)), - value: Box::new(v1.lub(v2)), - }, - - // Tuples of same arity: compute LUB pointwise - (Self::Tuple(e1), Self::Tuple(e2)) if e1.len() == e2.len() => { - Self::Tuple(e1.iter().zip(e2).map(|(a, b)| a.lub(b)).collect()) - } - - // Different sequence types: generalize to Sequence - _ if Self::is_sequence(self) && Self::is_sequence(other) => { - let elem1 = self.sequence_element_type().expect("must be seq"); - let elem2 = other.sequence_element_type().expect("must be seq"); - Self::Sequence(Box::new(elem1.lub(&elem2))) - } - - // Functions: covariant in return, contravariant in parameters - ( - Self::Function { - parameters: p1, - return_type: r1, - }, - Self::Function { - parameters: p2, - return_type: r2, - }, - ) => { - // Return type: covariant, so take LUB - let return_type = Box::new(r1.lub(r2)); - - // Parameters: contravariant, so we need GLB (greatest lower bound) - let parameters = match (p1, p2) { - (Some(ps1), Some(ps2)) if ps1.len() == ps2.len() => { - Some(ps1.iter().zip(ps2).map(|(a, b)| a.glb(b)).collect()) - } - // Incompatible or unknown parameter lists - _ => None, - }; - - Self::Function { - parameters, - return_type, - } - } - - // No common supertype found: default to Any - _ => Self::Any, - } - } - - /// Computes the Greatest Lower Bound (meet) of two types. - /// - /// The GLB is the most general type that is a subtype of both inputs. - /// This is required for contravariant positions (e.g., function parameters). - /// - /// Note: Not all type pairs have a representable GLB in our type system. - /// In such cases, we return a conservative approximation. - fn glb(&self, other: &Self) -> Self { - // Any is the top type: glb(Any, T) = T + /// The type a value of either type has: the type itself when both are + /// the same, and otherwise `Any`. `Never` has no values, so it joins as + /// the other type. `List` and `List` join as `Any`, not as + /// `List`, which neither list is. + #[must_use] + pub fn join(&self, other: &Self) -> Self { match (self, other) { - (unknown, t) | (t, unknown) if unknown.is_unknown() => t.clone(), + (Self::Never, other) | (other, Self::Never) => other.clone(), _ if self == other => self.clone(), - _ if self.is_subtype(other) => self.clone(), - _ if other.is_subtype(self) => other.clone(), - - // Covariant types: compute GLB pointwise - (Self::Option(s), Self::Option(t)) => Self::Option(Box::new(s.glb(t))), - (Self::List(s), Self::List(t)) => Self::List(Box::new(s.glb(t))), - (Self::Iterator(s), Self::Iterator(t)) => Self::Iterator(Box::new(s.glb(t))), - (Self::MinHeap(s), Self::MinHeap(t)) => Self::MinHeap(Box::new(s.glb(t))), - (Self::MaxHeap(s), Self::MaxHeap(t)) => Self::MaxHeap(Box::new(s.glb(t))), - (Self::Deque(s), Self::Deque(t)) => Self::Deque(Box::new(s.glb(t))), - (Self::Sequence(s), Self::Sequence(t)) => Self::Sequence(Box::new(s.glb(t))), - - (Self::Map { key: k1, value: v1 }, Self::Map { key: k2, value: v2 }) => Self::Map { - key: Box::new(k1.glb(k2)), - value: Box::new(v1.glb(v2)), - }, - - (Self::Tuple(e1), Self::Tuple(e2)) if e1.len() == e2.len() => { - Self::Tuple(e1.iter().zip(e2).map(|(a, b)| a.glb(b)).collect()) - } - - // No representable GLB: conservatively return first type - // A complete type system would have a Bottom type (⊥) here - _ => self.clone(), + _ => Self::Any, } } - /// Computes the element type of a tuple when viewed as a sequence. - /// Returns the LUB of all tuple elements. + /// Computes the element type of a tuple when viewed as a sequence: the + /// type its elements join as. fn compute_tuple_element_type(elems: &[Self]) -> Self { - if elems.is_empty() { - return Self::Any; - } - elems .iter() - .skip(1) - .fold(elems[0].clone(), |acc, elem| acc.lub(elem)) + .cloned() + .reduce(|acc, elem| acc.join(&elem)) + .unwrap_or(Self::Any) } /// Checks if a type is a sequence-like type. @@ -519,7 +271,7 @@ impl StaticType { /// /// - `List`, `Iterator`, etc. → `T` /// - `String` → `String` - /// - `Tuple` → `lub(T1, ..., Tn)` + /// - `Tuple` → the join of `T1`, ..., `Tn` /// - `Map` → `Tuple` pub fn sequence_element_type(&self) -> Option { match self { @@ -732,8 +484,8 @@ impl StaticType { /// Checks whether some runtime value could satisfy both `self` and `other`. /// /// # Examples - /// - `Sequence` overlaps `List`: a `List` inhabits - /// both, even though neither type is a subtype of the other + /// - `Sequence` overlaps `List`: a list of Strings is + /// both /// - `Any` overlaps every type /// - `List` does not overlap `List` /// - `Int` does not overlap `String` @@ -752,22 +504,20 @@ impl StaticType { /// `Never` overlaps nothing, since no value inhabits it. pub fn overlaps(&self, other: &Self) -> bool { // Never has no inhabitants, so nothing can satisfy it — not even - // another Never. This has to come first: Never is a subtype of every - // type, so the shortcut below would otherwise report overlap. + // another Never. if matches!(self, Self::Never) || matches!(other, Self::Never) { return false; } - // A subtype relation in either direction implies a common inhabitant. - // This also handles Any, the supertype of everything. - if self.is_subtype(other) || other.is_subtype(self) { + // `Any` and what isn't known yet could be anything, and a type has + // the values it has. + if self.is_unknown() || other.is_unknown() || self == other { return true; } match (self, other) { // Sequence overlaps every sequence-family type whose element - // type overlaps T, even when the subtype check fails because the - // relationship points in different directions per layer. + // type overlaps T. ( Self::Sequence(t), Self::List(u) @@ -825,11 +575,6 @@ impl StaticType { } } - // BRUH - pub fn is_incompatible_with(&self, other: &Self) -> bool { - !self.is_subtype(other) && !other.is_subtype(self) - } - /// Returns a new type with the element type replaced. For container types /// like `List`, this returns `List`. Returns `None` if the /// type does not have a replaceable element type. @@ -967,13 +712,17 @@ mod test { use super::*; #[test] - fn test_list_of_type_compatibility() { + fn a_list_fits_only_a_list_of_its_own_element_type() { let list_of_two_tuple_int = StaticType::List(Box::new(StaticType::Tuple(vec![ StaticType::Int, StaticType::Int, ]))); let list_of_any = StaticType::List(Box::new(StaticType::Any)); - assert!(list_of_two_tuple_int.is_subtype(&list_of_any)); + assert!(!list_of_two_tuple_int.fits(&list_of_any)); + assert!(list_of_two_tuple_int.fits(&list_of_two_tuple_int)); + assert!(list_of_two_tuple_int.fits(&StaticType::Any)); + // ...but might once an `Any` it's passed for turns out to hold one. + assert!(StaticType::Any.may_fit(&list_of_two_tuple_int)); } #[test] @@ -1042,10 +791,10 @@ mod test { #[test] fn public_numeric_types_are_siblings() { - assert!(!StaticType::Int.is_subtype(&StaticType::Number)); - assert!(!StaticType::Float.is_subtype(&StaticType::Number)); - assert_eq!(StaticType::Int.lub(&StaticType::Float), StaticType::Any); - assert_eq!(StaticType::Int.lub(&StaticType::Number), StaticType::Any); + assert!(!StaticType::Int.fits(&StaticType::Number)); + assert!(!StaticType::Float.fits(&StaticType::Number)); + assert_eq!(StaticType::Int.join(&StaticType::Float), StaticType::Any); + assert_eq!(StaticType::Int.join(&StaticType::Number), StaticType::Any); assert!(StaticType::from_name_and_args("Rational", vec![]).is_err()); assert!(StaticType::from_name_and_args("Complex", vec![]).is_err()); } diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index 61bbed29..dec6f6be 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -197,11 +197,11 @@ fn group_overloads<'a>( /// candidate subsuming all others wins); `[Int, Float, Number]` -> /// `Int | Float | Number` (no candidate subsumes the others). fn merged_type_display(candidates: &[&StaticType]) -> String { - if let Some(supertype) = candidates + if let Some(widest) = candidates .iter() .find(|t| candidates.iter().all(|c| c.fits(t))) { - return supertype.to_string(); + return widest.to_string(); } candidates .iter() diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 30ba0d4d..261a1cf1 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -44,7 +44,7 @@ fn bounded_element_type<'a>( } let found = value.static_type_with_budget(depth, budget); element_type = Some(match element_type { - Some(previous) => previous.lub(&found), + Some(previous) => previous.join(&found), None => found, }); } @@ -277,7 +277,7 @@ impl Value { /// # Performance /// /// **O(n) for `List`, `Map`, and `Deque`** — these variants iterate all - /// elements to compute the element type via `lub`. Avoid calling this in + /// elements to join their types into the element type. Avoid calling this in /// hot paths on container values. Use the dedicated helpers instead: /// /// - [`Value::is_number`] — O(1) check for numeric types @@ -325,26 +325,12 @@ impl Value { /// When `param` is a container type whose inner types are all `Any` (e.g. /// `Sequence(Any)`, `Map { Any, Any }`, `List(Any)`), only the outer kind is /// checked — no element iteration occurs. Typed container parameters are - /// rejected rather than scanned; the remaining cases fall back to - /// `self.static_type().is_subtype(param)`. + /// rejected rather than scanned; the remaining cases are checked as + /// [`Value::is_a`] does. pub fn matches_param(&self, param: &StaticType) -> bool { match param { // A type parameter like `T` in `push(List, T)` takes any value. param if param.is_unknown() => true, - // What a function takes and returns can't be checked as the - // program runs, only how many arguments it takes. - StaticType::Function { - parameters: expected, - .. - } => match self.static_type() { - StaticType::Function { - parameters: takes, .. - } => match (expected, takes) { - (Some(expected), Some(takes)) => expected.len() == takes.len(), - _ => true, - }, - _ => false, - }, StaticType::Int => { matches!(self, Self::Int(_)) } @@ -363,6 +349,21 @@ impl Value { StaticType::Map { key, value } if key.is_unknown() && value.is_unknown() => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::Map { .. })) } + StaticType::MinHeap(t) if t.is_unknown() => { + matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::MinHeap(_))) + } + StaticType::MaxHeap(t) if t.is_unknown() => { + matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::MaxHeap(_))) + } + // An option holds at most one value, which is cheap to check. + StaticType::Option(t) => match self { + Self::None => true, + Self::Object(o) => match o.as_ref() { + Object::Some(value) => value.matches_param(t), + _ => false, + }, + _ => false, + }, StaticType::Sequence(t) if t.is_unknown() => { matches!(self, Self::Object(o) if matches!( o.as_ref(), @@ -383,8 +384,34 @@ impl Value { | StaticType::Deque(_) | StaticType::Sequence(_) | StaticType::Map { .. } - | StaticType::Tuple(_) => false, - _ => self.static_type().is_subtype(param), + | StaticType::Tuple(_) + | StaticType::MinHeap(_) + | StaticType::MaxHeap(_) => false, + _ => self.is_a(param), + } + } + + /// Whether this value is a `target`, a type that holds no other values + /// as a container does: a scalar, a struct, a function or an iterator. + /// What a function takes and returns can't be checked as the program + /// runs, only how many arguments it takes, and what an iterator makes + /// can't be either without making it. + fn is_a(&self, target: &StaticType) -> bool { + match (target, self.static_type()) { + ( + StaticType::Function { + parameters: expected, + .. + }, + StaticType::Function { + parameters: takes, .. + }, + ) => match (expected, takes) { + (Some(expected), Some(takes)) => expected.len() == takes.len(), + _ => true, + }, + (StaticType::Iterator(element), StaticType::Iterator(_)) => element.is_unknown(), + (target, found) => found == *target, } } @@ -488,7 +515,7 @@ impl Value { .borrow() .iter() .all(|value| value.conforms_to_cached(element, cache)), - Object::String(_) => StaticType::String.is_subtype(element), + Object::String(_) => StaticType::String.fits(element), Object::Map { entries, .. } => { entries .borrow() @@ -537,7 +564,7 @@ impl Value { { false } - _ => self.static_type().is_subtype(target), + _ => self.is_a(target), }, }; @@ -664,8 +691,8 @@ impl Object { /// Returns the best static type descriptor for this runtime value. /// /// `[1, 2]` reports `List` and `%{"a": 1}` reports `Map`: - /// lists and deques join their element types into a least upper bound, while - /// maps do so separately for keys and values. Tuples preserve each position's + /// lists and deques join their element types, the type they share or else + /// `Any`, while maps do so separately for keys and values. Tuples preserve each position's /// type, and `Some` wraps the type of its inner value. Empty lists, deques, and /// maps report `Any` for the types they cannot infer. /// @@ -739,11 +766,11 @@ impl Object { } let found_value = value.static_type_with_budget(depth - 1, budget); key_type = Some(match key_type { - Some(previous) => previous.lub(&found_key), + Some(previous) => previous.join(&found_key), None => found_key, }); value_type = Some(match value_type { - Some(previous) => previous.lub(&found_value), + Some(previous) => previous.join(&found_value), None => found_value, }); } diff --git a/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc b/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc index 83250312..8396a7a4 100644 --- a/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc +++ b/tests/functional/programs/018_literals/011_two_literals_of_different_types_are_an_error.ndc @@ -1,4 +1,4 @@ // `v` is a list of Strings, so it can't be given a list of Ints. -// expect-error: mismatched types: found Int but expected String +// expect-error: mismatched types: found List but expected List let v = ["s"]; v = [1]; diff --git a/tests/functional/programs/018_literals/018_branches_of_different_types_join_as_any.ndc b/tests/functional/programs/018_literals/018_branches_of_different_types_join_as_any.ndc new file mode 100644 index 00000000..876b68de --- /dev/null +++ b/tests/functional/programs/018_literals/018_branches_of_different_types_join_as_any.ndc @@ -0,0 +1,6 @@ +// The `1` becomes a Float like the other branch, but a String and an Int +// have no type in common but `Any`. +// expect-output: 1 1 +print(if true { 1 } else { 2.5 }, if true { 1 } else { "a" }); +let x = if false { 1 } else { "a" }; +x = 2.5; diff --git a/tests/functional/programs/018_literals/019_branches_of_one_shape_are_made_one_type.ndc b/tests/functional/programs/018_literals/019_branches_of_one_shape_are_made_one_type.ndc new file mode 100644 index 00000000..42fb2566 --- /dev/null +++ b/tests/functional/programs/018_literals/019_branches_of_one_shape_are_made_one_type.ndc @@ -0,0 +1,5 @@ +// The two lists are one type, so `e` holds Ints like `[1]`. +// expect-error: No function called 'push' found that matches the arguments 'List, String' +let e = []; +let r = if true { e } else { [1] }; +e.push("s"); diff --git a/tests/functional/programs/018_literals/020_an_empty_branch_takes_the_other_branch_s_type.ndc b/tests/functional/programs/018_literals/020_an_empty_branch_takes_the_other_branch_s_type.ndc new file mode 100644 index 00000000..642c3ddf --- /dev/null +++ b/tests/functional/programs/018_literals/020_an_empty_branch_takes_the_other_branch_s_type.ndc @@ -0,0 +1,4 @@ +// expect-error: No function called 'push' found that matches the arguments 'List, String' +let xs = ["a".len()]; +let r = if true { xs } else { [] }; +r.push("s"); diff --git a/tests/functional/programs/018_literals/021_branches_of_literals_that_each_hold_any_are_one_type.ndc b/tests/functional/programs/018_literals/021_branches_of_literals_that_each_hold_any_are_one_type.ndc new file mode 100644 index 00000000..0e128256 --- /dev/null +++ b/tests/functional/programs/018_literals/021_branches_of_literals_that_each_hold_any_are_one_type.ndc @@ -0,0 +1,6 @@ +// Each inner list holds values that share no type, so each is a List, +// and the branches are one type: a List>. +// expect-output: nested +fn f(x: List>) => "nested"; +fn f(x: Any) => "any"; +print(f(if true { [[1, "a"]] } else { [[2, "b"]] })); diff --git a/tests/functional/programs/018_literals/022_a_literal_takes_the_any_of_a_variable_holding_any.ndc b/tests/functional/programs/018_literals/022_a_literal_takes_the_any_of_a_variable_holding_any.ndc new file mode 100644 index 00000000..cedeec2f --- /dev/null +++ b/tests/functional/programs/018_literals/022_a_literal_takes_the_any_of_a_variable_holding_any.ndc @@ -0,0 +1,6 @@ +// `v` holds `Any`s, so the list given to it does too. +// expect-output: ["b",2.5] +let v = [1, "a"]; +v = ["b"]; +v.push(2.5); +print(v); diff --git a/tests/proptest/tests/overloads.regressions b/tests/proptest/tests/overloads.regressions index f2afa9f9..28a08757 100644 --- a/tests/proptest/tests/overloads.regressions +++ b/tests/proptest/tests/overloads.regressions @@ -9,3 +9,4 @@ cc 28b971024cd28c1bd67b10fd47576f0057292aac87059605340f0ab7c835a6de # shrinks to cc 4c529d1b26bda369a7a7106e5e40396abfa3a955a96ea68d07756b86ca4f49fa # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: List) => 1; fn p(x) => 1; fn q(x) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0[0] = (1)([fn(a) => p(a), len]); cc ab11f2151f1074090878c174fcbb8148d9db5640b1b288bb551118afb4a91606 # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: Int) => 1; fn p(x) => x; fn q(x: List) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0 += (q(%{}))(["s"].map(p)); cc 1a8c5b5e8e0c29aa6847f08a786b42e2c659fcdc43101296a32471c5fad65d8f # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x) => 1; fn q(x: String) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 1; let v1 = 1; let v2 = 1; let v3 = 1; v0 = ["s"]["k"][0]; +cc 6adf8fd1236fcab1614255c0f54fc8dcba7032bb8692c3e9cad64da57eb4a6fb # shrinks to program = struct S { n: Int } struct T { n: String } fn p(x: List) => 1; fn p(x) => 1; fn q(x: List) => 1; fn g(a) => a; fn h(a) => g(a); let v0 = 2.5; let v1 = 1; let v2 = 1; let v3 = 1; v0.push([(1)(1), 1].map(p)); From 7c4747f1095ceab4714f0b0b2462c4d8ea73a867 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 09:32:48 +0100 Subject: [PATCH 14/39] =?UTF-8?q?feat(analyser):=20make=20Iterable=20a?= =?UTF-8?q?=20parameter=20type=20for=20any=20sequence=20=F0=9F=94=81=20(#3?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4b of the type system RFC (#281): `Sequence` becomes `Iterable`, which is no longer a type a value has, but a parameter type that takes any kind of sequence of `T`s. Behaviour changes: - `Sequence` is renamed `Iterable`, so annotations that name `Sequence` no longer compile. - `Iterable` can only be a parameter's type, or what a function passed for one returns, as in `fn apply(f: Fn(Int) -> Iterable)` or `flat_map`'s callback. `let xs: Iterable = [1]`, `x as Iterable` and a return type of `Iterable` are errors that ask for the kind of sequence. - `fn total(xs: Iterable)` takes a list, a tuple, a range or any other sequence of Ints, and is compiled once for each kind it's called with. A call with anything else is reported as one with no overload that fits, and `total` and `total(x: Any)` are two overloads, with the first more specific. - Iterating over something that doesn't iterate, like `for x in true` or `for i in 1`, is reported before the program runs, and so is a callback for an `Iterable` that returns one, as in `fn flatten(xs) => [0].flat_map(fn(_) => xs)` called with `true`. - When the program picks the overload as it runs, as for an `Any` argument, an `Iterable` parameter takes no sequence, as a `List` one doesn't, since what it holds isn't scanned. - A tuple iterates as the one type its elements are once made the same, so `(1, 2.5)` iterates as Floats. A tuple whose elements share no type iterates as `Any`, as a literal of such values does. - `prefixes` and `suffixes` return a `List>`; strings keep their own overloads. How it works: - A call whose argument for an `Iterable` isn't known yet waits for it, as `for x in xs` does, since the argument could be any kind of sequence. That replaces the special case that kept such a call from committing early, which made the argument a `Sequence` instead of what it was. - An annotated `Iterable` parameter is an unannotated one with an `Iterate` constraint that its elements are `T`s. A generic function carries the constraint to its calls, which is what makes it generic over the kind of sequence. The type table also keeps the parameter's shape, `Iterable`, which picking an overload and checking overloads against each other use, while a call binds the declared type. - A tuple whose elements aren't known yet is iterated once they are, so iterating `(a, b)` doesn't make `a` and `b` one type. - A function passed where a function type is expected binds its `Iterable` parameters to what the type passes, so `[[1], [2, 3]].map(len)` takes `len(Iterable)`. - A standard library test checks that no native signature has `Iterable` elsewhere. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/casts.md | 6 +- manual/src/reference/types.md | 19 ++- .../overload-dispatch-collections.md | 8 +- ndc_analyser/src/analyser/assignments.rs | 2 +- ndc_analyser/src/analyser/calls.rs | 11 +- ndc_analyser/src/analyser/declarations.rs | 62 ++++++-- ndc_analyser/src/analyser/function_values.rs | 6 +- ndc_analyser/src/analyser/generics.rs | 4 +- ndc_analyser/src/analyser/instances.rs | 17 +- ndc_analyser/src/analyser/mod.rs | 65 +++++++- ndc_analyser/src/analyser/solver.rs | 106 +++++++++---- ndc_analyser/src/analyser/tests.rs | 12 +- ndc_analyser/src/error.rs | 21 +++ ndc_analyser/src/overloads.rs | 10 +- ndc_analyser/src/types.rs | 146 ++++++++++++++++-- ndc_core/src/static_type.rs | 50 +++--- ndc_lsp/src/features/completion.rs | 4 +- ndc_macros/src/vm_convert.rs | 2 +- ndc_stdlib/src/aoc.rs | 2 +- ndc_stdlib/src/hash_map.rs | 2 +- ndc_stdlib/src/index.rs | 2 +- ndc_stdlib/src/lib.rs | 46 ++++++ ndc_stdlib/src/list.rs | 2 +- ndc_stdlib/src/math.rs | 2 +- ndc_stdlib/src/rand.rs | 2 +- ndc_stdlib/src/sequence.rs | 80 +++++----- ndc_stdlib/src/string.rs | 8 +- ndc_vm/src/value/mod.rs | 16 +- .../004_basic/046_annotated_let_binding.ndc | 1 - ...typed_container_param_not_dispatchable.ndc | 8 +- ...ast_suggested_when_no_overload_matches.ndc | 4 +- .../016_casts/014_overlapping_hierarchy.ndc | 7 +- .../015_overlapping_hierarchy_error.ndc | 4 +- ...e_parameter_takes_any_kind_of_sequence.ndc | 10 ++ ...iterable_parameter_checks_its_elements.ndc | 9 ++ ...terable_can_only_be_a_parameter_s_type.ndc | 3 + .../004_a_cast_cannot_be_to_an_iterable.ndc | 2 + ...ple_of_different_types_iterates_as_any.ndc | 5 + .../006_a_call_waits_for_what_it_iterates.ndc | 5 + ...llback_may_return_any_kind_of_sequence.ndc | 2 + ...function_taking_an_iterable_is_a_value.ndc | 2 + ...parameter_does_not_take_a_non_sequence.ndc | 3 + ...le_parameter_is_more_specific_than_any.ndc | 5 + ...ameter_may_return_any_kind_of_sequence.ndc | 3 + ...le_does_not_make_its_elements_one_type.ndc | 8 + .../013_a_bool_is_not_iterable.ndc | 4 + ...callback_returns_is_checked_to_iterate.ndc | 4 + ...pick_a_typed_iterable_overload_for_any.ndc | 8 + .../016_sequence_prefixes_suffixes.ndc | 4 +- 49 files changed, 628 insertions(+), 186 deletions(-) create mode 100644 tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc create mode 100644 tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc create mode 100644 tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc create mode 100644 tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc create mode 100644 tests/functional/programs/019_iterable/005_a_tuple_of_different_types_iterates_as_any.ndc create mode 100644 tests/functional/programs/019_iterable/006_a_call_waits_for_what_it_iterates.ndc create mode 100644 tests/functional/programs/019_iterable/007_a_callback_may_return_any_kind_of_sequence.ndc create mode 100644 tests/functional/programs/019_iterable/008_a_function_taking_an_iterable_is_a_value.ndc create mode 100644 tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc create mode 100644 tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc create mode 100644 tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc create mode 100644 tests/functional/programs/019_iterable/012_iterating_a_tuple_does_not_make_its_elements_one_type.ndc create mode 100644 tests/functional/programs/019_iterable/013_a_bool_is_not_iterable.ndc create mode 100644 tests/functional/programs/019_iterable/014_what_a_callback_returns_is_checked_to_iterate.ndc create mode 100644 tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index 7857f0f9..2b4521b3 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -61,9 +61,9 @@ value's type and checks it at the cast site. A wider annotation than the value needs can lose the detail a function needs: ```ndc -let words: Sequence = "a b c".split(" "); -words.remove(0); // error: no `remove` matches Sequence, Int -assert_eq((words as List).remove(0), "a"); +let values: List = [1, 2, 3]; +values.sum(); // error: no `sum` matches List +assert_eq((values as List).sum(), 6); ``` A specialized `op=` mutates its target in place and keeps its type, so it only diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index 224edf56..d19ff391 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -10,7 +10,7 @@ Every value is an `Any`. The other types are: * [`Int`](./types/number.md) — checked signed 64-bit integers * [`Float`](./types/number.md) — IEEE 754 `f64` values * [`Number`](./types/number.md) — arbitrary-size, rational, floating-point, and complex values - * `Sequence` + * Sequences, which a `for` loop iterates over: * [`String`](./types/string.md): a mutable list of characters * [`List`](./types/list.md): a mutable list * [`Tuple`](./types/tuple.md): an immutable list @@ -38,6 +38,23 @@ Nested generics work too — the parser handles the `>>` ambiguity for you: let grid: List> = [[1, 2], [3, 4]]; ``` +### `Iterable` parameters + +A parameter of type `Iterable` takes any kind of sequence of `T`s. The function is compiled once for each kind it's called with: + +```ndc +fn total(xs: Iterable) { + let sum = 0; + for x in xs { + sum += x; + } + sum +} +print(total([1, 2]), total((3, 4)), total(1..4)); // 3 7 6 +``` + +A value has a kind of sequence, so `Iterable` can only be a parameter's type: `let xs: Iterable = [1]` is an error. A tuple whose elements have different types iterates as `Any`. + ### Element types are exact A `List` is not a `List`: a `List` could be given a String, which the list of Ints would then hold. The same goes for maps, tuples, options, the other containers, and function types. To change a container's element type, go through `Any`: diff --git a/manual/src/troubleshooting/overload-dispatch-collections.md b/manual/src/troubleshooting/overload-dispatch-collections.md index 39b9e315..88914c0a 100644 --- a/manual/src/troubleshooting/overload-dispatch-collections.md +++ b/manual/src/troubleshooting/overload-dispatch-collections.md @@ -23,7 +23,7 @@ iterating the container contents. Specifically: | `List` | Is the value a list? | | `Map` | Is the value a map? | | `Deque` | Is the value a deque? | -| `Sequence` | Is the value any sequence type? | +| `Iterable` | Is the value any sequence type? | | `String`, `Int`, … | Exact kind check | This means that dispatch is **O(1)** regardless of how many elements are in the collection. @@ -45,9 +45,9 @@ Standard library overloads do differ by element type. Numeric sequence functions concrete numeric type, so `sum` has three typed overloads: ``` -fn sum(Sequence) -> Int -fn sum(Sequence) -> Float -fn sum(Sequence) -> Number +fn sum(Iterable) -> Int +fn sum(Iterable) -> Float +fn sum(Iterable) -> Number ``` A value the analyser only knows as `List` matches none of them. Rather than dispatch on diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 54721131..0ef7d4bc 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -271,7 +271,7 @@ impl Analyser { node: Option, ) -> bool { // A write passes the value where a value of the stored type is - // expected, as a call passes an argument: `let xs: Sequence = + // expected, as a call passes an argument: `let xs: Iterable = // []` settles the list's elements as Ints. let snapshot = self.vars.snapshot(); if !self.vars.bind_argument(stored_type, value_type) { diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 6a8a89e7..2f758adf 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -1,5 +1,5 @@ use super::Analyser; -use super::solver::{CallNodes, CallSite}; +use super::solver::{CallNodes, CallSite, Constraint}; use crate::error::AnalysisError; use crate::overloads::{CallKind, Overloads}; use ndc_core::{NodeId, StaticType}; @@ -104,6 +104,15 @@ impl Analyser { { self.expect(node, expected); } + for (sequence, element) in + self.iterations_waited_for(¶meters, &arguments) + { + self.wait(Constraint::Iterate { + sequence, + element, + span, + }); + } typ } else { let arguments = arguments diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 678c4dde..ca75f8e8 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -1,3 +1,4 @@ +use super::solver::Constraint; use super::{Analyser, Returns}; use crate::error::AnalysisError; use itertools::{Itertools, izip}; @@ -48,10 +49,10 @@ impl Analyser { self.declaring = declared.map(|(_, symbol)| symbol); } let first_use = self.uses.len(); - let annotations = parameters + let (annotations, iterates): (Vec<_>, Vec<_>) = parameters .iter() - .map(|param| self.lower_annotation(param.annotation.as_ref())) - .collect::>(); + .map(|param| self.lower_parameter_annotation(param.annotation.as_ref())) + .unzip(); let type_signature = TypeSignature::from_annotated_bindings( parameters .iter() @@ -72,7 +73,14 @@ impl Analyser { Some(_) => Some( annotations .iter() - .map(|annotation| annotation.clone().unwrap_or(StaticType::Any)) + .zip(&iterates) + .map(|(annotation, iterates)| match (annotation, iterates) { + (Some(annotation), _) => annotation.clone(), + // What it iterates as is checked too, so a call the + // program picks for doesn't take another kind. + (None, Some(element)) => StaticType::Iterable(Box::new(element.clone())), + (None, None) => StaticType::Any, + }) .collect(), ), None => type_signature.types(), @@ -95,7 +103,25 @@ impl Analyser { }; if let Some((name, symbol)) = declared { let parameters = type_signature.types(); - self.check_overlap(symbol, name, parameters.as_deref(), span); + // An `Iterable` parameter takes what each call settles, but + // only sequences of `T`s, which tells it from its other overloads. + let shape = iterates + .iter() + .map(|element| { + element + .as_ref() + .map(|element| StaticType::Iterable(Box::new(element.clone()))) + }) + .collect::>(); + let shaped = parameters.as_ref().map(|parameters| { + parameters + .iter() + .zip(&shape) + .map(|(parameter, shape)| shape.clone().unwrap_or_else(|| parameter.clone())) + .collect::>() + }); + self.types.declare_shape(symbol, shape); + self.check_overlap(symbol, name, shaped.as_deref(), span); let placeholder = StaticType::Function { parameters, return_type: Box::new( @@ -124,6 +150,19 @@ impl Analyser { .variable_types .insert(param.lvalue.id, typ.clone()); } + // An `Iterable` parameter is what each call passes, which has to + // iterate as `T`: `fn total(xs: Iterable)` takes a list or a + // tuple of Ints. + for ((param, typ), element) in parameters.iter().zip(¶m_types).zip(&iterates) { + if let Some(element) = element { + let span = param.annotation.as_ref().map_or(span, TypeExpr::span); + self.wait(Constraint::Iterate { + sequence: typ.clone(), + element: element.clone(), + span, + }); + } + } let implicit_return = self.analyse_expr(body); let mut returns = self.return_type_stack.pop().unwrap(); @@ -191,10 +230,13 @@ impl Analyser { .. }) = self.result.analysis.function_types.get_mut(&id) { - for ((runtime, parameter), annotation) in - runtime.iter_mut().zip(parameters).zip(&annotations) + for (((runtime, parameter), annotation), iterates) in runtime + .iter_mut() + .zip(parameters) + .zip(&annotations) + .zip(&iterates) { - if annotation.is_none() { + if annotation.is_none() && iterates.is_none() { *runtime = checkable_as_the_program_runs(¶meter); } } @@ -221,7 +263,7 @@ impl Analyser { ) -> StaticType { let field_types: Vec = fields .iter() - .map(|f| self.lower_type_expr(&f.annotation)) + .map(|f| self.lower_value_type(&f.annotation)) .collect(); let struct_id = self.struct_registry.register( @@ -391,7 +433,7 @@ fn checkable_as_the_program_runs(typ: &StaticType) -> StaticType { StaticType::Var(_) | StaticType::Generic(_) | StaticType::Tuple(_) => StaticType::Any, StaticType::List(_) => StaticType::List(any()), StaticType::Deque(_) => StaticType::Deque(any()), - StaticType::Sequence(_) => StaticType::Sequence(any()), + StaticType::Iterable(_) => StaticType::Iterable(any()), StaticType::Iterator(_) => StaticType::Iterator(any()), StaticType::MinHeap(_) => StaticType::MinHeap(any()), StaticType::MaxHeap(_) => StaticType::MaxHeap(any()), diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index ef327b1d..b02cf8e8 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -103,7 +103,7 @@ impl Analyser { /// `symbol`'s type with fresh variables for its type parameters. fn instantiated_type(&mut self, symbol: SymbolId) -> StaticType { - let typ = self.vars.resolve(self.types.get_type(symbol)); + let typ = self.vars.resolve(&self.types.callable_type(symbol)); if typ.contains(&|part| matches!(part, StaticType::Generic(_))) { self.vars.instantiate(&typ) } else { @@ -228,7 +228,7 @@ impl Analyser { /// `Fn(Int, Int) -> Int` isn't. fn might_be_expected(&self, symbol: SymbolId, expected: &StaticType) -> bool { self.vars - .resolve(self.types.get_type(symbol)) + .resolve(&self.types.callable_type(symbol)) .may_fit(&self.vars.resolve(expected)) } @@ -292,7 +292,7 @@ impl Analyser { }) .unwrap_or_else(|| self.resolver.innermost_reference(value.id)); self.vars - .shown(self.types.get_type(symbol)) + .shown(&self.types.callable_type(symbol)) .replace(&|part| matches!(part, StaticType::Generic(_)).then_some(StaticType::Any)) } diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 804435fb..c431d91d 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -401,7 +401,7 @@ impl Analyser { .iter() .filter(|&&earlier| self.resolver.symbol_name(earlier) == name) .filter_map( - |&earlier| match self.vars.resolve(self.types.get_type(earlier)) { + |&earlier| match self.vars.resolve(&self.types.callable_type(earlier)) { StaticType::Function { parameters, .. } => Some(parameters), _ => None, }, @@ -500,7 +500,7 @@ fn may_take_the_same(a: &StaticType, b: &StaticType) -> bool { match (a, b) { _ if wildcard(a) || wildcard(b) => true, (StaticType::Option(x), StaticType::Option(y)) - | (StaticType::Sequence(x), StaticType::Sequence(y)) + | (StaticType::Iterable(x), StaticType::Iterable(y)) | (StaticType::List(x), StaticType::List(y)) | (StaticType::Iterator(x), StaticType::Iterator(y)) | (StaticType::MinHeap(x), StaticType::MinHeap(y)) diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs index 417a122a..1cf84d2c 100644 --- a/ndc_analyser/src/analyser/instances.rs +++ b/ndc_analyser/src/analyser/instances.rs @@ -59,22 +59,31 @@ pub(super) struct Pending { pub(super) uses: Vec<(SymbolId, Vec)>, /// Where an argument of type `Any` is checked, and against what. pub(super) checks: Vec<(NodeId, StaticType)>, + /// The arguments not known yet that were passed for an `Iterable`, + /// with the `T` their elements settle once they're known. + pub(super) iterations: Vec<(StaticType, StaticType)>, } impl Pending { /// How much is pending, to [`Self::truncate`] to later. - pub(super) fn len(&self) -> (usize, usize, usize) { - (self.copies.len(), self.uses.len(), self.checks.len()) + pub(super) fn len(&self) -> [usize; 4] { + [ + self.copies.len(), + self.uses.len(), + self.checks.len(), + self.iterations.len(), + ] } - pub(super) fn truncate(&mut self, (copies, uses, checks): (usize, usize, usize)) { + pub(super) fn truncate(&mut self, [copies, uses, checks, iterations]: [usize; 4]) { self.copies.truncate(copies); self.uses.truncate(uses); self.checks.truncate(checks); + self.iterations.truncate(iterations); } pub(super) fn clear(&mut self) { - self.truncate((0, 0, 0)); + self.truncate([0; 4]); } } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index dedc387c..52fe87d5 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -314,8 +314,69 @@ impl Analyser { } } + /// Lowers an annotation of something other than a parameter, which an + /// `Iterable` can't be: `let xs: Iterable` doesn't say what + /// kind of sequence `xs` is. fn lower_annotation(&mut self, annotation: Option<&TypeExpr>) -> Option { - annotation.map(|expr| self.lower_type_expr(expr)) + annotation.map(|expr| self.lower_value_type(expr)) + } + + /// [`Self::lower_type_expr`] for the type of a value, which an + /// `Iterable` is only part of as a parameter's type. + fn lower_value_type(&mut self, expr: &TypeExpr) -> StaticType { + let typ = self.lower_type_expr(expr); + self.without_iterables(typ, expr.span()) + } + + /// `typ`, reporting each `Iterable` in it, which only a parameter's + /// type can be, as `Any`. + fn without_iterables(&mut self, typ: StaticType, span: Span) -> StaticType { + if !typ.contains(&|part| matches!(part, StaticType::Iterable(_))) { + return typ; + } + self.emit(AnalysisError::iterable_outside_a_parameter(span)); + typ.replace(&|part| matches!(part, StaticType::Iterable(_)).then_some(StaticType::Any)) + } + + /// Lowers a parameter's annotation, returning its type, and the element + /// type for an `Iterable`, which takes any kind of sequence of `T`s: + /// the parameter's type is then what each call passes, and only has to + /// iterate as `T`. + fn lower_parameter_annotation( + &mut self, + annotation: Option<&TypeExpr>, + ) -> (Option, Option) { + let Some(expr) = annotation else { + return (None, None); + }; + match self.lower_type_expr(expr) { + StaticType::Iterable(element) => { + (None, Some(self.without_iterables(*element, expr.span()))) + } + // A function passed for the parameter may return any kind of + // sequence, as `flat_map`'s callback does. + StaticType::Function { + parameters, + return_type, + } if matches!(*return_type, StaticType::Iterable(_)) => { + let StaticType::Iterable(element) = *return_type else { + unreachable!("checked by the guard"); + }; + let parameters = parameters.map(|parameters| { + parameters + .into_iter() + .map(|parameter| self.without_iterables(parameter, expr.span())) + .collect() + }); + let element = self.without_iterables(*element, expr.span()); + let callback = StaticType::Function { + parameters, + return_type: Box::new(StaticType::Iterable(Box::new(element))), + }; + (Some(callback), None) + } + typ => (Some(self.without_iterables(typ, expr.span())), None), + } } /// Analyses a top-level statement. Names resolve first; a statement with @@ -401,7 +462,7 @@ impl Analyser { } Expression::Grouping(expr) => self.analyse_expr(expr), Expression::Cast { value, annotation } => { - let target = self.lower_type_expr(annotation); + let target = self.lower_value_type(annotation); let found = self.analyse_expr(value); // What isn't known about the value yet is what the cast says // it is: `(fn(x) => x) as Fn(Int) -> Int` takes an Int, and diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 8a882e4b..0f1a2276 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -278,18 +278,27 @@ impl Analyser { /// What iterating over a value of type `sequence` gives, which waits /// until the kind of sequence is known. pub(super) fn constrain_iteration(&mut self, sequence: StaticType, span: Span) -> StaticType { - match self.vars.resolve(&sequence) { - StaticType::Var(_) => { - let element = self.vars.fresh(StaticType::Any); - self.wait(Constraint::Iterate { - sequence, - element: element.clone(), - span, - }); - element - } - known => known.sequence_element_type().unwrap_or(StaticType::Any), + if self.vars.waits_to_iterate(&sequence) { + let element = self.vars.fresh(StaticType::Any); + self.wait(Constraint::Iterate { + sequence, + element: element.clone(), + span, + }); + return element; } + self.iteration_gives(&sequence, span) + } + + /// What iterating over a value of the known type `sequence` gives, + /// reporting a value that doesn't iterate, like `true`. + fn iteration_gives(&mut self, sequence: &StaticType, span: Span) -> StaticType { + if let Some(element) = self.vars.element_type(sequence) { + return element; + } + let error = AnalysisError::not_iterable(&self.vars.shown(sequence), span); + self.emit(error); + StaticType::Any } /// The types `let (a, b, …) = value` gives each name when the kind of @@ -720,7 +729,7 @@ impl Analyser { } let reachable = self.overloads().reachable_symbols(&call.candidates); reachable.into_iter().any(|symbol| { - let function = self.vars.resolve(self.types.get_type(symbol)); + let function = self.vars.resolve(&self.types.callable_type(symbol)); let StaticType::Function { parameters: Some(_), .. @@ -827,12 +836,12 @@ impl Analyser { span, } => { let sequence = self.vars.resolve(sequence); - if matches!(sequence, StaticType::Var(_)) { + if self.vars.waits_to_iterate(&sequence) { return false; } - let found = sequence.sequence_element_type().unwrap_or(StaticType::Any); - let element = element.clone(); - self.settle_waited_for(&element, &found, *span, None); + let (element, span) = (element.clone(), *span); + let found = self.iteration_gives(&sequence, span); + self.settle_waited_for(&element, &found, span, None); true } Constraint::Unpack { value, parts, span } => { @@ -1159,7 +1168,7 @@ impl Analyser { let StaticType::Function { parameters: Some(_), .. - } = self.vars.resolve(self.types.get_type(symbol)) + } = self.vars.resolve(&self.types.callable_type(symbol)) else { return false; }; @@ -1169,7 +1178,7 @@ impl Analyser { .. } = self .vars - .instantiate(&self.vars.resolve(self.types.get_type(symbol))) + .instantiate(&self.vars.resolve(&self.types.callable_type(symbol))) else { unreachable!("instantiating a function type gives a function type"); }; @@ -1285,6 +1294,8 @@ impl Analyser { }; let checks = self.expected_of_any(¶meters, arguments, nodes); self.pending.checks.extend(checks); + let iterations = self.iterations_waited_for(¶meters, arguments); + self.pending.iterations.extend(iterations); Some(typ) } @@ -1344,6 +1355,13 @@ impl Analyser { for (node, expected) in std::mem::take(&mut self.pending.checks) { self.expect(node, expected); } + for (sequence, element) in std::mem::take(&mut self.pending.iterations) { + self.wait(Constraint::Iterate { + sequence, + element, + span: call, + }); + } let copies = std::mem::take(&mut self.pending.copies); for (function, constraint, origin) in copies { let name = self.resolver.symbol_name(function).to_string(); @@ -1397,6 +1415,43 @@ impl Analyser { Some((self.vars.resolve(return_type), parameters.clone())) } + /// Each argument nothing is known about yet that was passed for an + /// `Iterable`, or that a function passed for one returns, with the + /// `T` that iterating over it gives once it's known: in `fn total(xs) + /// => xs.sum()`, what `xs` is waits for a call, and so does `xs` in + /// `fn flatten(xs) => [0].flat_map(fn(_) => xs)`. + pub(super) fn iterations_waited_for( + &self, + parameters: &[StaticType], + arguments: &[StaticType], + ) -> Vec<(StaticType, StaticType)> { + parameters + .iter() + .zip(arguments) + .filter_map(|(parameter, argument)| { + let (element, sequence) = + match (self.vars.resolve(parameter), self.vars.resolve(argument)) { + (StaticType::Iterable(element), sequence) => (*element, sequence), + ( + StaticType::Function { return_type, .. }, + StaticType::Function { + return_type: returns, + .. + }, + ) => match *return_type { + StaticType::Iterable(element) => (*element, *returns), + _ => return None, + }, + _ => return None, + }; + match sequence { + StaticType::Var(var) if !self.vars.is_literal(var) => Some((sequence, element)), + _ => None, + } + }) + .collect() + } + /// Where each argument of type `Any` that `nodes` has a node for is /// checked against the parameter it was passed for. pub(super) fn expected_of_any( @@ -1539,7 +1594,7 @@ impl Analyser { let parameters = symbols .iter() .map( - |&symbol| match self.vars.resolve(self.types.get_type(symbol)) { + |&symbol| match self.vars.resolve(&self.types.callable_type(symbol)) { StaticType::Function { parameters: Some(parameters), .. @@ -1563,12 +1618,11 @@ impl Analyser { /// taking exact types once the arguments are known, so a waiting call /// doesn't commit to it: `xs[0] = []` on a `List` leaves only /// `[]=(List, Any, Any)` taking the empty list, but it's still an - /// error once the list's type is known. A `Sequence` parameter takes - /// lists, strings, maps and more, so committing to it would make an - /// argument nothing is known about a `Sequence` rather than what it is: - /// in `fn f(xs) { xs.len(); xs[0] = 1 }`, `xs` is a list. + /// error once the list's type is known. An `Iterable` parameter takes + /// any kind of sequence, so it can't tell what an argument nothing is + /// known about is, and the call waits for it as `for x in xs` does. fn fits_without_any(&self, symbol: SymbolId, arguments: &[StaticType]) -> bool { - match self.vars.resolve(self.types.get_type(symbol)) { + match self.vars.resolve(&self.types.callable_type(symbol)) { StaticType::Function { parameters: Some(parameters), .. @@ -1577,7 +1631,7 @@ impl Analyser { .zip(arguments) .all(|(parameter, argument)| match (parameter, argument) { (StaticType::Any, argument) => *argument == StaticType::Any, - (StaticType::Sequence(_), StaticType::Var(_)) => false, + (StaticType::Iterable(_), StaticType::Var(var)) => self.vars.is_literal(*var), _ => true, }), _ => false, @@ -1587,7 +1641,7 @@ impl Analyser { /// Whether `symbol` can accept arguments of these types, where each part /// not known yet fits anything. fn could_accept(&mut self, symbol: SymbolId, arguments: &[StaticType]) -> bool { - match self.vars.resolve(self.types.get_type(symbol)) { + match self.vars.resolve(&self.types.callable_type(symbol)) { StaticType::Function { parameters: None, .. } => true, diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 5f54d49a..72f19547 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -47,7 +47,7 @@ fn index_natives() -> Vec<(String, StaticType)> { ("[]", "(List, Iterator) -> List"), ("[]", "(Map, K) -> V"), ("[]=", "(List, Int, T)"), - ("[]=", "(List, Iterator, Sequence)"), + ("[]=", "(List, Iterator, Iterable)"), ("[]=", "(Map, K, V)"), ("[]=", "(String, Any, String)"), ] @@ -616,7 +616,7 @@ fn a_function_annotation_types_a_variable() { #[test] fn a_function_argument_gets_what_the_function_parameter_passes() { - let globals = natives(&[("map", "(Sequence, Fn(T) -> U) -> List")]); + let globals = natives(&[("map", "(Iterable, Fn(T) -> U) -> List")]); // A typed lambda fits a type parameter, and settles it. assert_eq!( analyse_last_type_with_globals( @@ -1098,12 +1098,10 @@ fn an_operator_doesnt_commit_early_while_an_operand_is_any() { #[test] fn a_write_passes_its_value_like_an_argument() { - // Any sequence goes where a `Sequence` is expected, and settles its + // Any sequence goes where an `Iterable` is expected, and settles its // elements as Ints. assert_eq!( - analyse_last_type( - "let xs: Sequence = []; let ys = []; let zs: Sequence = ys; ys" - ), + analyse_last_type("fn total(xs: Iterable) => xs; let ys = []; total(ys); ys"), StaticType::List(Box::new(StaticType::Int)) ); } @@ -1161,7 +1159,7 @@ fn a_return_annotation_picks_the_overloaded_function_value_returned() { #[test] fn a_return_annotation_settles_what_the_body_left_unknown() { assert_eq!( - errors("fn f() -> Sequence { let xs = []; xs }", vec![]), + errors("fn f() -> List { let xs = []; xs }", vec![]), Vec::::new() ); // Each value returned has to fit the annotation. diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index f61add91..db5b1d27 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -113,6 +113,27 @@ impl AnalysisError { } } + pub(crate) fn not_iterable(typ: &StaticType, span: Span) -> Self { + Self { + text: format!("{typ} is not iterable"), + span, + help_text: None, + notes: Vec::new(), + } + } + + pub(crate) fn iterable_outside_a_parameter(span: Span) -> Self { + Self { + text: "`Iterable` can only be the type of a parameter".to_string(), + span, + help_text: Some( + "It takes any kind of sequence. Say which kind a value is, as in `List`" + .to_string(), + ), + notes: Vec::new(), + } + } + pub(crate) fn impossible_cast(found: &StaticType, target: &StaticType, span: Span) -> Self { Self { text: format!("invalid cast: {found} can never be {target}"), diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 99031a4b..91d59f86 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -152,11 +152,9 @@ impl<'a> Overloads<'a> { /// The type of `symbol` as far as it's known: a parameter `g` whose type /// a call such as `g(1)` settled is a function from then on. fn type_of(self, symbol: SymbolId) -> Cow<'a, StaticType> { - let typ = self.types.get_type(symbol); - if typ.has_vars() { - Cow::Owned(self.vars.resolve(typ)) - } else { - Cow::Borrowed(typ) + match self.types.callable_type(symbol) { + typ if typ.has_vars() => Cow::Owned(self.vars.resolve(&typ)), + typ => typ, } } @@ -804,7 +802,7 @@ mod tests { // version must win even though the element version was registered later. #[test] fn more_specific_overload_wins_in_same_scope() { - let seq_any = StaticType::Sequence(Box::new(StaticType::Any)); + let seq_any = StaticType::Iterable(Box::new(StaticType::Any)); let fn_any = variadic_fn(StaticType::Generic("U".into())); let (types, locates) = table(vec![ fn_type(vec![seq_any.clone(), fn_any], StaticType::Any), diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 24ab71c0..dc7cf477 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -8,6 +8,10 @@ use ndc_resolver::SymbolId; #[derive(Debug, Default)] pub(crate) struct TypeTable { types: AHashMap, + /// The parameter types calls see, by function, where they differ from + /// the declared type's: a parameter annotated `Iterable` has a type + /// each call settles, but only takes sequences of Ints. + shapes: AHashMap>>, } impl TypeTable { @@ -23,6 +27,41 @@ impl TypeTable { .expect("internal error: a value is analysed after its declaration") } + /// Records the parameter types calls of the function `symbol` see, for + /// the parameters whose declared type doesn't say what they take. + pub(crate) fn declare_shape(&mut self, symbol: SymbolId, shape: Vec>) { + if shape.iter().any(Option::is_some) { + self.shapes.insert(symbol, shape); + } + } + + /// The type of `symbol` as a call sees it, which picks the overload a + /// call means: `fn total(xs: Iterable)` takes an `Iterable`, + /// although its declared type takes a type parameter each call settles. + pub(crate) fn callable_type(&self, symbol: SymbolId) -> std::borrow::Cow<'_, StaticType> { + let typ = self.get_type(symbol); + let ( + Some(shape), + StaticType::Function { + parameters: Some(parameters), + return_type, + }, + ) = (self.shapes.get(&symbol), typ) + else { + return std::borrow::Cow::Borrowed(typ); + }; + std::borrow::Cow::Owned(StaticType::Function { + parameters: Some( + parameters + .iter() + .zip(shape) + .map(|(parameter, shape)| shape.clone().unwrap_or_else(|| parameter.clone())) + .collect(), + ), + return_type: return_type.clone(), + }) + } + /// Replaces the settled type variables in every value's type with what /// they stand for. pub(crate) fn resolve_all(&mut self, vars: &TypeVars) { @@ -616,7 +655,7 @@ impl TypeVars { self.settle(*var, other.clone()) } (StaticType::Option(x), StaticType::Option(y)) - | (StaticType::Sequence(x), StaticType::Sequence(y)) + | (StaticType::Iterable(x), StaticType::Iterable(y)) | (StaticType::List(x), StaticType::List(y)) | (StaticType::Iterator(x), StaticType::Iterator(y)) | (StaticType::MinHeap(x), StaticType::MinHeap(y)) @@ -701,9 +740,10 @@ impl TypeVars { /// that a parameter of type `Any` takes anything, and an argument of /// type `Any` goes anywhere, as it's checked as the program runs. Inside /// a container, a function type, an `Option` or a tuple, `Any` is just - /// another type: a `List` doesn't fit a `List`. A - /// `Sequence` takes any kind of sequence, and settles `T` from its - /// elements. + /// another type: a `List` doesn't fit a `List`. An + /// `Iterable` takes any kind of sequence, and settles `T` from its + /// elements. An argument nothing is known about yet stays what it is, + /// as the call that takes it waits for it to iterate. pub(crate) fn bind_argument(&mut self, parameter: &StaticType, argument: &StaticType) -> bool { let (parameter, argument) = (self.shallow(parameter), self.shallow(argument)); match (¶meter, &argument) { @@ -712,28 +752,104 @@ impl TypeVars { // literal's type what the literal says: `n` in `let n = 1; // n = a` is still an Int. (StaticType::Var(var), StaticType::Any) if self.is_literal(*var) => true, + (StaticType::Iterable(_), StaticType::Var(var)) if !self.is_literal(*var) => true, (StaticType::Var(_), _) | (_, StaticType::Var(_)) => self.unify(¶meter, &argument), (_, StaticType::Any) => true, - // A function type that says nothing about the parameters takes - // any function that returns what it says. ( StaticType::Function { - parameters: None, + parameters: expected, return_type: p, }, - StaticType::Function { return_type: a, .. }, - ) => self.unify(p, a), - (StaticType::Sequence(p), StaticType::Sequence(a)) => self.bind_argument(p, a), - (StaticType::Sequence(p), _) if argument.sequence_element_type().is_some() => { - let element = argument - .sequence_element_type() - .expect("checked by the guard"); - self.bind_argument(p, &element) + StaticType::Function { + parameters: takes, + return_type: a, + }, + ) => { + // A function type that says nothing about the parameters + // takes any function. + let parameters = match (expected, takes) { + (None, _) => true, + (Some(expected), Some(takes)) => { + expected.len() == takes.len() + && expected.iter().zip(takes).all(|(e, t)| self.passes(e, t)) + } + (Some(_), None) => false, + }; + // A function returning an `Iterable` may return any kind of + // sequence of `U`s, as `flat_map`'s callback does. + parameters + && match self.shallow(p) { + StaticType::Iterable(_) => self.bind_argument(p, a), + _ => self.unify(p, a), + } } + (StaticType::Iterable(p), _) => match self.element_type(&argument) { + Some(element) => self.bind_argument(p, &element), + None => false, + }, _ => self.unify(¶meter, &argument), } } + /// Whether what a function type passes as an `expected` parameter is + /// what a function that `takes` it takes: the same type, or any kind of + /// sequence for an `Iterable`, as `len` takes the lists that + /// `[[1], [2]].map(len)` passes. + fn passes(&mut self, expected: &StaticType, takes: &StaticType) -> bool { + match self.shallow(takes) { + StaticType::Iterable(_) => self.bind_argument(takes, expected), + _ => self.unify(expected, takes), + } + } + + /// Whether what iterating over a value of type `typ` gives isn't known + /// yet: it's not known what `typ` is, or it's a tuple whose elements + /// aren't all known. + pub(crate) fn waits_to_iterate(&self, typ: &StaticType) -> bool { + match self.resolve(typ) { + StaticType::Var(var) => !self.is_literal(var), + StaticType::Tuple(elements) => elements.iter().any(|e| self.has_unknowns(e)), + _ => false, + } + } + + /// Whether `typ` has a part not known yet that isn't a literal's type. + fn has_unknowns(&self, typ: &StaticType) -> bool { + self.unsettled(typ) + .into_iter() + .any(|var| !self.is_literal(var)) + } + + /// What iterating over a value of type `typ` gives, or `None` if it + /// doesn't iterate. A tuple iterates as the one type its elements are + /// once made the same, or as `Any` if they can't be: `(1, 2.5)` iterates + /// as Floats, and `(1, "a")` as `Any`. + pub(crate) fn element_type(&mut self, typ: &StaticType) -> Option { + match self.resolve(typ) { + // A number doesn't iterate, whatever kind of number it becomes. + StaticType::Var(var) if self.is_number(var) => None, + // What its elements are isn't known yet, and they don't have to + // be one type, so they're not made one: `(a, b)` iterates as + // `Any` until `a` and `b` are known. + StaticType::Tuple(elements) if elements.iter().any(|e| self.has_unknowns(e)) => { + Some(StaticType::Any) + } + StaticType::Tuple(elements) if !elements.is_empty() => { + let snapshot = self.snapshot(); + if elements[1..] + .iter() + .all(|element| self.unify(&elements[0], element)) + { + Some(self.resolve(&elements[0])) + } else { + self.rollback(snapshot); + Some(StaticType::Any) + } + } + other => other.sequence_element_type(), + } + } + /// Settles every unsettled variable in `typ` as `to`. pub(crate) fn settle_unknowns(&mut self, typ: &StaticType, to: &StaticType) { for var in self.unsettled(typ) { diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 96bd26e7..bff14d55 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -86,8 +86,8 @@ pub enum StaticType { Float, Int, - // Sequences List -> Sequence - Sequence(Box), + // What a value of a kind of sequence iterates as, which only a parameter is: `List` passes for an `Iterable`. + Iterable(Box), List(Box), String, Tuple(Vec), @@ -131,7 +131,7 @@ impl StaticType { /// Must stay in sync with its match arms; the unit test /// `builtin_type_names_are_constructible` guards this. pub const BUILTIN_TYPE_NAMES: &'static [&'static str] = &[ - "Any", "Never", "Bool", "Number", "Float", "Int", "String", "Option", "Sequence", "List", + "Any", "Never", "Bool", "Number", "Float", "Int", "String", "Option", "Iterable", "List", "Iterator", "MinHeap", "MaxHeap", "Deque", "Tuple", "Map", ]; @@ -150,8 +150,8 @@ impl StaticType { "Option" => { Self::require_exactly_one_arg(name, args).map(|elem| Self::Option(Box::new(elem))) } - "Sequence" => { - Self::require_exactly_one_arg(name, args).map(|elem| Self::Sequence(Box::new(elem))) + "Iterable" => { + Self::require_exactly_one_arg(name, args).map(|elem| Self::Iterable(Box::new(elem))) } "List" => { Self::require_exactly_one_arg(name, args).map(|elem| Self::List(Box::new(elem))) @@ -255,7 +255,7 @@ impl StaticType { fn is_sequence(ty: &Self) -> bool { matches!( ty, - Self::Sequence(_) + Self::Iterable(_) | Self::List(_) | Self::String | Self::Tuple(_) @@ -275,7 +275,7 @@ impl StaticType { /// - `Map` → `Tuple` pub fn sequence_element_type(&self) -> Option { match self { - Self::Sequence(t) + Self::Iterable(t) | Self::List(t) | Self::Iterator(t) | Self::MinHeap(t) @@ -299,7 +299,7 @@ impl StaticType { /// known yet stand for any type. Inside a container, a function type, /// an `Option` or a tuple, `Any` is just another type: a `List` /// doesn't fit a `List`, as the list could then get a String. A - /// `Sequence` takes any kind of sequence of what fits `T`. + /// `Iterable` takes any kind of sequence of what fits `T`. pub fn fits(&self, parameter: &Self) -> bool { matches!(parameter, Self::Any) || self.matches(parameter, false) } @@ -325,8 +325,8 @@ impl StaticType { let matches = |a: &Self, p: &Self| a.matches(p, unknowns_match); match (self, parameter) { (Self::Never, _) => true, - (Self::Sequence(a), Self::Sequence(p)) => a.fits(p) || matches(a, p), - (argument, Self::Sequence(p)) if Self::is_sequence(argument) => { + (Self::Iterable(a), Self::Iterable(p)) => a.fits(p) || matches(a, p), + (argument, Self::Iterable(p)) if Self::is_sequence(argument) => { let element = argument .sequence_element_type() .expect("a sequence has elements"); @@ -403,7 +403,7 @@ impl StaticType { return_type: map(return_type), }, Self::Option(t) => Self::Option(map(t)), - Self::Sequence(t) => Self::Sequence(map(t)), + Self::Iterable(t) => Self::Iterable(map(t)), Self::List(t) => Self::List(map(t)), Self::Iterator(t) => Self::Iterator(map(t)), Self::MinHeap(t) => Self::MinHeap(map(t)), @@ -446,7 +446,7 @@ impl StaticType { .any(|parameter| parameter.contains(test)) } Self::Option(t) - | Self::Sequence(t) + | Self::Iterable(t) | Self::List(t) | Self::Iterator(t) | Self::MinHeap(t) @@ -484,7 +484,7 @@ impl StaticType { /// Checks whether some runtime value could satisfy both `self` and `other`. /// /// # Examples - /// - `Sequence` overlaps `List`: a list of Strings is + /// - `Iterable` overlaps `List`: a list of Strings is /// both /// - `Any` overlaps every type /// - `List` does not overlap `List` @@ -516,16 +516,16 @@ impl StaticType { } match (self, other) { - // Sequence overlaps every sequence-family type whose element + // Iterable overlaps every sequence-family type whose element // type overlaps T. ( - Self::Sequence(t), + Self::Iterable(t), Self::List(u) | Self::Iterator(u) | Self::MinHeap(u) | Self::MaxHeap(u) | Self::Deque(u) - | Self::Sequence(u), + | Self::Iterable(u), ) | ( Self::List(u) @@ -533,16 +533,16 @@ impl StaticType { | Self::MinHeap(u) | Self::MaxHeap(u) | Self::Deque(u), - Self::Sequence(t), + Self::Iterable(t), ) => t.overlaps(u), - (Self::Sequence(t), Self::String) | (Self::String, Self::Sequence(t)) => { + (Self::Iterable(t), Self::String) | (Self::String, Self::Iterable(t)) => { Self::String.overlaps(t) } - (Self::Sequence(t), Self::Tuple(elems)) | (Self::Tuple(elems), Self::Sequence(t)) => { + (Self::Iterable(t), Self::Tuple(elems)) | (Self::Tuple(elems), Self::Iterable(t)) => { elems.iter().all(|elem| elem.overlaps(t)) } - (Self::Sequence(t), Self::Map { key, value }) - | (Self::Map { key, value }, Self::Sequence(t)) => { + (Self::Iterable(t), Self::Map { key, value }) + | (Self::Map { key, value }, Self::Iterable(t)) => { Self::Tuple(vec![key.as_ref().clone(), value.as_ref().clone()]).overlaps(t) } @@ -581,7 +581,7 @@ impl StaticType { pub fn with_element_type(&self, new_elem: Self) -> Self { match self { Self::List(_) => Self::List(Box::new(new_elem)), - Self::Sequence(_) => Self::Sequence(Box::new(new_elem)), + Self::Iterable(_) => Self::Iterable(Box::new(new_elem)), Self::Iterator(_) => Self::Iterator(Box::new(new_elem)), Self::MinHeap(_) => Self::MinHeap(Box::new(new_elem)), Self::MaxHeap(_) => Self::MaxHeap(Box::new(new_elem)), @@ -635,7 +635,7 @@ impl StaticType { Some(Box::new(std::iter::repeat(&Self::Any))) } Self::List(elem) - | Self::Sequence(elem) + | Self::Iterable(elem) | Self::Iterator(elem) | Self::MinHeap(elem) | Self::MaxHeap(elem) @@ -677,7 +677,7 @@ impl fmt::Display for StaticType { Self::Number => write!(f, "Number"), Self::Float => write!(f, "Float"), Self::Int => write!(f, "Int"), - Self::Sequence(elem) => write!(f, "Sequence<{elem}>"), + Self::Iterable(elem) => write!(f, "Iterable<{elem}>"), Self::List(elem) => write!(f, "List<{elem}>"), Self::String => write!(f, "String"), Self::Tuple(tup) if tup.is_empty() => write!(f, "()"), @@ -749,7 +749,7 @@ mod test { #[test] fn test_overlaps() { let list_of = |elem: StaticType| StaticType::List(Box::new(elem)); - let seq_of = |elem: StaticType| StaticType::Sequence(Box::new(elem)); + let seq_of = |elem: StaticType| StaticType::Iterable(Box::new(elem)); // Mixed-direction relationships overlap: a List inhabits both. assert!(seq_of(StaticType::String).overlaps(&list_of(StaticType::Any))); diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index dec6f6be..4fb7b1f4 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -193,7 +193,7 @@ fn group_overloads<'a>( groups } -/// `[Int]` -> `Int`; `[List, Sequence]` -> `Sequence` (a +/// `[Int]` -> `Int`; `[List, Iterable]` -> `Iterable` (a /// candidate subsuming all others wins); `[Int, Float, Number]` -> /// `Int | Float | Number` (no candidate subsumes the others). fn merged_type_display(candidates: &[&StaticType]) -> String { @@ -498,7 +498,7 @@ mod tests { #[test] fn merged_type_display_shows_a_common_supertype_or_a_union() { let list = StaticType::List(Box::new(StaticType::Int)); - let seq = StaticType::Sequence(Box::new(StaticType::Int)); + let seq = StaticType::Iterable(Box::new(StaticType::Int)); assert_eq!(merged_type_display(&[&list, &seq]), seq.to_string()); assert_eq!( merged_type_display(&[&StaticType::Int, &StaticType::Float]), diff --git a/ndc_macros/src/vm_convert.rs b/ndc_macros/src/vm_convert.rs index 41ba731e..3aee245d 100644 --- a/ndc_macros/src/vm_convert.rs +++ b/ndc_macros/src/vm_convert.rs @@ -366,7 +366,7 @@ pub fn try_vm_input(ty: &syn::Type, position: usize) -> Option { NdcType::SeqValue => VmInputArg { extract: quote! {}, pass: quote! { #raw.clone() }, - static_type: quote! { ndc_core::StaticType::Sequence(Box::new(ndc_core::StaticType::Any)) }, + static_type: quote! { ndc_core::StaticType::Iterable(Box::new(ndc_core::StaticType::Any)) }, }, NdcType::MapValue => VmInputArg { diff --git a/ndc_stdlib/src/aoc.rs b/ndc_stdlib/src/aoc.rs index a7b2a287..16e9ae16 100644 --- a/ndc_stdlib/src/aoc.rs +++ b/ndc_stdlib/src/aoc.rs @@ -7,7 +7,7 @@ mod inner { use std::rc::Rc; /// Counts the occurrences of each item in a sequence and returns a map with the frequencies. - #[function(signature = "(Sequence) -> Map")] + #[function(signature = "(Iterable) -> Map")] pub fn frequencies(seq: SeqValue) -> anyhow::Result { let mut counts: HashMap = HashMap::new(); diff --git a/ndc_stdlib/src/hash_map.rs b/ndc_stdlib/src/hash_map.rs index 0ade336c..1cbd48c3 100644 --- a/ndc_stdlib/src/hash_map.rs +++ b/ndc_stdlib/src/hash_map.rs @@ -210,7 +210,7 @@ mod inner { } /// Converts the given sequence to set. - #[function(signature = "(Sequence) -> Map")] + #[function(signature = "(Iterable) -> Map")] pub fn set(seq: SeqValue) -> anyhow::Result { use ndc_vm::hash_map::HashMap; diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index c42ef0d3..314ab282 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -123,7 +123,7 @@ fn register_set(env: &mut FunctionRegistry>) { // An element by its index, or a slice by a range of indices. for signature in [ "(List, Int, T)", - "(List, Iterator, Sequence)", + "(List, Iterator, Iterable)", // An index whose type isn't known could be either. "(List, Any, Any)", ] { diff --git a/ndc_stdlib/src/lib.rs b/ndc_stdlib/src/lib.rs index b3e35324..399e4c09 100644 --- a/ndc_stdlib/src/lib.rs +++ b/ndc_stdlib/src/lib.rs @@ -53,6 +53,7 @@ pub fn register(env: &mut FunctionRegistry>) { #[cfg(test)] mod tests { use super::*; + use ndc_core::StaticType; /// A call that fits two overloads in one scope must find one of them /// more specific, so it never has to choose between generic overloads. @@ -66,4 +67,49 @@ mod tests { .collect::>(); assert_eq!(ndc_analyser::tied_natives(&natives), Vec::::new()); } + + /// `Iterable` takes any kind of sequence, which only a parameter can + /// do, or what a function passed for one returns, as `flat_map`'s + /// callback does. What a function returns is one kind of sequence. + #[test] + fn an_iterable_is_only_a_parameter() { + let iterable = + |typ: &StaticType| typ.contains(&|part| matches!(part, StaticType::Iterable(_))); + let mut registry = FunctionRegistry::default(); + register(&mut registry); + for function in registry.iter() { + let StaticType::Function { + parameters, + return_type, + } = &function.static_type + else { + continue; + }; + assert!( + !iterable(return_type), + "{} returns an Iterable", + function.name + ); + for parameter in parameters.iter().flatten() { + let nested = match parameter { + StaticType::Iterable(element) => iterable(element), + StaticType::Function { + parameters, + return_type, + } => { + parameters.iter().flatten().any(iterable) + || matches!(&**return_type, StaticType::Iterable(element) if iterable(element)) + || (!matches!(&**return_type, StaticType::Iterable(_)) + && iterable(return_type)) + } + other => iterable(other), + }; + assert!( + !nested, + "{} takes an Iterable inside another type", + function.name + ); + } + } + } } diff --git a/ndc_stdlib/src/list.rs b/ndc_stdlib/src/list.rs index 6b44f0f1..21b7c2aa 100644 --- a/ndc_stdlib/src/list.rs +++ b/ndc_stdlib/src/list.rs @@ -9,7 +9,7 @@ mod inner { use num::{BigInt, ToPrimitive}; /// Converts any sequence into a list - #[function(signature = "(Sequence) -> List")] + #[function(signature = "(Iterable) -> List")] pub fn list(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() diff --git a/ndc_stdlib/src/math.rs b/ndc_stdlib/src/math.rs index fceebd86..82a0a4fe 100644 --- a/ndc_stdlib/src/math.rs +++ b/ndc_stdlib/src/math.rs @@ -712,7 +712,7 @@ fn register_aggregates(env: &mut FunctionRegistry>) { declare( env, name, - vec![StaticType::Sequence(Box::new(mode.static_type()))], + vec![StaticType::Iterable(Box::new(mode.static_type()))], mode.static_type(), if product { "Returns the product of a numeric sequence." diff --git a/ndc_stdlib/src/rand.rs b/ndc_stdlib/src/rand.rs index 1aecc8bc..a8fd797d 100644 --- a/ndc_stdlib/src/rand.rs +++ b/ndc_stdlib/src/rand.rs @@ -33,7 +33,7 @@ mod inner { /// Returns a copy of the input sequence converted to a list with the elements shuffled in random order. /// /// Note: this currently does consume iterators - #[function(signature = "(Sequence) -> List")] + #[function(signature = "(Iterable) -> List")] pub fn shuffled(list: SeqValue) -> anyhow::Result { Ok(Value::list( list.try_into_iter() diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index 9a2655a8..d916e3c9 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -60,7 +60,7 @@ mod inner { use std::rc::Rc; /// Returns `true` if the element is contained in the sequence. - #[function(name = "in", signature = "(T, Sequence) -> Bool")] + #[function(name = "in", signature = "(T, Iterable) -> Bool")] pub fn op_contains(elem: Value, seq: SeqValue) -> bool { contains(&elem, &seq) } @@ -136,7 +136,7 @@ mod inner { } /// Returns the highest element in the sequence. - #[function(signature = "(Sequence) -> T")] + #[function(signature = "(Iterable) -> T")] pub fn max(seq: SeqValue) -> anyhow::Result { highest(seq) } @@ -170,13 +170,13 @@ mod inner { } /// Returns the element for which the key function returns the highest value. - #[function(signature = "(Sequence, Fn(T) -> U) -> T")] + #[function(signature = "(Iterable, Fn(T) -> U) -> T")] pub fn max_by_key(seq: SeqValue, func: &mut VmCallable<'_>) -> anyhow::Result { by_key(seq, func, Ordering::Greater) } /// Returns the element for which the key function returns the lowest value. - #[function(signature = "(Sequence, Fn(T) -> U) -> T")] + #[function(signature = "(Iterable, Fn(T) -> U) -> T")] pub fn min_by_key(seq: SeqValue, func: &mut VmCallable<'_>) -> anyhow::Result { by_key(seq, func, Ordering::Less) } @@ -186,7 +186,7 @@ mod inner { /// The comparator function takes two elements and returns a number. A positive result means the /// first argument is greater than the second, a negative result means the first argument is /// less than the second, and zero means they are equal. - #[function(signature = "(Sequence, Fn(T, T) -> U) -> T")] + #[function(signature = "(Iterable, Fn(T, T) -> U) -> T")] pub fn max_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { by_comp(seq, comp, Ordering::Greater) } @@ -196,13 +196,13 @@ mod inner { /// The comparator function takes two elements and returns a number. A positive result means the /// first argument is greater than the second, a negative result means the first argument is /// less than the second, and zero means they are equal. - #[function(signature = "(Sequence, Fn(T, T) -> U) -> T")] + #[function(signature = "(Iterable, Fn(T, T) -> U) -> T")] pub fn min_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { by_comp(seq, comp, Ordering::Less) } /// Returns the lowest element in the sequence. - #[function(signature = "(Sequence) -> T")] + #[function(signature = "(Iterable) -> T")] pub fn min(seq: SeqValue) -> anyhow::Result { lowest(seq) } @@ -293,7 +293,7 @@ mod inner { } /// Returns a sorted copy of the input sequence as a list. - #[function(signature = "(Sequence) -> List")] + #[function(signature = "(Iterable) -> List")] pub fn sorted(seq: SeqValue) -> anyhow::Result { let mut list: Vec = seq .try_into_iter() @@ -311,7 +311,7 @@ mod inner { /// - for values lower than `0` the first argument is smaller than the second argument /// - for values higher than `0` the first argument is greater than the second argument /// - for values equal to `0` the first argument is equal to the second argument - #[function(signature = "(Sequence, Fn(T, T) -> U) -> List")] + #[function(signature = "(Iterable, Fn(T, T) -> U) -> List")] pub fn sorted_by(seq: SeqValue, comp: &mut VmCallable<'_>) -> anyhow::Result { let mut list: Vec = seq .try_into_iter() @@ -348,7 +348,7 @@ mod inner { } /// Returns the length of the sequence, for strings this returns the number of UTF-8 characters. - #[function(signature = "(Sequence) -> Int")] + #[function(signature = "(Iterable) -> Int")] pub fn len(seq: SeqValue) -> anyhow::Result { match &seq { Value::Object(obj) => match obj.as_ref() { @@ -370,7 +370,7 @@ mod inner { } /// Returns a lazy iterator yielding `(index, value)` tuples for each element of `seq`. - #[function(signature = "(Sequence) -> Iterator<(Int, T)>")] + #[function(signature = "(Iterable) -> Iterator<(Int, T)>")] pub fn enumerate(seq: SeqValue) -> anyhow::Result { let iter = EnumerateIter::new(seq).ok_or_else(|| anyhow!("enumerate requires a sequence"))?; @@ -378,7 +378,7 @@ mod inner { } /// Reduces/folds the given sequence using the given combining function and a custom initial value. - #[function(signature = "(Sequence, A, Fn(A, T) -> A) -> A")] + #[function(signature = "(Iterable, A, Fn(A, T) -> A) -> A")] pub fn fold( seq: SeqValue, initial: Value, @@ -393,7 +393,7 @@ mod inner { } /// Reduces/folds the given sequence using the given combining function. - #[function(signature = "(Sequence, Fn(T, T) -> T) -> T")] + #[function(signature = "(Iterable, Fn(T, T) -> T) -> T")] pub fn reduce(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut iterator = seq .try_into_iter() @@ -405,7 +405,7 @@ mod inner { } /// Filters the given sequence using the `predicate`. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> List")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> List")] pub fn filter(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for element in seq @@ -425,7 +425,7 @@ mod inner { } /// Returns the number of elements in the input sequence for which the given `predicate` returns `true`. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> Int")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> Int")] pub fn count(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { let mut out = 0i64; for element in seq @@ -442,7 +442,7 @@ mod inner { } /// Returns the value of the first element for which the `predicate` is true for the given input sequence. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> T")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> T")] pub fn find(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for element in seq .try_into_iter() @@ -461,7 +461,7 @@ mod inner { } /// Returns the first index of the element for which the `predicate` is true in the input sequence. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> Int")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> Int")] pub fn locate(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for (idx, element) in seq .try_into_iter() @@ -478,7 +478,7 @@ mod inner { } /// Returns the first index of the element or produces an error - #[function(name = "locate", signature = "(Sequence, T) -> Int")] + #[function(name = "locate", signature = "(Iterable, T) -> Int")] pub fn locate_element(seq: SeqValue, element: Value) -> anyhow::Result { for (idx, el) in seq .try_into_iter() @@ -493,7 +493,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for none of the elements in `seq`. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> Bool")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> Bool")] pub fn none(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -514,7 +514,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for all the elements in `seq`. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> Bool")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> Bool")] pub fn all(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -535,7 +535,7 @@ mod inner { } /// Returns `true` if the `predicate` is true for any of the elements in `seq`. - #[function(signature = "(Sequence, Fn(T) -> Bool) -> Bool")] + #[function(signature = "(Iterable, Fn(T) -> Bool) -> Bool")] pub fn any(seq: SeqValue, predicate: &mut VmCallable<'_>) -> anyhow::Result { for item in seq .try_into_iter() @@ -556,7 +556,7 @@ mod inner { } /// Applies the function to each element in a sequence returning the result as a list. - #[function(signature = "(Sequence, Fn(T) -> U) -> List")] + #[function(signature = "(Iterable, Fn(T) -> U) -> List")] pub fn map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for item in seq @@ -569,7 +569,7 @@ mod inner { } /// Applies a function to each item in a sequence, flattens the resulting sequences, and returns a single combined sequence. - #[function(signature = "(Sequence, Fn(T) -> Sequence) -> List")] + #[function(signature = "(Iterable, Fn(T) -> Iterable) -> List")] pub fn flat_map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let mut out = Vec::new(); for item in seq @@ -586,7 +586,7 @@ mod inner { } /// Returns the first element of the sequence or the `default` value otherwise. - #[function(signature = "(Sequence, T) -> T")] + #[function(signature = "(Iterable, T) -> T")] pub fn first_or(seq: SeqValue, default: Value) -> Value { seq.try_into_iter() .and_then(|mut i| i.next()) @@ -594,7 +594,7 @@ mod inner { } /// Returns the first element of the sequence or the return value of the given function. - #[function(signature = "(Sequence, Fn() -> T) -> T")] + #[function(signature = "(Iterable, Fn() -> T) -> T")] pub fn first_or_else(seq: SeqValue, default: &mut VmCallable<'_>) -> anyhow::Result { if let Some(item) = seq.try_into_iter().and_then(|mut i| i.next()) { return Ok(item); @@ -603,7 +603,7 @@ mod inner { } /// Returns the `k` sized combinations of the given sequence `seq` as a lazy iterator of tuples. - #[function(signature = "(Sequence, Int) -> Iterator")] + #[function(signature = "(Iterable, Int) -> Iterator")] pub fn combinations(seq: SeqValue, k: usize) -> anyhow::Result { let iter = CombinationsIter::new(seq, k) .ok_or_else(|| anyhow!("combinations requires a sequence"))?; @@ -611,14 +611,14 @@ mod inner { } /// Returns a lazy iterator yielding the first `n` elements of `seq`. - #[function(signature = "(Sequence, Int) -> Iterator")] + #[function(signature = "(Iterable, Int) -> Iterator")] pub fn take(seq: SeqValue, n: usize) -> anyhow::Result { let iter = TakeIter::new(seq, n).ok_or_else(|| anyhow!("take requires a sequence"))?; Ok(Value::iterator(iter.into_shared())) } /// Returns the `k` sized permutations of the given sequence `seq` as a list of tuples. - #[function(signature = "(Sequence, Int) -> List")] + #[function(signature = "(Iterable, Int) -> List")] pub fn permutations(seq: SeqValue, k: usize) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -647,7 +647,7 @@ mod inner { } /// Returns all prefixes of a sequence, each as a list; for strings, returns string prefixes. - #[function(signature = "(Sequence) -> List>")] + #[function(signature = "(Iterable) -> List>")] pub fn prefixes(seq: SeqValue) -> anyhow::Result { // A sequence-typed caller can still supply a string. if let Value::Object(ref obj) = seq @@ -681,7 +681,7 @@ mod inner { } /// Returns all suffixes of a sequence, each as a list; for strings, returns all trailing substrings. - #[function(signature = "(Sequence) -> List>")] + #[function(signature = "(Iterable) -> List>")] pub fn suffixes(seq: SeqValue) -> anyhow::Result { // A sequence-typed caller can still supply a string. if let Value::Object(ref obj) = seq @@ -701,7 +701,7 @@ mod inner { } /// Transposes a sequence of sequences, turning rows into columns, and returns the result as a list of lists. - #[function(signature = "(Sequence) -> List>")] + #[function(signature = "(Iterable) -> List>")] pub fn transposed(seq: SeqValue) -> anyhow::Result { let main: Vec = seq .try_into_iter() @@ -724,7 +724,7 @@ mod inner { } /// Return a list of all windows, wrapping back to the first elements when the window would otherwise exceed the length of source list, producing tuples of size 2. - #[function(signature = "(Sequence) -> List<(T, T)>")] + #[function(signature = "(Iterable) -> List<(T, T)>")] pub fn circular_tuple_windows(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -738,7 +738,7 @@ mod inner { } /// Returns a list of all size-2 windows in `seq`. - #[function(signature = "(Sequence) -> List>")] + #[function(signature = "(Iterable) -> List>")] pub fn pairwise(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -753,7 +753,7 @@ mod inner { /// Applies a function to each pair of consecutive elements in a sequence and returns the results as a list. #[function( name = "pairwise", - signature = "(Sequence, Fn(T, T) -> U) -> List" + signature = "(Iterable, Fn(T, T) -> U) -> List" )] pub fn pairwise_map(seq: SeqValue, function: &mut VmCallable<'_>) -> anyhow::Result { let main: Vec = seq @@ -768,7 +768,7 @@ mod inner { } /// Returns a list of all contiguous windows of `length` size. The windows overlap. If the `seq` is shorter than size, the iterator returns no values. - #[function(signature = "(Sequence, Int) -> List>")] + #[function(signature = "(Iterable, Int) -> List>")] pub fn windows(seq: SeqValue, length: usize) -> anyhow::Result { if length == 0 { return Err(anyhow!("window size must be non-zero")); @@ -787,7 +787,7 @@ mod inner { /// /// The powerset of a set contains all subsets including the empty set and the full input set. A powerset has length `2^n` where `n` is the length of the input set. /// Each list produced by this function represents a subset of the elements in the source sequence. - #[function(signature = "(Sequence) -> List>")] + #[function(signature = "(Iterable) -> List>")] pub fn subsequences(seq: SeqValue) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() @@ -801,7 +801,7 @@ mod inner { /// Return a list that represents the powerset of the elements of `seq` that are exactly `length` long. #[function( name = "subsequences", - signature = "(Sequence, Int) -> List>" + signature = "(Iterable, Int) -> List>" )] pub fn subsequences_len(seq: SeqValue, length: usize) -> anyhow::Result { Ok(Value::list( @@ -840,7 +840,7 @@ mod inner { /// [3,"c",false] /// ] /// ``` - #[function(signature = "(Sequence) -> List>")] + #[function(signature = "(Iterable) -> List>")] pub fn multi_cartesian_product(seq: SeqValue) -> anyhow::Result { let mut iterators = Vec::new(); for elem in seq @@ -864,7 +864,7 @@ mod inner { /// Split the input sequence into evenly sized chunks. If the input length of the sequence /// is not dividable by the chunk_size the last chunk will contain fewer elements. - #[function(signature = "(Sequence, Int) -> List>")] + #[function(signature = "(Iterable, Int) -> List>")] pub fn chunks(seq: SeqValue, chunk_size: usize) -> anyhow::Result { if chunk_size == 0 { return Err(anyhow!("chunk size must be non-zero")); @@ -966,7 +966,7 @@ pub mod extra { pub fn register(env: &mut FunctionRegistry>) { for signature in [ // Two sequences zip into pairs of their elements. - "(Sequence, Sequence) -> List<(A, B)>", + "(Iterable, Iterable) -> List<(A, B)>", // More make tuples as long as the number of arguments. "(*) -> List", ] { diff --git a/ndc_stdlib/src/string.rs b/ndc_stdlib/src/string.rs index a98639d4..58862f62 100644 --- a/ndc_stdlib/src/string.rs +++ b/ndc_stdlib/src/string.rs @@ -125,7 +125,7 @@ mod inner { } /// Joins elements of the sequence into a single string using `sep` as the separator. - #[function(signature = "(Sequence, String) -> String")] + #[function(signature = "(Iterable, String) -> String")] pub fn join(list: SeqValue, sep: &str) -> anyhow::Result { join_to_string(list, sep) } @@ -137,7 +137,7 @@ mod inner { } /// Joins paragraphs into a single string, inserting blank lines between them. - #[function(signature = "(Sequence) -> String")] + #[function(signature = "(Iterable) -> String")] pub fn unparagraphs(list: SeqValue) -> anyhow::Result { join_to_string(list, "\n\n") } @@ -149,7 +149,7 @@ mod inner { } /// Joins lines into a single string, inserting newline characters between them. - #[function(signature = "(Sequence) -> String")] + #[function(signature = "(Iterable) -> String")] pub fn unlines(list: SeqValue) -> anyhow::Result { join_to_string(list, "\n") } @@ -161,7 +161,7 @@ mod inner { } /// Joins words into a single string, separating them with spaces. - #[function(signature = "(Sequence) -> String")] + #[function(signature = "(Iterable) -> String")] pub fn unwords(list: SeqValue) -> anyhow::Result { join_to_string(list, " ") } diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 261a1cf1..884d1af8 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -130,11 +130,11 @@ impl Ord for OrdValue { } } -/// Type alias for [`Value`] that signals `StaticType::Sequence` to the `#[export_module]` macro. +/// Type alias for [`Value`] that signals `StaticType::Iterable` to the `#[export_module]` macro. /// /// Use this instead of `ndc_vm::value::Value` in stdlib function signatures when the parameter /// must accept any iterable sequence. The extraction is identical (zero-copy pass-through on the -/// VM path) but the macro emits `StaticType::Sequence(Any)` instead of `StaticType::Any`, which +/// VM path) but the macro emits `StaticType::Iterable(Any)` instead of `StaticType::Any`, which /// preserves the correct type for dispatch and static analysis. pub type SeqValue = Value; @@ -323,7 +323,7 @@ impl Value { /// avoiding full container iteration for common cases. /// /// When `param` is a container type whose inner types are all `Any` (e.g. - /// `Sequence(Any)`, `Map { Any, Any }`, `List(Any)`), only the outer kind is + /// `Iterable(Any)`, `Map { Any, Any }`, `List(Any)`), only the outer kind is /// checked — no element iteration occurs. Typed container parameters are /// rejected rather than scanned; the remaining cases are checked as /// [`Value::is_a`] does. @@ -364,7 +364,7 @@ impl Value { }, _ => false, }, - StaticType::Sequence(t) if t.is_unknown() => { + StaticType::Iterable(t) if t.is_unknown() => { matches!(self, Self::Object(o) if matches!( o.as_ref(), Object::List(_) @@ -382,7 +382,7 @@ impl Value { // compile time; if it falls through to dynamic dispatch, return false. StaticType::List(_) | StaticType::Deque(_) - | StaticType::Sequence(_) + | StaticType::Iterable(_) | StaticType::Map { .. } | StaticType::Tuple(_) | StaticType::MinHeap(_) @@ -502,7 +502,7 @@ impl Value { Self::Object(object) if matches!(object.as_ref(), Object::MaxHeap(values) if values.borrow().iter().all(|OrdValue(value)| value.conforms_to_cached(element, cache))) ), - StaticType::Sequence(element) => match self { + StaticType::Iterable(element) => match self { Self::Object(object) => match object.as_ref() { Object::List(values) => values .borrow() @@ -526,7 +526,7 @@ impl Value { key.conforms_to_cached(&elements[0], cache) && value.conforms_to_cached(&elements[1], cache) } - StaticType::Sequence(inner) => { + StaticType::Iterable(inner) => { key.conforms_to_cached(inner, cache) && value.conforms_to_cached(inner, cache) } @@ -1296,7 +1296,7 @@ mod tests { // Dynamic dispatch must only inspect the outer container kind. let _borrow = elements.borrow_mut(); assert!(value.matches_param(&StaticType::List(Box::new(StaticType::Any)))); - assert!(value.matches_param(&StaticType::Sequence(Box::new(StaticType::Any,)))); + assert!(value.matches_param(&StaticType::Iterable(Box::new(StaticType::Any,)))); assert!(!value.matches_param(&StaticType::List(Box::new(StaticType::Int,)))); } diff --git a/tests/functional/programs/004_basic/046_annotated_let_binding.ndc b/tests/functional/programs/004_basic/046_annotated_let_binding.ndc index 9f2901d1..5bc66f51 100644 --- a/tests/functional/programs/004_basic/046_annotated_let_binding.ndc +++ b/tests/functional/programs/004_basic/046_annotated_let_binding.ndc @@ -12,7 +12,6 @@ let number_value: Number = 3n; let string_value: String = "hello"; let option_value: Option = Some(3); -let sequence_value: Sequence = [1, 2, 3]; let list_value: List = [1, 2, 3]; let iterator_value: Iterator = 1..10; let min_heap_value: MinHeap = MinHeap(); diff --git a/tests/functional/programs/005_functions/042_typed_container_param_not_dispatchable.ndc b/tests/functional/programs/005_functions/042_typed_container_param_not_dispatchable.ndc index 208d59ca..db25662f 100644 --- a/tests/functional/programs/005_functions/042_typed_container_param_not_dispatchable.ndc +++ b/tests/functional/programs/005_functions/042_typed_container_param_not_dispatchable.ndc @@ -1,6 +1,6 @@ -// Neither type is a subtype of the other, so the call is rejected at compile -// time. A cast is how you tell the analyser what the value actually holds. -// expect-error: No function called 'consume' found that matches the arguments 'Sequence' +// A List isn't a List, so the call is rejected at compile time. +// A cast is how you tell the analyser what the value actually holds. +// expect-error: No function called 'consume' found that matches the arguments 'List' fn consume(xs: List) => xs; -let xs: Sequence = [1]; +let xs = ["a".len()]; consume(xs) diff --git a/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc b/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc index f5d148ee..ae52b7ca 100644 --- a/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc +++ b/tests/functional/programs/005_functions/044_cast_suggested_when_no_overload_matches.ndc @@ -1,5 +1,5 @@ // The call would resolve if the argument type were narrower, so the error // carries help pointing at a cast rather than leaving the user at a dead end. // expect-error: Cast to say what the value holds -let words: Sequence = "a b c".split(" "); -print(words.remove(0)) +let values: List = [1, 2]; +print(values.sum()) diff --git a/tests/functional/programs/016_casts/014_overlapping_hierarchy.ndc b/tests/functional/programs/016_casts/014_overlapping_hierarchy.ndc index 70c3f476..75e615be 100644 --- a/tests/functional/programs/016_casts/014_overlapping_hierarchy.ndc +++ b/tests/functional/programs/016_casts/014_overlapping_hierarchy.ndc @@ -1,8 +1,7 @@ -// Neither List nor Sequence is a subtype of the other, but a -// List is a subtype of both, so the cast is possible and gets checked +// A List may hold only Ints, so the cast is possible and gets checked // at runtime rather than rejected outright. let xs: List = [1, 2]; -assert_eq(xs as Sequence, [1, 2]); +assert_eq(xs as List, [1, 2]); let m: Map = %{1: 2}; -assert_eq(m as Sequence<(Int, Int)>, %{1: 2}); +assert_eq(m as Map, %{1: 2}); diff --git a/tests/functional/programs/016_casts/015_overlapping_hierarchy_error.ndc b/tests/functional/programs/016_casts/015_overlapping_hierarchy_error.ndc index b3414ef6..7a22a9c9 100644 --- a/tests/functional/programs/016_casts/015_overlapping_hierarchy_error.ndc +++ b/tests/functional/programs/016_casts/015_overlapping_hierarchy_error.ndc @@ -1,5 +1,5 @@ // The cast is possible for the type, so it reaches the runtime check — which // then rejects this particular value. -// expect-error: cannot cast List to Sequence +// expect-error: cannot cast List to List let xs: List = [1, "two"]; -xs as Sequence +xs as List diff --git a/tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc b/tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc new file mode 100644 index 00000000..d164834a --- /dev/null +++ b/tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc @@ -0,0 +1,10 @@ +// `total` is generic: each kind of sequence gets its own copy. +// expect-output: 3 7 6 +fn total(xs: Iterable) { + let sum = 0; + for x in xs { + sum += x; + } + sum +} +print(total([1, 2]), total((3, 4)), total(1..4)); diff --git a/tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc b/tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc new file mode 100644 index 00000000..aaeeeef4 --- /dev/null +++ b/tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc @@ -0,0 +1,9 @@ +// expect-error: No function called 'total' found that matches the arguments 'List' +fn total(xs: Iterable) { + let sum = 0; + for x in xs { + sum += x; + } + sum +} +total(["a"]); diff --git a/tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc b/tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc new file mode 100644 index 00000000..4165e653 --- /dev/null +++ b/tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc @@ -0,0 +1,3 @@ +// A value has a kind of sequence, which the annotation has to say. +// expect-error: `Iterable` can only be the type of a parameter +let xs: Iterable = [1]; diff --git a/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc b/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc new file mode 100644 index 00000000..ded1e7fd --- /dev/null +++ b/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc @@ -0,0 +1,2 @@ +// expect-error: `Iterable` can only be the type of a parameter +print([1] as Iterable); diff --git a/tests/functional/programs/019_iterable/005_a_tuple_of_different_types_iterates_as_any.ndc b/tests/functional/programs/019_iterable/005_a_tuple_of_different_types_iterates_as_any.ndc new file mode 100644 index 00000000..a9094d84 --- /dev/null +++ b/tests/functional/programs/019_iterable/005_a_tuple_of_different_types_iterates_as_any.ndc @@ -0,0 +1,5 @@ +// expect-output: 1 +// expect-output: a +for x in (1, "a") { + print(x); +} diff --git a/tests/functional/programs/019_iterable/006_a_call_waits_for_what_it_iterates.ndc b/tests/functional/programs/019_iterable/006_a_call_waits_for_what_it_iterates.ndc new file mode 100644 index 00000000..98ca98e3 --- /dev/null +++ b/tests/functional/programs/019_iterable/006_a_call_waits_for_what_it_iterates.ndc @@ -0,0 +1,5 @@ +// `len` takes any kind of sequence, so `f` waits for its calls to say what +// `xs` is, and each gets its own copy. +// expect-output: 2 3 +fn f(xs) => xs.len(); +print(f("ab"), f([1, 2, 3])); diff --git a/tests/functional/programs/019_iterable/007_a_callback_may_return_any_kind_of_sequence.ndc b/tests/functional/programs/019_iterable/007_a_callback_may_return_any_kind_of_sequence.ndc new file mode 100644 index 00000000..efa5ae82 --- /dev/null +++ b/tests/functional/programs/019_iterable/007_a_callback_may_return_any_kind_of_sequence.ndc @@ -0,0 +1,2 @@ +// expect-output: [1,1,2,2] ["a","b"] +print([1, 2].flat_map(fn(x) => [x, x]), ["ab"].flat_map(fn(s) => s)); diff --git a/tests/functional/programs/019_iterable/008_a_function_taking_an_iterable_is_a_value.ndc b/tests/functional/programs/019_iterable/008_a_function_taking_an_iterable_is_a_value.ndc new file mode 100644 index 00000000..466be680 --- /dev/null +++ b/tests/functional/programs/019_iterable/008_a_function_taking_an_iterable_is_a_value.ndc @@ -0,0 +1,2 @@ +// expect-output: [1,2] +print([[1], [2, 3]].map(len)); diff --git a/tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc b/tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc new file mode 100644 index 00000000..86ecf0a7 --- /dev/null +++ b/tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc @@ -0,0 +1,3 @@ +// expect-error: No function called 'consume' found that matches the arguments 'Bool' +fn consume(xs: Iterable) => 0; +consume(true); diff --git a/tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc b/tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc new file mode 100644 index 00000000..0943f3af --- /dev/null +++ b/tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc @@ -0,0 +1,5 @@ +// The two are different overloads, and a sequence of Ints takes the first. +// expect-output: 1 2 2 +fn describe(xs: Iterable) => 1; +fn describe(x: Any) => 2; +print(describe([1]), describe(true), describe("a")); diff --git a/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc b/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc new file mode 100644 index 00000000..8c8f1d23 --- /dev/null +++ b/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc @@ -0,0 +1,3 @@ +// expect-output: [1,1,2,2] [1,2] +fn apply(f: Fn(Int) -> Iterable) => [1, 2].flat_map(f); +print(apply(fn(x) => [x, x]), apply(fn(x) => (x,))); diff --git a/tests/functional/programs/019_iterable/012_iterating_a_tuple_does_not_make_its_elements_one_type.ndc b/tests/functional/programs/019_iterable/012_iterating_a_tuple_does_not_make_its_elements_one_type.ndc new file mode 100644 index 00000000..7570ba8c --- /dev/null +++ b/tests/functional/programs/019_iterable/012_iterating_a_tuple_does_not_make_its_elements_one_type.ndc @@ -0,0 +1,8 @@ +// expect-output: true +// expect-output: a +fn visit(a, b) { + for x in (a, b) { + print(x); + } +} +visit(true, "a"); diff --git a/tests/functional/programs/019_iterable/013_a_bool_is_not_iterable.ndc b/tests/functional/programs/019_iterable/013_a_bool_is_not_iterable.ndc new file mode 100644 index 00000000..0f6a4c80 --- /dev/null +++ b/tests/functional/programs/019_iterable/013_a_bool_is_not_iterable.ndc @@ -0,0 +1,4 @@ +// expect-error: Bool is not iterable +for x in true { + print(x); +} diff --git a/tests/functional/programs/019_iterable/014_what_a_callback_returns_is_checked_to_iterate.ndc b/tests/functional/programs/019_iterable/014_what_a_callback_returns_is_checked_to_iterate.ndc new file mode 100644 index 00000000..ad7e0d8d --- /dev/null +++ b/tests/functional/programs/019_iterable/014_what_a_callback_returns_is_checked_to_iterate.ndc @@ -0,0 +1,4 @@ +// `flat_map` iterates what its callback returns, which is `xs`. +// expect-error: Bool is not iterable +fn flatten(xs) => [0].flat_map(fn(_) => xs); +flatten(true); diff --git a/tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc b/tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc new file mode 100644 index 00000000..62f574ab --- /dev/null +++ b/tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc @@ -0,0 +1,8 @@ +// When the program picks the overload as it runs, an `Iterable` +// parameter isn't scanned to see what it holds, as a `List` one isn't, +// so a value of type `Any` goes to the overload that takes anything. +// expect-output: 2 1 +fn describe(xs: Iterable) => 1; +fn describe(x: Any) => 2; +let x: Any = ["a"]; +print(describe(x), describe([1])); diff --git a/tests/functional/programs/603_stdlib_seq/016_sequence_prefixes_suffixes.ndc b/tests/functional/programs/603_stdlib_seq/016_sequence_prefixes_suffixes.ndc index cbf75fcd..4fbaedb1 100644 --- a/tests/functional/programs/603_stdlib_seq/016_sequence_prefixes_suffixes.ndc +++ b/tests/functional/programs/603_stdlib_seq/016_sequence_prefixes_suffixes.ndc @@ -1,8 +1,8 @@ // The general overload must retain string behavior when the concrete kind is hidden. -let text: Sequence = "aé🦀"; +let text: Any = "aé🦀"; assert_eq(text.prefixes(), ["a", "aé", "aé🦀"]); assert_eq(text.suffixes(), ["aé🦀", "é🦀", "🦀"]); -let empty_text: Sequence = ""; +let empty_text: Any = ""; assert_eq(empty_text.prefixes(), []); assert_eq(empty_text.suffixes(), []); From accf4814021fd07e6f8f1ecb7084ccb96cc6e28b Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 09:43:50 +0100 Subject: [PATCH 15/39] =?UTF-8?q?feat(vm):=20check=20container=20elements?= =?UTF-8?q?=20before=20runtime=20dispatch=20gives=20up=20=F0=9F=94=8E=20(#?= =?UTF-8?q?315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4d of the type system RFC (#281), for issue #206: struct fields reached through `Any`. The type checks #206 asked for are already in place after the earlier steps. A constructor called through `Any` is rejected by runtime dispatch's parameter check, a field written through `Any` gets the runtime check from #308, and the issue's own example is now an analysis error. What remained was a false rejection: runtime dispatch never lets a value through for a parameter like `List`, because it doesn't scan containers, so a struct with a `List` field couldn't be made through `Any` even with a list of Ints, and `process(List)` and `process(List)` never dispatched at all. Behaviour change: - When no overload takes the arguments without looking at their elements, runtime dispatch checks the elements before the call fails, as `as` does. `let make: Any = Q; make([1, 2])` now makes a `Q` with a `List` field, and `make(["s"])` is still rejected. Calls that dispatch today pick the same overload as before, at the same cost, and only a call that would otherwise fail scans its arguments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG Co-authored-by: Claude Opus 5.5 --- .../overload-dispatch-collections.md | 8 +++---- ndc_vm/src/value/function.rs | 22 +++++++++++++++++++ ndc_vm/src/vm.rs | 20 +++++++++++++---- ...checks_elements_when_nothing_else_fits.ndc | 8 +++++++ ...r_called_through_any_checks_its_fields.ndc | 4 ++++ ...ed_through_any_takes_a_typed_container.ndc | 5 +++++ ...a_field_written_through_any_is_checked.ndc | 5 +++++ ...r_field_written_through_any_is_checked.ndc | 6 +++++ 8 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 tests/functional/programs/005_functions/086_runtime_dispatch_checks_elements_when_nothing_else_fits.ndc create mode 100644 tests/functional/programs/015_struct/033_a_constructor_called_through_any_checks_its_fields.ndc create mode 100644 tests/functional/programs/015_struct/034_a_constructor_called_through_any_takes_a_typed_container.ndc create mode 100644 tests/functional/programs/015_struct/035_a_field_written_through_any_is_checked.ndc create mode 100644 tests/functional/programs/015_struct/036_a_container_field_written_through_any_is_checked.ndc diff --git a/manual/src/troubleshooting/overload-dispatch-collections.md b/manual/src/troubleshooting/overload-dispatch-collections.md index 88914c0a..e36164df 100644 --- a/manual/src/troubleshooting/overload-dispatch-collections.md +++ b/manual/src/troubleshooting/overload-dispatch-collections.md @@ -28,18 +28,16 @@ iterating the container contents. Specifically: This means that dispatch is **O(1)** regardless of how many elements are in the collection. -## Limitation: element types are not checked at runtime +## Element types are checked only when nothing else fits -Because the element-type check is skipped, the VM **cannot distinguish** overloads that differ -only in their container element types via dynamic dispatch. For example, two hypothetical overloads: +Because the element-type check is skipped, a parameter like `List` doesn't take a list in that first pass. When no overload takes the arguments without looking at their elements, the VM checks the elements before the call fails, scanning each container. So two overloads like these still work under dynamic dispatch: ``` fn process(List) fn process(List) ``` -would both fail to match under dynamic dispatch if the list type cannot be resolved at compile -time, because verifying element types would require scanning the entire container. +Each call through an `Any` scans the list, though, and an overload that takes the value without looking at its elements, like `process(Any)`, is picked before either of them. Standard library overloads do differ by element type. Numeric sequence functions preserve the concrete numeric type, so `sum` has three typed overloads: diff --git a/ndc_vm/src/value/function.rs b/ndc_vm/src/value/function.rs index 7608d437..43144daa 100644 --- a/ndc_vm/src/value/function.rs +++ b/ndc_vm/src/value/function.rs @@ -213,6 +213,28 @@ impl Function { } } + /// Whether this function accepts the given arguments, checking every + /// element of a container argument against a parameter like + /// `List`, which [`Self::matches_value_args`] doesn't. + pub fn conforms_value_args(&self, args: &[Value]) -> bool { + match self.static_type() { + StaticType::Function { + parameters: Some(params), + .. + } => { + params.len() == args.len() + && params + .iter() + .zip(args) + .all(|(param, arg)| arg.conforms_to(param)) + } + StaticType::Function { + parameters: None, .. + } => true, + _ => false, + } + } + /// Returns true if this function accepts the given arguments, without /// allocating a `StaticType` for each of them. pub fn matches_value_args(&self, args: &[Value]) -> bool { diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index 78f63a9d..a0f0aaef 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -841,10 +841,14 @@ impl Vm { /// Walks the `OverloadSet`'s scalar candidates in priority order, /// returning the first whose parameter types accept `args`. Mirrors /// master's `find_overload` body so the hot path on numerics-heavy - /// code stays at the same shape and footprint. + /// code stays at the same shape and footprint. A parameter like + /// `List` isn't checked element by element on that path, so when no + /// candidate accepts the arguments that way, they're checked again + /// element by element before the call fails: a struct with a + /// `List` field can be made through `Any` with a list of Ints. fn find_scalar_overload(&self, scalars: &[ResolvedVar], args: &[Value]) -> Option { let frame_pointer = self.frames.last().expect("no frame").frame_pointer; - scalars.iter().find_map(|var| { + let function = |var| { let value = self.resolve_var(var, frame_pointer); let Value::Object(obj) = value else { return None; @@ -852,8 +856,16 @@ impl Vm { let Object::Function(f) = obj.as_ref() else { return None; }; - f.matches_value_args(args).then(|| f.clone()) - }) + Some(f.clone()) + }; + scalars + .iter() + .find_map(|var| function(var).filter(|f| f.matches_value_args(args))) + .or_else(|| { + scalars + .iter() + .find_map(|var| function(var).filter(|f| f.conforms_value_args(args))) + }) } /// Vec dispatch for `Binding::Dynamic` operator calls — the runtime diff --git a/tests/functional/programs/005_functions/086_runtime_dispatch_checks_elements_when_nothing_else_fits.ndc b/tests/functional/programs/005_functions/086_runtime_dispatch_checks_elements_when_nothing_else_fits.ndc new file mode 100644 index 00000000..aafe0268 --- /dev/null +++ b/tests/functional/programs/005_functions/086_runtime_dispatch_checks_elements_when_nothing_else_fits.ndc @@ -0,0 +1,8 @@ +// Neither overload takes a list without looking at what it holds, so the +// program checks the elements to pick one. +// expect-output: ints strings +fn process(xs: List) => "ints"; +fn process(xs: List) => "strings"; +let a: Any = [1]; +let b: Any = ["s"]; +print(process(a), process(b)); diff --git a/tests/functional/programs/015_struct/033_a_constructor_called_through_any_checks_its_fields.ndc b/tests/functional/programs/015_struct/033_a_constructor_called_through_any_checks_its_fields.ndc new file mode 100644 index 00000000..a9953f25 --- /dev/null +++ b/tests/functional/programs/015_struct/033_a_constructor_called_through_any_checks_its_fields.ndc @@ -0,0 +1,4 @@ +// expect-error: no function called 'P' found matches the arguments: (String) +struct P { x: Int } +let make: Any = P; +make("not an int"); diff --git a/tests/functional/programs/015_struct/034_a_constructor_called_through_any_takes_a_typed_container.ndc b/tests/functional/programs/015_struct/034_a_constructor_called_through_any_takes_a_typed_container.ndc new file mode 100644 index 00000000..620932cb --- /dev/null +++ b/tests/functional/programs/015_struct/034_a_constructor_called_through_any_takes_a_typed_container.ndc @@ -0,0 +1,5 @@ +// The list is checked element by element, as nothing takes it otherwise. +// expect-output: [1,2] +struct Q { xs: List } +let make: Any = Q; +print(make([1, 2]).xs); diff --git a/tests/functional/programs/015_struct/035_a_field_written_through_any_is_checked.ndc b/tests/functional/programs/015_struct/035_a_field_written_through_any_is_checked.ndc new file mode 100644 index 00000000..2d55d6bf --- /dev/null +++ b/tests/functional/programs/015_struct/035_a_field_written_through_any_is_checked.ndc @@ -0,0 +1,5 @@ +// expect-error: expected Int but found String +struct P { x: Int } +let p: Any = P(1); +let v: Any = "s"; +p.x = v; diff --git a/tests/functional/programs/015_struct/036_a_container_field_written_through_any_is_checked.ndc b/tests/functional/programs/015_struct/036_a_container_field_written_through_any_is_checked.ndc new file mode 100644 index 00000000..3475d7c4 --- /dev/null +++ b/tests/functional/programs/015_struct/036_a_container_field_written_through_any_is_checked.ndc @@ -0,0 +1,6 @@ +// expect-output: [2] +struct Q { xs: List } +let q = Q([1]); +let v: Any = [2]; +q.xs = v; +print(q.xs); From 1b4b44f1d050714e7521aaba7ef3f2d712caad98 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 10:01:31 +0100 Subject: [PATCH 16/39] =?UTF-8?q?fix(analyser):=20keep=20promised=20types?= =?UTF-8?q?=20true=20for=20pops,=20maps=20and=20Any=20indexes=20?= =?UTF-8?q?=F0=9F=A9=B9=20(#316)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the review findings still open on the type checker PR (#291): places where a type the analyser promised didn't match the value the program gets, so a later call could pick the wrong overload. Behaviour changes: - `pop` and `pop_left` on an empty list are an error, as `pop` on a deque or heap already is, since their return type promises an element. `pop?` and `pop_left?` return `None` instead. - A list write with an index of type `Any` checks the value: `xs[i] = "bad"` on a `List` is an analysis error, and `x["foo"] = 69` is one too, where it failed at runtime with "Invalid list index" before. An `Any` index still picks between the element and the range overload as the program runs. Bug fixes: - A map comprehension's default is one of the map's values: `%{:"missing", x: x for x in xs}` holds Strings as well as Ints. - A map literal key without a value holds `()`: `%{"a", "b": 1}` holds `()` as well as Ints. - The catch-all `[]=(List, Any, Any)` overload is gone. Calls the program picks the overload of as it runs keep only the overloads that take the arguments with each type parameter one type, as early picks already did, so `[]=(List, Int, T)` no longer looks like it takes a String for a `List`. Operators, which may apply to each element of a tuple, and function arguments, whose types aren't checked as the program runs, are left out of that check. - Runtime dispatch matches `()` as cheaply as a scalar, and any iterator conforms to an `Iterator`, since its elements can't be checked without consuming it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/generics.rs | 3 +- ndc_analyser/src/analyser/mod.rs | 33 ++++++++--- ndc_analyser/src/analyser/solver.rs | 58 ++++++++++++++++--- ndc_stdlib/src/index.rs | 2 - ndc_stdlib/src/list.rs | 17 +++--- ndc_vm/src/value/mod.rs | 6 +- .../015_string_as_list_index_error.ndc | 2 +- ..._index_with_augmented_assignment_error.ndc | 2 +- ...ce_append_settles_each_list_in_a_tuple.ndc | 3 +- .../056_pop_on_an_empty_list_is_an_error.ndc | 8 +++ ...ite_with_an_any_index_checks_the_value.ndc | 5 ++ ...e_with_an_any_index_picks_by_the_index.ndc | 8 +++ .../033_a_key_without_a_value_holds_unit.ndc | 6 ++ ...rehension_default_is_one_of_the_values.ndc | 7 +++ 14 files changed, 130 insertions(+), 30 deletions(-) create mode 100644 tests/functional/programs/006_lists/056_pop_on_an_empty_list_is_an_error.ndc create mode 100644 tests/functional/programs/006_lists/057_a_write_with_an_any_index_checks_the_value.ndc create mode 100644 tests/functional/programs/006_lists/058_a_write_with_an_any_index_picks_by_the_index.ndc create mode 100644 tests/functional/programs/007_map_and_set/033_a_key_without_a_value_holds_unit.ndc create mode 100644 tests/functional/programs/007_map_and_set/034_a_comprehension_default_is_one_of_the_values.ndc diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index c431d91d..88d54c40 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -375,8 +375,7 @@ impl Analyser { }) .collect::>(); let binding = self - .overloads() - .resolve_call(call.candidates(), &arguments, call.kind()) + .resolve_overloads(call.candidates(), &arguments, call.kind()) .binding; self.record_pick(call.site(), binding, &arguments); } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 52fe87d5..57ada60b 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -564,14 +564,26 @@ impl Analyser { Expression::For { iterations, body } => { // A map comprehension's default is evaluated once, before the // loop starts. - if let ForBody::Map { - default: Some(default), - .. - } = body.as_ref() - { - self.analyse_expr(default); + let default = match body.as_ref() { + ForBody::Map { + default: Some(default), + .. + } => { + let typ = self.analyse_expr(default); + Some(self.constrain_default_value(typ, default.span)) + } + _ => None, + }; + match (self.analyse_for_iterations(iterations, body), default) { + // Reads can produce the default value, so it's one of the + // map's values: `%{:"none", x: x for x in xs}` holds + // Strings as well as what `xs` does. + (StaticType::Map { key, value }, Some(default)) => StaticType::Map { + key, + value: Box::new(self.join(&value, &default, *span)), + }, + (typ, _) => typ, } - self.analyse_for_iterations(iterations, body) } Expression::Call { function, @@ -626,6 +638,13 @@ impl Analyser { held.push((Some(value.id), value.span, self.analyse_expr(value))); } } + // A key without a value holds `()`, which in `%{"a", "b": 1}` + // is one of the map's values too. + if !held.is_empty() { + for (key, _) in values.iter().filter(|(_, value)| value.is_none()) { + held.push((None, key.span, StaticType::unit())); + } + } // Reads can produce the default value, so its type is part of // the map's value type. A default that's a function, as an diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 0f1a2276..f279ed88 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -426,8 +426,7 @@ impl Analyser { .map(|argument| self.vars.resolve(argument)) .collect::>(); let binding = self - .overloads() - .resolve_call(&candidates, &arguments, kind) + .resolve_overloads(&candidates, &arguments, kind) .binding; assert!( binding == picked, @@ -1198,15 +1197,60 @@ impl Analyser { merged } + /// [`Overloads::resolve_call`], with the overloads it leaves the program + /// to pick from as it runs cut to the ones that take the arguments with + /// each type parameter one type. Picking looks at each argument on its + /// own, so `[]=(List, Int, T)` looks like it takes a String for a + /// `List` when the index is `Any`, but it can't. + pub(super) fn resolve_overloads( + &mut self, + candidates: &Candidates, + arguments: &[StaticType], + kind: CallKind, + ) -> ResolvedCall { + let mut resolved = self.overloads().resolve_call(candidates, arguments, kind); + // An operator may apply an overload to each element of a tuple + // instead, which takes what the elements are. + if kind == CallKind::Operator { + return resolved; + } + if let Binding::Dynamic(picked) = &resolved.binding { + let picked = picked.clone(); + // What a function takes and returns isn't checked as the program + // runs, so it's left out: a lambda that returns `Any` may still be + // what's expected. + let snapshot = self.vars.snapshot(); + let arguments = arguments + .iter() + .map(|argument| match self.vars.resolve(argument) { + StaticType::Function { .. } => self.vars.fresh(StaticType::Any), + argument => argument, + }) + .collect::>(); + let kept = picked + .into_iter() + .filter(|candidate| match candidate { + Candidate::Scalar(symbol) => self.could_accept(*symbol, &arguments), + Candidate::Vec(_) => true, + }) + .collect::>(); + self.vars.rollback(snapshot); + resolved.binding = if kept.is_empty() { + Binding::None + } else { + Binding::Dynamic(kept) + }; + } + resolved + } + /// Resolves `call` with the arguments as they are, reporting it when no /// overload accepts them. fn resolve_call_now(&mut self, call: &CallConstraint, arguments: &[StaticType]) -> StaticType { let ResolvedCall { binding, return_type, - } = self - .overloads() - .resolve_call(&call.candidates, arguments, call.kind); + } = self.resolve_overloads(&call.candidates, arguments, call.kind); if matches!(binding, Binding::None) { self.report_failed_call(call, arguments); self.record_pick(call.site, binding, arguments); @@ -1498,9 +1542,7 @@ impl Analyser { let ResolvedCall { binding, return_type, - } = self - .overloads() - .resolve_call(&call.candidates, arguments, call.kind); + } = self.resolve_overloads(&call.candidates, arguments, call.kind); let fits = !matches!(binding, Binding::None) && self .commit_call(&binding, arguments, &call.nodes.arguments, return_type) diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index 314ab282..fbf8f279 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -124,8 +124,6 @@ fn register_set(env: &mut FunctionRegistry>) { for signature in [ "(List, Int, T)", "(List, Iterator, Iterable)", - // An index whose type isn't known could be either. - "(List, Any, Any)", ] { env.declare_global_fn(Rc::new(NativeFunction { name: "[]=".to_string(), diff --git a/ndc_stdlib/src/list.rs b/ndc_stdlib/src/list.rs index 21b7c2aa..6d847b30 100644 --- a/ndc_stdlib/src/list.rs +++ b/ndc_stdlib/src/list.rs @@ -98,10 +98,12 @@ mod inner { } } - /// Removes and returns the last element from the list, or unit if empty. + /// Removes and returns the last element from the list. Use `pop?` to get + /// `None` from an empty list instead of an error. #[function(signature = "(List) -> T")] - pub fn pop(list: &mut Vec) -> Value { - list.pop().unwrap_or_else(Value::unit) + pub fn pop(list: &mut Vec) -> anyhow::Result { + list.pop() + .ok_or_else(|| anyhow::anyhow!("the list is empty")) } /// Removes and returns the first element from the list, or `None` if empty. @@ -113,13 +115,14 @@ mod inner { Value::Object(Rc::new(Object::Some(list.remove(0)))) } - /// Removes and returns the first element from the list, or unit if empty. + /// Removes and returns the first element from the list. Use `pop_left?` + /// to get `None` from an empty list instead of an error. #[function(signature = "(List) -> T")] - pub fn pop_left(list: &mut Vec) -> Value { + pub fn pop_left(list: &mut Vec) -> anyhow::Result { if list.is_empty() { - return Value::unit(); + return Err(anyhow::anyhow!("the list is empty")); } - list.remove(0) + Ok(list.remove(0)) } /// Resolves a possibly-negative index against `len`, wrapping negative diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 884d1af8..af892356 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -355,6 +355,10 @@ impl Value { StaticType::MaxHeap(t) if t.is_unknown() => { matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::MaxHeap(_))) } + // Unit, the empty tuple, is checked as cheaply as a scalar. + StaticType::Tuple(elements) if elements.is_empty() => { + matches!(self, Self::Object(o) if matches!(o.as_ref(), Object::Tuple(values) if values.is_empty())) + } // An option holds at most one value, which is cheap to check. StaticType::Option(t) => match self { Self::None => true, @@ -410,7 +414,7 @@ impl Value { (Some(expected), Some(takes)) => expected.len() == takes.len(), _ => true, }, - (StaticType::Iterator(element), StaticType::Iterator(_)) => element.is_unknown(), + (StaticType::Iterator(_), StaticType::Iterator(_)) => true, (target, found) => found == *target, } } diff --git a/tests/functional/programs/006_lists/015_string_as_list_index_error.ndc b/tests/functional/programs/006_lists/015_string_as_list_index_error.ndc index 6fe80975..ab413124 100644 --- a/tests/functional/programs/006_lists/015_string_as_list_index_error.ndc +++ b/tests/functional/programs/006_lists/015_string_as_list_index_error.ndc @@ -1,3 +1,3 @@ -// expect-error: Invalid list index +// expect-error: No function called '[]=' found that matches the arguments 'List, String let x = [1,2,3]; x["foo"] = 69; // ERROR diff --git a/tests/functional/programs/006_lists/016_string_as_list_index_with_augmented_assignment_error.ndc b/tests/functional/programs/006_lists/016_string_as_list_index_with_augmented_assignment_error.ndc index 152533dd..ba7ebc38 100644 --- a/tests/functional/programs/006_lists/016_string_as_list_index_with_augmented_assignment_error.ndc +++ b/tests/functional/programs/006_lists/016_string_as_list_index_with_augmented_assignment_error.ndc @@ -1,3 +1,3 @@ -// expect-error: Invalid list index +// expect-error: No function called '[]=' found that matches the arguments 'List, String let x = [1,2,3]; x["foo"] += 69; // ERROR diff --git a/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc b/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc index 11a7d640..6c852696 100644 --- a/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc +++ b/tests/functional/programs/006_lists/052_an_in_place_append_settles_each_list_in_a_tuple.ndc @@ -1,6 +1,7 @@ // `++=` on a tuple of lists appends to each one, so each empty list takes // the type of what it appends. -// expect-error: mismatched types: found String but expected Int +// expect-error: No function called '++' found that matches the arguments 'Tuple, List>, List' +// (`a[0] = "x"` decides first that `a` holds Strings.) let t = ([], []); t ++= [1, 2]; let a, b = t; diff --git a/tests/functional/programs/006_lists/056_pop_on_an_empty_list_is_an_error.ndc b/tests/functional/programs/006_lists/056_pop_on_an_empty_list_is_an_error.ndc new file mode 100644 index 00000000..d1219484 --- /dev/null +++ b/tests/functional/programs/006_lists/056_pop_on_an_empty_list_is_an_error.ndc @@ -0,0 +1,8 @@ +// `pop` returns an element, so an empty list has none to give; `pop?` gives +// `None` instead. +// expect-output: None +// expect-error: the list is empty +let xs = ["a".len()]; +xs.pop(); +print(xs.pop?()); +xs.pop(); diff --git a/tests/functional/programs/006_lists/057_a_write_with_an_any_index_checks_the_value.ndc b/tests/functional/programs/006_lists/057_a_write_with_an_any_index_checks_the_value.ndc new file mode 100644 index 00000000..b6c0ce31 --- /dev/null +++ b/tests/functional/programs/006_lists/057_a_write_with_an_any_index_checks_the_value.ndc @@ -0,0 +1,5 @@ +// Whatever the index turns out to be, a String doesn't go in a list of Ints. +// expect-error: mismatched types: found String but expected Int +let xs = ["a".len()]; +let i: Any = 0; +xs[i] = "bad"; diff --git a/tests/functional/programs/006_lists/058_a_write_with_an_any_index_picks_by_the_index.ndc b/tests/functional/programs/006_lists/058_a_write_with_an_any_index_picks_by_the_index.ndc new file mode 100644 index 00000000..5db14f8b --- /dev/null +++ b/tests/functional/programs/006_lists/058_a_write_with_an_any_index_picks_by_the_index.ndc @@ -0,0 +1,8 @@ +// expect-output: [5] [7,8,3] +let xs = ["a".len()]; +let i: Any = 0; +xs[i] = 5; +let ys = [1, 2, 3]; +let range: Any = 0..2; +ys[range] = [7, 8]; +print(xs, ys); diff --git a/tests/functional/programs/007_map_and_set/033_a_key_without_a_value_holds_unit.ndc b/tests/functional/programs/007_map_and_set/033_a_key_without_a_value_holds_unit.ndc new file mode 100644 index 00000000..3c2593bc --- /dev/null +++ b/tests/functional/programs/007_map_and_set/033_a_key_without_a_value_holds_unit.ndc @@ -0,0 +1,6 @@ +// expect-output: unit int +fn g(x: Int) => "int"; +fn g(x: ()) => "unit"; +fn g(x: Any) => "any"; +let m = %{"a", "b": 1}; +print(g(m["a"]), g(m["b"])); diff --git a/tests/functional/programs/007_map_and_set/034_a_comprehension_default_is_one_of_the_values.ndc b/tests/functional/programs/007_map_and_set/034_a_comprehension_default_is_one_of_the_values.ndc new file mode 100644 index 00000000..2accb112 --- /dev/null +++ b/tests/functional/programs/007_map_and_set/034_a_comprehension_default_is_one_of_the_values.ndc @@ -0,0 +1,7 @@ +// The default is a String, so a read may give one, not just the Ints. +// expect-output: str int +fn g(x: Int) => "int"; +fn g(x: String) => "str"; +fn g(x: Any) => "any"; +let m = %{:"missing", x: x for x in 1..3}; +print(g(m[99]), g(m[1])); From 9522a595775053641c124b57ce5923ba6ab24dec Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 11:12:32 +0100 Subject: [PATCH 17/39] =?UTF-8?q?fix(analyser):=20let=20a=20list=20literal?= =?UTF-8?q?=20adapt=20to=20uses=20of=20its=20elements=20=F0=9F=A7=AE=20(#3?= =?UTF-8?q?17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list literal's numbers took the type their uses needed when the list itself was passed (`n.push(0.5)` makes `[1]` a list of Floats), but not when one of its elements was used. These were all errors: - `let n = [1]; n[0] += 0.5` and `n[0] = n[0] + 0.5` - `let x = n[0]; x = 0.5` - `let y = n.first() + 0.5; n.push(y)` - `m["a"] += 0.5` on `%{"a": 1}` They now run, with Float elements. A list whose elements are real Ints, such as `["a".len()]`, still rejects `+= 0.5`. When a literal tries one of its defaults (Int, Float, Number), the analyser checks that the calls waiting on it still fit. That check was too loose in three ways, and it now: - Counts only the overload that resolution would pick once the arguments are known, scope by scope as a call does. Before, any overload that took them counted, so `[](Any, Any) -> Any` let `n[0]` fit whatever it had to give back. - Checks the waiting calls together. A call whose overload is known keeps what it binds, such as its result, and the calls waiting on that are checked too. - Also checks the literal's holder, here the list's element type. A holder used to wait until its numbers were settled, so its calls were never checked when the numbers took their defaults. Now it is settled in the same trial. In the same trial, the other numbers in that list take the same default. The values of `[1, 2]` are Ints or Floats, never one of each, so `[1, 2].push("a")` stays an error rather than turning the list into a `List`. This also keeps a large list literal linear: one trial settles every number in it. Literals that another literal holds now get their defaults after the ones that aren't held. For example, the index `0` in `n[0]` is settled as an Int before the list's numbers try theirs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/augmented-assignment.md | 8 + ndc_analyser/src/analyser/solver.rs | 244 +++++++++++++++--- ndc_analyser/src/types.rs | 56 +++- ..._index_write_lets_a_list_literal_adapt.ndc | 10 + ...d_element_takes_the_type_its_uses_need.ndc | 10 + ..._the_values_of_a_literal_take_one_type.ndc | 5 + ...eral_s_use_picks_its_overload_by_scope.ndc | 10 + 7 files changed, 303 insertions(+), 40 deletions(-) create mode 100644 tests/functional/programs/018_literals/023_an_index_write_lets_a_list_literal_adapt.ndc create mode 100644 tests/functional/programs/018_literals/024_a_read_element_takes_the_type_its_uses_need.ndc create mode 100644 tests/functional/programs/018_literals/025_the_values_of_a_literal_take_one_type.ndc create mode 100644 tests/functional/programs/018_literals/026_a_literal_s_use_picks_its_overload_by_scope.ndc diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index 93c1dc49..fefe5d52 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -100,6 +100,14 @@ let numbers = ["a".len()]; // a List numbers[0] += 0.5; // error: Int + Float is a Float, which doesn't fit in List ``` +A list of number literals takes the type its uses need, so there the numbers are Floats: + +```ndc +let numbers = [1, 2]; +numbers[0] += 0.5; +print(numbers); // [1.5,2.0] +``` + ## Optimization You might expect `list ++= [1,2,3]` to desugar to `list = list ++ [1,2,3]`, but that would waste work. Andy C++ handles some augmented assignments directly. In this case, it appends `[1,2,3]` without creating an intermediate list. diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index f279ed88..a3804cde 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -7,6 +7,7 @@ use super::function_values::{Choice, FunctionValue}; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall, is_strictly_more_specific}; use crate::types::TypeVars; +use ahash::{AHashMap, AHashSet}; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; @@ -14,6 +15,16 @@ use ndc_resolver::{Candidates, SymbolId}; use std::collections::BTreeSet; use std::rc::Rc; +/// Whether a waiting call can still take its arguments. +enum Fit { + /// Its overload is known and takes them. + Picked, + /// Some overload might. + Maybe, + /// None can. + No, +} + /// What a call can commit to before its arguments are fully known. pub(super) enum EarlyOverload { /// This overload, whatever the arguments turn out to be. @@ -633,6 +644,7 @@ impl Analyser { let mut settled_any = false; loop { let mut progress = false; + let holders = self.vars.holders(); for var in self.vars.unsettled_literals(&belongs) { if !self.vars.is_literal(var) { continue; @@ -642,14 +654,24 @@ impl Analyser { }; let fits = defaults.iter().position(|default| { let snapshot = self.vars.snapshot(); - let fits = self.vars.settle_literal(var, default) && self.calls_still_fit(var); + let fits = self.literal_fits_as(var, default, &holders); self.vars.rollback(snapshot); fits }); // When none fits, the first one says best what's wrong. let default = &defaults[fits.unwrap_or(0)]; + let holders_of_var = holders + .get(&var) + .into_iter() + .flatten() + .copied() + .filter(|&holder| self.vars.is_literal(holder)) + .collect::>(); let snapshot = self.vars.snapshot(); - if !self.vars.settle_literal(var, default) { + if self + .settle_with_fellows(var, default, &holders_of_var) + .is_none() + { self.vars.rollback(snapshot); if self.vars.holds_several_literals(var) { self.report_literals_that_differ(var, default); @@ -699,60 +721,204 @@ impl Analyser { self.vars.settle_literal(var, default); } - /// Whether each waiting call that waits on `var` can still take its - /// arguments, and give back what's expected of it. - fn calls_still_fit(&mut self, var: TypeVar) -> bool { - let serials = self.watchers.get(&var).cloned().unwrap_or_default(); - serials.into_iter().all(|serial| { - let Some(Waiting { - constraint: Constraint::Call(call), - .. - }) = self.waiting.get(&serial).cloned() - else { + /// Settles the literal `var` as `default`, and returns whether the + /// waiting calls on it still fit, and those on the literals that hold + /// it, `holders` says which, with each settled as one of its own + /// defaults: in `let x = n[0]; x = 0.5` on `n = [1]`, `n`'s element type + /// holds the `1`, and `[]` gives a Float only if it's a Float. The + /// other values those hold take the same type, as the values of a + /// literal have one type when they can: `[1, 2]` holds Ints or Floats, + /// not one of each. + fn literal_fits_as( + &mut self, + var: TypeVar, + default: &StaticType, + holders: &AHashMap>, + ) -> bool { + let holders_of_var = holders + .get(&var) + .into_iter() + .flatten() + .copied() + .filter(|&holder| self.vars.is_literal(holder)) + .collect::>(); + let Some(settled) = self.settle_with_fellows(var, default, &holders_of_var) else { + return false; + }; + if !self.calls_still_fit(&settled) { + return false; + } + holders_of_var.into_iter().all(|holder| { + if !self.vars.is_literal(holder) { + return true; + } + // One that still waits for another literal is checked with it. + let Some(defaults) = self.vars.literal_defaults(holder) else { return true; }; - self.call_still_fits(&call) + defaults.iter().any(|default| { + let snapshot = self.vars.snapshot(); + let fits = self.literal_fits_as(holder, default, holders); + if !fits { + self.vars.rollback(snapshot); + } + fits + }) }) } + /// Settles the literal `var` as `default`, and the other literals that + /// `holders` hold too where they can take it, as the values of `[1, 2]` + /// have one type. Returns the literals it settled, or `None` if `var` + /// can't be `default`. + fn settle_with_fellows( + &mut self, + var: TypeVar, + default: &StaticType, + holders: &[TypeVar], + ) -> Option> { + if !self.vars.settle_literal(var, default) { + return None; + } + let mut settled = vec![var]; + for &holder in holders { + for other in self.vars.held_literals(holder) { + let snapshot = self.vars.snapshot(); + if self.vars.settle_literal(other, default) { + settled.push(other); + } else { + self.vars.rollback(snapshot); + } + } + } + Some(settled) + } + + /// Whether the waiting calls that wait on `vars` can still take their + /// arguments, and give back what's expected of them, together: in + /// `let x = xs[0] + 0.5; xs.push(x)` on `xs = [1]`, `+` gives a Float + /// when the `1` is an Int, which `push` then can't take. A call whose + /// overload is known says what it gives back, so the calls that wait + /// on that are checked too, and the others again. + fn calls_still_fit(&mut self, vars: &[TypeVar]) -> bool { + let mut serials = Vec::new(); + let mut seen = AHashSet::new(); + for var in vars { + for &watcher in self.watchers.get(var).into_iter().flatten() { + if seen.insert(watcher) { + serials.push(watcher); + } + } + } + let mut picked = AHashSet::new(); + loop { + let mut learned = false; + let mut next = 0; + while let Some(&serial) = serials.get(next) { + next += 1; + if picked.contains(&serial) { + continue; + } + let Some(Waiting { + constraint: Constraint::Call(call), + .. + }) = self.waiting.get(&serial).cloned() + else { + continue; + }; + let reached = std::iter::once(&call.result) + .chain(&call.arguments) + .flat_map(|typ| self.vars.unsettled(typ)) + .collect::>(); + match self.call_still_fits(&call) { + Fit::No => return false, + Fit::Maybe => {} + Fit::Picked => { + picked.insert(serial); + learned = true; + for var in reached { + for &watcher in self.watchers.get(&var).into_iter().flatten() { + if seen.insert(watcher) { + serials.push(watcher); + } + } + } + } + } + } + if !learned { + return true; + } + } + } + /// Whether some overload of `call` takes its arguments, as far as - /// they're known, and returns what fits its result. - fn call_still_fits(&mut self, call: &CallConstraint) -> bool { + /// they're known, and returns what fits its result. Once they're + /// known, the call gets the overload that resolution picks, scope by + /// scope, so only that one counts, and what it binds is kept: + /// `[](Any, Any)` takes `xs[0]` but loses to `[](List, Int) -> T`. + fn call_still_fits(&mut self, call: &CallConstraint) -> Fit { let arguments = call .arguments .iter() .map(|argument| self.vars.resolve(argument)) .collect::>(); if Self::operand_could_be_a_tuple(call.kind, &arguments) { - return true; + return Fit::Maybe; + } + if arguments.iter().all(|argument| !argument.has_vars()) + && let Binding::Resolved(Candidate::Scalar(symbol)) = self + .overloads() + .resolve_call(&call.candidates, &arguments, call.kind) + .binding + { + let snapshot = self.vars.snapshot(); + if self.overload_fits(symbol, &arguments, &call.result) { + return Fit::Picked; + } + self.vars.rollback(snapshot); + return Fit::No; } let reachable = self.overloads().reachable_symbols(&call.candidates); - reachable.into_iter().any(|symbol| { - let function = self.vars.resolve(&self.types.callable_type(symbol)); - let StaticType::Function { - parameters: Some(_), - .. - } = &function - else { - return true; - }; + let fits = reachable.into_iter().any(|symbol| { let snapshot = self.vars.snapshot(); - let StaticType::Function { - parameters: Some(parameters), - return_type, - } = self.vars.instantiate(&function) - else { - unreachable!("instantiating a function type gives a function type"); - }; - let fits = parameters.len() == arguments.len() - && parameters - .iter() - .zip(&arguments) - .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)) - && self.vars.bind_argument(&call.result, &return_type); + let fits = self.overload_fits(symbol, &arguments, &call.result); self.vars.rollback(snapshot); fits - }) + }); + if fits { Fit::Maybe } else { Fit::No } + } + + /// Whether `symbol` takes `arguments` and returns what fits `result`, + /// binding the types on the way. One whose parameters aren't known + /// takes anything. + fn overload_fits( + &mut self, + symbol: SymbolId, + arguments: &[StaticType], + result: &StaticType, + ) -> bool { + let function = self.vars.resolve(&self.types.callable_type(symbol)); + let StaticType::Function { + parameters: Some(_), + .. + } = &function + else { + return true; + }; + let StaticType::Function { + parameters: Some(parameters), + return_type, + } = self.vars.instantiate(&function) + else { + unreachable!("instantiating a function type gives a function type"); + }; + parameters.len() == arguments.len() + && parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)) + && self.vars.bind_argument(result, &return_type) } /// Tries the waiting constraint `serial` again, if it's still waiting. diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index dc7cf477..ca57bdd0 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -186,6 +186,46 @@ impl TypeVars { }) } + /// The unsettled literal types that each unsettled literal type is + /// held by: the element type of `[1]` holds the `1`'s. + pub(crate) fn holders(&self) -> AHashMap> { + let mut holders = AHashMap::>::new(); + let mut seen = AHashSet::new(); + for &holder in &self.literals { + if (holder.0 as usize) >= self.solutions.len() + || !self.is_literal(holder) + || !seen.insert(holder) + { + continue; + } + for held in self.held_literals(holder) { + let held_by = holders.entry(held).or_default(); + if !held_by.contains(&holder) { + held_by.push(holder); + } + } + } + holders + } + + /// The unsettled literal types whose values the literal type `var` + /// holds: the `1`'s and the `2`'s in `[1, 2]`. + pub(crate) fn held_literals(&self, var: TypeVar) -> Vec { + let mut held_literals = Vec::new(); + let mut seen = AHashSet::new(); + for bound in &self.bounds[var.0 as usize] { + if let Bound::Holds(held, _, _) = bound + && let StaticType::Var(held) = self.resolve(held) + && held != var + && self.is_literal(held) + && seen.insert(held) + { + held_literals.push(held); + } + } + held_literals + } + /// The values the literal type `var` holds, with where each is. pub(crate) fn held(&self, var: TypeVar) -> Vec<(StaticType, Span)> { self.bounds[var.0 as usize] @@ -239,7 +279,21 @@ impl TypeVars { literals.push(var); } } - literals.sort_by_key(|&var| self.is_number(var)); + // What the literals a literal holds take decides whether its uses + // fit, so they're settled last: in `n[0]` on `n = [1]`, whether `[]` + // gives the `1` back depends on the index `0` being an Int. + let held = literals + .iter() + .flat_map(|&var| self.bounds[var.0 as usize].iter()) + .filter_map(|bound| match bound { + Bound::Holds(held, _, _) => match self.resolve(held) { + StaticType::Var(held) => Some(held), + _ => None, + }, + Bound::Number(..) => None, + }) + .collect::>(); + literals.sort_by_key(|&var| (self.is_number(var), held.contains(&var))); literals } diff --git a/tests/functional/programs/018_literals/023_an_index_write_lets_a_list_literal_adapt.ndc b/tests/functional/programs/018_literals/023_an_index_write_lets_a_list_literal_adapt.ndc new file mode 100644 index 00000000..60c159a3 --- /dev/null +++ b/tests/functional/programs/018_literals/023_an_index_write_lets_a_list_literal_adapt.ndc @@ -0,0 +1,10 @@ +// The `1` and `2` are Floats, as adding 0.5 to an element of the list +// gives a Float that's written back into it. +// expect-output: [1.5,2.0] [[1.0,2.5]] %{"a": 1.5} +let xs = [1, 2]; +xs[0] += 0.5; +let grid = [[1, 2]]; +grid[0][1] = grid[0][1] + 0.5; +let m = %{"a": 1}; +m["a"] += 0.5; +print(xs, grid, m); diff --git a/tests/functional/programs/018_literals/024_a_read_element_takes_the_type_its_uses_need.ndc b/tests/functional/programs/018_literals/024_a_read_element_takes_the_type_its_uses_need.ndc new file mode 100644 index 00000000..4a6eda93 --- /dev/null +++ b/tests/functional/programs/018_literals/024_a_read_element_takes_the_type_its_uses_need.ndc @@ -0,0 +1,10 @@ +// What `[]` and `first` give back is what the list holds, so a use of the +// element that needs a Float makes the list's `1` a Float too. +// expect-output: [1.0] 0.5 [1.0,1.5] +let xs = [1]; +let x = xs[0]; +x = 0.5; +let ys = [1]; +let y = ys.first() + 0.5; +ys.push(y); +print(xs, x, ys); diff --git a/tests/functional/programs/018_literals/025_the_values_of_a_literal_take_one_type.ndc b/tests/functional/programs/018_literals/025_the_values_of_a_literal_take_one_type.ndc new file mode 100644 index 00000000..6ed5e1bb --- /dev/null +++ b/tests/functional/programs/018_literals/025_the_values_of_a_literal_take_one_type.ndc @@ -0,0 +1,5 @@ +// The `1` and `2` are of one type, so no type they can both be takes a +// String: the list doesn't become a list of Any. +// expect-error: No function called 'push' found that matches the arguments 'List, String' +let xs = [1, 2]; +xs.push("a"); diff --git a/tests/functional/programs/018_literals/026_a_literal_s_use_picks_its_overload_by_scope.ndc b/tests/functional/programs/018_literals/026_a_literal_s_use_picks_its_overload_by_scope.ndc new file mode 100644 index 00000000..355e9fbe --- /dev/null +++ b/tests/functional/programs/018_literals/026_a_literal_s_use_picks_its_overload_by_scope.ndc @@ -0,0 +1,10 @@ +// The inner `f` takes the call, so it doesn't matter that the outer one +// would give a String back for an Int: the `1` stays an Int. +// expect-output: 1 0.5 +fn f(x: Int) => "outer"; +{ + fn f(x: Any) => 0.5; + let n = 1; + let r: Float = f(n); + print(n, r); +} From 49cfc7cbb4a64cee5a3c2961d3c9adf8d560157e Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 11:13:20 +0100 Subject: [PATCH 18/39] =?UTF-8?q?fix:=20address=20small=20findings=20from?= =?UTF-8?q?=20the=20type=20checker=20review=20=F0=9F=A7=B9=20(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small fixes from the review of the type checker (#291): - `zip` takes any sequence, as its `Iterable` signature says. It used to accept only lists and tuples, so `zip("ab", "cd")` or `zip` over a range passed analysis and failed at runtime. It now steps the arguments' iterators together and stops at the shortest, so an endless range works as one of them. - The compiler no longer copies an instance's pick tables for each instance it compiles. It refers to the instance by its declaration and index in `Analysis::instances`. - The manual's number page says that a later use can make a `let`-bound literal a Float, which changes what `/` and `\` do. - `declared_symbol`'s doc comment is back on `declared_symbol`; it had ended up on `declare_instance`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/number.md | 10 ++++++ ndc_resolver/src/name_resolution.rs | 4 +-- ndc_stdlib/src/sequence.rs | 36 +++++++++---------- ndc_vm/src/compiler.rs | 30 +++++++++++----- .../024_zip_takes_any_kind_of_sequence.ndc | 4 +++ 5 files changed, 54 insertions(+), 30 deletions(-) create mode 100644 tests/functional/programs/603_stdlib_seq/024_zip_takes_any_kind_of_sequence.ndc diff --git a/manual/src/reference/types/number.md b/manual/src/reference/types/number.md index afe4c9bf..df2a998c 100644 --- a/manual/src/reference/types/number.md +++ b/manual/src/reference/types/number.md @@ -53,6 +53,16 @@ print([1.5, 2.25].fold(0, fn(acc, x) => acc + x)); // 3.75 A value that's already an `Int`, like the result of `len`, stays one. +A variable has one type, which all its uses decide, including the ones after it's read. So a later use can make a literal a `Float`, and `/` and `\` then divide `Float`s rather than truncate: + +```ndc +let x = 7; +print(x / 2); // 3.5 +x = 2.5; // x is a Float, so the 7 is too +``` + +Write `7.0` or annotate `let x: Int = 7` to say which one is meant. + Arbitrary-radix literals such as `16r2a` remain `Int` literals and do not accept `n`. The `i` and `j` suffixes create complex `Number` values: ```ndc diff --git a/ndc_resolver/src/name_resolution.rs b/ndc_resolver/src/name_resolution.rs index bbdab23b..5af00797 100644 --- a/ndc_resolver/src/name_resolution.rs +++ b/ndc_resolver/src/name_resolution.rs @@ -475,8 +475,6 @@ impl NameResolver { natives.into_iter().map(|(&symbol, _)| symbol).collect() } - /// The symbol a resolved declaration introduced: a binding pattern identifier's - /// or a named function's, keyed by its node. /// Declares a top-level value named `name` that no code refers to by /// name, as no identifier can be spelled like it: an instance of a generic /// function, which the analyser picks for the calls that use it. Being @@ -488,6 +486,8 @@ impl NameResolver { symbol } + /// The symbol a resolved declaration introduced: a binding pattern identifier's + /// or a named function's, keyed by its node. pub fn declared_symbol(&self, node: NodeId) -> SymbolId { self.declarations[&node] } diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index d916e3c9..56a3c0cd 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -985,26 +985,24 @@ pub mod extra { "zip must be called with 2 or more arguments".to_string(), )); } - let iters: Option>> = args + // The iterators are stepped together, so an endless one like `1..` + // stops with the others. + let mut iters = args .iter() - .map(|arg| match arg { - Value::Object(obj) => match obj.as_ref() { - Object::List(l) => Some(l.borrow().clone()), - Object::Tuple(t) => Some(t.clone()), - _ => None, - }, - _ => None, - }) - .collect(); - let iters = iters.ok_or_else(|| VmError::native("zip requires sequences".to_string()))?; - let min_len = iters.iter().map(|v| v.len()).min().unwrap_or(0); - let result: Vec = (0..min_len) - .map(|i| { - Value::Object(Rc::new(Object::Tuple( - iters.iter().map(|v| v[i].clone()).collect(), - ))) - }) - .collect(); + .map(|arg| arg.clone().try_into_iter()) + .collect::>>() + .ok_or_else(|| VmError::native("zip requires sequences".to_string()))?; + let mut result = Vec::new(); + 'zipping: loop { + let mut tuple = Vec::with_capacity(iters.len()); + for iter in &mut iters { + let Some(value) = iter.next() else { + break 'zipping; + }; + tuple.push(value); + } + result.push(Value::Object(Rc::new(Object::Tuple(tuple)))); + } Ok(Value::Object(Rc::new(Object::list(result)))) } } diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 200a51ba..cdf272f6 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -13,6 +13,14 @@ use ndc_parser::{ use std::cell::{Cell, RefCell}; use std::rc::Rc; +/// Where an instance of a generic function is in [`Analysis::instances`]: +/// the declaration it's an instance of, and which of its instances it is. +#[derive(Clone, Copy)] +struct InstanceAt { + declaration: NodeId, + index: usize, +} + pub struct Compiler { ir: OptimizerIr, source_locals: usize, @@ -25,7 +33,7 @@ pub struct Compiler { analysis: Rc, /// The instance of a generic function whose body is being compiled, /// whose picks take the place of the function's own. - instance: Option>, + instance: Option, /// Whether the code compiled so far used a pick of `instance` that /// differs from the function's own, so it differs from what the /// function's own body compiles to. @@ -633,7 +641,10 @@ impl Compiler { &'a self, pick: impl FnOnce(&'a Instance) -> Option<&'a T>, ) -> Option<&'a T> { - let found = self.instance.as_deref().and_then(pick); + let found = self + .instance + .map(|at| &self.analysis.instances[&at.declaration][at.index]) + .and_then(pick); if found.is_some() { self.used_instance.set(true); } @@ -879,9 +890,12 @@ impl Compiler { (self.prototype(id, name, body)?, Vec::new()) } else { let compiled = self.prototype(id, name.clone(), body.clone())?; - let instance_bodies = instances - .iter() - .map(|instance| { + let instance_bodies = (0..instances.len()) + .map(|index| { + let instance = InstanceAt { + declaration: id, + index, + }; self.compile_function_body(id, name.clone(), body.clone(), Some(instance)) .map(|(compiled, _)| compiled) }) @@ -991,7 +1005,7 @@ impl Compiler { id: NodeId, name: Option, body: ExpressionLocation, - instance: Option<&Instance>, + instance: Option, ) -> Result<(CompiledFunction, bool), CompileError> { let static_type = self.analysis.function_type(id).clone(); let mut fn_compiler = Self { @@ -1003,9 +1017,7 @@ impl Compiler { allow_return: true, optimize: self.optimize, analysis: Rc::clone(&self.analysis), - instance: instance - .map(|instance| Rc::new(instance.clone())) - .or_else(|| self.instance.clone()), + instance: instance.or(self.instance), used_instance: Cell::new(false), prototypes: Rc::clone(&self.prototypes), }; diff --git a/tests/functional/programs/603_stdlib_seq/024_zip_takes_any_kind_of_sequence.ndc b/tests/functional/programs/603_stdlib_seq/024_zip_takes_any_kind_of_sequence.ndc new file mode 100644 index 00000000..f921aac6 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/024_zip_takes_any_kind_of_sequence.ndc @@ -0,0 +1,4 @@ +// `zip` takes any sequence, and stops at the shortest, so an endless range +// can be one of them. +// expect-output: [("a","c"),("b","d")] [(1,4),(2,5)] [(1,3,"x"),(2,4,"y")] +print(zip("ab", "cd"), zip(1..3, 4..), zip([1, 2], (3, 4), "xy")); From a9532aab7b189a5b9cd8eaa85f01c1b0178745cb Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 12:14:29 +0100 Subject: [PATCH 19/39] =?UTF-8?q?fix(analyser):=20let=20each=20call=20sett?= =?UTF-8?q?le=20a=20generic=20body's=20literals=20=F0=9F=A7=AC=20(#320)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The literals in a generic function's body were all settled when the body ended, before its type parameters were named. That pinned the function to the literals' default types, so these were rejected: - `fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }; fact(5.0)` - `fn k(x) => [x, "b"]; k(1)` - `fn g(x) => [x, 1]; g(2.5)` - `fn f(xs) { xs.push(1); xs }; f([2.5])` These now work. A literal that the parameters' types reach, through a waiting constraint or through what a literal holds, becomes a type parameter and keeps its literal bounds. Each call gets a fresh literal with those bounds, which the call's uses settle as they would a literal written at the call. Literals the parameters don't reach are still settled in the body. The integer literals one literal holds share one type parameter, as the values of `[x, 1, 2]` have one type. Each instance records the types of its integer literals, and the compiler emits those values, so `fact`'s instance for Floats returns `1.0`. A call whose instance key is all `Any`, apart from integer-literal parameters that are Int, uses the function itself, which compiles its literals as Ints. Only a call in the batch that declares the function gets an instance. A function used as a value, as in `let f = pair`, and a call of a function declared on an earlier REPL line both run the function's own body, so their integer-literal parameters are pinned to Int. `fact(1.0)` on a later REPL line is still an analysis error, as before, and `let f: Fn(Float) -> List = pair` is an error. Without the pin, either would return an Int typed as a Float. When merging the copies of a callee's constraints, copies of one literal from two calls with the same arguments now count as the same call. Unifying two literal types also skips the bounds the target already has: same position, same held type. Without these, `fn f1(x) => f0(x) + f0(x)` and so on grew exponentially. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/function.md | 9 + ndc_analyser/src/analyser/declarations.rs | 3 +- ndc_analyser/src/analyser/function_values.rs | 4 +- ndc_analyser/src/analyser/generics.rs | 174 +++++++++++++++-- ndc_analyser/src/analyser/instances.rs | 38 +++- ndc_analyser/src/analyser/mod.rs | 11 +- ndc_analyser/src/analyser/solver.rs | 29 ++- ndc_analyser/src/types.rs | 176 +++++++++++++++++- ndc_analysis/src/lib.rs | 5 + ndc_layout/src/lib.rs | 5 + ndc_vm/src/compiler.rs | 5 +- ...n_a_generic_body_takes_the_call_s_type.ndc | 6 + ...ding_a_parameter_takes_the_call_s_type.ndc | 7 + ...c_function_as_a_value_has_int_literals.ndc | 5 + ...pies_of_a_literal_keep_what_each_holds.ndc | 8 + 15 files changed, 450 insertions(+), 35 deletions(-) create mode 100644 tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc create mode 100644 tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc create mode 100644 tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc create mode 100644 tests/functional/programs/005_functions/090_copies_of_a_literal_keep_what_each_holds.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index 3c7f4438..082ec72c 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -233,6 +233,15 @@ print(a, b, i); // 2 3.5 55 The body is compiled for each set of types it's called with, so the calls in it are picked when the program is compiled, not as it runs. Called with a value of type `Any`, the function picks them as it runs. +A number literal in the body that meets a parameter takes its type from each call, as a literal written at the call would: + +```ndc +fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }; +fn pair(x) => [x, 1]; +print(fact(5), fact(5.0)); // 120 120.0 +print(pair(2), pair(2.5)); // [2,1] [2.5,1.0] +``` + A call with an argument the body can't use is an error at the call, with a note on the line in the body that can't take it: ```ndc,fails diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index ca75f8e8..4a0aedb1 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -49,6 +49,7 @@ impl Analyser { self.declaring = declared.map(|(_, symbol)| symbol); } let first_use = self.uses.len(); + let first_literal = self.number_literals.len(); let (annotations, iterates): (Vec<_>, Vec<_>) = parameters .iter() .map(|param| self.lower_parameter_annotation(param.annotation.as_ref())) @@ -204,7 +205,7 @@ impl Analyser { self.vars.leave_level(); self.declaring = None; self.declared_in_batch.insert(symbol); - self.generalize(symbol, &signature, id, first_use) + self.generalize(symbol, &signature, id, first_use, first_literal) } _ => signature, }; diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index b02cf8e8..ab769ae1 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -76,7 +76,7 @@ impl Analyser { .insert(id, Binding::Resolved(Candidate::Scalar(symbol))); // A generic function used as a value, as in `let p = push`, has one // type there, which its uses settle. - let typ = self.instantiate_function(symbol); + let typ = self.instantiate_function(symbol, false); self.flush_copies(None, span); typ } @@ -297,7 +297,7 @@ impl Analyser { } fn commit_function_value(&mut self, value: &FunctionValue, symbol: SymbolId, early: bool) { - let typ = self.instantiate_function(symbol); + let typ = self.instantiate_function(symbol, false); self.vars.bind_argument(&value.expected, &typ); self.flush_copies(None, value.span); if early && cfg!(debug_assertions) { diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 88d54c40..bba07313 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -10,8 +10,8 @@ use super::instances::Scheme; use super::solver::{CallConstraint, Constraint, Waiting}; use crate::error::AnalysisError; use crate::overloads::is_strictly_more_specific; -use crate::types::TypeVars; -use ahash::AHashSet; +use crate::types::{LiteralBounds, TypeVars}; +use ahash::{AHashMap, AHashSet}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; use ndc_resolver::{ScopeKind, SymbolId}; @@ -38,11 +38,15 @@ impl Analyser { signature: &StaticType, declaration: NodeId, first_use: usize, + first_literal: usize, ) -> StaticType { self.solve_waiting(); - // A literal in the body has one type, which a call can't change. - self.default_literals(TypeVars::is_generalizable); - self.settle_ambiguous(signature); + // A literal in the body that its type parameters don't reach has + // one type, which a call can't change. The others are type + // parameters, which each call settles. + let tied = self.literals_tied_to_parameters(signature); + self.default_literals(|vars, var| vars.is_generalizable(var) && !tied.contains(&var)); + self.settle_ambiguous(signature, &tied); self.merge_copies(); let scheme = self @@ -88,12 +92,68 @@ impl Analyser { } } let mut named = 0; - for var in parameters { - if self.vars.is_generalizable(var) { - self.vars.generalize(var, &type_parameter_name(named)); - named += 1; + let mut literals: Vec<(Box, LiteralBounds)> = Vec::new(); + let mut seen = parameters.iter().copied().collect::>(); + // The integer literals one literal holds have one type, `[x, 1, 2]` + // holds Ints or Floats, so they're one type parameter, named for + // the first of them. + let mut first_number = AHashMap::::new(); + let mut names = AHashMap::>::new(); + let mut next = 0; + while let Some(&var) = parameters.get(next) { + next += 1; + // What a literal holds is a type parameter too: the `1` in + // `fn g(x) => [x, 1]` is a Float when `x` is. + let mut first = None; + for held in self.vars.held_vars(var) { + if seen.insert(held) { + parameters.push(held); + } + if self.vars.is_literal(held) && self.vars.is_number(held) { + match first { + None => first = Some(held), + Some(first) if first != held => { + first_number.entry(held).or_insert(first); + } + Some(_) => {} + } + } + } + if !self.vars.is_generalizable(var) { + continue; + } + if let Some(name) = first_number.get(&var).and_then(|first| names.get(first)) { + let name = name.clone(); + if let Some(bounds) = self.vars.generalize(var, &name) + && let Some((_, first_bounds)) = + literals.iter_mut().find(|(literal, _)| *literal == name) + { + first_bounds.extend(bounds); + } + continue; + } + let name: Box = type_parameter_name(named).into(); + if let Some(bounds) = self.vars.generalize(var, &name) { + literals.push((name.clone(), bounds)); } + names.insert(var, name); + named += 1; } + let literals = literals + .into_iter() + .map(|(name, bounds)| (name, self.vars.resolve_bounds(&bounds))) + .collect::>(); + // A literal that's tied to a type parameter but that nothing outside + // the body sees, like the list in `fn f(x) { let xs = [x]; 0 }`, + // takes its default. + self.default_literals(TypeVars::is_generalizable); + let literal_nodes = self + .number_literals + .split_off(first_literal) + .into_iter() + .map(|(node, typ)| (node, self.vars.resolve(&typ))) + .filter(|(_, typ)| matches!(typ, StaticType::Generic(_))) + .collect::>(); // A type only the body makes, as of a list nothing else uses, gets // its fallback. @@ -133,10 +193,12 @@ impl Analyser { // An instance is for the types of the type parameters the // constraints and uses depend on. let mut key: Vec> = Vec::new(); + // The literals typed by a type parameter compile to what it is. for typ in scheme .iter() .flat_map(|waiting| waiting.constraint.inputs()) .chain(uses.iter().flat_map(|generic_use| &generic_use.key)) + .chain(literal_nodes.iter().map(|(_, typ)| typ)) { collect_type_parameters(typ, &mut key); } @@ -148,11 +210,76 @@ impl Analyser { uses: uses.into(), declaration, recursive_calls: std::mem::take(&mut self.recursive_calls).into(), + literals: literals.into(), + literal_nodes: literal_nodes.into(), }, ); self.vars.resolve(signature) } + /// The literals in the body of the generic function whose type is + /// `signature` that its parameters' types reach, through a waiting + /// constraint or what a literal holds: the `1` in `fn fact(n) => if n < + /// 2 { 1 } else { n * fact(n - 1) }` is what `fact` returns, like `n * + /// fact(n - 1)`, and the list in `fn k(x) => [x, "b"]` holds `x`. + fn literals_tied_to_parameters(&self, signature: &StaticType) -> AHashSet { + let open = |var: &TypeVar| self.vars.is_generalizable(*var); + let literals = self + .vars + .unsettled_literals(TypeVars::is_generalizable) + .into_iter() + .collect::>(); + // The variables that are the same or decide each other: those of one + // constraint, and a literal with what it holds. + let mut groups = self + .generic_constraints() + .into_iter() + .map(|serial| { + let constraint = &self.waiting[&serial].constraint; + constraint + .inputs() + .iter() + .chain(constraint.outputs()) + .flat_map(|typ| self.vars.unsettled(typ)) + .filter(open) + .collect::>() + }) + .collect::>(); + for &literal in &literals { + let mut group = self.vars.held_vars(literal); + group.retain(open); + group.push(literal); + groups.push(group); + } + let parameters = match signature { + StaticType::Function { + parameters: Some(parameters), + .. + } => parameters.as_slice(), + _ => &[], + }; + let mut reached = parameters + .iter() + .flat_map(|typ| self.vars.unsettled(typ)) + .filter(open) + .collect::>(); + loop { + let mut changed = false; + for group in &groups { + if group.iter().any(|var| reached.contains(var)) { + for &var in group { + changed |= reached.insert(var); + } + } + } + if !changed { + break; + } + } + reached.retain(|var| literals.contains(var)); + reached + } + /// The waiting constraints that wait on or settle a variable of the /// generic function whose body just ended, oldest first. fn generic_constraints(&self) -> Vec { @@ -211,12 +338,17 @@ impl Analyser { /// a constraint settles is decided when what it waits on is. An /// overloaded function's name used as a value has to be decided in the /// body, which is compiled once, so what it waits on counts too. - fn ambiguous_vars(&self, parameters: &[StaticType]) -> Vec<(u64, Vec)> { + fn ambiguous_vars( + &self, + parameters: &[StaticType], + tied: &AHashSet, + ) -> Vec<(u64, Vec)> { let serials = self.generic_constraints(); let mut decided = parameters .iter() .flat_map(|typ| self.vars.unsettled(typ)) .filter(|&var| self.vars.is_generalizable(var)) + .chain(tied.iter().copied()) .collect::>(); loop { let mut changed = false; @@ -259,7 +391,7 @@ impl Analyser { /// that its parameters don't decide, like the end of the program /// settles its own: a call that a default rule decides is decided by it, /// and what's left gets its fallbacks, oldest constraint first. - fn settle_ambiguous(&mut self, signature: &StaticType) { + fn settle_ambiguous(&mut self, signature: &StaticType, tied: &AHashSet) { let parameters = match signature { StaticType::Function { parameters: Some(parameters), @@ -268,7 +400,7 @@ impl Analyser { _ => Vec::new(), }; loop { - let ambiguous = self.ambiguous_vars(¶meters); + let ambiguous = self.ambiguous_vars(¶meters, tied); let Some((_, vars)) = ambiguous.first().cloned() else { return; }; @@ -348,7 +480,23 @@ impl Analyser { ) else { unreachable!("both are calls"); }; - let (earlier, later) = (earlier.result().clone(), call.result().clone()); + // Copies of one literal become one, as they're settled + // the same way. + let earlier = StaticType::Tuple( + earlier + .arguments() + .iter() + .chain([earlier.result()]) + .cloned() + .collect(), + ); + let later = StaticType::Tuple( + call.arguments() + .iter() + .chain([call.result()]) + .cloned() + .collect(), + ); if self.vars.try_unify(&earlier, &later) { self.waiting.remove(&serial); } else { diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs index 1cf84d2c..379ed4d6 100644 --- a/ndc_analyser/src/analyser/instances.rs +++ b/ndc_analyser/src/analyser/instances.rs @@ -8,6 +8,7 @@ use super::Analyser; use super::solver::{CallSite, Constraint, Origin, Waiting}; +use crate::types::LiteralBounds; use ahash::AHashMap; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType}; @@ -31,6 +32,15 @@ pub(super) struct Scheme { pub(super) declaration: NodeId, /// The calls its body makes of itself, which have its own types. pub(super) recursive_calls: Rc<[NodeId]>, + /// Its type parameters that are the types of literals in its body, + /// with their bounds, which each call settles as a literal there would + /// be: the `1` in `fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) + /// }` is a Float for `fact(5.0)`. + pub(super) literals: Rc<[(Box, LiteralBounds)]>, + /// The integer literals in its body whose type is one of its type + /// parameters, with that type, which an instance records its own types + /// for. + pub(super) literal_nodes: Rc<[(NodeId, StaticType)]>, } /// A call of a generic function, or its name used as a value, which needs @@ -188,16 +198,24 @@ impl Analyser { /// The instance of `function` for these types of its key: the function /// itself when they're all `Any`, as its own body picks its calls as the /// program runs, or when it was declared by an earlier batch, which - /// compiled it already. + /// compiled it already. Its own body's integer literals are Ints, so an + /// integer literal's type parameter may be one. fn instance_for( &mut self, function: SymbolId, key: Vec, new: &mut Vec<(SymbolId, SymbolId, Vec)>, ) -> SymbolId { - if key.iter().all(|typ| *typ == StaticType::Any) - || !self.declared_in_batch.contains(&function) - { + let scheme = &self.schemes[&function]; + let is_own = scheme.key.iter().zip(&key).all(|(name, typ)| { + *typ == StaticType::Any + || (*typ == StaticType::Int + && scheme + .literals + .iter() + .any(|(literal, bounds)| literal == name && bounds.is_number())) + }); + if is_own || !self.declared_in_batch.contains(&function) { return function; } if let Some(&instance) = self.instances.get(&(function, key.clone())) { @@ -235,6 +253,7 @@ impl Analyser { .collect::>(); let outer = self.recording.replace(instance); let first = self.vars.count(); + self.vars.instantiate_literals(&scheme.literals, &mut made); for waiting in scheme.constraints.iter() { let mut copy = Waiting { constraint: waiting @@ -266,6 +285,17 @@ impl Analyser { }); } self.settle_waiting(first); + let literal_types = scheme + .literal_nodes + .iter() + .map(|(node, typ)| { + let typ = self.vars.instantiate_with(typ, &mut made); + (*node, self.vars.resolve(&typ)) + }) + .collect::>(); + if let Some(picks) = self.picks.instances.get_mut(&instance) { + picks.literal_types.extend(literal_types); + } self.recording = outer; } } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 57ada60b..daca6f2a 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -75,6 +75,9 @@ pub struct Analyser { declaring: Option, /// The calls its body makes of itself. recursive_calls: Vec, + /// The integer literals in the batch, with their types, which a + /// generic function's instances may give types of their own. + number_literals: Vec<(NodeId, StaticType)>, /// The calls the constraint being solved was copied through, when it's /// a copy of a generic function's constraint. copied_through: Option, @@ -150,6 +153,7 @@ impl Analyser { declared_in_batch: AHashSet::new(), declaring: None, recursive_calls: Vec::new(), + number_literals: Vec::new(), copied_through: None, checks: Vec::new(), next_serial: 0, @@ -183,6 +187,7 @@ impl Analyser { self.check_early_picks(); } self.failed_reads.clear(); + self.number_literals.clear(); self.types.resolve_all(&self.vars); for (instance, node, typ, expected) in std::mem::take(&mut self.checks) { if self.vars.resolve(&typ) != StaticType::Any { @@ -425,7 +430,11 @@ impl Analyser { StaticType::Option(Box::new(self.vars.fresh(StaticType::Any))) } Expression::StringLiteral(_) => StaticType::String, - Expression::NumericLiteral(NumericLiteral::Int64(_)) => self.vars.fresh_number(*span), + Expression::NumericLiteral(NumericLiteral::Int64(_)) => { + let typ = self.vars.fresh_number(*span); + self.number_literals.push((*id, typ.clone())); + typ + } Expression::NumericLiteral(NumericLiteral::Float64(_)) => StaticType::Float, Expression::NumericLiteral( NumericLiteral::NumberInt(_) diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index a3804cde..1a7b73c5 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -240,7 +240,9 @@ impl CallConstraint { } /// Whether `other` calls what this calls, the same way, with arguments - /// of the same types, so it returns the same type. + /// of the same types, so it returns the same type. Copies of one + /// literal count as the same type, as the same call settles them the + /// same way. pub(super) fn is_same_call(&self, other: &Self, vars: &TypeVars) -> bool { std::mem::discriminant(&self.site) == std::mem::discriminant(&other.site) && self.kind == other.kind @@ -250,7 +252,7 @@ impl CallConstraint { .arguments .iter() .zip(&other.arguments) - .all(|(a, b)| vars.resolve(a) == vars.resolve(b)) + .all(|(a, b)| vars.with_literals_named(a) == vars.with_literals_named(b)) } } @@ -1479,7 +1481,7 @@ impl Analyser { Binding::Dynamic(_) | Binding::None => None, }; let pending = self.pending.len(); - let signature = picked.map(|symbol| self.instantiate_function(symbol)); + let signature = picked.map(|symbol| self.instantiate_function(symbol, true)); let Some(StaticType::Function { parameters: Some(parameters), return_type: declared_return, @@ -1512,10 +1514,27 @@ impl Analyser { /// `symbol`'s type with new variables for its type parameters. For a /// generic function declared in the program, its constraints get the /// same variables, and wait in [`Self::pending`] until - /// [`Self::flush_copies`] knows which call they're for. - pub(super) fn instantiate_function(&mut self, symbol: SymbolId) -> StaticType { + /// [`Self::flush_copies`] knows which call they're for. `call` says + /// whether it's for a call, which may get an instance of its own. + pub(super) fn instantiate_function(&mut self, symbol: SymbolId, call: bool) -> StaticType { let declared = self.vars.resolve(self.types.get_type(symbol)); let mut made = Vec::new(); + if let Some(scheme) = self.schemes.get(&symbol) { + let literals = Rc::clone(&scheme.literals); + // Only a call in the batch that declares the function gets an + // instance. A value, as in `let f = pair`, or a call of a + // function an earlier batch declared, runs the function's own + // body, whose integer literals are Ints. + if !call || !self.declared_in_batch.contains(&symbol) { + made.extend( + literals + .iter() + .filter(|(_, bounds)| bounds.is_number()) + .map(|(name, _)| (name.clone(), StaticType::Int)), + ); + } + self.vars.instantiate_literals(&literals, &mut made); + } let typ = self.vars.instantiate_with(&declared, &mut made); // A copy of a function's own constraint that calls the function // again, as a recursive call its body couldn't decide does, is diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index ca57bdd0..35072048 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -123,6 +123,52 @@ enum Bound { Holds(StaticType, TypeVar, Span), } +/// The bounds of a literal's type that became a type parameter of a generic +/// function, which each call's copy of the parameter gets: the `1` in +/// `fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }` is an Int or a +/// Float, whichever the call needs. +#[derive(Debug, Clone)] +pub(crate) struct LiteralBounds(Vec); + +impl Bound { + /// The type of the value it holds, if it holds one. + fn held(&self) -> Option<&StaticType> { + match self { + Self::Holds(held, _, _) => Some(held), + Self::Number(..) => None, + } + } + + /// Which bound of which literal it is: the value's kind and where it is + /// in the source. + fn position(&self) -> (bool, usize, usize, usize) { + let (is_number, span) = match self { + Self::Number(_, span) => (true, span), + Self::Holds(_, _, span) => (false, span), + }; + ( + is_number, + span.source_id().index(), + span.offset(), + span.end(), + ) + } +} + +impl LiteralBounds { + /// Adds the bounds of another literal of the same type. + pub(crate) fn extend(&mut self, other: Self) { + self.0.extend(other.0); + } + + /// Whether they're an integer literal's. + pub(crate) fn is_number(&self) -> bool { + self.0 + .iter() + .any(|bound| matches!(bound, Bound::Number(..))) + } +} + /// The state of the variables at one point, which [`TypeVars::rollback`] /// returns to: overload resolution tries each candidate this way. #[derive(Debug, Clone, Copy)] @@ -310,7 +356,7 @@ impl TypeVars { } /// Whether `var` is the type of an integer literal. - fn is_number(&self, var: TypeVar) -> bool { + pub(crate) fn is_number(&self, var: TypeVar) -> bool { self.bounds[var.0 as usize] .iter() .any(|bound| matches!(bound, Bound::Number(..))) @@ -484,14 +530,108 @@ impl TypeVars { } /// Settles `var` as the type parameter `name` of the generic function - /// whose body made it. A literal's type is settled before, so `var` has - /// no bounds. - pub(crate) fn generalize(&mut self, var: TypeVar, name: &str) { - debug_assert!( - self.bounds[var.0 as usize].is_empty(), - "the literal type {var:?} is generalized" - ); + /// whose body made it, and returns its bounds if it's a literal's type, + /// which the parameter keeps. + pub(crate) fn generalize(&mut self, var: TypeVar, name: &str) -> Option { + let bounds = std::mem::take(&mut self.bounds[var.0 as usize]); self.settle(var, StaticType::Generic(name.into())); + (!bounds.is_empty()).then_some(LiteralBounds(bounds)) + } + + /// `bounds` with the types the values held have now, which are type + /// parameters once the generic function's body has ended. + pub(crate) fn resolve_bounds(&self, bounds: &LiteralBounds) -> LiteralBounds { + LiteralBounds( + bounds + .0 + .iter() + .map(|bound| match bound { + Bound::Holds(held, literal, span) => { + Bound::Holds(self.resolve(held), *literal, *span) + } + number @ Bound::Number(..) => number.clone(), + }) + .collect(), + ) + } + + /// Adds a new literal type to `made` for each of a generic function's + /// literal type parameters `literals` it doesn't have yet, with the + /// parameter's bounds, so each call settles its own: `fact(5.0)` makes + /// the `1` in `fact` a Float. + pub(crate) fn instantiate_literals( + &mut self, + literals: &[(Box, LiteralBounds)], + made: &mut Vec<(Box, StaticType)>, + ) { + let mut new = Vec::new(); + for (name, bounds) in literals { + if made.iter().any(|(generic, _)| generic == name) { + continue; + } + let is_number = bounds + .0 + .iter() + .any(|bound| matches!(bound, Bound::Number(..))); + let typ = self.fresh(if is_number { + StaticType::Int + } else { + StaticType::Any + }); + let StaticType::Var(var) = typ else { + unreachable!("a fresh type is a variable"); + }; + made.push((name.clone(), typ)); + new.push((var, bounds)); + } + for (var, bounds) in new { + let bounds = bounds + .0 + .iter() + .map(|bound| match bound { + Bound::Number(_, span) => Bound::Number(var, *span), + Bound::Holds(held, _, span) => { + Bound::Holds(self.instantiate_with(held, made), var, *span) + } + }) + .collect(); + self.bounds[var.0 as usize] = bounds; + self.literals.push(var); + } + } + + /// `typ` with each unsettled literal type in it replaced by a type that + /// says which literals it's the type of, so two copies of one generic + /// function's literal type parameter look the same: each call of `f0` + /// in `fn f1(x) => f0(x) + f0(x)` copies the `1` of `fn f0(x) => x + 1`. + pub(crate) fn with_literals_named(&self, typ: &StaticType) -> StaticType { + self.resolve(typ).replace(&|part| match part { + StaticType::Var(var) if self.is_literal(*var) => { + let mut spans = self.bounds[var.0 as usize] + .iter() + .map(|bound| match bound { + Bound::Number(_, span) | Bound::Holds(_, _, span) => { + (span.source_id().index(), span.offset(), span.end()) + } + }) + .collect::>(); + spans.sort_unstable(); + spans.dedup(); + Some(StaticType::Generic(format!("literal{spans:?}").into())) + } + _ => None, + }) + } + + /// The variables in what the literal type `var` holds: `x`'s in `[x, 1]`. + pub(crate) fn held_vars(&self, var: TypeVar) -> Vec { + self.bounds[var.0 as usize] + .iter() + .flat_map(|bound| match bound { + Bound::Holds(held, _, _) => self.unsettled(held), + Bound::Number(..) => Vec::new(), + }) + .collect() } /// How many variables there are, so [`Self::settle_from`] can settle @@ -529,6 +669,18 @@ impl TypeVars { } match shallow { StaticType::Var(other) => { + // Copies of one literal, as two calls of a generic function + // make, have the same bounds, which it needs once. Copies + // may hold values of different types, as `wrap(1)` and + // `wrap("s")` of `fn wrap(x) => [x]` do, which it needs both. + let mut known = AHashMap::<_, Vec>::new(); + for bound in &self.bounds[other.0 as usize] { + known.entry(bound.position()).or_default().push( + bound + .held() + .map_or(StaticType::Int, |held| self.resolve(held)), + ); + } for bound in bounds { // A literal holding a value of the type the other // variable already is says nothing more. @@ -537,6 +689,14 @@ impl TypeVars { { continue; } + let held = bound + .held() + .map_or(StaticType::Int, |held| self.resolve(held)); + let same = known.entry(bound.position()).or_default(); + if same.contains(&held) { + continue; + } + same.push(held); self.bounds[other.0 as usize].push(bound); self.bounded.push(other); self.literals.push(other); diff --git a/ndc_analysis/src/lib.rs b/ndc_analysis/src/lib.rs index 4dbff040..b468e078 100644 --- a/ndc_analysis/src/lib.rs +++ b/ndc_analysis/src/lib.rs @@ -93,6 +93,11 @@ pub struct Instance { pub augmented_assignments: AHashMap, /// As [`Analysis::type_checks`], for the instance's body. pub type_checks: AHashMap, + /// The types of the integer literals in the instance's body whose type + /// is one of the function's type parameters, in place of the ones in + /// [`Analysis::expr_types`]: the `1` in `fn fact(n) => if n < 2 { 1 } + /// else { n * fact(n - 1) }` is a Float in the instance for Floats. + pub literal_types: AHashMap, } impl Analysis { diff --git a/ndc_layout/src/lib.rs b/ndc_layout/src/lib.rs index d0794941..9659bd2f 100644 --- a/ndc_layout/src/lib.rs +++ b/ndc_layout/src/lib.rs @@ -51,6 +51,9 @@ pub struct InstancePicks { pub augmented_assignments: AHashMap>, /// As [`Analysis::type_checks`], for the instance's body. pub type_checks: AHashMap, + /// The types of the integer literals in the instance's body whose type + /// is one of the function's type parameters. + pub literal_types: AHashMap, } impl InstancePicks { @@ -62,6 +65,7 @@ impl InstancePicks { setters: AHashMap::new(), augmented_assignments: AHashMap::new(), type_checks: AHashMap::new(), + literal_types: AHashMap::new(), } } } @@ -162,6 +166,7 @@ impl<'a> Layout<'a> { .map(locate_binding) .collect(), type_checks: picks.type_checks, + literal_types: picks.literal_types, }; analysis .instances diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index cdf272f6..310b6e35 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -215,7 +215,10 @@ impl Compiler { let value = match literal { // An integer literal is what its use made it: `1` in // `[1.5, 1]` is a Float. - NumericLiteral::Int64(i) => match self.analysis.expr_types.get(&id) { + NumericLiteral::Int64(i) => match self + .instance_pick(|instance| instance.literal_types.get(&id)) + .or_else(|| self.analysis.expr_types.get(&id)) + { Some(StaticType::Float) => Value::float(i as f64), Some(StaticType::Number) => { Value::number(crate::value::AdvancedNumber::Int(i.into())) diff --git a/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc b/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc new file mode 100644 index 00000000..ad76228e --- /dev/null +++ b/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc @@ -0,0 +1,6 @@ +// The `1` in `fact` is what `fact` returns, so it's a Float when `fact` is +// called with one: each call settles the literal as a literal there would be. +// expect-output: 120 120.0 1.0 55.0 +fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }; +fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; +print(fact(5), fact(5.0), fact(1.0), fib(10.0)); diff --git a/tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc b/tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc new file mode 100644 index 00000000..4a9ec6ad --- /dev/null +++ b/tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc @@ -0,0 +1,7 @@ +// A literal that holds a parameter, or is passed with one, is as generic +// as the parameter: `[x, 1]` is a list of Floats when `x` is a Float. +// expect-output: [2.5,1.0] [2,1] [1,"b"] [2.5,1.0] +fn g(x) => [x, 1]; +fn k(x) => [x, "b"]; +fn f(xs) { xs.push(1); xs } +print(g(2.5), g(2), k(1), f([2.5])); diff --git a/tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc b/tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc new file mode 100644 index 00000000..f60c6ab9 --- /dev/null +++ b/tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc @@ -0,0 +1,5 @@ +// A generic function used as a value runs its own body, whose integer +// literals are Ints, so `pair` can't be a function returning Floats. +// expect-error: mismatched types +fn pair(x) => [x, 1]; +let f: Fn(Float) -> List = pair; diff --git a/tests/functional/programs/005_functions/090_copies_of_a_literal_keep_what_each_holds.ndc b/tests/functional/programs/005_functions/090_copies_of_a_literal_keep_what_each_holds.ndc new file mode 100644 index 00000000..1143122b --- /dev/null +++ b/tests/functional/programs/005_functions/090_copies_of_a_literal_keep_what_each_holds.ndc @@ -0,0 +1,8 @@ +// Each call of `wrap` makes a list of what it's given, so the two lists +// hold different types and the outer list holds both. +// expect-output: [[1],["s"]] [[1,3],[2]] +fn wrap(x) => [x]; +let xs = [wrap(1), wrap("s")]; +let ys = [wrap(1), wrap(2)]; +ys[0].push(3); +print(xs, ys); From 8eea77b09fb264ac34ae8c5b7354c6443ab57651 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 12:27:25 +0100 Subject: [PATCH 20/39] =?UTF-8?q?fix(analyser):=20explain=20growing=20recu?= =?UTF-8?q?rsion=20and=20overloaded=20values=20in=20lists=20=F0=9F=92=AC?= =?UTF-8?q?=20(#321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearer errors for two programs the type checker rejects on purpose. A generic function that calls itself with an argument built from one of its parameters, as `nest([x], n - 1)` does in `fn nest(x, n)`, would need a version of itself for each deeper type. Binding `[x]` to `x` made the parameter a list holding itself, so the error came at the outer call: "No function called 'nest' found that matches the arguments 'Int, Int'". The error is now at the recursive call. It says that each call would need a version for a deeper type, and suggests annotating the parameter `Any`, which already works. When another overload might take the call, the body can't tell. Instead the check happens when instances are made: a use inside an instance of the same function, whose key holds that instance's key in a bigger type, gets the same error. `fn f(xs: List) => 42; fn f(x) => f([x]); f(1)` used to hang analysis, since `[x]` is a `List`, not a `List`. An overloaded function in a list literal, such as `abs` in `[len, abs]`, was expected to have the type of the list's other values, and failed with "No overload of 'abs' is a Fn(Iterable) -> Int". The other values of a literal don't say which overload is meant, so it's now the usual "'abs' has several overloads, and nothing here says which one is meant" error, with the function-type hint. A sibling that fits still picks the overload: `[fn(x: Int) => x, abs]` works. Also adds a test that a literal map key takes the map's key type (`%{1: "a", 2.5: "b"}[1]`). #317 and #320 already fixed that. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015rQP9SKVmpvME4F2LXeWmG --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/function.md | 13 ++++++- ndc_analyser/src/analyser/function_values.rs | 25 ++++++++++++ ndc_analyser/src/analyser/instances.rs | 38 ++++++++++++++++++- ndc_analyser/src/analyser/mod.rs | 13 ++++++- ndc_analyser/src/analyser/solver.rs | 38 ++++++++++++++++++- ndc_analyser/src/error.rs | 14 +++++++ ndc_analyser/src/types.rs | 16 ++++++++ ...ive_call_can_t_grow_its_argument_types.ndc | 5 +++ ..._a_recursive_call_through_any_can_grow.ndc | 4 ++ ...loaded_function_in_a_list_needs_a_type.ndc | 3 ++ ...ll_through_another_overload_can_t_grow.ndc | 8 ++++ ...a_literal_key_takes_the_map_s_key_type.ndc | 5 +++ 12 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 tests/functional/programs/005_functions/091_a_recursive_call_can_t_grow_its_argument_types.ndc create mode 100644 tests/functional/programs/005_functions/092_a_recursive_call_through_any_can_grow.ndc create mode 100644 tests/functional/programs/005_functions/093_an_overloaded_function_in_a_list_needs_a_type.ndc create mode 100644 tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc create mode 100644 tests/functional/programs/007_map_and_set/035_a_literal_key_takes_the_map_s_key_type.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index 082ec72c..1da1e120 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -253,6 +253,17 @@ shout("a"); // error: No function called '+' found that matches the arguments What the body can do with a parameter can make its type known right away: `xs.push(1)` only works on a list of Ints, so `fn f(xs) { xs.push(1) }` takes a `List`. +A recursive call has to pass the types the function was called with. One that passes something built from a parameter would need a version of the function for every deeper type, so it's an error. Annotate the parameter with `Any` to let it change as the program runs: + +```ndc,fails +fn nest(x, n) { if n == 0 { x } else { nest([x], n - 1) } } // error: `nest` calls itself with an argument built from what it was given +``` + +```ndc +fn nest(x: Any, n) { if n == 0 { x } else { nest([x], n - 1) } } +print(nest(1, 3)); // [[[1]]] +``` + Only a function declared in the program itself is generic. One declared in another function or in a block has one type, like a lambda in a variable, and its first call decides it: ```ndc,fails @@ -345,7 +356,7 @@ When nothing says which overload is meant, it's an error that asks for a functio let g = split; // error: 'split' has several overloads, and nothing here says which one is meant ``` -`print(abs)` is the same error, as `print` takes any value. +`print(abs)` is the same error, as `print` takes any value, and so is `[len, abs]`: the other values of a list don't say which overload is meant. Like a call, a value uses the nearest scope with an overload that fits. diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index ab769ae1..8d28e226 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -24,6 +24,9 @@ pub(super) struct FunctionValue { groups: Vec>, /// The type expected of the value, which its uses settle. expected: StaticType, + /// Whether it's a value of a literal, as `abs` is in `[len, abs]`, whose + /// other values say what's expected of it, but not which overload it is. + in_literal: bool, span: Span, } @@ -31,6 +34,14 @@ impl FunctionValue { pub(super) fn expected(&self) -> &StaticType { &self.expected } + + pub(super) fn id(&self) -> NodeId { + self.id + } + + pub(super) fn mark_in_literal(&mut self) { + self.in_literal = true; + } } /// Which overload a function value means, given what's known of the type @@ -64,6 +75,7 @@ impl Analyser { candidates: self.resolver.reference_candidates(id).clone(), groups, expected: expected.clone(), + in_literal: false, span, }; if !self.try_function_value(&value) { @@ -262,6 +274,19 @@ impl Analyser { self.give_up_on_function_value(value); true } + // The other values of a literal can't say which overload is + // meant, as the literal could hold `Any`s: `[len, abs]`. + Choice::None if value.in_literal => { + let example = self.overload_example(value, &StaticType::Any); + self.emit(AnalysisError::ambiguous_function_value( + &value.name, + &StaticType::Any, + &example, + value.span, + )); + self.give_up_on_function_value(value); + true + } Choice::None => { self.emit(AnalysisError::no_overload_fits( &value.name, diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs index 379ed4d6..3f97a787 100644 --- a/ndc_analyser/src/analyser/instances.rs +++ b/ndc_analyser/src/analyser/instances.rs @@ -8,11 +8,13 @@ use super::Analyser; use super::solver::{CallSite, Constraint, Origin, Waiting}; +use crate::error::AnalysisError; use crate::types::LiteralBounds; use ahash::AHashMap; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType}; use ndc_layout::InstancePicks; +use ndc_lexer::Span; use ndc_resolver::SymbolId; use std::rc::Rc; @@ -55,6 +57,8 @@ pub(super) struct GenericUse { /// The instance whose body the use is in, `None` for code that's compiled /// once, like the program's top level. pub(super) instance: Option, + /// Where the use is. + pub(super) span: Span, } /// What a call that's about to commit copied from the generic function it @@ -102,7 +106,7 @@ impl Analyser { /// [`Pending::uses`] holds, unless it's in a copy of a constraint, whose /// pick belongs to the function it was copied from. A tuple operator's /// call commits to one overload per element, which needs no instance. - pub(super) fn record_uses(&mut self, site: CallSite) { + pub(super) fn record_uses(&mut self, site: CallSite, span: Span) { let uses = std::mem::take(&mut self.pending.uses); if self.copied_through.is_some() { return; @@ -113,6 +117,7 @@ impl Analyser { function: *function, key: key.clone(), instance: self.recording, + span, }); } } @@ -155,6 +160,17 @@ impl Analyser { .any(|typ| typ.contains(&|part| matches!(part, StaticType::Generic(_)))), "internal error: a use outside of generic bodies has a type parameter in {key:?}" ); + // A function whose instance calls it with types built from its own, + // as `fn f(x) => f([x])` does, would need instances without end. + // The call is left to the program as it runs. + if self.grows_an_instance(generic_use, &key) { + let name = self.resolver.symbol_name(generic_use.function).to_string(); + self.emit(AnalysisError::recursion_changes_types( + &name, + generic_use.span, + )); + return; + } let instance = self.instance_for(generic_use.function, key, new); if instance == generic_use.function { return; @@ -175,6 +191,25 @@ impl Analyser { ); } + /// Whether `generic_use`, in the body of an instance of the function it + /// uses, needs an instance whose key holds that instance's key inside a + /// bigger type: `f>` from `f`. + fn grows_an_instance(&self, generic_use: &GenericUse, key: &[StaticType]) -> bool { + let Some(outer) = generic_use.instance else { + return false; + }; + self.instances + .iter() + .any(|((function, outer_key), &instance)| { + instance == outer + && *function == generic_use.function + && key + .iter() + .zip(outer_key) + .any(|(new, old)| new != old && new.contains(&|part| part == old)) + }) + } + /// The tables a pick in the body of `instance` goes in, or the shared /// ones for `None`. pub(super) fn pick_tables(&mut self, instance: Option) -> PickTables<'_> { @@ -282,6 +317,7 @@ impl Analyser { function: generic_use.function, key, instance: Some(instance), + span: generic_use.span, }); } self.settle_waiting(first); diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index daca6f2a..da554bf9 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -24,7 +24,7 @@ mod solver; use declarations::FunctionDeclaration; pub use generics::tied_natives; use instances::{GenericUse, Pending, Scheme}; -use solver::{EarlyPick, Origin, Waiting}; +use solver::{Constraint, EarlyPick, Origin, Waiting}; #[cfg(test)] mod tests; @@ -784,6 +784,17 @@ impl Analyser { ); for (node, _, typ) in values { if let Some(node) = node { + // An overloaded function value waiting for its type, as + // `abs` in `[len, abs]`, is one of the literal's values. + if matches!(self.vars.resolve(&typ), StaticType::Var(_)) { + for waiting in self.waiting.values_mut() { + if let Constraint::FunctionValue(value) = &mut waiting.constraint + && value.id() == node + { + value.mark_in_literal(); + } + } + } self.expect_if_any(node, typ, element.clone()); } } diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 1a7b73c5..aa703ca4 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -1117,6 +1117,42 @@ impl Analyser { .collect::>(); let reachable = self.overloads().reachable_symbols(&call.candidates); + // A generic function that calls itself with an argument made from + // the parameter it's passed for, as `nest([x], n - 1)` in `fn + // nest(x, n)`, would need a version of itself for `x`'s type, one + // for lists of it, one for lists of those, without end. + // Another overload may take the call, as `f(xs: List)` does + // `f([x])`, so it's only this function's when no other can. + if let Some(function) = self.declaring + && self.copied_through.is_none() + && self.recording.is_none() + && reachable.contains(&function) + && reachable + .iter() + .all(|&symbol| symbol == function || !self.could_accept(symbol, &arguments)) + && let StaticType::Function { + parameters: Some(parameters), + .. + } = self.vars.resolve(self.types.get_type(function)) + && parameters.len() == arguments.len() + && parameters + .iter() + .zip(&arguments) + .any(|(parameter, argument)| { + matches!(parameter, StaticType::Var(var) + if *argument != *parameter && self.vars.is_made_from(argument, *var)) + }) + { + let name = self.resolver.symbol_name(function).to_string(); + self.emit(AnalysisError::recursion_changes_types(&name, call.span)); + self.record_pick( + call.site, + Binding::Resolved(Candidate::Scalar(function)), + &call.arguments, + ); + return Some(StaticType::Any); + } + // A variable whose type isn't known yet, like the parameter `f` in // `fn(f) => f(1)`, holds a function that takes these arguments. if let [only] = reachable[..] @@ -1578,7 +1614,7 @@ impl Analyser { /// `site`, stays the function itself, so two values of it are equal. pub(super) fn flush_copies(&mut self, site: Option, call: Span) { match site { - Some(site) => self.record_uses(site), + Some(site) => self.record_uses(site, call), None => self.pending.uses.clear(), } for (node, expected) in std::mem::take(&mut self.pending.checks) { diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index db5b1d27..7fa5b92d 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -122,6 +122,20 @@ impl AnalysisError { } } + pub(crate) fn recursion_changes_types(function: &str, span: Span) -> Self { + Self { + text: format!( + "`{function}` calls itself with an argument built from what it was given, so each call would need a version of `{function}` for a deeper type" + ), + span, + help_text: Some( + "A recursive call has to pass the types the function was called with. Annotate the parameter with `Any` to let them change as the program runs" + .to_string(), + ), + notes: Vec::new(), + } + } + pub(crate) fn iterable_outside_a_parameter(span: Span) -> Self { Self { text: "`Iterable` can only be the type of a parameter".to_string(), diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 35072048..b5ff7859 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -623,6 +623,22 @@ impl TypeVars { }) } + /// Whether `typ` is made from the unsettled variable `var`, directly or + /// through what a literal in it holds: `[x]` is made from `x`'s type. + pub(crate) fn is_made_from(&self, typ: &StaticType, var: TypeVar) -> bool { + let mut seen = AHashSet::new(); + let mut todo = self.unsettled(typ); + while let Some(next) = todo.pop() { + if next == var { + return true; + } + if seen.insert(next) && self.is_literal(next) { + todo.extend(self.held_vars(next)); + } + } + false + } + /// The variables in what the literal type `var` holds: `x`'s in `[x, 1]`. pub(crate) fn held_vars(&self, var: TypeVar) -> Vec { self.bounds[var.0 as usize] diff --git a/tests/functional/programs/005_functions/091_a_recursive_call_can_t_grow_its_argument_types.ndc b/tests/functional/programs/005_functions/091_a_recursive_call_can_t_grow_its_argument_types.ndc new file mode 100644 index 00000000..9958e439 --- /dev/null +++ b/tests/functional/programs/005_functions/091_a_recursive_call_can_t_grow_its_argument_types.ndc @@ -0,0 +1,5 @@ +// `nest` for an Int would need `nest` for a list of Ints, which needs one +// for lists of those, without end, so the recursive call is the error. +// expect-error: `nest` calls itself with an argument built from what it was given +fn nest(x, n) { if n == 0 { x } else { nest([x], n - 1) } } +nest(1, 3); diff --git a/tests/functional/programs/005_functions/092_a_recursive_call_through_any_can_grow.ndc b/tests/functional/programs/005_functions/092_a_recursive_call_through_any_can_grow.ndc new file mode 100644 index 00000000..c657fa9e --- /dev/null +++ b/tests/functional/programs/005_functions/092_a_recursive_call_through_any_can_grow.ndc @@ -0,0 +1,4 @@ +// A parameter of type `Any` takes anything, so the recursive call can wrap it. +// expect-output: [[[1]]] +fn nest(x: Any, n) { if n == 0 { x } else { nest([x], n - 1) } } +print(nest(1, 3)); diff --git a/tests/functional/programs/005_functions/093_an_overloaded_function_in_a_list_needs_a_type.ndc b/tests/functional/programs/005_functions/093_an_overloaded_function_in_a_list_needs_a_type.ndc new file mode 100644 index 00000000..6e98689a --- /dev/null +++ b/tests/functional/programs/005_functions/093_an_overloaded_function_in_a_list_needs_a_type.ndc @@ -0,0 +1,3 @@ +// The other values of a list don't say which `abs` is meant. +// expect-error: 'abs' has several overloads, and nothing here says which one is meant +let fs = [len, abs]; diff --git a/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc b/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc new file mode 100644 index 00000000..03472edf --- /dev/null +++ b/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc @@ -0,0 +1,8 @@ +// `[x]` with an Int `x` is a `List`, which isn't the `List` the +// first `f` takes, so `f(1)` would need `f` for `List`, then for +// `List>`, without end. Another overload taking the call only +// keeps the error from being reported in the body. +// expect-error: `f` calls itself with an argument built from what it was given +fn f(xs: List) => 42; +fn f(x) => f([x]); +print(f(1)); diff --git a/tests/functional/programs/007_map_and_set/035_a_literal_key_takes_the_map_s_key_type.ndc b/tests/functional/programs/007_map_and_set/035_a_literal_key_takes_the_map_s_key_type.ndc new file mode 100644 index 00000000..05495e45 --- /dev/null +++ b/tests/functional/programs/007_map_and_set/035_a_literal_key_takes_the_map_s_key_type.ndc @@ -0,0 +1,5 @@ +// The `1` is a key of a map of Floats, so it's the Float `1.0`. +// expect-output: a false true +let m = %{1: "a", 2.5: "b"}; +let n = %{1.5: "c"}; +print(m[1], 1 in n, 1.5 in n); From f2cadd92a16042c134788aa349df6f5e0cb65ee7 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 13:19:05 +0100 Subject: [PATCH 21/39] =?UTF-8?q?fix:=20bugs=20and=20slowdowns=20found=20r?= =?UTF-8?q?unning=20Advent=20of=20Code=20solutions=20=F0=9F=8E=84=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes found by running a set of Advent of Code 2025 solutions on the type checker and on master. Bug fixes: - A generic function's own body, compiled for arguments of any type, failed to compile with "encountered unresolved binding" when a call in it fit no overload once the type parameters were `Any`, as `sum` fits no `List`: `fn f(s) { [i for (i, c) in s.enumerate()].sum() }`. That body now leaves such a call to the program as it runs. - An integer literal could become a Float to reach an overload that takes it only through `Any`: in `let e: String = cs[0]` on a `List`, the index became `0.0` to pick `[](List, Any) -> Any`, so analysis passed and the program failed with "List indices must be Int values". An `Any` parameter is now a fallback that a literal doesn't change its type to reach when another overload in the same scope takes a number type or a type parameter there, so this is reported as the mismatch it is. Where nothing in that scope takes a number, as with `print`, a literal still adapts to its other uses. - An operator on tuples committed at once, element by element, when an operand or a tuple element wasn't known yet, giving `Any` there for good: `(cs[0] - (1, 1)).product()` on a list built by a comprehension failed with "No function called 'product' found that matches the arguments 'Tuple'". It now waits for operands and elements nothing is known about, while a partly known one, like `[]`, is still settled by the overload each position picks. Performance: - An operator on tuples through runtime dispatch, as `(l - r) * (l - r)` with `l` from `combinations`, first tried every scalar overload again, element by element, before applying itself to each element. Applying to each element now comes first, and the element-by-element check stays the last thing tried before a call fails. `conforms_value_args` no longer clones the function's type, and conformance is only memoized for container targets. - Marking an overloaded function value as one of a list literal's values scanned every waiting constraint for each value of unknown type, which made large list literals quadratic. Only nodes that waited as function values are looked up now. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/function_values.rs | 1 + ndc_analyser/src/analyser/generics.rs | 37 ++++++++- ndc_analyser/src/analyser/mod.rs | 7 +- ndc_analyser/src/analyser/solver.rs | 75 +++++++++++++++++++ ndc_analyser/src/overloads.rs | 14 ++++ ndc_vm/src/value/function.rs | 8 +- ndc_vm/src/value/mod.rs | 20 ++++- ndc_vm/src/vm.rs | 32 ++++++-- ...body_s_own_calls_are_picked_as_it_runs.ndc | 7 ++ ...tor_on_a_tuple_waits_for_what_it_holds.ndc | 5 ++ ...ecome_a_float_to_reach_an_any_overload.ndc | 7 ++ ...r_overload_doesn_t_hold_a_literal_back.ndc | 11 +++ 12 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 tests/functional/programs/005_functions/095_a_generic_body_s_own_calls_are_picked_as_it_runs.ndc create mode 100644 tests/functional/programs/013_vector_math/015_an_operator_on_a_tuple_waits_for_what_it_holds.ndc create mode 100644 tests/functional/programs/018_literals/027_a_literal_doesn_t_become_a_float_to_reach_an_any_overload.ndc create mode 100644 tests/functional/programs/018_literals/028_an_outer_number_overload_doesn_t_hold_a_literal_back.ndc diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index 8d28e226..a8f6c2ec 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -79,6 +79,7 @@ impl Analyser { span, }; if !self.try_function_value(&value) { + self.waiting_function_values.insert(id); self.wait(Constraint::FunctionValue(value)); } return self.vars.resolve(&expected); diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index bba07313..3080086f 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -12,6 +12,7 @@ use crate::error::AnalysisError; use crate::overloads::is_strictly_more_specific; use crate::types::{LiteralBounds, TypeVars}; use ahash::{AHashMap, AHashSet}; +use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; use ndc_resolver::{ScopeKind, SymbolId}; @@ -522,9 +523,41 @@ impl Analyser { }) }) .collect::>(); - let binding = self + let binding = match self .resolve_overloads(call.candidates(), &arguments, call.kind()) - .binding; + .binding + { + // An overload may take the values the body gets as the program + // runs although none takes their types with `Any` in them, as + // `sum` takes no `List`. The program picks among them as it + // runs, which looks at a container's elements. + Binding::None => { + let candidates = self + .overloads() + .reachable_symbols(call.candidates()) + .into_iter() + .filter( + |&symbol| match self.vars.resolve(&self.types.callable_type(symbol)) { + StaticType::Function { + parameters: Some(parameters), + .. + } => parameters.len() == arguments.len(), + StaticType::Function { + parameters: None, .. + } => true, + _ => false, + }, + ) + .map(Candidate::Scalar) + .collect::>(); + if candidates.is_empty() { + Binding::None + } else { + Binding::Dynamic(candidates) + } + } + binding => binding, + }; self.record_pick(call.site(), binding, &arguments); } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index da554bf9..f5fbd30b 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -78,6 +78,9 @@ pub struct Analyser { /// The integer literals in the batch, with their types, which a /// generic function's instances may give types of their own. number_literals: Vec<(NodeId, StaticType)>, + /// The nodes of overloaded function values that waited for the type + /// expected of them, as `abs` in `[len, abs]` does. + waiting_function_values: AHashSet, /// The calls the constraint being solved was copied through, when it's /// a copy of a generic function's constraint. copied_through: Option, @@ -154,6 +157,7 @@ impl Analyser { declaring: None, recursive_calls: Vec::new(), number_literals: Vec::new(), + waiting_function_values: AHashSet::new(), copied_through: None, checks: Vec::new(), next_serial: 0, @@ -188,6 +192,7 @@ impl Analyser { } self.failed_reads.clear(); self.number_literals.clear(); + self.waiting_function_values.clear(); self.types.resolve_all(&self.vars); for (instance, node, typ, expected) in std::mem::take(&mut self.checks) { if self.vars.resolve(&typ) != StaticType::Any { @@ -786,7 +791,7 @@ impl Analyser { if let Some(node) = node { // An overloaded function value waiting for its type, as // `abs` in `[len, abs]`, is one of the literal's values. - if matches!(self.vars.resolve(&typ), StaticType::Var(_)) { + if self.waiting_function_values.contains(&node) { for waiting in self.waiting.values_mut() { if let Constraint::FunctionValue(value) = &mut waiting.constraint && value.id() == node diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index aa703ca4..65717684 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -874,6 +874,9 @@ impl Analyser { .resolve_call(&call.candidates, &arguments, call.kind) .binding { + if self.takes_a_number_as_a_fallback(symbol, &call.candidates, &arguments) { + return Fit::No; + } let snapshot = self.vars.snapshot(); if self.overload_fits(symbol, &arguments, &call.result) { return Fit::Picked; @@ -891,6 +894,60 @@ impl Analyser { if fits { Fit::Maybe } else { Fit::No } } + /// Whether `symbol` takes a number among `arguments` through an `Any` + /// parameter where another of `candidates` in its scope takes a number + /// type or a type parameter, which makes `symbol` a fallback that a + /// literal doesn't take a type to reach: `[](List, Any)` takes the + /// Float index `0.0`, but `[](List, Int)` is what takes an index. + /// Where nothing else in that scope takes a number, as for `print`, the + /// `Any` says nothing about what the literal is. + fn takes_a_number_as_a_fallback( + &self, + symbol: SymbolId, + candidates: &Candidates, + arguments: &[StaticType], + ) -> bool { + let parameters_of = + |symbol: SymbolId| match self.vars.resolve(&self.types.callable_type(symbol)) { + StaticType::Function { + parameters: Some(parameters), + .. + } if parameters.len() == arguments.len() => Some(parameters), + _ => None, + }; + let is_number = |typ: &StaticType| { + matches!( + typ, + StaticType::Int | StaticType::Float | StaticType::Number + ) + }; + let Some(parameters) = parameters_of(symbol) else { + return false; + }; + let fallbacks = parameters + .iter() + .zip(arguments) + .enumerate() + .filter(|(_, (parameter, argument))| { + **parameter == StaticType::Any && is_number(argument) + }) + .map(|(position, _)| position) + .collect::>(); + !fallbacks.is_empty() + && self + .overloads() + .same_scope_symbols(candidates, symbol) + .into_iter() + .filter(|&other| other != symbol) + .filter_map(parameters_of) + .any(|parameters| { + fallbacks.iter().any(|&position| { + is_number(¶meters[position]) + || matches!(parameters[position], StaticType::Generic(_)) + }) + }) + } + /// Whether `symbol` takes `arguments` and returns what fits `result`, /// binding the types on the way. One whose parameters aren't known /// takes anything. @@ -1213,6 +1270,24 @@ impl Analyser { { None } + // An operator applies to each element of a tuple, which waits + // for the operands and elements nothing is known about yet: in + // `(xs[0], 1) - (1, 1)`, the first element is whatever `xs` + // holds, and in `p - (1, 1)`, `p` may be a tuple of anything. An + // element partly known, as `[]` is a list, is settled by the + // overload each position picks. + EarlyOverload::NoneFits + if call.kind == CallKind::Operator + && arguments.iter().any(|argument| match argument { + StaticType::Var(_) => true, + StaticType::Tuple(elements) => elements + .iter() + .any(|element| matches!(element, StaticType::Var(_))), + _ => false, + }) => + { + None + } EarlyOverload::NoneFits => Some(self.resolve_call_now(call, &arguments)), EarlyOverload::Only(only) => { let Some(typ) = self.commit_early(call, &arguments, only) else { diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 91d59f86..aec7b413 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -170,6 +170,20 @@ impl<'a> Overloads<'a> { self.reachable(candidates).flatten().copied().collect() } + /// The reachable candidates in the scope `symbol` is declared in, which + /// are the ones a call that picks `symbol` could pick instead: one + /// further out loses to any that takes the call. + pub(crate) fn same_scope_symbols( + self, + candidates: &Candidates, + symbol: SymbolId, + ) -> Vec { + self.reachable(candidates) + .find(|group| group.contains(&symbol)) + .map(<[SymbolId]>::to_vec) + .unwrap_or_default() + } + /// The variable that ends the search, if one does: in /// `fn foo(a) { … } let foo = fn(a, b) { … };`, a call of `foo` ends at the `let`. pub(crate) fn variable_ending_search(self, candidates: &Candidates) -> Option { diff --git a/ndc_vm/src/value/function.rs b/ndc_vm/src/value/function.rs index 43144daa..53540d9f 100644 --- a/ndc_vm/src/value/function.rs +++ b/ndc_vm/src/value/function.rs @@ -217,7 +217,13 @@ impl Function { /// element of a container argument against a parameter like /// `List`, which [`Self::matches_value_args`] doesn't. pub fn conforms_value_args(&self, args: &[Value]) -> bool { - match self.static_type() { + let st = match self { + Self::Native(f) => &f.static_type, + Self::Compiled(f) => &f.static_type, + Self::Closure(c) => &c.prototype.static_type, + Self::Memoized { function, .. } => return function.conforms_value_args(args), + }; + match st { StaticType::Function { parameters: Some(params), .. diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index fb8d6032..1443fdb8 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -432,8 +432,23 @@ impl Value { } fn conforms_to_cached(&self, target: &StaticType, cache: &mut ConformanceCache) -> bool { + // Only a container target looks inside the value, which may be shared + // or cyclic; checking against another type takes no time to redo. + let looks_inside = matches!( + target, + StaticType::Option(_) + | StaticType::List(_) + | StaticType::Deque(_) + | StaticType::Tuple(_) + | StaticType::Map { .. } + | StaticType::MinHeap(_) + | StaticType::MaxHeap(_) + | StaticType::Iterable(_) + ); let cache_key = match self { - Self::Object(object) => Some((Rc::as_ptr(object), std::ptr::from_ref(target))), + Self::Object(object) if looks_inside => { + Some((Rc::as_ptr(object), std::ptr::from_ref(target))) + } _ => None, }; if let Some(cache_key) = cache_key @@ -1296,7 +1311,8 @@ mod tests { let mut cache = ConformanceCache::default(); assert!(value.conforms_to_cached(&target, &mut cache)); - assert_eq!(cache.len(), 4); + // One entry per list level; the `Any` inside needs no looking into. + assert_eq!(cache.len(), 3); // Break the reference cycle so the test does not leak its allocations. elements.borrow_mut().clear(); diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index a0f0aaef..0229793d 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -776,9 +776,17 @@ impl Vm { match &self.stack[self.stack.len() - args - 1] { Value::Object(obj) => match obj.as_ref() { Object::Function(f) => Ok(Some(f.clone())), - Object::OverloadSet { scalars, .. } => { + Object::OverloadSet { + scalars, + vec_candidates, + } => { let start = self.stack.len() - args; - Ok(self.find_scalar_overload(scalars, &self.stack[start..])) + let args = &self.stack[start..]; + // An operator on tuples applies to each element, which + // comes before looking inside containers for a scalar + // overload: that's only before the call fails. + let elementwise = !vec_candidates.is_empty() && vec_axis_len(args).is_some(); + Ok(self.find_scalar_overload(scalars, args, !elementwise)) } other => Err(format!( "Unable to invoke {} as a function.", @@ -844,9 +852,15 @@ impl Vm { /// code stays at the same shape and footprint. A parameter like /// `List` isn't checked element by element on that path, so when no /// candidate accepts the arguments that way, they're checked again - /// element by element before the call fails: a struct with a - /// `List` field can be made through `Any` with a list of Ints. - fn find_scalar_overload(&self, scalars: &[ResolvedVar], args: &[Value]) -> Option { + /// element by element before the call fails, if `look_inside`: a struct + /// with a `List` field can be made through `Any` with a list of + /// Ints. + fn find_scalar_overload( + &self, + scalars: &[ResolvedVar], + args: &[Value], + look_inside: bool, + ) -> Option { let frame_pointer = self.frames.last().expect("no frame").frame_pointer; let function = |var| { let value = self.resolve_var(var, frame_pointer); @@ -862,9 +876,11 @@ impl Vm { .iter() .find_map(|var| function(var).filter(|f| f.matches_value_args(args))) .or_else(|| { - scalars - .iter() - .find_map(|var| function(var).filter(|f| f.conforms_value_args(args))) + look_inside.then(|| { + scalars + .iter() + .find_map(|var| function(var).filter(|f| f.conforms_value_args(args))) + })? }) } diff --git a/tests/functional/programs/005_functions/095_a_generic_body_s_own_calls_are_picked_as_it_runs.ndc b/tests/functional/programs/005_functions/095_a_generic_body_s_own_calls_are_picked_as_it_runs.ndc new file mode 100644 index 00000000..5dfc0082 --- /dev/null +++ b/tests/functional/programs/005_functions/095_a_generic_body_s_own_calls_are_picked_as_it_runs.ndc @@ -0,0 +1,7 @@ +// The function's own body is compiled for arguments of any type, where +// `sum` gets a list of what `s` holds: no overload takes a `List`, so +// the call is picked as the program runs. +// expect-output: 1 3 +fn f(s) { [i for (i, c) in s.enumerate()].sum() } +let a: Any = "abc"; +print(f("ab"), f(a)); diff --git a/tests/functional/programs/013_vector_math/015_an_operator_on_a_tuple_waits_for_what_it_holds.ndc b/tests/functional/programs/013_vector_math/015_an_operator_on_a_tuple_waits_for_what_it_holds.ndc new file mode 100644 index 00000000..293840d0 --- /dev/null +++ b/tests/functional/programs/013_vector_math/015_an_operator_on_a_tuple_waits_for_what_it_holds.ndc @@ -0,0 +1,5 @@ +// `cs[0]` isn't known when `-` is reached, but it's a tuple of Ints, so +// the result is one too. +// expect-output: 6 +let cs = [(x, y) for (x, y) in [[3, 4]]]; +print((cs[0] - (1, 1)).product()); diff --git a/tests/functional/programs/018_literals/027_a_literal_doesn_t_become_a_float_to_reach_an_any_overload.ndc b/tests/functional/programs/018_literals/027_a_literal_doesn_t_become_a_float_to_reach_an_any_overload.ndc new file mode 100644 index 00000000..a886e8b6 --- /dev/null +++ b/tests/functional/programs/018_literals/027_a_literal_doesn_t_become_a_float_to_reach_an_any_overload.ndc @@ -0,0 +1,7 @@ +// `[](List, Int)` gives an Int, which isn't a String. A Float index would +// reach `[](List, Any)`, but that overload is a fallback, so the `0` +// stays an Int and the mismatch is reported. +// expect-error: mismatched types: found Int but expected String +let ys = ["a".len()]; +let cs = [x for x in ys]; +let e: String = cs[0]; diff --git a/tests/functional/programs/018_literals/028_an_outer_number_overload_doesn_t_hold_a_literal_back.ndc b/tests/functional/programs/018_literals/028_an_outer_number_overload_doesn_t_hold_a_literal_back.ndc new file mode 100644 index 00000000..21a16ac8 --- /dev/null +++ b/tests/functional/programs/018_literals/028_an_outer_number_overload_doesn_t_hold_a_literal_back.ndc @@ -0,0 +1,11 @@ +// The inner `f` takes the call whatever `n` is, so the outer `f(Int)` says +// nothing about `n`, which `n += 0.5` makes a Float. +// expect-output: 0.5 +fn f(x: Int) => "outer"; +{ + fn f(x: Any) => 0.5; + let n = 0; + f(n); + n += 0.5; + print(n); +} From fbced07dc2adb69be3dc7ba81bf12d07c4fa4b57 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 13:21:04 +0100 Subject: [PATCH 22/39] =?UTF-8?q?feat(analyser):=20give=20a=20tuple=20elem?= =?UTF-8?q?ent=20picked=20by=20a=20literal=20index=20its=20type=20?= =?UTF-8?q?=F0=9F=8E=AF=20(#323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indexing a tuple gave `Any`, as signatures can't say that the result's type depends on the index's value. That `Any` spread through code that uses tuples as points or pairs: an Advent of Code solution needed `as List>` to type-check, and the calls that took the `Any` were dispatched as the program ran. An index written as an integer in the code, as in `t[1]` or `t[-1]`, now gives that element's type: - `(1, "a")[1]` is a `String`, and `(1, "a")[-2]` an `Int`. - It works per instance in generic functions: `fn first(t) => t[0]` returns a `String` for `first(("x", 2))`. - The `[]` call is picked when the program is compiled. - An index past either end is an analysis error: "Index 3 is out of bounds for a tuple of 3 elements". - An index computed as the program runs (`t[i]`) still gives `Any`. The `[]` call's constraint records the literal index (`CallConstraint::tuple_index`). Once the container is known to be a tuple, the solver gives the element's type and settles the index as an Int. Copies of `t[0]` and `t[1]` have the same argument types, so the index is part of what makes two copies the same call. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/tuple.md | 16 ++++++ ndc_analyser/src/analyser/calls.rs | 31 +++++++++++- ndc_analyser/src/analyser/solver.rs | 49 +++++++++++++++++++ ndc_analyser/src/error.rs | 9 ++++ ...literal_index_gives_the_element_s_type.ndc | 11 +++++ ...literal_index_past_the_end_is_an_error.ndc | 3 ++ .../003_an_element_has_its_own_type.ndc | 4 ++ 7 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/functional/programs/020_tuples/001_a_literal_index_gives_the_element_s_type.ndc create mode 100644 tests/functional/programs/020_tuples/002_a_literal_index_past_the_end_is_an_error.ndc create mode 100644 tests/functional/programs/020_tuples/003_an_element_has_its_own_type.ndc diff --git a/manual/src/reference/types/tuple.md b/manual/src/reference/types/tuple.md index 4eabb5ac..9aba9359 100644 --- a/manual/src/reference/types/tuple.md +++ b/manual/src/reference/types/tuple.md @@ -21,6 +21,22 @@ let my_tuple = (1,2,3); my_tuple[0] = 99; // ERROR ``` +An index written as a number, like `t[1]` or `t[-1]`, picks one element, and has that element's type. One past the end is an error before the program runs: + +```ndc +let point = ("origin", 0, 0); +let name: String = point[0]; +let y: Int = point[-1]; +print(name, y); // origin 0 +``` + +```ndc,fails +let point = ("origin", 0, 0); +print(point[3]); // error: Index 3 is out of bounds for a tuple of 3 elements +``` + +An index computed as the program runs, like `t[i]`, could pick any element, so it gives `Any`. + Create a 1-element tuple by adding a trailing comma: ```ndc diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 2f758adf..4a47316e 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -3,7 +3,7 @@ use super::solver::{CallNodes, CallSite, Constraint}; use crate::error::AnalysisError; use crate::overloads::{CallKind, Overloads}; use ndc_core::{NodeId, StaticType}; -use ndc_lexer::Span; +use ndc_lexer::{NumericLiteral, Span}; use ndc_parser::{Expression, ExpressionLocation}; use ndc_resolver::Candidates; @@ -147,11 +147,15 @@ impl Analyser { // A named callee declares nothing, so its overload can be picked after // the arguments' types are known. + let tuple_index = match arguments { + [_, index] if name == "[]" => literal_index(index), + _ => None, + }; let arguments = arguments .iter() .map(|arg| self.analyse_expr(arg)) .collect::>(); - self.constrain_call( + self.constrain_index_call( CallSite::Call(function.id), arguments, CallNodes { @@ -161,6 +165,29 @@ impl Analyser { kind, name, span, + tuple_index, ) } } + +/// The value of an index that's an integer written in the code, as in +/// `t[1]` or `t[-1]`. +fn literal_index(index: &ExpressionLocation) -> Option { + match &index.expression { + Expression::NumericLiteral(NumericLiteral::Int64(value)) => Some(*value), + Expression::Call { + function, + arguments, + } + | Expression::OperatorCall { + function, + arguments, + } => match (&function.expression, &arguments[..]) { + (Expression::Identifier { name, .. }, [operand]) if name == "-" => { + literal_index(operand).and_then(i64::checked_neg) + } + _ => None, + }, + _ => None, + } +} diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 65717684..bc8a9d4f 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -216,6 +216,9 @@ pub(super) struct CallConstraint { kind: CallKind, name: String, span: Span, + /// The index of `t[1]`, a number written in the code, which picks the + /// element of a tuple `t` and its type. + tuple_index: Option, } impl CallConstraint { @@ -247,6 +250,7 @@ impl CallConstraint { std::mem::discriminant(&self.site) == std::mem::discriminant(&other.site) && self.kind == other.kind && self.candidates == other.candidates + && self.tuple_index == other.tuple_index && self.arguments.len() == other.arguments.len() && self .arguments @@ -268,6 +272,22 @@ impl Analyser { kind: CallKind, name: &str, span: Span, + ) -> StaticType { + self.constrain_index_call(site, arguments, nodes, kind, name, span, None) + } + + /// [`Self::constrain_call`], for `[]` with the index `tuple_index` when + /// it's a number written in the code, which picks a tuple's element. + #[allow(clippy::too_many_arguments)] + pub(super) fn constrain_index_call( + &mut self, + site: CallSite, + arguments: Vec, + nodes: CallNodes, + kind: CallKind, + name: &str, + span: Span, + tuple_index: Option, ) -> StaticType { let mut call = CallConstraint { candidates: self.site_candidates(site).clone(), @@ -278,6 +298,7 @@ impl Analyser { kind, name: name.to_string(), span, + tuple_index, }; if let Some(typ) = self.try_call(&call) { return typ; @@ -1172,6 +1193,34 @@ impl Analyser { .iter() .map(|argument| self.vars.resolve(argument)) .collect::>(); + // A tuple indexed by a number written in the code gives that + // element, of its own type: `(1, "a")[1]` is a String, and + // `(1, "a")[-2]` an Int. + if let (Some(index), Some(StaticType::Tuple(elements))) = + (call.tuple_index, arguments.first()) + { + let length = i64::try_from(elements.len()).expect("a tuple's length fits an i64"); + let position = if index < 0 { length + index } else { index }; + let element = usize::try_from(position) + .ok() + .and_then(|position| elements.get(position)) + .cloned(); + let indexed = vec![arguments[0].clone(), StaticType::Int]; + if let [_, index_type] = &arguments[..] { + self.vars.bind_argument(&StaticType::Int, index_type); + } + self.resolve_call_now(call, &indexed); + let Some(element) = element else { + self.emit(AnalysisError::tuple_index_out_of_bounds( + index, + elements.len(), + call.span, + )); + return Some(StaticType::Any); + }; + return Some(element); + } + let reachable = self.overloads().reachable_symbols(&call.candidates); // A generic function that calls itself with an argument made from diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index 7fa5b92d..e90df755 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -136,6 +136,15 @@ impl AnalysisError { } } + pub(crate) fn tuple_index_out_of_bounds(index: i64, length: usize, span: Span) -> Self { + Self { + text: format!("Index {index} is out of bounds for a tuple of {length} elements"), + span, + help_text: None, + notes: Vec::new(), + } + } + pub(crate) fn iterable_outside_a_parameter(span: Span) -> Self { Self { text: "`Iterable` can only be the type of a parameter".to_string(), diff --git a/tests/functional/programs/020_tuples/001_a_literal_index_gives_the_element_s_type.ndc b/tests/functional/programs/020_tuples/001_a_literal_index_gives_the_element_s_type.ndc new file mode 100644 index 00000000..a0f30b84 --- /dev/null +++ b/tests/functional/programs/020_tuples/001_a_literal_index_gives_the_element_s_type.ndc @@ -0,0 +1,11 @@ +// `t[1]` picks the second element, so it's a String, and `t[-2]` the +// first, an Int. A generic function gets each call's element types. +// expect-output: a 2 x ("x",1) +fn first(t) => t[0]; +fn swap(t) => (t[1], t[0]); +let t = (1, "a"); +let s: String = t[1]; +let n: Int = t[-2] + 1; +let f: String = first(("x", 2)); +let p: (String, Int) = swap((1, "x")); +print(s, n, f, p); diff --git a/tests/functional/programs/020_tuples/002_a_literal_index_past_the_end_is_an_error.ndc b/tests/functional/programs/020_tuples/002_a_literal_index_past_the_end_is_an_error.ndc new file mode 100644 index 00000000..8b05a274 --- /dev/null +++ b/tests/functional/programs/020_tuples/002_a_literal_index_past_the_end_is_an_error.ndc @@ -0,0 +1,3 @@ +// expect-error: Index -3 is out of bounds for a tuple of 2 elements +let t = (1, "a"); +print(t[-3]); diff --git a/tests/functional/programs/020_tuples/003_an_element_has_its_own_type.ndc b/tests/functional/programs/020_tuples/003_an_element_has_its_own_type.ndc new file mode 100644 index 00000000..373f880c --- /dev/null +++ b/tests/functional/programs/020_tuples/003_an_element_has_its_own_type.ndc @@ -0,0 +1,4 @@ +// The first element is an Int, which isn't a String. +// expect-error: mismatched types: found Int but expected String +let t = (1, "a"); +let s: String = t[0]; From 68643dcb7d564de5110489c34285ef707ac0c22d Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 13:23:23 +0100 Subject: [PATCH 23/39] =?UTF-8?q?fix(stdlib):=20give=20min=20and=20max=20o?= =?UTF-8?q?f=20two=20values=20the=20values'=20type=20=F0=9F=93=8F=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-argument `min` and `max` were `(A, B) -> Any`. So `min(a, b)` was `Any`, and every call that used the result was dispatched as the program ran, as with the corners `(min(...), min(...))` of a rectangle. They're now `(T, T) -> T`. A literal takes the other argument's type, so `min(2.5, 1)` is the Float `1.0`. Behaviour change: an Int and a Float together, as in `min(n, 2.5)` with `n` an Int variable, is now an analysis error: "No function called 'min' found that matches the arguments 'Int, Float'". Convert one of them, as with `float(n)`. The manual's `lowest min= g(x)` example starts `lowest` at `Inf`, a Float, so it now prints `0.0 3481.0`. An `(Any, Any) -> Any` overload for mixed arguments can't sit next to `(T, T) -> T`. Overload resolution treats an unannotated type parameter and `Any` as equally specific fallbacks, so whichever is declared later wins. That sends either the mixed arguments or the same-typed ones to the wrong overload. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- manual/src/features/augmented-assignment.md | 2 +- ndc_stdlib/src/cmp.rs | 36 +++++++++++-------- ...d_max_give_the_type_of_their_arguments.ndc | 7 ++++ .../005_min_takes_two_values_of_one_type.ndc | 4 +++ 4 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 tests/functional/programs/604_stdlib_math/004_min_and_max_give_the_type_of_their_arguments.ndc create mode 100644 tests/functional/programs/604_stdlib_math/005_min_takes_two_values_of_one_type.ndc diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index fefe5d52..d2c6d1b0 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -136,5 +136,5 @@ for x in 1..100 { highest max= g(x); } -print(lowest, highest); // 0 3481 +print(lowest, highest); // 0.0 3481.0 ``` diff --git a/ndc_stdlib/src/cmp.rs b/ndc_stdlib/src/cmp.rs index e41917ac..0f66e369 100644 --- a/ndc_stdlib/src/cmp.rs +++ b/ndc_stdlib/src/cmp.rs @@ -51,30 +51,38 @@ mod inner { } /// Returns the larger of `left` and `right`, preferring `left` if they are equal. - #[function(signature = "(A, B) -> Any")] + #[function(signature = "(T, T) -> T")] pub fn max(left: Value, right: Value) -> anyhow::Result { + larger(left, right) + } + + /// Returns the smaller of `left` and `right`, preferring `left` if they are equal. + #[function(signature = "(T, T) -> T")] + pub fn min(left: Value, right: Value) -> anyhow::Result { + smaller(left, right) + } + + fn larger(left: Value, right: Value) -> anyhow::Result { match left.partial_cmp(&right) { Some(Ordering::Equal | Ordering::Greater) => Ok(left), Some(Ordering::Less) => Ok(right), - None => Err(anyhow!( - "cannot compare {} and {}", - left.static_type(), - right.static_type() - )), + None => Err(incomparable(&left, &right)), } } - /// Returns the smaller of `left` and `right`, preferring `left` if they are equal. - #[function(signature = "(A, B) -> Any")] - pub fn min(left: Value, right: Value) -> anyhow::Result { + fn smaller(left: Value, right: Value) -> anyhow::Result { match left.partial_cmp(&right) { Some(Ordering::Equal | Ordering::Less) => Ok(left), Some(Ordering::Greater) => Ok(right), - None => Err(anyhow!( - "cannot compare {} and {}", - left.static_type(), - right.static_type() - )), + None => Err(incomparable(&left, &right)), } } + + fn incomparable(left: &Value, right: &Value) -> anyhow::Error { + anyhow!( + "cannot compare {} and {}", + left.static_type(), + right.static_type() + ) + } } diff --git a/tests/functional/programs/604_stdlib_math/004_min_and_max_give_the_type_of_their_arguments.ndc b/tests/functional/programs/604_stdlib_math/004_min_and_max_give_the_type_of_their_arguments.ndc new file mode 100644 index 00000000..3ff06d3d --- /dev/null +++ b/tests/functional/programs/604_stdlib_math/004_min_and_max_give_the_type_of_their_arguments.ndc @@ -0,0 +1,7 @@ +// `min` and `max` of two values give a value of their type, and a literal +// takes the other argument's type. +// expect-output: 2 1.0 3.0 +let a = "ab".len(); +let m: Int = min(a, 5); +let x = 2.5; +print(m, min(x, 1), max(x, 3)); diff --git a/tests/functional/programs/604_stdlib_math/005_min_takes_two_values_of_one_type.ndc b/tests/functional/programs/604_stdlib_math/005_min_takes_two_values_of_one_type.ndc new file mode 100644 index 00000000..56b36581 --- /dev/null +++ b/tests/functional/programs/604_stdlib_math/005_min_takes_two_values_of_one_type.ndc @@ -0,0 +1,4 @@ +// An Int and a Float have no type in common, so say which one is meant. +// expect-error: No function called 'min' found that matches the arguments 'Int, Float' +let a = "ab".len(); +print(min(a, 2.5)); From 261ab90a8527432d4b8b56c9ed9e8e212188121d Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 15:14:36 +0100 Subject: [PATCH 24/39] =?UTF-8?q?feat(analyser):=20give=20a=20lambda's=20p?= =?UTF-8?q?arameters=20the=20types=20the=20call=20passes=20=F0=9F=8E=AF=20?= =?UTF-8?q?(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anonymous function passed to a call used to be analysed before the analyser looked at the function it's passed to, so its parameters got their types from its body. In `players.map(fn(p) => p.size)`, `Team` is the only struct with a `size` field, so `p` became a `Team`, and the error was that `map` doesn't take `List, Fn(Team) -> Int`, pointing at the whole call. Now a call analyses its other arguments first. An anonymous function with unannotated parameters then gets the parameter types that every overload that takes those arguments agrees on, before its body is checked, as Rust, TypeScript, Kotlin and C# do. The example now reports `No function called 'size' found that matches the arguments 'Player'` at `p.size`. Where the overloads disagree, or a type isn't known yet, the parameter works out its type as before. - Error messages about calls with a lambda show the parameter types it got: `filter`'s test now expects `Fn(Int) -> Int` where it said `Fn(Any) -> Int`. - `merges_literals` counted a literal that appears in two arguments twice, as the element of `[0]` does in `[0].flat_map(fn(_) => xs)` once `_` has its type. That made the call wait, and `flatten(true)` reported a failed `flat_map` instead of `Bool is not iterable`. It now counts each literal once. The manual's lambda section gets the example. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/function.md | 9 + ndc_analyser/src/analyser/calls.rs | 168 +++++++++++++++++- ndc_analyser/src/analyser/declarations.rs | 5 +- ndc_analyser/src/analyser/mod.rs | 5 + ndc_analyser/src/analyser/solver.rs | 4 + ndc_analyser/src/analyser/tests.rs | 4 +- ...a_passed_to_map_takes_the_element_type.ndc | 8 + ...akes_its_parameter_types_from_the_call.ndc | 8 + ...ilter_takes_a_predicate_returning_bool.ndc | 2 +- 9 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 tests/functional/programs/015_struct/037_a_lambda_passed_to_map_takes_the_element_type.ndc create mode 100644 tests/functional/programs/015_struct/038_a_lambda_takes_its_parameter_types_from_the_call.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index 1da1e120..9e3fdf07 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -202,6 +202,15 @@ print(tens); // [30,10,20] let double: Fn(Int) -> Int = fn(x) => x ++ "!"; // error: `x ++ "!"` makes x a String, so the lambda is a Fn(String) -> String ``` +A lambda passed to a function gets its parameter types from that function and its other arguments before its body is checked, so a mistake in the body is reported where it is: + +```ndc,fails +struct Player { name: String, score: Int } +struct Team { name: String, size: Int } +let players = [Player("ann", 3), Player("bob", 5)]; +players.map(fn(p) => p.size); // error: p is a Player, which has no size +``` + A lambda stored in a variable has one type, like any variable, so it can't take an `Int` in one call and a `Float` in the next: ```ndc,fails diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 4a47316e..afec3f86 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -5,7 +5,7 @@ use crate::overloads::{CallKind, Overloads}; use ndc_core::{NodeId, StaticType}; use ndc_lexer::{NumericLiteral, Span}; use ndc_parser::{Expression, ExpressionLocation}; -use ndc_resolver::Candidates; +use ndc_resolver::{Candidates, SymbolId}; impl Analyser { /// Why no candidate accepts a call of `name` with arguments of these types: @@ -151,10 +151,7 @@ impl Analyser { [_, index] if name == "[]" => literal_index(index), _ => None, }; - let arguments = arguments - .iter() - .map(|arg| self.analyse_expr(arg)) - .collect::>(); + let arguments = self.analyse_arguments(function.id, arguments); self.constrain_index_call( CallSite::Call(function.id), arguments, @@ -168,6 +165,167 @@ impl Analyser { tuple_index, ) } + + /// Analyses the arguments of a call of the function named at `callee` + /// and returns their types. An anonymous function among them is + /// analysed after the others, with the parameter types the overloads + /// that take those agree on, so its body is checked against what it + /// will be passed: in `players.map(fn(p) => p.size)`, `p` is a + /// `Player`, which has no `size`. + fn analyse_arguments( + &mut self, + callee: NodeId, + arguments: &[ExpressionLocation], + ) -> Vec { + let mut types = arguments + .iter() + .map(|argument| { + (!takes_parameter_types_from_the_call(argument)) + .then(|| self.analyse_expr(argument)) + }) + .collect::>(); + for (position, argument) in arguments.iter().enumerate() { + if types[position].is_some() { + continue; + } + if let Some(expected) = self.parameters_the_overloads_agree_on(callee, &types, position) + && expected.len() == parameter_count(argument) + { + self.expected_parameters.insert(argument.id, expected); + } + types[position] = Some(self.analyse_expr(argument)); + } + types.into_iter().flatten().collect() + } + + /// The types of the parameters of the function passed at `position` + /// that every overload at `callee` that takes the `known` arguments + /// agrees on, each `None` where they don't, or where it isn't known + /// yet. `None` when no overload takes them, or one takes a function of + /// any type there. + fn parameters_the_overloads_agree_on( + &mut self, + callee: NodeId, + known: &[Option], + position: usize, + ) -> Option>> { + let candidates = self.resolver.reference_candidates(callee).clone(); + let mut agreed: Option>> = None; + for symbol in self.overloads().reachable_symbols(&candidates) { + let snapshot = self.vars.snapshot(); + let expected = self.parameters_one_overload_expects(symbol, known, position); + self.vars.rollback(snapshot); + let expected = match expected { + Expects::Parameters(expected) => expected, + Expects::Nothing => continue, + Expects::Anything => return None, + }; + agreed = Some(match agreed { + None => expected, + Some(agreed) => agreed + .into_iter() + .zip(expected) + .map(|(agreed, expected)| { + agreed.filter(|agreed| Some(agreed) == expected.as_ref()) + }) + .collect(), + }); + } + agreed + } + + /// The parameter types `symbol` expects of the function passed at + /// `position` if it takes the `known` arguments. Settles variables, so + /// the caller rolls them back. + fn parameters_one_overload_expects( + &mut self, + symbol: SymbolId, + known: &[Option], + position: usize, + ) -> Expects { + let function = self.vars.resolve(&self.types.callable_type(symbol)); + if !matches!( + function, + StaticType::Function { + parameters: Some(_), + .. + } + ) { + return Expects::Anything; + } + let first = self.vars.count(); + let StaticType::Function { + parameters: Some(parameters), + .. + } = self.vars.instantiate(&function) + else { + unreachable!("instantiating a function type gives a function type"); + }; + let takes_known = parameters.len() == known.len() + && parameters.iter().zip(known).all(|(parameter, argument)| { + argument + .as_ref() + .is_none_or(|argument| self.vars.bind_argument(parameter, argument)) + }); + if !takes_known { + return Expects::Nothing; + } + match self.vars.resolve(¶meters[position]) { + StaticType::Function { + parameters: Some(expected), + .. + } => Expects::Parameters( + expected + .iter() + .map(|typ| { + Some(self.vars.resolve(typ)).filter(|typ| is_known_before(typ, first)) + }) + .collect(), + ), + _ => Expects::Anything, + } + } +} + +/// What an overload expects of a function passed to it. +enum Expects { + /// It doesn't take the call's other arguments. + Nothing, + /// A function whose parameters have these types, where they're known. + Parameters(Vec>), + /// It takes a function of any type, or anything at all. + Anything, +} + +/// Whether `argument` is an anonymous function with a parameter whose type +/// isn't written, which the call it's passed to can give: `fn(p) => +/// p.score`, but not `fn(p: Player) => p.score`. +fn takes_parameter_types_from_the_call(argument: &ExpressionLocation) -> bool { + matches!( + &argument.expression, + Expression::FunctionDeclaration { name: None, parameters, .. } + if parameters.iter().any(|parameter| parameter.annotation.is_none()) + ) +} + +/// How many parameters the function declared by `argument` has. +fn parameter_count(argument: &ExpressionLocation) -> usize { + match &argument.expression { + Expression::FunctionDeclaration { parameters, .. } => parameters.len(), + _ => 0, + } +} + +/// Whether `typ` is known as far as the code around the call knows it: +/// nothing in it is only known as the program runs, and its variables are +/// from before `first`, not ones an overload's signature made. A list +/// literal's elements are a variable until its uses settle them. +fn is_known_before(typ: &StaticType, first: usize) -> bool { + !typ.contains(&|part| match part { + StaticType::Var(var) => var.0 as usize >= first, + StaticType::Any | StaticType::Generic(_) => true, + _ => false, + }) } /// The value of an index that's an integer written in the code, as in diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 4a0aedb1..e706005f 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -54,13 +54,16 @@ impl Analyser { .iter() .map(|param| self.lower_parameter_annotation(param.annotation.as_ref())) .unzip(); + let expected = self.expected_parameters.remove(&id).unwrap_or_default(); let type_signature = TypeSignature::from_annotated_bindings( parameters .iter() .zip(&annotations) - .map(|(param, annotation)| { + .enumerate() + .map(|(position, (param, annotation))| { let typ = annotation .clone() + .or_else(|| expected.get(position).cloned().flatten()) .unwrap_or_else(|| self.vars.fresh(StaticType::Any)); (param.name().to_string(), Some(typ)) }) diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index f5fbd30b..41dbb65c 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -81,6 +81,10 @@ pub struct Analyser { /// The nodes of overloaded function values that waited for the type /// expected of them, as `abs` in `[len, abs]` does. waiting_function_values: AHashSet, + /// The types an anonymous function's unannotated parameters get from + /// the call it's passed to, by the function's node: in + /// `players.map(fn(p) => p.score)`, `p` is a `Player`. + expected_parameters: AHashMap>>, /// The calls the constraint being solved was copied through, when it's /// a copy of a generic function's constraint. copied_through: Option, @@ -158,6 +162,7 @@ impl Analyser { recursive_calls: Vec::new(), number_literals: Vec::new(), waiting_function_values: AHashSet::new(), + expected_parameters: AHashMap::new(), copied_through: None, checks: Vec::new(), next_serial: 0, diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index bc8a9d4f..f9f9fac8 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -8,6 +8,7 @@ use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall, is_strictly_more_specific}; use crate::types::TypeVars; use ahash::{AHashMap, AHashSet}; +use itertools::Itertools; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; @@ -1484,10 +1485,13 @@ impl Analyser { /// `["a"]`. Each literal first gets its own type, which says better /// whether they're the same. fn merges_literals(&mut self, symbol: SymbolId, arguments: &[StaticType]) -> bool { + // A literal in two arguments, as the element of `[0]` is in + // `[0].flat_map(fn(x) => …)` once `x` has its type, is one literal. let literals = arguments .iter() .flat_map(|argument| self.vars.unsettled(argument)) .filter(|&var| self.vars.is_literal(var)) + .unique() .collect::>(); if literals.len() < 2 { return false; diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 72f19547..9cdde341 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -641,14 +641,14 @@ fn a_function_argument_gets_what_the_function_parameter_passes() { #[test] fn a_function_argument_fits_a_function_parameter_where_their_known_parts_agree() { let apply = "fn apply(f: Fn(Int) -> Int) { f(1) } "; - // An untyped lambda is `Fn(Any) -> Any`, which is checked as the program runs. + // An untyped lambda's parameters take the types `apply` passes. assert_eq!( errors(&format!("{apply}apply(fn(x) => x);"), vec![]), Vec::::new() ); for (lambda, shown) in [ ("fn(x: String) => 1", "Fn(String) -> Int"), - ("fn(x) => \"a\"", "Fn(Any) -> String"), + ("fn(x) => \"a\"", "Fn(Int) -> String"), ("fn(a, b) => 1", "Fn(Any, Any) -> Int"), ] { assert_eq!( diff --git a/tests/functional/programs/015_struct/037_a_lambda_passed_to_map_takes_the_element_type.ndc b/tests/functional/programs/015_struct/037_a_lambda_passed_to_map_takes_the_element_type.ndc new file mode 100644 index 00000000..b8189215 --- /dev/null +++ b/tests/functional/programs/015_struct/037_a_lambda_passed_to_map_takes_the_element_type.ndc @@ -0,0 +1,8 @@ +// A lambda passed to `map` gets its parameter's type from the list before +// its body is checked: `p` is a `Player`, which has no `size`, though a +// `Team` does. +// expect-error: No function called 'size' found that matches the arguments 'Player' +struct Player { name: String, score: Int } +struct Team { name: String, size: Int } +let players = [Player("ann", 3), Player("bob", 5)]; +print(players.map(fn(p) => p.size)); diff --git a/tests/functional/programs/015_struct/038_a_lambda_takes_its_parameter_types_from_the_call.ndc b/tests/functional/programs/015_struct/038_a_lambda_takes_its_parameter_types_from_the_call.ndc new file mode 100644 index 00000000..6e53dbc5 --- /dev/null +++ b/tests/functional/programs/015_struct/038_a_lambda_takes_its_parameter_types_from_the_call.ndc @@ -0,0 +1,8 @@ +// Each lambda's parameters take the types the function it's passed to +// gives them, from the arguments before and after it. +// expect-output: 8 ["ann","bob"] ["bob"] +struct Player { name: String, score: Int } +struct Team { name: String, score: Int } +let players = [Player("ann", 3), Player("bob", 5)]; +let total = players.fold(0, fn(sum, p) => sum + p.score); +print(total, players.map(fn(p) => p.name), players.filter(fn(p) => p.score > 4).map(fn(p) => p.name)); diff --git a/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc b/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc index 8f3ae5a0..57bd9cf3 100644 --- a/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc +++ b/tests/functional/programs/603_stdlib_seq/021_filter_takes_a_predicate_returning_bool.ndc @@ -1,4 +1,4 @@ // `filter` keeps the elements its predicate returns `true` for, so a // predicate returning an Int is an error. -// expect-error: No function called 'filter' found that matches the arguments 'List, Fn(Any) -> Int' +// expect-error: No function called 'filter' found that matches the arguments 'List, Fn(Int) -> Int' [1, 2, 3].filter(fn(x) => 1); From 4a2c55cc8f8baa0bfd55e5391634d636e2617191 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 16:15:12 +0100 Subject: [PATCH 25/39] =?UTF-8?q?refactor(analyser):=20merge=20duplicated?= =?UTF-8?q?=20helpers=20and=20drop=20leftovers=20=F0=9F=A7=B9=20(#327)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup found by a review of the type-checker branch. No language behaviour changes; −387 lines. Analyser: - One `instantiate_signature` and `bind_arguments` replace six copies of "give a signature fresh variables and pass the arguments" (`could_accept`, `overload_fits`, `merges_literals`, `bind_signature`, `commit_call`, the lambda parameter pass). - Function declarations build their parameter types directly instead of going through `TypeSignature`, so `declare_parameters`, `TypeSignature::from_annotated_bindings` and `TypeSignature::types` go. - Duplicate overload helpers merged (`parameters_of`, `could_pass`, `is_compatible`/`could_accept_call`), as are the five "check a write, report it if it doesn't fit" sites, the two "settle if it fits, else roll back" loops in `TypeVars`, the two type-parameter collectors (now `StaticType::type_parameters`), and the two copies of the in-place-then-operator candidate merge for `op=`. `merge_copies` is one flat loop. - A call that commits early to one overload is now a direct call. Its `Any` arguments were already checked where they're passed, so the single-candidate `Dynamic` binding only made the VM search a set of one. Leftovers from before the type checker: - The VM's element-wise tuple path for a picked function had code for choosing between several functions, which its only caller never passes. Both element-wise paths now share one loop. - `[](List, Any)` and `[](Deque, Any)` are gone: indices are inferred now, and an `Any` index still goes to `[](Any, Any)`. - `ndc_analyser/RETURN_TYPE_INFERENCE.md` described the removed subtyping design. The troubleshooting page on overload dispatch claimed dispatch never scans and that user functions can't take typed containers; it now describes calls with `Any` values as they work. The compiled output of the analysis benchmarks is unchanged by the analyser refactor, and their analysis time is the same. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../overload-dispatch-collections.md | 58 ++--- ndc_analyser/RETURN_TYPE_INFERENCE.md | 98 -------- ndc_analyser/src/analyser/assignments.rs | 68 ++++-- ndc_analyser/src/analyser/calls.rs | 17 +- ndc_analyser/src/analyser/declarations.rs | 79 ++----- ndc_analyser/src/analyser/function_values.rs | 6 +- ndc_analyser/src/analyser/generics.rs | 99 +++----- ndc_analyser/src/analyser/mod.rs | 24 +- ndc_analyser/src/analyser/solver.rs | 213 +++++------------- ndc_analyser/src/overloads.rs | 88 +++----- ndc_analyser/src/types.rs | 107 +++++---- ndc_core/src/static_type.rs | 35 ++- ndc_stdlib/src/index.rs | 4 - ndc_vm/src/vm.rs | 173 ++++---------- ...bug0021_chained_vectorized_tuple_arith.ndc | 8 +- 15 files changed, 345 insertions(+), 732 deletions(-) delete mode 100644 ndc_analyser/RETURN_TYPE_INFERENCE.md diff --git a/manual/src/troubleshooting/overload-dispatch-collections.md b/manual/src/troubleshooting/overload-dispatch-collections.md index 33585dad..d918dcd2 100644 --- a/manual/src/troubleshooting/overload-dispatch-collections.md +++ b/manual/src/troubleshooting/overload-dispatch-collections.md @@ -2,21 +2,13 @@ ## Background -When Andy C++ can determine at compile time which function overload to call, it does so — the -call is free of any type-checking overhead at runtime. When it cannot (because an argument was -inferred as `Any`), the VM performs **dynamic dispatch**: it tests each candidate overload at -runtime to find the best match. +Andy C++ picks the overload a call uses before the program runs, from the types of its arguments, so the call costs nothing extra at runtime. Only when an argument has type `Any`, because it was annotated as `Any` or comes from a function that returns `Any`, does the VM pick as the program runs, by testing each candidate overload against the values. -The reverse also holds. Sometimes every argument type is known at compile time, but no -overload can accept them: each candidate is fully annotated, and none matches the call's -arity and argument types. Such a call could only ever fail at runtime. Instead of waiting -for that, it is rejected at compile time with a -`No function called '…' found that matches the arguments` error. +The reverse also holds. When every argument type is known and no overload can accept them, the call could only ever fail at runtime, so it is rejected before the program runs with a `No function called '…' found that matches the arguments` error. -## O(1) dispatch guarantee +## Element types cost a scan -For dynamic dispatch the VM checks whether a value *conforms to* the parameter type without -iterating the container contents. Specifically: +To pick an overload for a value of type `Any`, the VM first looks only at what kind of value it is, which doesn't depend on its size: | Parameter type | Check performed | |---|---| @@ -26,21 +18,18 @@ iterating the container contents. Specifically: | `Iterable` | Is the value any sequence type? | | `String`, `Int`, … | Exact kind check | -This means that dispatch is **O(1)** regardless of how many elements are in the collection. - -## Element types are checked only when nothing else fits - -Because the element-type check is skipped, a parameter like `List` doesn't take a list in that first pass. When no overload takes the arguments without looking at their elements, the VM checks the elements before the call fails, scanning each container. So two overloads like these still work under dynamic dispatch: +A parameter like `List` doesn't take a list in that first pass. When no overload takes the arguments without looking at their elements, the VM checks the elements before the call fails, scanning each container. So two overloads like these still work for a value of type `Any`: ``` -fn process(List) -fn process(List) +fn process(xs: List) +fn process(xs: List) ``` -Each call through an `Any` scans the list, though, and an overload that takes the value without looking at its elements, like `process(Any)`, is picked before either of them. +Each call through an `Any` scans the list, though, and an overload that takes the value without looking at its elements, like `process(x: Any)`, is picked before either of them. -Standard library overloads do differ by element type. Numeric sequence functions preserve the -concrete numeric type, so `sum` has three typed overloads: +## A `List` fits no typed overload + +Standard library overloads also differ by element type. Numeric sequence functions keep the numeric type, so `sum` has three typed overloads: ``` fn sum(Iterable) -> Int @@ -48,8 +37,7 @@ fn sum(Iterable) -> Float fn sum(Iterable) -> Number ``` -A value the analyser only knows as `List` matches none of them. Rather than dispatch on -it and scan, the call is rejected at compile time: +A value known only as a `List` fits none of them, as its elements could be anything. Rather than pick as the program runs and scan, the call is rejected before it runs: ```ndc,fails let values: List = [1, 2, 3]; @@ -59,15 +47,9 @@ values.sum() // as in `value as List`. ``` -Overloads that differ only by container *kind* (e.g. `pop(List)` vs `pop(MinHeap)`) -are still distinguished by the kind check alone. User-defined functions cannot yet declare -typed container parameters (the syntax is not implemented), so user overloads always use -`Any`. - ## Workaround -State the element type with a [cast](../features/casts.md). A cast scans the value, but the -cost lands at a site you wrote rather than inside every dispatch: +State the element type with a [cast](../features/casts.md). A cast scans the value, but the cost lands at a site you wrote rather than inside every call: ```ndc let values: List = [1, 2, 3]; @@ -76,16 +58,4 @@ let values: List = [1, 2, 3]; print((values as List).sum()); // 6 ``` -Otherwise, move the call to a location where Andy C++ can infer the argument types -statically — for example, directly at the call site rather than through an intermediate -untyped function parameter: - -```ndc,norun -// The type of `data` is Any here — dynamic dispatch used -fn handle(data) { - process(data) -} - -// Preferred: call process() directly where the type is known -process(my_list) -``` +Better still, leave out the `Any`: a value keeps the type it was created with unless it passes through something typed `Any`, and a parameter without an annotation takes the type of each call's argument. diff --git a/ndc_analyser/RETURN_TYPE_INFERENCE.md b/ndc_analyser/RETURN_TYPE_INFERENCE.md deleted file mode 100644 index 507c5491..00000000 --- a/ndc_analyser/RETURN_TYPE_INFERENCE.md +++ /dev/null @@ -1,98 +0,0 @@ -# Return type inference problem and solution - -## The problem - -The analyser infers function return types from the body expression's type. The body is usually a `Block`, and a block's type is the type of its last expression. - -The issue: when a function uses an explicit `return` statement with a semicolon (which is common), the parser wraps it as `Statement(Return { value })`. The `Statement` handler discards the inner type and returns `unit`: - -```rust -Expression::Statement(inner) => { - self.analyse(inner)?; - Ok(StaticType::unit()) // ← return type lost -} -``` - -This means a function like: - -```ndc -fn make_fn() { - fn inner() { 42 } - return inner; // ← semicolon makes this a Statement(Return) -} -``` - -...has its return type inferred as `Tuple([])` (unit) instead of `Function([] -> Int)`. - -### Why this matters now - -Previously this was masked because the function resolution fallback (`find_all_slots_by_name`) included ALL bindings regardless of type. A variable like `let f = make_fn()` with type `unit` would still be included in the dynamic overload set, and the VM would resolve it correctly at runtime. - -When we changed function resolution to skip known non-callable types (so that `let foo = 300; foo(42)` produces a compile error instead of a confusing runtime error), the mis-typed `unit` bindings got filtered out, breaking legitimate code. - -## The solution: `Never` bottom type + `return_type_stack` - -Two complementary changes: - -### 1. `StaticType::Never` — bottom type for diverging expressions - -`return`, `break`, and `continue` now produce `StaticType::Never` instead of the value's type or unit. `Never` is a subtype of every type, so `lub(T, Never) = T`. The `Statement` handler propagates `Never` instead of converting it to unit: - -```rust -Expression::Statement(inner) => { - let typ = self.analyse(inner)?; - if typ == StaticType::Never { - Ok(StaticType::Never) // diverging — don't convert to unit - } else { - Ok(StaticType::unit()) - } -} -``` - -This means `return 1;` (a `Statement(Return)`) has type `Never`, not unit. When the block computes its implicit return type, `lub(previous_type, Never) = previous_type` — the dead code after a return doesn't pollute the type. - -Without this, `return 1;` would contribute both `Int` (via the return stack) and `unit` (via the block's implicit type), and `lub(Int, unit) = Any` — making every function with explicit returns infer `Any`. - -### 2. `return_type_stack` — tracking explicit return types - -Added a `return_type_stack: Vec>` to the `Analyser` struct. It tracks explicit `return` types for each nested function scope: - -1. **On function entry**: push `None` onto the stack -2. **On `return expr`**: analyse `expr`, `fold_lub` its type into the top of the stack, return `Never` -3. **On function exit**: pop the stack, combine with the block's implicit return type: - -```rust -let implicit_return = self.analyse(body)?; -let explicit_return = self.return_type_stack.pop().unwrap(); - -let return_type = match explicit_return { - Some(ret) => ret.lub(&implicit_return), - None => implicit_return, -}; -``` - -### How they work together - -For `fn foo() { return 1; }`: -- `return 1` → pushes `Int` to stack, returns `Never` -- `Statement(Return)` → propagates `Never` -- Block implicit type: `Never` -- Stack: `Some(Int)` -- Combined: `lub(Int, Never) = Int` ✓ - -For `fn foo(x) { if x > 0 { return x; } "default" }`: -- `return x` → pushes `Int` to stack, returns `Never` -- `Statement(Return)` → `Never` -- If-true branch: `Never`, if-false (implicit): unit -- `lub(Never, unit) = unit` → if expression type is unit -- Statement wrapping if → unit -- `"default"` → `String` -- Block implicit type: `String` -- Stack: `Some(Int)` -- Combined: `lub(Int, String) = Any` ✓ - -For `fn foo() { 1 }`: -- No explicit return -- Block implicit type: `Int` -- Stack: `None` -- Combined: `Int` ✓ diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 0ef7d4bc..c3e307d6 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -3,10 +3,11 @@ use super::solver::{CallNodes, CallSite}; use crate::error::AnalysisError; use crate::overloads::{CallKind, ResolvedCall}; use itertools::izip; -use ndc_analysis::Binding; +use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType}; use ndc_lexer::Span; use ndc_parser::{AssignmentTarget, AssignmentTargetLocation, ExpressionLocation}; +use ndc_resolver::SymbolId; impl Analyser { /// Analyses `l_value op= r_value`: picks the in-place overload (`+=`) if one @@ -92,29 +93,14 @@ impl Analyser { self.record_augmented_pick(id, Binding::Resolved(candidate)); None } - Binding::Dynamic(mut assign_candidates) if assign_is_eligible => { - match op_binding.clone() { - Binding::Resolved(candidate) => { - if !assign_candidates.contains(&candidate) { - assign_candidates.push(candidate); - } - } - Binding::Dynamic(op_candidates) => { - for candidate in op_candidates { - if !assign_candidates.contains(&candidate) { - assign_candidates.push(candidate); - } - } - } - Binding::None => {} - } - + Binding::Dynamic(assign_candidates) if assign_is_eligible => { + let binding = Self::in_place_then_operators(assign_candidates, op_binding.clone()); let result_type = if has_op_binding { assign_return.join(&op_return) } else { assign_return }; - self.record_augmented_pick(id, Binding::Dynamic(assign_candidates)); + self.record_augmented_pick(id, binding); Some(result_type) } Binding::Resolved(_) | Binding::Dynamic(_) => { @@ -140,6 +126,26 @@ impl Analyser { writeback_type.unwrap_or_else(|| left_type.clone()) } + /// What `a op= b` does when the program picks as it runs: the in-place + /// overloads `in_place` first, so it mutates in place where it can, and + /// then the operators `operators` picks that aren't among them. + pub(super) fn in_place_then_operators( + mut in_place: Vec>, + operators: Binding, + ) -> Binding { + let operators = match operators { + Binding::Resolved(operator) => vec![operator], + Binding::Dynamic(operators) => operators, + Binding::None => Vec::new(), + }; + for operator in operators { + if !in_place.contains(&operator) { + in_place.push(operator); + } + } + Binding::Dynamic(in_place) + } + /// Records where an assignment stores its value, and returns the type of /// what is stored there. pub(super) fn analyse_assignment_target( @@ -286,6 +292,22 @@ impl Analyser { true } + /// Checks that a value of `value_type` can be stored where values of + /// `stored_type` are kept, as [`Self::accepts_write`], and reports it + /// where it can't. A constraint that waited settles the type it gave + /// out this way, which code analysed meanwhile may have settled already. + pub(super) fn check_write( + &mut self, + stored_type: &StaticType, + value_type: &StaticType, + span: Span, + node: Option, + ) { + if !self.accepts_write(stored_type, value_type, node) { + self.emit_rejected_write(stored_type, value_type, span); + } + } + /// Reports that a value of `value_type` can't be stored where values of /// `stored_type` are kept. pub(super) fn emit_rejected_write( @@ -315,14 +337,10 @@ impl Analyser { AssignmentTarget::Identifier { .. } if self.picks.variables.contains_key(&lvalue.id) => { - if !self.accepts_write(stored_type, value_type, Some(lvalue.id)) { - self.emit_rejected_write(stored_type, value_type, span); - } + self.check_write(stored_type, value_type, span, Some(lvalue.id)); } AssignmentTarget::Member { .. } => { - if !self.accepts_write(stored_type, value_type, Some(lvalue.id)) { - self.emit_rejected_write(stored_type, value_type, span); - } + self.check_write(stored_type, value_type, span, Some(lvalue.id)); } AssignmentTarget::Index { index, .. } => { if self.accepts_write(stored_type, value_type, Some(lvalue.id)) { diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index afec3f86..ef006db8 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -244,22 +244,9 @@ impl Analyser { position: usize, ) -> Expects { let function = self.vars.resolve(&self.types.callable_type(symbol)); - if !matches!( - function, - StaticType::Function { - parameters: Some(_), - .. - } - ) { - return Expects::Anything; - } let first = self.vars.count(); - let StaticType::Function { - parameters: Some(parameters), - .. - } = self.vars.instantiate(&function) - else { - unreachable!("instantiating a function type gives a function type"); + let Some((parameters, _)) = self.vars.instantiate_signature(&function) else { + return Expects::Anything; }; let takes_known = parameters.len() == known.len() && parameters.iter().zip(known).all(|(parameter, argument)| { diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index e706005f..4a83550b 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -1,9 +1,9 @@ use super::solver::Constraint; use super::{Analyser, Returns}; use crate::error::AnalysisError; -use itertools::{Itertools, izip}; +use itertools::izip; use ndc_core::r#struct::StructInfo; -use ndc_core::{NodeId, StaticType, TypeSignature}; +use ndc_core::{NodeId, StaticType}; use ndc_lexer::Span; use ndc_parser::{ BindingPattern, BindingPatternLocation, ExpressionLocation, FunctionParameter, StructField, @@ -55,20 +55,16 @@ impl Analyser { .map(|param| self.lower_parameter_annotation(param.annotation.as_ref())) .unzip(); let expected = self.expected_parameters.remove(&id).unwrap_or_default(); - let type_signature = TypeSignature::from_annotated_bindings( - parameters - .iter() - .zip(&annotations) - .enumerate() - .map(|(position, (param, annotation))| { - let typ = annotation - .clone() - .or_else(|| expected.get(position).cloned().flatten()) - .unwrap_or_else(|| self.vars.fresh(StaticType::Any)); - (param.name().to_string(), Some(typ)) - }) - .collect(), - ); + let param_types = annotations + .iter() + .enumerate() + .map(|(position, annotation)| { + annotation + .clone() + .or_else(|| expected.get(position).cloned().flatten()) + .unwrap_or_else(|| self.vars.fresh(StaticType::Any)) + }) + .collect::>(); let annotated_return = self.lower_annotation(return_annotation); // Until the body says more, a named function's value takes what its // annotations say, as its body is compiled once for every type it's @@ -87,7 +83,7 @@ impl Analyser { }) .collect(), ), - None => type_signature.types(), + None => Some(param_types.clone()), }; self.result.analysis.function_types.insert( id, @@ -106,7 +102,6 @@ impl Analyser { _ => None, }; if let Some((name, symbol)) = declared { - let parameters = type_signature.types(); // An `Iterable` parameter takes what each call settles, but // only sequences of `T`s, which tells it from its other overloads. let shape = iterates @@ -117,17 +112,15 @@ impl Analyser { .map(|element| StaticType::Iterable(Box::new(element.clone()))) }) .collect::>(); - let shaped = parameters.as_ref().map(|parameters| { - parameters - .iter() - .zip(&shape) - .map(|(parameter, shape)| shape.clone().unwrap_or_else(|| parameter.clone())) - .collect::>() - }); + let shaped = param_types + .iter() + .zip(&shape) + .map(|(parameter, shape)| shape.clone().unwrap_or_else(|| parameter.clone())) + .collect::>(); self.types.declare_shape(symbol, shape); - self.check_overlap(symbol, name, shaped.as_deref(), span); + self.check_overlap(symbol, name, Some(&shaped), span); let placeholder = StaticType::Function { - parameters, + parameters: Some(param_types.clone()), return_type: Box::new( annotated_return .clone() @@ -145,10 +138,9 @@ impl Analyser { // A function body starts outside of any loop: `break` can't // leave the function to end a loop around its declaration. let outer_loop_depth = std::mem::take(&mut self.loop_depth); - let parameter_nodes = parameters.iter().map(|p| p.lvalue.id).collect_vec(); - let param_types = self.declare_parameters(&type_signature, ¶meter_nodes); - for (param, typ) in parameters.iter().zip(¶m_types) { + let symbol = self.resolver.declared_symbol(param.lvalue.id); + self.types.declare(symbol, typ.clone()); self.result .analysis .variable_types @@ -299,27 +291,6 @@ impl Analyser { StaticType::unit() } - /// Declares a function's parameters and returns their types. - fn declare_parameters( - &mut self, - type_signature: &TypeSignature, - parameter_nodes: &[NodeId], - ) -> Vec { - let TypeSignature::Exact(parameters) = type_signature else { - return vec![]; - }; - - for (param, &node) in parameters.iter().zip(parameter_nodes) { - let symbol = self.resolver.declared_symbol(node); - self.types.declare(symbol, param.type_name.clone()); - } - - parameters - .iter() - .map(|param| param.type_name.clone()) - .collect() - } - /// Declares each name in a `let` or `for` pattern, checking /// `found_type` against the annotation and the pattern's shape. pub(super) fn declare_binding_pattern( @@ -333,10 +304,8 @@ impl Analyser { BindingPattern::Identifier { span, .. } => { // The value is checked like an assignment to the annotated // variable, so `let o: Option = None` settles `None`. - if let Some(expected_type) = &expected_type - && !self.accepts_write(expected_type, &found_type, Some(lvalue.id)) - { - self.emit_rejected_write(expected_type, &found_type, *span); + if let Some(expected_type) = &expected_type { + self.check_write(expected_type, &found_type, *span, Some(lvalue.id)); } // An annotation decides the type, or else the value does. diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index a8f6c2ec..5120b62b 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -117,11 +117,7 @@ impl Analyser { /// `symbol`'s type with fresh variables for its type parameters. fn instantiated_type(&mut self, symbol: SymbolId) -> StaticType { let typ = self.vars.resolve(&self.types.callable_type(symbol)); - if typ.contains(&|part| matches!(part, StaticType::Generic(_))) { - self.vars.instantiate(&typ) - } else { - typ - } + self.vars.instantiate(&typ) } /// Which overload `value` means where a value of type `expected` is diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 3080086f..3dc169ff 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -201,7 +201,11 @@ impl Analyser { .chain(uses.iter().flat_map(|generic_use| &generic_use.key)) .chain(literal_nodes.iter().map(|(_, typ)| typ)) { - collect_type_parameters(typ, &mut key); + for name in typ.type_parameters() { + if !key.contains(&name) { + key.push(name); + } + } } self.schemes.insert( symbol, @@ -456,55 +460,38 @@ impl Analyser { fn merge_copies(&mut self) { let serials = self.generic_constraints(); let mut kept: Vec = Vec::new(); + // A call's arguments and result, as one type, so copies of one + // literal in them become one, as they're settled the same way. + let call_types = |call: &CallConstraint| { + StaticType::Tuple( + call.arguments() + .iter() + .chain([call.result()]) + .cloned() + .collect(), + ) + }; for serial in serials { - let duplicate = { - let waiting = &self.waiting[&serial]; - let Constraint::Call(call) = &waiting.constraint else { - continue; - }; - if waiting.origin.is_none() { - kept.push(serial); - continue; - } - kept.iter().copied().find(|earlier| { - let Constraint::Call(earlier) = &self.waiting[earlier].constraint else { - return false; - }; - earlier.is_same_call(call, &self.vars) - }) + let waiting = &self.waiting[&serial]; + let Constraint::Call(call) = &waiting.constraint else { + continue; }; - match duplicate { - Some(earlier) => { - let (Constraint::Call(earlier), Constraint::Call(call)) = ( - &self.waiting[&earlier].constraint, - &self.waiting[&serial].constraint, - ) else { - unreachable!("both are calls"); - }; - // Copies of one literal become one, as they're settled - // the same way. - let earlier = StaticType::Tuple( - earlier - .arguments() - .iter() - .chain([earlier.result()]) - .cloned() - .collect(), - ); - let later = StaticType::Tuple( - call.arguments() - .iter() - .chain([call.result()]) - .cloned() - .collect(), - ); - if self.vars.try_unify(&earlier, &later) { - self.waiting.remove(&serial); - } else { - kept.push(serial); - } - } - None => kept.push(serial), + // Only a copy can be the same as an earlier call. + let earlier = waiting.origin.as_ref().and_then(|_| { + kept.iter() + .find_map(|earlier| match &self.waiting[earlier].constraint { + Constraint::Call(earlier) if earlier.is_same_call(call, &self.vars) => { + Some(earlier) + } + _ => None, + }) + }); + if let Some(earlier) = earlier + && self.vars.try_unify(&call_types(earlier), &call_types(call)) + { + self.waiting.remove(&serial); + } else { + kept.push(serial); } } self.solve_waiting(); @@ -624,22 +611,6 @@ impl Analyser { } } -/// Adds the type parameters in `typ` that `parameters` doesn't have yet. -fn collect_type_parameters(typ: &StaticType, parameters: &mut Vec>) { - let found = std::cell::RefCell::new(Vec::new()); - typ.contains(&|part| { - if let StaticType::Generic(name) = part { - found.borrow_mut().push(name.clone()); - } - false - }); - for name in found.into_inner() { - if !parameters.contains(&name) { - parameters.push(name); - } - } -} - /// The name of a generic function's `index`th type parameter: `A`, `B`, …, /// `Z`, `A1`, …. fn type_parameter_name(index: usize) -> String { diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 41dbb65c..02956525 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -821,26 +821,18 @@ impl Analyser { } } - /// Joins a new type into an accumulator, as with [`Self::join`]. /// Adds the value `value` of type `typ` that a function returns to /// `returns`. A function with an annotated return type returns that - /// type, and each value it returns has to fit it. + /// type, and each value it returns has to fit it. Without one, it + /// returns the type its values join as, as with [`Self::join`]. fn add_return(&mut self, returns: &mut Returns, value: &ExpressionLocation, typ: StaticType) { - match &returns.annotated { - Some(annotated) => { - let annotated = annotated.clone(); - if !self.accepts_write(&annotated, &typ, Some(value.id)) { - self.emit_rejected_write(&annotated, &typ, value.span); - } - } - None => self.fold_join(&mut returns.joined, typ, value.span), + if let Some(annotated) = &returns.annotated { + self.check_write(annotated, &typ, value.span, Some(value.id)); + return; } - } - - fn fold_join(&mut self, acc: &mut Option, new_type: StaticType, span: Span) { - *acc = Some(match acc.take() { - Some(prev) => self.join(&prev, &new_type, span), - None => new_type, + returns.joined = Some(match returns.joined.take() { + Some(joined) => self.join(&joined, &typ, value.span), + None => typ, }); } } diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index f9f9fac8..6a5ae395 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -417,15 +417,9 @@ impl Analyser { .iter() .map(|argument| self.vars.resolve(argument)) .collect::>(); - let binding = if self - .vars - .resolve(self.types.get_type(picked)) - .is_fn_and_matches(&arguments) - { - Binding::Resolved(Candidate::Scalar(picked)) - } else { - Binding::Dynamic(vec![Candidate::Scalar(picked)]) - }; + // An argument of type `Any` is checked where it's passed, so the + // program can call `picked` directly. + let binding = Binding::Resolved(Candidate::Scalar(picked)); if cfg!(debug_assertions) { self.early_picks.push(EarlyPick::Call { candidates: call.candidates.clone(), @@ -460,9 +454,15 @@ impl Analyser { .iter() .map(|argument| self.vars.resolve(argument)) .collect::>(); - let binding = self + // A pick left to the program among one overload is a + // call of it, with its `Any` arguments checked. + let binding = match self .resolve_overloads(&candidates, &arguments, kind) - .binding; + .binding + { + Binding::Dynamic(only) if only.len() == 1 => Binding::Resolved(only[0]), + binding => binding, + }; assert!( binding == picked, "internal error: `{name}` committed to {picked:?} before its arguments \ @@ -929,14 +929,11 @@ impl Analyser { candidates: &Candidates, arguments: &[StaticType], ) -> bool { - let parameters_of = - |symbol: SymbolId| match self.vars.resolve(&self.types.callable_type(symbol)) { - StaticType::Function { - parameters: Some(parameters), - .. - } if parameters.len() == arguments.len() => Some(parameters), - _ => None, - }; + let parameters_of = |symbol: SymbolId| { + self.overloads() + .parameters_of(symbol) + .filter(|parameters| parameters.len() == arguments.len()) + }; let is_number = |typ: &StaticType| { matches!( typ, @@ -980,25 +977,10 @@ impl Analyser { result: &StaticType, ) -> bool { let function = self.vars.resolve(&self.types.callable_type(symbol)); - let StaticType::Function { - parameters: Some(_), - .. - } = &function - else { + let Some((parameters, return_type)) = self.vars.instantiate_signature(&function) else { return true; }; - let StaticType::Function { - parameters: Some(parameters), - return_type, - } = self.vars.instantiate(&function) - else { - unreachable!("instantiating a function type gives a function type"); - }; - parameters.len() == arguments.len() - && parameters - .iter() - .zip(arguments) - .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)) + self.vars.bind_arguments(¶meters, arguments) && self.vars.bind_argument(result, &return_type) } @@ -1070,7 +1052,7 @@ impl Analyser { } CallSite::Getter(_) => {} _ => { - self.settle_waited_for(&result, &typ, call.span, call.nodes.result); + self.check_write(&result, &typ, call.span, call.nodes.result); } } true @@ -1087,7 +1069,7 @@ impl Analyser { } let (element, span) = (element.clone(), *span); let found = self.iteration_gives(&sequence, span); - self.settle_waited_for(&element, &found, span, None); + self.check_write(&element, &found, span, None); true } Constraint::Unpack { value, parts, span } => { @@ -1116,7 +1098,7 @@ impl Analyser { }; let found = found.unwrap_or_else(|| vec![StaticType::Any; parts.len()]); for (part, found) in parts.iter().zip(found) { - self.settle_waited_for(part, &found, *span, None); + self.check_write(part, &found, *span, None); } true } @@ -1130,7 +1112,7 @@ impl Analyser { return false; } let value = value.clone(); - self.settle_waited_for(&value, &Self::default_value_type(default), *span, None); + self.check_write(&value, &Self::default_value_type(default), *span, None); true } Constraint::Join { @@ -1160,28 +1142,12 @@ impl Analyser { (StaticType::Never, other) | (other, StaticType::Never) => other.clone(), _ => self.join_known(&a, &b), }; - self.settle_waited_for(&result, &joined, *span, None); + self.check_write(&result, &joined, *span, None); true } } } - /// Settles `waited`, the type a constraint gave out while it waited, as - /// `found`, the type it turned out to be. Code analysed meanwhile may - /// have settled `waited` already, and then `found` has to fit it: a - /// value of type `Any` is checked at `node` as the program runs. - fn settle_waited_for( - &mut self, - waited: &StaticType, - found: &StaticType, - span: Span, - node: Option, - ) { - if !self.accepts_write(waited, found, node) { - self.emit_rejected_write(waited, found, span); - } - } - /// Picks the overload `call` means, records it, and returns the call's /// type, or `None` when the arguments aren't known well enough yet. A /// call whose arguments are known is resolved the usual way. Otherwise @@ -1496,22 +1462,10 @@ impl Analyser { if literals.len() < 2 { return false; } - let StaticType::Function { - parameters: Some(_), - .. - } = self.vars.resolve(&self.types.callable_type(symbol)) - else { - return false; - }; + let function = self.vars.resolve(&self.types.callable_type(symbol)); let snapshot = self.vars.snapshot(); - let StaticType::Function { - parameters: Some(parameters), - .. - } = self - .vars - .instantiate(&self.vars.resolve(&self.types.callable_type(symbol))) - else { - unreachable!("instantiating a function type gives a function type"); + let Some((parameters, _)) = self.vars.instantiate_signature(&function) else { + return false; }; for (parameter, argument) in parameters.iter().zip(arguments) { self.vars.bind_argument(parameter, argument); @@ -1646,25 +1600,19 @@ impl Analyser { }; let pending = self.pending.len(); let signature = picked.map(|symbol| self.instantiate_function(symbol, true)); - let Some(StaticType::Function { - parameters: Some(parameters), - return_type: declared_return, - }) = signature + let Some( + signature @ StaticType::Function { + parameters: Some(_), + .. + }, + ) = signature else { for argument in arguments { self.vars.settle_unknowns(argument, &StaticType::Any); } return Some(return_type); }; - - let typ = self.bind_signature( - &StaticType::Function { - parameters: Some(parameters), - return_type: declared_return, - }, - arguments, - ); - let Some((typ, parameters)) = typ else { + let Some((typ, parameters)) = self.bind_signature(&signature, arguments) else { self.pending.truncate(pending); return None; }; @@ -1796,12 +1744,7 @@ impl Analyser { unreachable!("only a function with known parameters has a signature to bind"); }; let snapshot = self.vars.snapshot(); - let fits = parameters.len() == arguments.len() - && parameters - .iter() - .zip(arguments) - .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)); - if !fits { + if !self.vars.bind_arguments(parameters, arguments) { self.vars.rollback(snapshot); return None; } @@ -1962,7 +1905,7 @@ impl Analyser { let committed = self.commit_early(&call, &arguments, narrowest); if let Some(typ) = &committed { let result = call.result.clone(); - self.settle_waited_for(&result, typ, call.span, call.nodes.result); + self.check_write(&result, typ, call.span, call.nodes.result); } self.copied_through = outer; if committed.is_none() { @@ -1984,15 +1927,7 @@ impl Analyser { fn most_specific(&self, symbols: &[SymbolId]) -> Option { let parameters = symbols .iter() - .map( - |&symbol| match self.vars.resolve(&self.types.callable_type(symbol)) { - StaticType::Function { - parameters: Some(parameters), - .. - } => Some(parameters), - _ => None, - }, - ) + .map(|&symbol| self.overloads().parameters_of(symbol)) .collect::>>()?; let (narrowest, _) = parameters.iter().enumerate().find(|&(i, narrower)| { parameters @@ -2013,51 +1948,33 @@ impl Analyser { /// any kind of sequence, so it can't tell what an argument nothing is /// known about is, and the call waits for it as `for x in xs` does. fn fits_without_any(&self, symbol: SymbolId, arguments: &[StaticType]) -> bool { - match self.vars.resolve(&self.types.callable_type(symbol)) { - StaticType::Function { - parameters: Some(parameters), - .. - } => parameters - .iter() - .zip(arguments) - .all(|(parameter, argument)| match (parameter, argument) { - (StaticType::Any, argument) => *argument == StaticType::Any, - (StaticType::Iterable(_), StaticType::Var(var)) => self.vars.is_literal(*var), - _ => true, - }), - _ => false, - } + self.overloads() + .parameters_of(symbol) + .is_some_and(|parameters| { + parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| match (parameter, argument) { + (StaticType::Any, argument) => *argument == StaticType::Any, + (StaticType::Iterable(_), StaticType::Var(var)) => { + self.vars.is_literal(*var) + } + _ => true, + }) + }) } /// Whether `symbol` can accept arguments of these types, where each part /// not known yet fits anything. fn could_accept(&mut self, symbol: SymbolId, arguments: &[StaticType]) -> bool { - match self.vars.resolve(&self.types.callable_type(symbol)) { - StaticType::Function { - parameters: None, .. - } => true, - function @ StaticType::Function { - parameters: Some(_), - .. - } => { - let snapshot = self.vars.snapshot(); - let StaticType::Function { - parameters: Some(parameters), - .. - } = self.vars.instantiate(&function) - else { - unreachable!("instantiating a function type gives a function type"); - }; - let fits = parameters.len() == arguments.len() - && parameters - .iter() - .zip(arguments) - .all(|(parameter, argument)| self.vars.bind_argument(parameter, argument)); - self.vars.rollback(snapshot); - fits - } - other => other.is_unknown(), - } + let function = self.vars.resolve(&self.types.callable_type(symbol)); + let snapshot = self.vars.snapshot(); + let Some((parameters, _)) = self.vars.instantiate_signature(&function) else { + return matches!(function, StaticType::Function { .. }) || function.is_unknown(); + }; + let fits = self.vars.bind_arguments(¶meters, arguments); + self.vars.rollback(snapshot); + fits } fn site_candidates(&self, site: CallSite) -> &Candidates { @@ -2113,18 +2030,8 @@ impl Analyser { (Binding::Resolved(candidate), _) if compatible => Binding::Resolved(candidate), // Which one applies is left to the program as it runs, // which mutates in place where it can. - (Binding::Dynamic(mut candidates), operators) if compatible => { - let operators = match operators { - Binding::Resolved(operator) => vec![operator], - Binding::Dynamic(operators) => operators, - Binding::None => Vec::new(), - }; - for operator in operators { - if !candidates.contains(&operator) { - candidates.push(operator); - } - } - Binding::Dynamic(candidates) + (Binding::Dynamic(in_place), operators) if compatible => { + Self::in_place_then_operators(in_place, operators) } (_, binding) => binding, }; diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index aec7b413..3d4b17de 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -49,13 +49,30 @@ struct ScalarWalk { all_by_name: Vec, } -/// Whether a value of type `arg` might be accepted where `param` is expected -/// when the program runs: an `Any` might turn out to be anything, and the -/// parts of either type not known yet could be anything. A lambda -/// `fn(x) => x * 2` can be passed for an `Fn(Int) -> Int`, but -/// `fn(x: String) => 1` can't, and neither can a function of two arguments. -fn could_pass(param: &StaticType, arg: &StaticType) -> bool { - arg.may_fit(param) +/// Whether a value of type `typ` could still accept a call with this +/// signature at runtime: its type is `Any`, a variadic function, or a function whose +/// parameters don't provably conflict with `sig`: an `Any` might turn +/// out to be anything, and the parts of either type not known yet could +/// be anything. A lambda `fn(x) => x * 2` can be passed for an +/// `Fn(Int) -> Int`, but `fn(x: String) => 1` can't. +fn could_take(typ: &StaticType, sig: &[StaticType]) -> bool { + match typ { + StaticType::Function { + parameters: Some(parameters), + .. + } => { + parameters.len() == sig.len() + && parameters + .iter() + .zip(sig) + .all(|(param, arg)| arg.may_fit(param)) + } + StaticType::Function { + parameters: None, .. + } => true, + // A value whose type isn't known may hold a function. + unknown => unknown.is_unknown(), + } } /// Returns `true` iff `more` is strictly more specific than `less`: each of @@ -356,7 +373,7 @@ impl<'a> Overloads<'a> { if walk .all_by_name .iter() - .any(|&symbol| self.could_accept_call(symbol, sig)) + .any(|&symbol| could_take(&self.type_of(symbol), sig)) { let candidates = walk .all_by_name @@ -457,28 +474,17 @@ impl<'a> Overloads<'a> { .filter(|&symbol| self.type_of(symbol).is_fn_and_matches(sig)) .collect::>(); - let params_of = |symbol: SymbolId| -> Option> { - match self.type_of(symbol).into_owned() { - StaticType::Function { - parameters: Some(p), - .. - } => Some(p), - // Variadic / non-function bindings have no concrete shape. - _ => None, - } - }; - // `group` is newest first, so ties resolve to the most-recently registered overload. matches.iter().copied().find(|&i| { - let Some(pi) = params_of(i) else { + let Some(pi) = self.parameters_of(i) else { // Variadic only wins if every other match is also variadic. - return matches.iter().all(|&j| params_of(j).is_none()); + return matches.iter().all(|&j| self.parameters_of(j).is_none()); }; matches.iter().all(|&j| { if i == j { return true; } - let Some(pj) = params_of(j) else { + let Some(pj) = self.parameters_of(j) else { // Variadic never dominates a concrete overload. return true; }; @@ -489,7 +495,7 @@ impl<'a> Overloads<'a> { /// The parameter types of `symbol`, if it's a function that isn't /// variadic. - fn parameters_of(self, symbol: SymbolId) -> Option> { + pub(crate) fn parameters_of(self, symbol: SymbolId) -> Option> { match self.type_of(symbol).into_owned() { StaticType::Function { parameters: Some(parameters), @@ -553,18 +559,7 @@ impl<'a> Overloads<'a> { /// with `sig`, so it might accept the call at runtime. fn is_compatible(self, symbol: SymbolId, sig: &[StaticType]) -> bool { let typ = self.type_of(symbol); - let StaticType::Function { parameters, .. } = typ.as_ref() else { - return false; - }; - // A variadic function accepts any arguments. - let Some(parameters) = parameters else { - return true; - }; - parameters.len() == sig.len() - && parameters - .iter() - .zip(sig) - .all(|(param, arg)| could_pass(param, arg)) + matches!(typ.as_ref(), StaticType::Function { .. }) && could_take(&typ, sig) } /// Try to resolve a vec candidate set for `sig`. @@ -641,29 +636,6 @@ impl<'a> Overloads<'a> { out } - /// Whether a candidate could still accept a call with this signature at - /// runtime: its type is `Any`, a variadic function, or a function whose - /// parameters don't provably conflict with `sig`. - fn could_accept_call(self, symbol: SymbolId, sig: &[StaticType]) -> bool { - match self.type_of(symbol).as_ref() { - StaticType::Function { - parameters: Some(parameters), - .. - } => { - parameters.len() == sig.len() - && parameters - .iter() - .zip(sig) - .all(|(param, arg)| could_pass(param, arg)) - } - StaticType::Function { - parameters: None, .. - } => true, - // A value whose type isn't known may hold a function. - unknown => unknown.is_unknown(), - } - } - /// Return type of a scalar candidate. Falls back to `Any` if the binding /// turned out not to carry a function type (e.g. `Any`-typed callee). /// diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index b5ff7859..203c3d13 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -182,8 +182,7 @@ pub(crate) struct Snapshot { impl TypeVars { /// A new variable, which becomes `fallback` if nothing settles it. pub(crate) fn fresh(&mut self, fallback: StaticType) -> StaticType { - let var = - TypeVar(u32::try_from(self.solutions.len()).expect("fewer than 2^32 type variables")); + let var = self.next_var(); self.solutions.push(None); self.fallbacks.push(fallback); self.levels.push(self.level); @@ -451,18 +450,27 @@ impl TypeVars { let Some(defaults) = self.literal_defaults(var) else { return true; }; - defaults.iter().any(|default| { - if self - .resolve(default) - .contains(&|part| *part == StaticType::Var(var)) - { - return false; - } - let snapshot = self.snapshot(); - let fits = self.settle(var, default.clone()); + let snapshot = self.snapshot(); + let fits = defaults.iter().any(|default| self.try_settle(var, default)); + self.rollback(snapshot); + fits + } + + /// Settles `var` as `typ` if `typ` is within its bounds, and returns + /// whether it did. A variable can't stand for a type that contains it. + fn try_settle(&mut self, var: TypeVar, typ: &StaticType) -> bool { + if self + .resolve(typ) + .contains(&|part| *part == StaticType::Var(var)) + { + return false; + } + let snapshot = self.snapshot(); + let settled = self.settle(var, typ.clone()); + if !settled { self.rollback(snapshot); - fits - }) + } + settled } /// What the unsettled literal type `var` is shown as: what it would be @@ -569,11 +577,7 @@ impl TypeVars { if made.iter().any(|(generic, _)| generic == name) { continue; } - let is_number = bounds - .0 - .iter() - .any(|bound| matches!(bound, Bound::Number(..))); - let typ = self.fresh(if is_number { + let typ = self.fresh(if bounds.is_number() { StaticType::Int } else { StaticType::Any @@ -822,22 +826,7 @@ impl TypeVars { Some(defaults) if !self.bounds[var.0 as usize].is_empty() => defaults, _ => vec![self.fallbacks[var.0 as usize].clone()], }; - let settled = defaults.iter().any(|default| { - // A variable can't stand for a type that contains it. - if self - .resolve(default) - .contains(&|part| *part == StaticType::Var(var)) - { - return false; - } - let snapshot = self.snapshot(); - let settled = self.settle(var, default.clone()); - if !settled { - self.rollback(snapshot); - } - settled - }); - if !settled { + if !defaults.iter().any(|default| self.try_settle(var, default)) { // `Any` takes anything a literal holds. self.settle(var, StaticType::Any); } @@ -931,6 +920,45 @@ impl TypeVars { self.instantiate_with(typ, &mut Vec::new()) } + /// The parameters and return type of the function type `typ`, with new + /// variables for its type parameters, or `None` when `typ` isn't a + /// function whose parameters are known. + pub(crate) fn instantiate_signature( + &mut self, + typ: &StaticType, + ) -> Option<(Vec, StaticType)> { + let StaticType::Function { + parameters: Some(_), + .. + } = typ + else { + return None; + }; + let StaticType::Function { + parameters: Some(parameters), + return_type, + } = self.instantiate(typ) + else { + unreachable!("instantiating a function type gives a function type"); + }; + Some((parameters, *return_type)) + } + + /// Passes each of `arguments` where its parameter is expected, as + /// [`Self::bind_argument`], and returns whether there's one argument + /// for each parameter and each fits. + pub(crate) fn bind_arguments( + &mut self, + parameters: &[StaticType], + arguments: &[StaticType], + ) -> bool { + parameters.len() == arguments.len() + && parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| self.bind_argument(parameter, argument)) + } + /// [`Self::instantiate`], with the variables already made for some type /// parameters in `made`, which gets the ones made for the others. A /// generic function's constraints are instantiated with its signature. @@ -939,16 +967,7 @@ impl TypeVars { typ: &StaticType, made: &mut Vec<(Box, StaticType)>, ) -> StaticType { - let names = std::cell::RefCell::new(Vec::>::new()); - typ.contains(&|part| { - if let StaticType::Generic(name) = part - && !names.borrow().contains(name) - { - names.borrow_mut().push(name.clone()); - } - false - }); - for name in names.into_inner() { + for name in typ.type_parameters() { if !made.iter().any(|(generic, _)| *generic == name) { let var = self.fresh(StaticType::Any); made.push((name, var)); diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index bff14d55..17bbd617 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -14,26 +14,6 @@ impl Default for TypeSignature { } } -impl TypeSignature { - pub fn from_annotated_bindings(bindings: Vec<(String, Option)>) -> Self { - Self::Exact( - bindings - .into_iter() - .map(|(name, annotation)| { - Parameter::new(name, annotation.unwrap_or(StaticType::Any)) - }) - .collect(), - ) - } - - pub fn types(&self) -> Option> { - match self { - Self::Variadic => None, - Self::Exact(v) => Some(v.iter().map(|p| p.type_name.clone()).collect()), - } - } -} - #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Parameter { pub name: String, @@ -467,6 +447,21 @@ impl StaticType { } } + /// The type parameters in this type, each once, outermost first: `T` + /// and `U` in `Fn(T) -> List`. + pub fn type_parameters(&self) -> Vec> { + let names = std::cell::RefCell::new(Vec::>::new()); + self.contains(&|part| { + if let Self::Generic(name) = part + && !names.borrow().contains(name) + { + names.borrow_mut().push(name.clone()); + } + false + }); + names.into_inner() + } + /// Whether any type variable is left in this type. pub fn has_vars(&self) -> bool { self.contains(&|t| matches!(t, Self::Var(_))) diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index fbf8f279..f0381901 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -59,9 +59,6 @@ fn register_get(env: &mut FunctionRegistry>) { for signature in [ "(List, Int) -> T", "(List, Iterator) -> List", - // An index whose type isn't known could be either, and keeps the - // allocation-free path rather than falling back to `[](Any, Any)`. - "(List, Any) -> Any", ] { env.declare_global_fn(Rc::new(NativeFunction { name: "[]".to_string(), @@ -99,7 +96,6 @@ fn register_get(env: &mut FunctionRegistry>) { for signature in [ "(Deque, Int) -> T", "(Deque, Iterator) -> List", - "(Deque, Any) -> Any", ] { env.declare_global_fn(Rc::new(NativeFunction { name: "[]".to_string(), diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index 0229793d..16091494 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -361,10 +361,8 @@ impl Vm { } OpCode::CallVec(args) => { let args = *args; - // Compiler emitted a directly-loaded scalar function — no - // OverloadSet probing needed. We still verify it's callable - // because the analyser may have widened its slot to Any - // somewhere along the way. + // The analyser picked the scalar function, which the + // compiler loaded directly: no overload set to probe. let scalar = { let callee = &self.stack[self.stack.len() - args - 1]; let Value::Object(obj) = callee else { @@ -396,8 +394,11 @@ impl Vm { )); } }; - let scalars = [scalar]; - if let Err(mut e) = self.dispatch_vec_call(&scalars, args, axis_len, span) { + if let Err(mut e) = + self.call_elementwise(args, axis_len, scalar.name(), span, |_, _, _| { + Ok(scalar.clone()) + }) + { e.span.get_or_insert(span); return Err(e); } @@ -884,11 +885,10 @@ impl Vm { }) } - /// Vec dispatch for `Binding::Dynamic` operator calls — the runtime - /// picks the matching scalar overload per element position. Resolves - /// candidates lazily from `vec_candidates` with a last-match cache, so - /// homogeneous tuples (the common case) pay one resolve per outer call - /// instead of N. No upfront `Vec` allocation. + /// Vec dispatch for `Binding::Dynamic` operator calls: the program picks + /// the scalar overload for each element position, trying the one the + /// previous position used first, so a tuple whose elements all have one + /// type picks once. fn dispatch_vec_call_dynamic( &mut self, overload_set: &Rc, @@ -900,11 +900,6 @@ impl Vm { unreachable!("dispatch_vec_call_dynamic invoked with non-OverloadSet callee"); }; debug_assert!(!vec_candidates.is_empty()); - - let arg_start = self.stack.len() - args; - let arg_values: Vec = self.stack.split_off(arg_start); - self.stack.pop(); // discard the callee slot - let frame_pointer = self.frames.last().expect("no frame").frame_pointer; // Resolve one candidate up front so the error paths can borrow its name @@ -923,136 +918,62 @@ impl Vm { f.name() }); - let mut elem_args: Vec = Vec::with_capacity(args); - let mut results: Vec = Vec::with_capacity(axis_len); - // Cached last-match Function. Homogeneous tuples reuse this across - // positions; heterogeneous tuples fall through to the candidate walk. let mut last_match: Option = None; - - for i in 0..axis_len { - elem_args.clear(); - for arg in &arg_values { - elem_args.push(vec_element_at(arg, i)); - } - - let scalar: Function = if let Some(f) = &last_match - && f.matches_value_args(&elem_args) + self.call_elementwise(args, axis_len, callee_name, span, |vm, elements, i| { + if let Some(f) = &last_match + && f.matches_value_args(elements) { - f.clone() - } else { - let mut found: Option = None; - for var in vec_candidates { - let value = self.resolve_var(var, frame_pointer); - let Value::Object(obj) = value else { continue }; - let Object::Function(f) = obj.as_ref() else { - continue; - }; - if f.matches_value_args(&elem_args) { - found = Some(f.clone()); - break; - } - } - let Some(f) = found else { - let element_types = elem_args - .iter() - .map(|v| v.static_type().to_string()) - .collect::>() - .join(", "); - let name = callee_name.unwrap_or("?"); - return Err(VmError::new( - format!("no overload of '{name}' accepts element {i}: ({element_types})"), - span, - )); + return Ok(f.clone()); + } + let found = vec_candidates.iter().find_map(|var| { + let Value::Object(obj) = vm.resolve_var(var, frame_pointer) else { + return None; }; - last_match = Some(f.clone()); - f - }; - - let result = self.call_callback(scalar, &elem_args).map_err(|mut e| { - let prefix = match callee_name { - Some(name) => format!("while vectorising '{name}' at index {i}: "), - None => format!("while vectorising at index {i}: "), + let Object::Function(f) = obj.as_ref() else { + return None; }; - e.message = format!("{prefix}{}", e.message); - e.span.get_or_insert(span); - e - })?; - results.push(result); - } - - self.stack - .push(Value::Object(Rc::new(Object::Tuple(results)))); - Ok(()) + f.matches_value_args(elements).then(|| f.clone()) + }); + let Some(f) = found else { + let element_types = elements + .iter() + .map(|v| v.static_type().to_string()) + .collect::>() + .join(", "); + let name = callee_name.unwrap_or("?"); + return Err(VmError::new( + format!("no overload of '{name}' accepts element {i}: ({element_types})"), + span, + )); + }; + last_match = Some(f.clone()); + Ok(f) + }) } - /// Vec dispatch when the scalar set is already resolved — the `CallVec` - /// opcode path. `scalars.len() == 1` is the analyser-pinned fast path - /// that skips the per-element probe entirely. - fn dispatch_vec_call( + /// Calls the function `pick` gives for each element position of the + /// tuple arguments on the stack, and replaces the callee and arguments + /// with the tuple of the results: `(1, 2) + (3, 4)` is `(1 + 3, 2 + 4)`. + fn call_elementwise( &mut self, - scalars: &[Function], args: usize, axis_len: usize, + callee_name: Option<&str>, span: Span, + mut pick: impl FnMut(&mut Self, &[Value], usize) -> Result, ) -> Result<(), VmError> { - let arg_start = self.stack.len() - args; - // Borrow the callee name straight off the resolved scalars — its lifetime - // is tied to the caller-owned slice, not to `self`, so the error paths - // can use it without allocating a `String` on the success path. - let callee_name: Option<&str> = scalars.first().and_then(|f| f.name()); - // Materialise the broadcast arguments up front so the inner // call_callback can hold &mut self without conflicting with stack // borrows. Element values clone Rcs only. - let arg_values: Vec = self.stack.split_off(arg_start); + let arg_values: Vec = self.stack.split_off(self.stack.len() - args); self.stack.pop(); // discard the callee slot - let pinned: Option<&Function> = if scalars.len() == 1 { - Some(&scalars[0]) - } else { - None - }; - let mut elem_args: Vec = Vec::with_capacity(args); let mut results: Vec = Vec::with_capacity(axis_len); - // Cache the last scalar that matched. Homogeneous tuples — the common - // case — all want the same scalar at every position, so probing it - // first short-circuits the candidate walk for positions 1..N. - let mut last_match: Option = None; - for i in 0..axis_len { elem_args.clear(); - for arg in &arg_values { - elem_args.push(vec_element_at(arg, i)); - } - - let scalar: Function = if let Some(f) = pinned { - f.clone() - } else if let Some(idx) = last_match - && scalars[idx].matches_value_args(&elem_args) - { - scalars[idx].clone() - } else { - let Some((idx, found)) = scalars - .iter() - .enumerate() - .find(|(_, f)| f.matches_value_args(&elem_args)) - else { - let element_types = elem_args - .iter() - .map(|v| v.static_type().to_string()) - .collect::>() - .join(", "); - let name = callee_name.unwrap_or("?"); - return Err(VmError::new( - format!("no overload of '{name}' accepts element {i}: ({element_types})"), - span, - )); - }; - last_match = Some(idx); - found.clone() - }; - + elem_args.extend(arg_values.iter().map(|arg| vec_element_at(arg, i))); + let scalar = pick(self, &elem_args, i)?; let result = self.call_callback(scalar, &elem_args).map_err(|mut e| { let prefix = match callee_name { Some(name) => format!("while vectorising '{name}' at index {i}: "), diff --git a/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc b/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc index dc116e84..91c7a2b3 100644 --- a/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc +++ b/tests/functional/programs/900_bugs/bug0021_chained_vectorized_tuple_arith.ndc @@ -16,11 +16,9 @@ assert_eq(diff * diff, (9, 9, 9)); assert_eq((a - b) * (a - b), (9, 9, 9)); -// Same hazard but the tuple elements are statically `Any`. `id` here erases -// type information so the tuples below are `Tuple`; this -// mirrors what happens with stdlib natives whose `Value` return type infers -// to `Any`. The vectorization detection must treat `Any` as a potentially- -// numeric element so chained ops still go through dynamic dispatch. +// Same hazard but the tuple elements are statically `Any`: `id` returns +// `Any`, so the tuples below are `(Any, Any, Any)`. An `Any` element may be +// a number, so chained ops still go through dynamic dispatch. fn id(x) -> Any => x; let av = (id(1), id(2), id(3)); let bv = (id(4), id(5), id(6)); From 2744adfb658af2291a33df2667738041edda02b6 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 16:31:06 +0100 Subject: [PATCH 26/39] =?UTF-8?q?fix(analyser):=20check=20Any=20values=20a?= =?UTF-8?q?=20generic=20body=20passes=20for=20its=20type=20parameters=20?= =?UTF-8?q?=F0=9F=94=8D=20(#328)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value of type `Any` that a generic function's body passed where one of its type parameters was expected went unchecked. A call could then return a value whose static type was wrong: ```ndc fn id2(x) { let a: Any = 5; let b = [x]; b.push(a); b[1] } let r = id2("s"); // typed String, held the Int 5 ``` The check was recorded once, in terms of the type parameter, and an instance's key only had the type parameters its waiting constraints, generic calls and literals depend on, so `id2` had no instances and its own body checked `a` against `Any`. The checks the body makes of `Any` values against a type with a type parameter in it are now part of the scheme: they add their type parameters to the key, and each instance checks them against its own types. `id2("s")` now fails at `b.push(a)` with "expected String but found Int". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/declarations.rs | 8 +++++- ndc_analyser/src/analyser/generics.rs | 25 +++++++++++++++---- ndc_analyser/src/analyser/instances.rs | 9 +++++++ ...passed_for_a_type_parameter_is_checked.ndc | 12 +++++++++ 4 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 tests/functional/programs/005_functions/096_an_any_value_passed_for_a_type_parameter_is_checked.ndc diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 4a83550b..5f61d35e 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -50,6 +50,7 @@ impl Analyser { } let first_use = self.uses.len(); let first_literal = self.number_literals.len(); + let first_check = self.checks.len(); let (annotations, iterates): (Vec<_>, Vec<_>) = parameters .iter() .map(|param| self.lower_parameter_annotation(param.annotation.as_ref())) @@ -200,7 +201,12 @@ impl Analyser { self.vars.leave_level(); self.declaring = None; self.declared_in_batch.insert(symbol); - self.generalize(symbol, &signature, id, first_use, first_literal) + self.generalize( + symbol, + &signature, + id, + [first_use, first_literal, first_check], + ) } _ => signature, }; diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 3dc169ff..21a49095 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -38,8 +38,7 @@ impl Analyser { symbol: SymbolId, signature: &StaticType, declaration: NodeId, - first_use: usize, - first_literal: usize, + [first_use, first_literal, first_check]: [usize; 3], ) -> StaticType { self.solve_waiting(); // A literal in the body that its type parameters don't reach has @@ -191,15 +190,30 @@ impl Analyser { self.pick_for_any_arguments(call); } } - // An instance is for the types of the type parameters the - // constraints and uses depend on. + // A value of type `Any` that the body passes where a type parameter + // is expected, as `a` in `fn f(x) { let a: Any = 5; [x].push(a) }`, + // is checked against what each call makes it. + let checks = self.checks[first_check..] + .iter() + .filter(|(instance, ..)| instance.is_none()) + .map(|(_, node, typ, expected)| { + (*node, self.vars.resolve(typ), self.vars.resolve(expected)) + }) + .filter(|(_, typ, expected)| { + *typ == StaticType::Any + && expected.contains(&|part| matches!(part, StaticType::Generic(_))) + }) + .collect::>(); + // An instance is for the types of the type parameters that what it + // compiles differently depends on: the constraints and uses it + // picks for, the literals typed by a type parameter, and the checks. let mut key: Vec> = Vec::new(); - // The literals typed by a type parameter compile to what it is. for typ in scheme .iter() .flat_map(|waiting| waiting.constraint.inputs()) .chain(uses.iter().flat_map(|generic_use| &generic_use.key)) .chain(literal_nodes.iter().map(|(_, typ)| typ)) + .chain(checks.iter().map(|(_, _, expected)| expected)) { for name in typ.type_parameters() { if !key.contains(&name) { @@ -217,6 +231,7 @@ impl Analyser { recursive_calls: std::mem::take(&mut self.recursive_calls).into(), literals: literals.into(), literal_nodes: literal_nodes.into(), + checks: checks.into(), }, ); self.vars.resolve(signature) diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs index 3f97a787..ffc46f32 100644 --- a/ndc_analyser/src/analyser/instances.rs +++ b/ndc_analyser/src/analyser/instances.rs @@ -43,6 +43,10 @@ pub(super) struct Scheme { /// parameters, with that type, which an instance records its own types /// for. pub(super) literal_nodes: Rc<[(NodeId, StaticType)]>, + /// The values its body checks as the program runs against a type that + /// has its type parameters in it, with their types and that type, which + /// an instance checks against what its types make it. + pub(super) checks: Rc<[(NodeId, StaticType, StaticType)]>, } /// A call of a generic function, or its name used as a value, which needs @@ -300,6 +304,11 @@ impl Analyser { self.wait_as(copy); } } + for (node, typ, expected) in scheme.checks.iter() { + let typ = self.vars.instantiate_with(typ, &mut made); + let expected = self.vars.instantiate_with(expected, &mut made); + self.expect_if_any(*node, typ, expected); + } for &call in scheme.recursive_calls.iter() { self.pick_tables(Some(instance)).insert( CallSite::Call(call), diff --git a/tests/functional/programs/005_functions/096_an_any_value_passed_for_a_type_parameter_is_checked.ndc b/tests/functional/programs/005_functions/096_an_any_value_passed_for_a_type_parameter_is_checked.ndc new file mode 100644 index 00000000..570c4a52 --- /dev/null +++ b/tests/functional/programs/005_functions/096_an_any_value_passed_for_a_type_parameter_is_checked.ndc @@ -0,0 +1,12 @@ +// A value of type `Any` passed where a generic function's type parameter is +// expected is checked against what each call makes it: `with_five("s")` +// pushes the Int 5 into a list of Strings. +// expect-error: expected String but found Int +fn with_five(x) { + let five: Any = 5; + let xs = [x]; + xs.push(five); + xs +} +print(with_five(1)); +print(with_five("s")); From 4619a52db1ee17c3a125c058a736af198cc9afa8 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 16:40:28 +0100 Subject: [PATCH 27/39] =?UTF-8?q?feat(stdlib):=20give=20transposed,=20mult?= =?UTF-8?q?i=5Fcartesian=5Fproduct=20and=20zip=20typed=20results=20?= =?UTF-8?q?=F0=9F=93=90=20(#330)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some built-ins still returned `Any` in their element types, so everything computed from their results was dispatched as the program ran. - `transposed` and `multi_cartesian_product` take a sequence of lists and return a `List>` of the lists' element type. Rows of different types (`[[1, 2], ["a"]]`) still give a `List>`, and `transposed` of strings gives lists of strings. - `zip` of three or four sequences returns a list of typed tuples (`List<(A, B, C)>`, `List<(A, B, C, D)>`); five or more still give a `List`. The rows are lists rather than any sequence because a signature can't nest `Iterable` inside another type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- ndc_stdlib/src/sequence.rs | 111 ++++++++++++------ ...rtesian_products_keep_the_element_type.ndc | 6 + ...e_or_four_sequences_makes_typed_tuples.ndc | 5 + 3 files changed, 83 insertions(+), 39 deletions(-) create mode 100644 tests/functional/programs/603_stdlib_seq/026_transposed_and_cartesian_products_keep_the_element_type.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/027_zip_of_three_or_four_sequences_makes_typed_tuples.ndc diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index 56a3c0cd..2ecc80e0 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -1,6 +1,7 @@ #![allow(clippy::ptr_arg)] use anyhow::anyhow; +use itertools::Itertools; use ndc_macros::export_module; use ndc_vm::VmCallable; use ndc_vm::value::{Object, SeqValue, Value}; @@ -701,26 +702,21 @@ mod inner { } /// Transposes a sequence of sequences, turning rows into columns, and returns the result as a list of lists. - #[function(signature = "(Iterable) -> List>")] + #[function(signature = "(Iterable>) -> List>")] pub fn transposed(seq: SeqValue) -> anyhow::Result { - let main: Vec = seq - .try_into_iter() - .ok_or_else(|| anyhow!("transposed requires a sequence"))? - .collect(); - let mut iterators: Vec>> = Vec::new(); - for elem in main { - iterators.push(Box::new(elem.try_into_iter().ok_or_else(|| { - anyhow!("elements of transposed sequence must be iterable") - })?)); - } - let mut out = Vec::new(); - loop { - let row: Vec = iterators.iter_mut().filter_map(|i| i.next()).collect(); - if row.is_empty() { - return Ok(Value::list(out)); - } - out.push(Value::list(row)); - } + transpose(seq) + } + + /// Transposes a sequence of strings, turning rows of characters into columns. + #[function(name = "transposed", signature = "(Iterable) -> List>")] + pub fn transposed_strings(seq: SeqValue) -> anyhow::Result { + transpose(seq) + } + + /// Transposes a sequence of sequences whose elements have different types. + #[function(name = "transposed", signature = "(Iterable) -> List>")] + pub fn transposed_any(seq: SeqValue) -> anyhow::Result { + transpose(seq) } /// Return a list of all windows, wrapping back to the first elements when the window would otherwise exceed the length of source list, producing tuples of size 2. @@ -840,26 +836,18 @@ mod inner { /// [3,"c",false] /// ] /// ``` - #[function(signature = "(Iterable) -> List>")] + #[function(signature = "(Iterable>) -> List>")] pub fn multi_cartesian_product(seq: SeqValue) -> anyhow::Result { - let mut iterators = Vec::new(); - for elem in seq - .try_into_iter() - .ok_or_else(|| anyhow!("multi_cartesian_product requires a sequence"))? - { - let inner: Vec = elem - .try_into_iter() - .ok_or_else(|| anyhow!("elements of sequence must be iterable"))? - .collect_vec(); - iterators.push(inner.into_iter()); - } - Ok(Value::list( - iterators - .into_iter() - .multi_cartesian_product() - .map(Value::list) - .collect_vec(), - )) + cartesian_product(seq) + } + + /// The cartesian product of sequences whose elements have different types. + #[function( + name = "multi_cartesian_product", + signature = "(Iterable) -> List>" + )] + pub fn multi_cartesian_product_any(seq: SeqValue) -> anyhow::Result { + cartesian_product(seq) } /// Split the input sequence into evenly sized chunks. If the input length of the sequence @@ -896,6 +884,48 @@ mod inner { } } +fn transpose(seq: SeqValue) -> anyhow::Result { + let main: Vec = seq + .try_into_iter() + .ok_or_else(|| anyhow!("transposed requires a sequence"))? + .collect(); + let mut iterators: Vec>> = Vec::new(); + for elem in main { + iterators.push(Box::new(elem.try_into_iter().ok_or_else(|| { + anyhow!("elements of transposed sequence must be iterable") + })?)); + } + let mut out = Vec::new(); + loop { + let row: Vec = iterators.iter_mut().filter_map(|i| i.next()).collect(); + if row.is_empty() { + return Ok(Value::list(out)); + } + out.push(Value::list(row)); + } +} + +fn cartesian_product(seq: SeqValue) -> anyhow::Result { + let mut iterators = Vec::new(); + for elem in seq + .try_into_iter() + .ok_or_else(|| anyhow!("multi_cartesian_product requires a sequence"))? + { + let inner: Vec = elem + .try_into_iter() + .ok_or_else(|| anyhow!("elements of sequence must be iterable"))? + .collect_vec(); + iterators.push(inner.into_iter()); + } + Ok(Value::list( + iterators + .into_iter() + .multi_cartesian_product() + .map(Value::list) + .collect_vec(), + )) +} + fn by_key(seq: Value, func: &mut VmCallable<'_>, better: Ordering) -> anyhow::Result { let mut best_value: Option = None; let mut best_key: Option = None; @@ -965,8 +995,11 @@ pub mod extra { pub fn register(env: &mut FunctionRegistry>) { for signature in [ - // Two sequences zip into pairs of their elements. + // Two sequences zip into pairs of their elements, three into + // triples, and four into quadruples. "(Iterable, Iterable) -> List<(A, B)>", + "(Iterable, Iterable, Iterable) -> List<(A, B, C)>", + "(Iterable, Iterable, Iterable, Iterable) -> List<(A, B, C, D)>", // More make tuples as long as the number of arguments. "(*) -> List", ] { diff --git a/tests/functional/programs/603_stdlib_seq/026_transposed_and_cartesian_products_keep_the_element_type.ndc b/tests/functional/programs/603_stdlib_seq/026_transposed_and_cartesian_products_keep_the_element_type.ndc new file mode 100644 index 00000000..2e2a3661 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/026_transposed_and_cartesian_products_keep_the_element_type.ndc @@ -0,0 +1,6 @@ +// `transposed` and `multi_cartesian_product` give lists of the elements the +// rows hold, and sequences of mixed elements give lists of `Any`. +// expect-output: [[1,3],[2,4]] [[1,3],[2,3]] [[1,"a"],[2,"a"]] +let columns: List> = [[1, 2], [3, 4]].transposed(); +let products: List> = [[1, 2], [3]].multi_cartesian_product(); +print(columns, products, [[1, 2], ["a"]].multi_cartesian_product()); diff --git a/tests/functional/programs/603_stdlib_seq/027_zip_of_three_or_four_sequences_makes_typed_tuples.ndc b/tests/functional/programs/603_stdlib_seq/027_zip_of_three_or_four_sequences_makes_typed_tuples.ndc new file mode 100644 index 00000000..8894b392 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/027_zip_of_three_or_four_sequences_makes_typed_tuples.ndc @@ -0,0 +1,5 @@ +// Zipping three or four sequences makes tuples of their element types. +// expect-output: [(1,"a",1.5)] [(1,"a",true,2)] +let triples: List<(Int, String, Float)> = zip([1], ["a"], [1.5]); +let quadruples: List<(Int, String, Bool, Int)> = zip([1], ["a"], [true], [2]); +print(triples, quadruples); From 1aebf2419ffda35c54f3fe5862a83d9a2afece68 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 17:15:17 +0100 Subject: [PATCH 28/39] =?UTF-8?q?fix(vm):=20check=20a=20function=20from=20?= =?UTF-8?q?Any=20against=20the=20function=20type=20it's=20used=20as=20?= =?UTF-8?q?=F0=9F=94=92=20(#329)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A value of type `Any` used as a function type was only checked for how many arguments the function takes, so a call through it could return anything: ```ndc let a: Any = fn(x) => "s"; let f: Fn(Int) -> Int = a; let n: Int = f(1); // n held "s" ``` A function is now checked against what its own type says it takes and returns, and when what it returns isn't known, its calls are checked. - A function whose type rules out the function type isn't one. The example now fails at `let f` with "expected Fn(Int) -> Int but found Fn(Any) -> String", as does a lambda that takes a String. An `Any` in the function's own type, as an unannotated parameter has, fits anything. - A lambda's value now records the return type the analyser inferred, rather than `Any`, so most lambdas are decided without checking their calls. - A function that may fit, such as a named function's value, whose return type isn't recorded, becomes a new `Function::Checked` when it's cast or passed where the function type is expected. Each call through it runs the function and checks its result: "expected the function to return Int but it returned String". It equals the function it checks. - Inside a container, and for an overload the program picks as it runs, a function can't be replaced by a checked one, so only a function whose own type shows it fits does. Cast the value first to have its calls checked. Before, both accepted any function with the right number of parameters. - A call the analyser leaves to the program among one overload with known parameters is now a direct call, with its `Any` arguments checked where they're passed. That's what gives a function argument passed to such a call its checks. A variable of type `Any` being called, and a function with an `Iterable` parameter, whose kind isn't in its declared type, still have the program check the arguments. A checked call records the return type it expects on the callee's VM frame, which checks the result when it returns, so recursion through a checked function goes as deep as any other. A function it returns may get its own calls checked, and a function returning `Never` fits any return type. The new variant's type is boxed so that function values stay the same size; a call-heavy benchmark (`fib(27)`) runs in the same time as before (1.3% more instructions). The manual's casts page describes the checks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/casts.md | 18 ++ ndc_analyser/src/analyser/declarations.rs | 8 + ndc_analyser/src/analyser/solver.rs | 29 ++- ndc_analyser/src/types.rs | 7 + ndc_vm/src/value/function.rs | 203 ++++++++++++++++-- ndc_vm/src/value/mod.rs | 50 +++-- ndc_vm/src/vm.rs | 102 ++++++++- ...nction_from_any_checks_what_it_returns.ndc | 11 + ...rom_any_must_return_what_its_type_says.ndc | 4 + ..._checked_function_is_the_same_function.ndc | 7 + ...rogram_runs_takes_a_function_that_fits.ndc | 9 + ...rom_any_must_take_what_its_type_passes.ndc | 4 + ...ram_runs_rejects_an_unchecked_function.ndc | 8 + ...turning_a_function_checks_that_one_too.ndc | 8 + ...hat_never_returns_fits_any_return_type.ndc | 6 + ...n_through_a_checked_function_runs_deep.ndc | 8 + ...rable_parameter_checks_an_any_argument.ndc | 5 + ..._a_list_fits_one_returning_an_iterable.ndc | 7 + ..._checked_function_checks_its_arguments.ndc | 9 + ...function_tells_checked_functions_apart.ndc | 11 + 20 files changed, 470 insertions(+), 44 deletions(-) create mode 100644 tests/functional/programs/017_dynamic_any/012_a_function_from_any_checks_what_it_returns.ndc create mode 100644 tests/functional/programs/017_dynamic_any/013_a_function_from_any_must_return_what_its_type_says.ndc create mode 100644 tests/functional/programs/017_dynamic_any/014_a_checked_function_is_the_same_function.ndc create mode 100644 tests/functional/programs/017_dynamic_any/015_an_overload_picked_as_the_program_runs_takes_a_function_that_fits.ndc create mode 100644 tests/functional/programs/017_dynamic_any/016_a_function_from_any_must_take_what_its_type_passes.ndc create mode 100644 tests/functional/programs/017_dynamic_any/017_an_overload_picked_as_the_program_runs_rejects_an_unchecked_function.ndc create mode 100644 tests/functional/programs/017_dynamic_any/018_a_checked_function_returning_a_function_checks_that_one_too.ndc create mode 100644 tests/functional/programs/017_dynamic_any/019_a_function_that_never_returns_fits_any_return_type.ndc create mode 100644 tests/functional/programs/017_dynamic_any/020_recursion_through_a_checked_function_runs_deep.ndc create mode 100644 tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc create mode 100644 tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc create mode 100644 tests/functional/programs/017_dynamic_any/023_a_checked_function_checks_its_arguments.ndc create mode 100644 tests/functional/programs/017_dynamic_any/024_a_pure_function_tells_checked_functions_apart.ndc diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index ec093004..c6c1f2e5 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -37,6 +37,24 @@ missing-key lookup inserts it. A default *function*'s results can't be verified without calling it, so such a map only conforms when the value type is `Any`. +A function is checked against what its own type says: one that takes a +`String` can't be a `Fn(Int) -> Int`. What a function returns often isn't +known until it runs, so a function that may fit a function type returns a +function that checks each call's result: + +```ndc,fails +fn text(x) => "s"; // its value takes and returns anything +let value: Any = text; +let f = value as Fn(Int) -> Int; +f(1) +// error[vm]: expected the function to return Int but it returned String +``` + +Inside a container, where the function can't be replaced by one that checks +its calls, only a function whose own type shows it fits does. So does an +overload picked as the program runs from a value of type `Any`: cast the +value to the function type first to have its calls checked. + The analyser rejects casts between types that cannot share a value: ```ndc,fails diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 5f61d35e..fe170e93 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -217,6 +217,14 @@ impl Analyser { .analysis .inferred_return_types .insert(id, inferred_return.clone()); + // A lambda's value says what it returns, which tells whether it's + // a function type an `Any` is used as without checking its calls. + if declared.is_none() + && let Some(StaticType::Function { return_type, .. }) = + self.result.analysis.function_types.get_mut(&id) + { + **return_type = inferred_return.clone(); + } } // A named function's value is checked as the program runs against diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 6a5ae395..4e77c00a 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -460,7 +460,16 @@ impl Analyser { .resolve_overloads(&candidates, &arguments, kind) .binding { - Binding::Dynamic(only) if only.len() == 1 => Binding::Resolved(only[0]), + Binding::Dynamic(only) + if let [Candidate::Scalar(only)] = only[..] + && !self.types.has_shape(only) + && let StaticType::Function { + parameters: Some(_), + .. + } = self.vars.resolve(&self.types.callable_type(only)) => + { + Binding::Resolved(Candidate::Scalar(only)) + } binding => binding, }; assert!( @@ -1537,6 +1546,24 @@ impl Analyser { binding, return_type, } = self.resolve_overloads(&call.candidates, arguments, call.kind); + // A pick left to the program among one function with known + // parameters is a call of it, whose `Any` arguments are checked where + // they're passed. A variable of type `Any` may hold any function, and + // an `Iterable` parameter's kind isn't in its declared type, so the + // program checks those arguments itself. + let binding = match binding { + Binding::Dynamic(only) + if let [Candidate::Scalar(only)] = only[..] + && !self.types.has_shape(only) + && let StaticType::Function { + parameters: Some(_), + .. + } = self.vars.resolve(&self.types.callable_type(only)) => + { + Binding::Resolved(Candidate::Scalar(only)) + } + binding => binding, + }; if matches!(binding, Binding::None) { self.report_failed_call(call, arguments); self.record_pick(call.site, binding, arguments); diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 203c3d13..881d3afb 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -38,6 +38,13 @@ impl TypeTable { /// The type of `symbol` as a call sees it, which picks the overload a /// call means: `fn total(xs: Iterable)` takes an `Iterable`, /// although its declared type takes a type parameter each call settles. + /// Whether a parameter of `symbol` takes any sequence of a type, as + /// `xs: Iterable` does, which its declared type leaves to an + /// iteration constraint. + pub(crate) fn has_shape(&self, symbol: SymbolId) -> bool { + self.shapes.contains_key(&symbol) + } + pub(crate) fn callable_type(&self, symbol: SymbolId) -> std::borrow::Cow<'_, StaticType> { let typ = self.get_type(symbol); let ( diff --git a/ndc_vm/src/value/function.rs b/ndc_vm/src/value/function.rs index 53540d9f..a6eea7c2 100644 --- a/ndc_vm/src/value/function.rs +++ b/ndc_vm/src/value/function.rs @@ -22,6 +22,29 @@ pub enum Function { cache: Rc>>, function: Rc, }, + + /// A function passed as a function type it may not have: a value of + /// type `Any` used as a `Fn(Int) -> Int` that holds a function whose + /// return type isn't known. Each call checks what it returns. + Checked { + function: Rc, + /// The function type it's used as, boxed so that it doesn't make + /// every function value bigger. + typ: Box, + }, +} + +/// How a function fits a function type, from the function's own type, +/// which leaves out what isn't known as the program runs. +#[derive(Debug, PartialEq, Eq)] +pub enum FunctionFit { + /// It takes what the type passes and returns what it returns. + Proven, + /// It takes what the type passes, but what it returns has to be checked + /// at each call. + IfChecked, + /// It can't be a function of that type. + No, } pub struct NativeFunction { @@ -182,7 +205,9 @@ impl Function { match self { Self::Closure(c) => Some(&c.prototype), Self::Compiled(f) => Some(f), - Self::Memoized { function, .. } => function.prototype(), + Self::Memoized { function, .. } | Self::Checked { function, .. } => { + function.prototype() + } Self::Native(_) => None, } } @@ -190,7 +215,9 @@ impl Function { pub fn documentation(&self) -> Option<&str> { match self { Self::Native(f) => f.documentation.as_deref(), - Self::Memoized { function, .. } => function.documentation(), + Self::Memoized { function, .. } | Self::Checked { function, .. } => { + function.documentation() + } _ => None, } } @@ -200,16 +227,87 @@ impl Function { Self::Compiled(f) => f.name.as_deref(), Self::Native(f) => Some(&f.name), Self::Closure(c) => c.prototype.name.as_deref(), - Self::Memoized { function, .. } => function.name(), + Self::Memoized { function, .. } | Self::Checked { function, .. } => function.name(), } } pub fn static_type(&self) -> StaticType { + self.signature().clone() + } + + /// The function's type: what it takes and returns, as far as that's + /// known as the program runs. + fn signature(&self) -> &StaticType { match self { - Self::Compiled(f) => f.static_type.clone(), - Self::Native(f) => f.static_type.clone(), - Self::Closure(c) => c.prototype.static_type.clone(), - Self::Memoized { function, .. } => function.static_type(), + Self::Compiled(f) => &f.static_type, + Self::Native(f) => &f.static_type, + Self::Closure(c) => &c.prototype.static_type, + Self::Memoized { function, .. } => function.signature(), + Self::Checked { typ, .. } => typ, + } + } + + /// The function a [`Self::Checked`] checks, or this one: checking its + /// calls doesn't make it another function, as far as `==` goes. + pub fn unchecked(&self) -> &Self { + match self { + Self::Checked { function, .. } => function.unchecked(), + function => function, + } + } + + /// The types of the parameters, when it takes a known number of them. + pub fn parameters(&self) -> Option<&[StaticType]> { + match self.signature() { + StaticType::Function { + parameters: Some(parameters), + .. + } => Some(parameters), + _ => None, + } + } + + /// How this function fits `expected`, a function type. A parameter or + /// return type that its own type leaves unknown, as `Any`, fits + /// anything, but an unknown return type has to be checked at each call: + /// a lambda that returns a String doesn't fit `Fn(Int) -> Int`, and one + /// whose return type isn't known fits it if checked. + pub fn fit(&self, expected: &StaticType) -> FunctionFit { + let StaticType::Function { + parameters: expected_parameters, + return_type: expected_return, + } = expected + else { + return FunctionFit::No; + }; + let StaticType::Function { + parameters, + return_type, + } = self.signature() + else { + return FunctionFit::No; + }; + let (takes, takes_proven) = match (expected_parameters, parameters) { + (Some(expected), Some(parameters)) => ( + expected.len() == parameters.len() + && expected + .iter() + .zip(parameters) + .all(|(expected, parameter)| could_be(expected, parameter)), + expected + .iter() + .zip(parameters) + .all(|(expected, parameter)| proves(expected, parameter)), + ), + (_, None) => (true, true), + (None, Some(_)) => (true, false), + }; + if !takes || !could_be(return_type, expected_return) { + FunctionFit::No + } else if takes_proven && proves(return_type, expected_return) { + FunctionFit::Proven + } else { + FunctionFit::IfChecked } } @@ -217,13 +315,7 @@ impl Function { /// element of a container argument against a parameter like /// `List`, which [`Self::matches_value_args`] doesn't. pub fn conforms_value_args(&self, args: &[Value]) -> bool { - let st = match self { - Self::Native(f) => &f.static_type, - Self::Compiled(f) => &f.static_type, - Self::Closure(c) => &c.prototype.static_type, - Self::Memoized { function, .. } => return function.conforms_value_args(args), - }; - match st { + match self.signature() { StaticType::Function { parameters: Some(params), .. @@ -244,13 +336,7 @@ impl Function { /// Returns true if this function accepts the given arguments, without /// allocating a `StaticType` for each of them. pub fn matches_value_args(&self, args: &[Value]) -> bool { - let st = match self { - Self::Native(f) => &f.static_type, - Self::Compiled(f) => &f.static_type, - Self::Closure(c) => &c.prototype.static_type, - Self::Memoized { function, .. } => return function.matches_value_args(args), - }; - let StaticType::Function { parameters, .. } = st else { + let StaticType::Function { parameters, .. } = self.signature() else { return false; }; match parameters { @@ -273,6 +359,7 @@ impl fmt::Debug for Function { Self::Native(native) => write!(f, "{native:?}"), Self::Closure(closure) => write!(f, "", closure.prototype.name), Self::Memoized { function, .. } => write!(f, "", function), + Self::Checked { function, typ } => write!(f, "<{function:?} checked as {typ}>"), } } } @@ -287,6 +374,7 @@ impl fmt::Display for Function { Self::Native(native) => write!(f, "", native.name), Self::Closure(closure) => write!(f, "", closure.prototype.name), Self::Memoized { function, .. } => write!(f, ""), + Self::Checked { function, .. } => write!(f, "{function}"), } } } @@ -295,3 +383,76 @@ impl fmt::Debug for NativeFunction { write!(f, "", self.name, self.static_type) } } + +/// Whether a value of type `found` could be an `expected`, where what isn't +/// known in either, `Any` or a type parameter, could be anything. +fn could_be(found: &StaticType, expected: &StaticType) -> bool { + let unknown = |typ: &StaticType| { + matches!( + typ, + StaticType::Any | StaticType::Generic(_) | StaticType::Var(_) + ) + }; + // A function that never returns returns whatever is expected. + if unknown(found) || unknown(expected) || *found == StaticType::Never { + return true; + } + match (found, expected) { + (StaticType::List(a), StaticType::List(b)) + | (StaticType::Deque(a), StaticType::Deque(b)) + | (StaticType::Option(a), StaticType::Option(b)) + | (StaticType::Iterator(a), StaticType::Iterator(b)) + | (StaticType::Iterable(a), StaticType::Iterable(b)) + | (StaticType::MinHeap(a), StaticType::MinHeap(b)) + | (StaticType::MaxHeap(a), StaticType::MaxHeap(b)) => could_be(a, b), + (found, StaticType::Iterable(element)) => found + .sequence_element_type() + .is_some_and(|found| could_be(&found, element)), + (StaticType::Tuple(a), StaticType::Tuple(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| could_be(a, b)) + } + ( + StaticType::Map { key, value }, + StaticType::Map { + key: expected_key, + value: expected_value, + }, + ) => could_be(key, expected_key) && could_be(value, expected_value), + ( + StaticType::Function { + parameters, + return_type, + }, + StaticType::Function { + parameters: expected_parameters, + return_type: expected_return, + }, + ) => { + let takes = match (parameters, expected_parameters) { + (Some(a), Some(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| could_be(b, a)) + } + _ => true, + }; + takes && could_be(return_type, expected_return) + } + (found, expected) => found == expected, + } +} + +/// Whether every value of type `found` is an `expected`: a function that +/// takes `Any` takes anything, and one that returns a `List` returns an +/// `Iterable`. +fn proves(found: &StaticType, expected: &StaticType) -> bool { + *expected == StaticType::Any || (!has_unknowns(found) && found.fits(expected)) +} + +/// Whether `typ` leaves something unknown: `Any` or a type parameter. +fn has_unknowns(typ: &StaticType) -> bool { + typ.contains(&|part| { + matches!( + part, + StaticType::Any | StaticType::Generic(_) | StaticType::Var(_) + ) + }) +} diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 1443fdb8..479d70f3 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -397,28 +397,43 @@ impl Value { /// Whether this value is a `target`, a type that holds no other values /// as a container does: a scalar, a struct, a function or an iterator. - /// What a function takes and returns can't be checked as the program - /// runs, only how many arguments it takes, and what an iterator makes - /// can't be either without making it. + /// A function is one if its own type says so (see [`Function::fit`]): + /// only [`Self::converted_to`] can make one that may be one a function + /// whose calls are checked. What an iterator makes can't be checked + /// without making it. fn is_a(&self, target: &StaticType) -> bool { + if let StaticType::Function { .. } = target + && let Self::Object(object) = self + && let Object::Function(function) = object.as_ref() + { + return function.fit(target) == FunctionFit::Proven; + } match (target, self.static_type()) { - ( - StaticType::Function { - parameters: expected, - .. - }, - StaticType::Function { - parameters: takes, .. - }, - ) => match (expected, takes) { - (Some(expected), Some(takes)) => expected.len() == takes.len(), - _ => true, - }, (StaticType::Iterator(_), StaticType::Iterator(_)) => true, (target, found) => found == *target, } } + /// This value as a `target`, if it is one, checking every element of a + /// container: a function that may be a function `target` is wrapped so + /// that each call checks what it returns. + pub fn converted_to(&self, target: &StaticType) -> Option { + if let StaticType::Function { .. } = target + && let Self::Object(object) = self + && let Object::Function(function) = object.as_ref() + { + return match function.fit(target) { + FunctionFit::Proven => Some(self.clone()), + FunctionFit::IfChecked => Some(Self::function(Function::Checked { + function: Rc::new(function.unchecked().clone()), + typ: Box::new(target.clone()), + })), + FunctionFit::No => None, + }; + } + self.conforms_to(target).then(|| self.clone()) + } + /// Check whether this value conforms to `target`, scanning container /// elements recursively. Empty containers conform to any element type. /// @@ -1098,7 +1113,7 @@ impl PartialEq for Object { ) => a_entries.borrow().eq(&*b_entries.borrow()), (Self::Function(a), Self::Function(b)) => { // Compare function values by identity (pointer equality) - match (a, b) { + match (a.unchecked(), b.unchecked()) { (Function::Compiled(a), Function::Compiled(b)) => { std::ptr::addr_eq(Rc::as_ptr(a), Rc::as_ptr(b)) } @@ -1187,7 +1202,7 @@ impl Hash for Object { } Self::Function(f) => { state.write_u8(9); - match f { + match f.unchecked() { Function::Compiled(func) => { state.write_u8(1); Rc::as_ptr(func).hash(state); @@ -1204,6 +1219,7 @@ impl Hash for Object { state.write_u8(4); Rc::as_ptr(cache).hash(state); } + Function::Checked { .. } => unreachable!("`unchecked` strips the checks"), } } Self::OverloadSet { .. } => { diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index 16091494..51b5b0b8 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -8,6 +8,7 @@ use crate::iterator::{ use crate::value::{CompiledFunction, Function, NativeFunc, NativeFunction}; use crate::{ClosureFunction, Object, Value}; use ndc_analysis::{CaptureSource, ResolvedVar}; +use ndc_core::StaticType; use ndc_lexer::Span; use std::cell::RefCell; use std::hash::{Hash, Hasher}; @@ -74,6 +75,9 @@ pub struct CallFrame { /// cache to write into and the hash key when the frame returns. `None` /// for ordinary (non-memoized) calls. memo: Option, + /// What the frame's call has to return, when it's a call of a + /// [`Function::Checked`]. Boxed to keep frames small. + returns: Option>, } /// The VM's globals for `natives`: native `i` is global slot `i`. The analyser @@ -108,6 +112,7 @@ impl Vm { ip: 0, frame_pointer: 0, memo: None, + returns: None, }], output: OutputSink::Stdout, #[cfg(feature = "trace")] @@ -195,19 +200,27 @@ impl Vm { } OpCode::Return => { let ret = self.stack.pop().expect("stack underflow"); - let frame = self.frames.last().expect("no frame"); + let frame = self.frames.last_mut().expect("no frame"); let frame_pointer = frame.frame_pointer; // If this was a memoized call, cache the return value now, // before the frame is torn down. if let Some((cache, key)) = &frame.memo { cache.borrow_mut().insert(*key, ret.clone()); } + let returns = frame.returns.take(); if frame_pointer == 0 { return Err(VmError::new("Return at top level", span)); } self.stack.truncate(frame_pointer - 1); self.frames.pop().expect("no frame to pop"); self.stack.push(ret); + // A checked call's result is checked where it was called. + if let Some(returns) = returns { + let caller = self.frames.last().expect("a call has a caller"); + let call = caller.closure.prototype.body.span(caller.ip - 1); + convert_result(&mut self.stack, &returns) + .map_err(|message| VmError::new(message, call))?; + } if self.frames.len() == target_depth { break Ok(()); } @@ -218,8 +231,8 @@ impl Vm { .push(frame.closure.prototype.body.constant(idx).clone()); } OpCode::CheckType(target) => { - let value = self.stack.last().expect("stack underflow"); - if !value.conforms_to(target) { + if !convert_top(&mut self.stack, target) { + let value = self.stack.last().expect("stack underflow"); return Err(VmError::new( format!("cannot cast {} to {target}", value.diagnostic_type()), span, @@ -227,8 +240,8 @@ impl Vm { } } OpCode::ExpectType(target) => { - let value = self.stack.last().expect("stack underflow"); - if !value.conforms_to(target) { + if !convert_top(&mut self.stack, target) { + let value = self.stack.last().expect("stack underflow"); return Err(VmError::new( format!("expected {target} but found {}", value.diagnostic_type()), span, @@ -649,6 +662,14 @@ impl Vm { let mut hasher = DefaultHasher::default(); for arg in &self.stack[start..] { arg.hash(&mut hasher); + // A function checked as another function type is the same + // function, but a call may return it, so the cache tells + // them apart. + if let Value::Object(object) = arg + && let Object::Function(Function::Checked { typ, .. }) = object.as_ref() + { + typ.hash(&mut hasher); + } } let key = hasher.finish(); @@ -667,6 +688,44 @@ impl Vm { } } + /// Calls the function a [`Function::Checked`] checks, and checks that + /// what it returns is what the function type it's used as says: right + /// away when the call completes here, or when its frame returns. + #[cold] + fn call_checked( + &mut self, + function: Rc, + typ: StaticType, + args: usize, + ) -> Result { + let StaticType::Function { return_type, .. } = typ else { + unreachable!("a checked function is used as a function type"); + }; + // The function type it's used as may take more than the function + // does, as `Fn(Any) -> Int` would of a function taking an Int. + let arguments = &self.stack[self.stack.len() - args..]; + if !function.conforms_value_args(arguments) { + return Err(VmError::native(format!( + "the function doesn't take the arguments ({})", + arguments + .iter() + .map(|argument| argument.diagnostic_type().to_string()) + .collect::>() + .join(", ") + ))); + } + match self.dispatch_call(Rc::unwrap_or_clone(function), args)? { + Dispatch::FramePushed => { + self.frames.last_mut().expect("a frame was pushed").returns = Some(return_type); + Ok(Dispatch::FramePushed) + } + Dispatch::Completed => { + convert_result(&mut self.stack, &return_type).map_err(VmError::native)?; + Ok(Dispatch::Completed) + } + } + } + /// Inner helper that pushes a new call frame. `memo` is `Some` when the /// call originates from a memoized function and the return value should be /// stored in the cache. @@ -705,6 +764,7 @@ impl Vm { prototype: f, upvalues: Vec::new(), }, + Function::Checked { function, typ } => return self.call_checked(function, *typ, args), Function::Memoized { .. } => { // Already unwrapped in dispatch_call; this branch is unreachable. unreachable!( @@ -718,6 +778,7 @@ impl Vm { ip: 0, frame_pointer: self.stack.len() - args, memo, + returns: None, }); for _ in args..num_locals { self.stack.push(Value::unit()); @@ -1169,3 +1230,34 @@ fn bad_range_bound(value: &Value) -> String { } format!("range bounds must be Int, got {}", value.diagnostic_type()) } + +/// Checks that the value on top of `stack` is a `target`, and makes it one: +/// a function that may be a function `target` gets its calls checked (see +/// [`Value::converted_to`]). +fn convert_top(stack: &mut [Value], target: &StaticType) -> bool { + let value = stack.last_mut().expect("stack underflow"); + if !matches!(target, StaticType::Function { .. }) { + return value.conforms_to(target); + } + match value.converted_to(target) { + Some(converted) => { + *value = converted; + true + } + None => false, + } +} + +/// Checks that the result of a call of a [`Function::Checked`], on top of +/// `stack`, is a `returns`, and makes it one, as [`convert_top`] does: a +/// function it returns may get its own calls checked. +fn convert_result(stack: &mut [Value], returns: &StaticType) -> Result<(), String> { + if convert_top(stack, returns) { + return Ok(()); + } + let returned = stack.last().expect("a call leaves its result"); + Err(format!( + "expected the function to return {returns} but it returned {}", + returned.diagnostic_type() + )) +} diff --git a/tests/functional/programs/017_dynamic_any/012_a_function_from_any_checks_what_it_returns.ndc b/tests/functional/programs/017_dynamic_any/012_a_function_from_any_checks_what_it_returns.ndc new file mode 100644 index 00000000..7189fe54 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/012_a_function_from_any_checks_what_it_returns.ndc @@ -0,0 +1,11 @@ +// A function of type `Any` used as a `Fn(Int) -> Int` checks what each call +// returns when its own type doesn't say: `text`'s value takes and returns +// anything. +// expect-error: expected the function to return Int but it returned String +fn show(x: Int) { "int" } +fn show(x: String) { "string" } +fn text(x) => "s"; +let a: Any = text; +let f: Fn(Int) -> Int = a; +let n: Int = f(1); +print(show(n), n); diff --git a/tests/functional/programs/017_dynamic_any/013_a_function_from_any_must_return_what_its_type_says.ndc b/tests/functional/programs/017_dynamic_any/013_a_function_from_any_must_return_what_its_type_says.ndc new file mode 100644 index 00000000..397f9e9f --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/013_a_function_from_any_must_return_what_its_type_says.ndc @@ -0,0 +1,4 @@ +// A lambda whose own type says it returns a String isn't a `Fn(Int) -> Int`. +// expect-error: expected Fn(Int) -> Int but found Fn(Any) -> String +let a: Any = fn(x) => "s"; +let f: Fn(Int) -> Int = a; diff --git a/tests/functional/programs/017_dynamic_any/014_a_checked_function_is_the_same_function.ndc b/tests/functional/programs/017_dynamic_any/014_a_checked_function_is_the_same_function.ndc new file mode 100644 index 00000000..d5203a43 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/014_a_checked_function_is_the_same_function.ndc @@ -0,0 +1,7 @@ +// A function of type `Any` used as a function type is the same function: it +// equals itself, and a call that returns what's expected works. +// expect-output: 3 true true +fn inc(x) => x + 1; +let a: Any = inc; +let f: Fn(Int) -> Int = a; +print(f(2), f == inc, a == f); diff --git a/tests/functional/programs/017_dynamic_any/015_an_overload_picked_as_the_program_runs_takes_a_function_that_fits.ndc b/tests/functional/programs/017_dynamic_any/015_an_overload_picked_as_the_program_runs_takes_a_function_that_fits.ndc new file mode 100644 index 00000000..ddd84ef6 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/015_an_overload_picked_as_the_program_runs_takes_a_function_that_fits.ndc @@ -0,0 +1,9 @@ +// An overload picked as the program runs takes a function of type `Any` only +// if its own type shows it fits: `text`'s value takes and returns anything, +// so nothing says it's a `Fn(Int) -> Int`. Casting it first checks its calls. +// expect-output: 2 +fn h(f: Fn(Int) -> Int) => f(1) + 1; +fn h(x: Int) => x; +fn one(x) => x; +let a: Any = one; +print(h(a as Fn(Int) -> Int)); diff --git a/tests/functional/programs/017_dynamic_any/016_a_function_from_any_must_take_what_its_type_passes.ndc b/tests/functional/programs/017_dynamic_any/016_a_function_from_any_must_take_what_its_type_passes.ndc new file mode 100644 index 00000000..8e84ae13 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/016_a_function_from_any_must_take_what_its_type_passes.ndc @@ -0,0 +1,4 @@ +// A lambda that takes a String isn't a `Fn(Int) -> Int`. +// expect-error: expected Fn(Int) -> Int but found Fn(String) -> Int +let a: Any = fn(s: String) => 1; +let f: Fn(Int) -> Int = a; diff --git a/tests/functional/programs/017_dynamic_any/017_an_overload_picked_as_the_program_runs_rejects_an_unchecked_function.ndc b/tests/functional/programs/017_dynamic_any/017_an_overload_picked_as_the_program_runs_rejects_an_unchecked_function.ndc new file mode 100644 index 00000000..8fd7d398 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/017_an_overload_picked_as_the_program_runs_rejects_an_unchecked_function.ndc @@ -0,0 +1,8 @@ +// An overload picked as the program runs doesn't take a function of type +// `Any` whose own type doesn't show it fits the parameter. +// expect-error: no function called 'h' found matches the arguments +fn h(f: Fn(Int) -> Int) => f(1) + 1; +fn h(x: Int) => x; +fn text(x) => "s"; +let a: Any = text; +print(h(a)); diff --git a/tests/functional/programs/017_dynamic_any/018_a_checked_function_returning_a_function_checks_that_one_too.ndc b/tests/functional/programs/017_dynamic_any/018_a_checked_function_returning_a_function_checks_that_one_too.ndc new file mode 100644 index 00000000..1b386aee --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/018_a_checked_function_returning_a_function_checks_that_one_too.ndc @@ -0,0 +1,8 @@ +// A checked function that returns a function makes that one checked too, when +// its own type doesn't show it fits. +// expect-output: 1 +fn id(x) => x; +fn make() => id; +let a: Any = make; +let f = a as Fn() -> Fn(Int) -> Int; +print(f()(1)); diff --git a/tests/functional/programs/017_dynamic_any/019_a_function_that_never_returns_fits_any_return_type.ndc b/tests/functional/programs/017_dynamic_any/019_a_function_that_never_returns_fits_any_return_type.ndc new file mode 100644 index 00000000..816d6da1 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/019_a_function_that_never_returns_fits_any_return_type.ndc @@ -0,0 +1,6 @@ +// A function that never returns can be used as one returning anything. +// expect-output: ok +fn forever() -> Never { forever() } +let a: Any = forever; +let f = a as Fn() -> Int; +print("ok"); diff --git a/tests/functional/programs/017_dynamic_any/020_recursion_through_a_checked_function_runs_deep.ndc b/tests/functional/programs/017_dynamic_any/020_recursion_through_a_checked_function_runs_deep.ndc new file mode 100644 index 00000000..3fb5b0bd --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/020_recursion_through_a_checked_function_runs_deep.ndc @@ -0,0 +1,8 @@ +// A checked function's calls run on the VM's own stack, so recursion through +// one goes as deep as any other. +// expect-output: 0 +let f: Fn(Int) -> Int = fn(x: Int) => 0; +fn step(x: Int) { if x == 0 { 0 } else { f(x - 1) } } +let a: Any = step; +f = a; +print(f(10000)); diff --git a/tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc b/tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc new file mode 100644 index 00000000..646ea57b --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc @@ -0,0 +1,5 @@ +// An `Any` passed for an `Iterable` parameter is checked to be a sequence. +// expect-error: no function called 'consume' found matches the arguments: (Bool) +fn consume(xs: Iterable) => 0; +let a: Any = true; +print(consume(a)); diff --git a/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc b/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc new file mode 100644 index 00000000..a37cdb65 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc @@ -0,0 +1,7 @@ +// A function whose own type says it returns a list fits a function type that +// returns a sequence, without checking its calls. +// expect-output: 1 +fn h(f: Fn(Int) -> Iterable) => 1; +fn h(x: Int) => 0; +let a: Any = fn(x: Int) => [x]; +print(h(a)); diff --git a/tests/functional/programs/017_dynamic_any/023_a_checked_function_checks_its_arguments.ndc b/tests/functional/programs/017_dynamic_any/023_a_checked_function_checks_its_arguments.ndc new file mode 100644 index 00000000..90bdfc7f --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/023_a_checked_function_checks_its_arguments.ndc @@ -0,0 +1,9 @@ +// A function used as a function type that takes more than it does checks +// each call's arguments: `store` takes an Int, not anything. +// expect-error: the function doesn't take the arguments (Bool) +let n: Int = 0; +fn store(x: Int) { n = x; 0 } +let a: Any = store; +let g: Any = a as Fn(Any) -> Int; +g(true); +print(n); diff --git a/tests/functional/programs/017_dynamic_any/024_a_pure_function_tells_checked_functions_apart.ndc b/tests/functional/programs/017_dynamic_any/024_a_pure_function_tells_checked_functions_apart.ndc new file mode 100644 index 00000000..7f4429b1 --- /dev/null +++ b/tests/functional/programs/017_dynamic_any/024_a_pure_function_tells_checked_functions_apart.ndc @@ -0,0 +1,11 @@ +// A function checked as two function types gives two arguments a `pure fn` +// caches separately. +// expect-output: ok +pure fn cached(f: Any) => f; +fn id(x) => x; +let a: Any = id; +let ints = a as Fn(Int) -> Int; +let strs = a as Fn(String) -> String; +cached(ints); +let s: Fn(String) -> String = cached(strs); +print(s("ok")); From acce12d4fbb4e7ba5a473027e3e2f2a815613700 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 17:35:53 +0100 Subject: [PATCH 29/39] =?UTF-8?q?fix(analyser):=20make=20a=20type=20parame?= =?UTF-8?q?ter=20one=20type=20across=20a=20call,=20and=20op=3D=20a=20call?= =?UTF-8?q?=20like=20any=20other=20=E2=9A=96=EF=B8=8F=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking an overload matched each argument against its parameter on its own, so a type parameter could stand for a different type at each position: `max(T, T) -> T` looked like it took a Float and an Int. The solver's unification got it right for ordinary calls, but three places worked around the picker instead, and `a op= b` went through the picker alone. So `best max= x`, with `best` a Float and `x` an Int, passed analysis and failed when it ran, while `best = max(best, x)` was rejected (found in an Advent of Code solution). - The picker now makes each type parameter one type across the call. Inside a container a type parameter is the type there (a `List` makes `T` `Any`), and a value passed for `T` itself has to fit it, so `push(List, String)` still fits `push(List, T)`. An argument of type `Any` as a whole agrees with anything, as it's checked where it's passed. - `a op= b` is now a call constraint like any other, as `a = op(a, b)`: one path instead of two (the separate one that picked overloads for known operand types is gone), and an in-place overload such as `++=` is used where it takes the operands exactly. An operator with no plain counterpart, like a set's `-=`, is called when no plain operator takes the operands. - The workarounds go: the filter on runtime picks in `resolve_overloads`, and the eligibility gate for exact in-place picks. Behaviour changes: - `best max= x` with a Float and an Int is an analysis error, as `max(best, x)` is. - `a op= b` with a right operand of type `Any` is no longer an error. It works like `a = a op b`: the operator makes a new value, which is checked where it's written back, so a value that doesn't fit never reaches the target. As nothing says the operand fits, it doesn't change the target in place; a cast to the target's type does. - `values ++= ["two"]` on a `List` now reports that no `++` takes `List, List` instead of a type mismatch. The manual's augmented-assignment and casts pages describe the new rules. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/augmented-assignment.md | 44 ++++--- manual/src/features/casts.md | 8 +- ndc_analyser/src/analyser/assignments.rs | 95 ++++----------- ndc_analyser/src/analyser/solver.rs | 86 ++++++------- ndc_analyser/src/analyser/tests.rs | 18 ++- ndc_analyser/src/overloads.rs | 1 + ndc_core/src/static_type.rs | 115 ++++++++++++++++++ ndc_stdlib/src/sequence.rs | 5 +- ...incompatible_specialized_op_assignment.ndc | 2 +- .../004_basic/066_op_assignment_any_rhs.ndc | 9 +- .../067_op_assignment_supertype_rhs.ndc | 4 +- ...nt_takes_one_type_for_a_type_parameter.ndc | 6 + ...nment_checks_what_an_any_operand_makes.ndc | 6 + ...t_checks_an_any_target_before_the_call.ndc | 7 ++ 14 files changed, 250 insertions(+), 156 deletions(-) create mode 100644 tests/functional/programs/004_basic/075_op_assignment_takes_one_type_for_a_type_parameter.ndc create mode 100644 tests/functional/programs/004_basic/076_op_assignment_checks_what_an_any_operand_makes.ndc create mode 100644 tests/functional/programs/004_basic/077_op_assignment_checks_an_any_target_before_the_call.ndc diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index d2c6d1b0..16c85646 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -39,49 +39,53 @@ source order. The augmented assignment expression itself evaluates to `()`. ## Type checking -An augmented assignment with an in-place operator such as `++=` never changes -the type of its target. If the right-hand side would force a type change the -program is rejected: +`a op= b` is checked like `a = a op b`: the operator has to take both operands, +and what it returns has to fit `a`. An in-place operator such as `++=` is used +where it does the same thing, so it never changes the type of its target. A +right-hand side whose element type differs is rejected: ```ndc,fails let values = [1]; -values ++= ["two"]; // error: mismatched types: found List but expected List +values ++= ["two"]; // error: No function called '++' found that matches the arguments 'List, List' ``` -The right-hand side has to *provably* fit. A wider element type is not enough, -because the operator copies the values across without checking them: +A wider element type is not enough either, as `++` keeps one element type: ```ndc,fails let values: List = [1]; let rhs: List = [0.5n]; -values ++= rhs; // error: mismatched types: found List but expected List +values ++= rhs; // error: No function called '++' found that matches the arguments 'List, List' ``` -The same goes for an operand whose type is unknown. `Any` says nothing about -what the value holds, and no later step re-checks it, so it is rejected too: +An operand of type `Any` is checked as the program runs, as in `a = a op b`. +What the operator makes is checked where it's written back, so a value that +doesn't fit fails there and leaves the target as it was. As nothing says the +operand fits before it's checked, the operator makes a new value rather than +changing the target in place: -```ndc,fails +```ndc fn opaque(x: Any) => x; let values = [1]; -values ++= opaque([2, 3]); // error: mismatched types: found Any but expected List +let same = values; +values ++= opaque([2, 3]); +print(values, same); // [1,2,3] [1] ``` -[Cast](casts.md) the operand to say what it holds. The cast checks the value, so -the assignment gets its guarantee from the cast site: - -```ndc +```ndc,fails fn opaque(x: Any) => x; let values = [1]; -values ++= opaque([2, 3]) as List; -print(values); // [1,2,3] +values ++= opaque([0.5]); // error: expected List but found List ``` -A cast that does not hold fails there rather than corrupting the target: +[Cast](casts.md) the operand to say what it holds, which checks it at the cast +and lets `++=` change the target in place: -```ndc,fails +```ndc fn opaque(x: Any) => x; let values = [1]; -values ++= opaque([0.5]) as List; // error: cannot cast List to List +let same = values; +values ++= opaque([2, 3]) as List; +print(values, same); // [1,2,3] [1,2,3] ``` Annotate the target with `Any` to opt into heterogeneous contents: diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index c6c1f2e5..2293ab35 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -92,10 +92,10 @@ let values: List = [1, 2, 3]; print((values as List).sum()); // 6 ``` -A specialized `op=` mutates its target in place and keeps its type, so it only -accepts a right operand that provably fits. A value of unknown type does not, -and a cast gets the elements checked instead of letting them slip into the -target unverified: +A specialized `op=` such as `++=` changes its target in place, so it's only +used with a right operand that provably fits. With a value of unknown type, +`a op= b` makes a new value like `a = a op b` does; a cast checks the elements +first, so the target can be changed in place: ```ndc fn opaque(x) => x; diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index c3e307d6..a2f2b0a1 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -23,25 +23,26 @@ impl Analyser { ) -> StaticType { let left_type = self.analyse_assignment_target(l_value, span); let right_type = self.analyse_expr(r_value); - // `a op= b` is `a = op(a, b)`. Where the operands aren't fully known - // yet, the operator waits for them like any call, and the in-place - // overload is picked along with it. + // `a op= b` is `a = op(a, b)`: the operator is a call like any other, + // and the in-place overload is picked along with it where it does the + // same (see `record_pick`). let (left_type, right_type) = ( self.vars.resolve(&left_type), self.vars.resolve(&right_type), ); - if (left_type.has_vars() || right_type.has_vars()) - && !self - .overloads() - .reachable_symbols(&self.resolver.augmented_candidates(id).operator) - .is_empty() + if !self + .overloads() + .reachable_symbols(&self.resolver.augmented_candidates(id).operator) + .is_empty() { let result = self.constrain_call( CallSite::OpAssignment(id), vec![left_type.clone(), right_type], + // What the operator returns is checked where it's written + // back, when the program picks the operator as it runs. CallNodes { arguments: vec![None, Some(r_value.id)], - result: None, + result: Some(l_value.id), }, CallKind::Operator, operation, @@ -50,16 +51,15 @@ impl Analyser { self.validate_assignment_write(l_value, &left_type, &result, span); return StaticType::unit(); } - let written = self.op_assignment_now(id, &left_type, &right_type, operation, span); + let written = self.in_place_only(id, &left_type, &right_type, operation, span); self.validate_assignment_write(l_value, &left_type, &written, span); StaticType::unit() } - /// Picks what `a op= b` with operands of these types does and returns - /// the type of the value written back to `a`. An in-place overload - /// such as `++=` is used where it keeps `a`'s type, and otherwise `op` - /// makes a new value. - pub(super) fn op_assignment_now( + /// Picks the in-place overload `a op= b` calls when there's no operator + /// `op` to fall back on, and returns the type of the value written back + /// to `a`, which an in-place overload doesn't change. + fn in_place_only( &mut self, id: NodeId, left_type: &StaticType, @@ -67,63 +67,20 @@ impl Analyser { operation: &str, span: Span, ) -> StaticType { - let arg_types = vec![left_type.clone(), right_type.clone()]; - - let ResolvedCall { - binding: assign_binding, - return_type: assign_return, - } = self.overloads().resolve_call( + let arguments = [left_type.clone(), right_type.clone()]; + let ResolvedCall { binding, .. } = self.overloads().resolve_call( &self.resolver.augmented_candidates(id).in_place, - &arg_types, - CallKind::Operator, - ); - let ResolvedCall { - binding: op_binding, - return_type: op_return, - } = self.overloads().resolve_call( - &self.resolver.augmented_candidates(id).operator, - &arg_types, + &arguments, CallKind::Operator, ); - let has_op_binding = !matches!(op_binding, Binding::None); - let assign_is_eligible = Self::augmented_rhs_is_compatible(left_type, right_type); - - let writeback_type = match assign_binding { - Binding::Resolved(candidate) if assign_is_eligible => { - self.record_augmented_pick(id, Binding::Resolved(candidate)); - None - } - Binding::Dynamic(assign_candidates) if assign_is_eligible => { - let binding = Self::in_place_then_operators(assign_candidates, op_binding.clone()); - let result_type = if has_op_binding { - assign_return.join(&op_return) - } else { - assign_return - }; - self.record_augmented_pick(id, binding); - Some(result_type) - } - Binding::Resolved(_) | Binding::Dynamic(_) => { - // A specialized mutation exists, but using it would - // 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(right_type, left_type, span)); - Some(StaticType::Any) - } - Binding::None if has_op_binding => { - self.record_augmented_pick(id, op_binding); - Some(op_return) - } - Binding::None => { - self.emit(AnalysisError::function_not_found( - operation, &arg_types, span, - )); - Some(StaticType::Any) - } - }; - - writeback_type.unwrap_or_else(|| left_type.clone()) + if matches!(binding, Binding::None) { + self.emit(AnalysisError::function_not_found( + operation, &arguments, span, + )); + return StaticType::Any; + } + self.record_augmented_pick(id, binding); + left_type.clone() } /// What `a op= b` does when the program picks as it runs: the in-place diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 4e77c00a..564417be 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -418,8 +418,13 @@ impl Analyser { .map(|argument| self.vars.resolve(argument)) .collect::>(); // An argument of type `Any` is checked where it's passed, so the - // program can call `picked` directly. - let binding = Binding::Resolved(Candidate::Scalar(picked)); + // program can call `picked` directly, unless one has nowhere to be + // checked. + let binding = if Self::checks_every_any(&arguments, &call.nodes.arguments) { + Binding::Resolved(Candidate::Scalar(picked)) + } else { + Binding::Dynamic(vec![Candidate::Scalar(picked)]) + }; if cfg!(debug_assertions) { self.early_picks.push(EarlyPick::Call { candidates: call.candidates.clone(), @@ -1271,9 +1276,6 @@ impl Analyser { } if arguments.iter().all(|argument| !argument.has_vars()) { - if let (CallSite::OpAssignment(id), [left, right]) = (call.site, &arguments[..]) { - return Some(self.op_assignment_now(id, left, right, &call.name, call.span)); - } return Some(self.resolve_call_now(call, &arguments)); } @@ -1492,51 +1494,23 @@ impl Analyser { merged } - /// [`Overloads::resolve_call`], with the overloads it leaves the program - /// to pick from as it runs cut to the ones that take the arguments with - /// each type parameter one type. Picking looks at each argument on its - /// own, so `[]=(List, Int, T)` looks like it takes a String for a - /// `List` when the index is `Any`, but it can't. + /// Whether every argument of type `Any` has a node in `nodes` to be checked + /// at where it's passed. + fn checks_every_any(arguments: &[StaticType], nodes: &[Option]) -> bool { + arguments + .iter() + .zip(nodes) + .all(|(argument, node)| node.is_some() || *argument != StaticType::Any) + } + + /// [`Overloads::resolve_call`] over the types as they're known now. pub(super) fn resolve_overloads( &mut self, candidates: &Candidates, arguments: &[StaticType], kind: CallKind, ) -> ResolvedCall { - let mut resolved = self.overloads().resolve_call(candidates, arguments, kind); - // An operator may apply an overload to each element of a tuple - // instead, which takes what the elements are. - if kind == CallKind::Operator { - return resolved; - } - if let Binding::Dynamic(picked) = &resolved.binding { - let picked = picked.clone(); - // What a function takes and returns isn't checked as the program - // runs, so it's left out: a lambda that returns `Any` may still be - // what's expected. - let snapshot = self.vars.snapshot(); - let arguments = arguments - .iter() - .map(|argument| match self.vars.resolve(argument) { - StaticType::Function { .. } => self.vars.fresh(StaticType::Any), - argument => argument, - }) - .collect::>(); - let kept = picked - .into_iter() - .filter(|candidate| match candidate { - Candidate::Scalar(symbol) => self.could_accept(*symbol, &arguments), - Candidate::Vec(_) => true, - }) - .collect::>(); - self.vars.rollback(snapshot); - resolved.binding = if kept.is_empty() { - Binding::None - } else { - Binding::Dynamic(kept) - }; - } - resolved + self.overloads().resolve_call(candidates, arguments, kind) } /// Resolves `call` with the arguments as they are, reporting it when no @@ -1548,13 +1522,15 @@ impl Analyser { } = self.resolve_overloads(&call.candidates, arguments, call.kind); // A pick left to the program among one function with known // parameters is a call of it, whose `Any` arguments are checked where - // they're passed. A variable of type `Any` may hold any function, and - // an `Iterable` parameter's kind isn't in its declared type, so the - // program checks those arguments itself. + // they're passed. A variable of type `Any` may hold any function, an + // `Iterable` parameter's kind isn't in its declared type, and an + // `Any` without a node to check it at, as the target of `a op= b`, + // isn't checked where it's passed, so the program checks those itself. let binding = match binding { Binding::Dynamic(only) if let [Candidate::Scalar(only)] = only[..] && !self.types.has_shape(only) + && Self::checks_every_any(arguments, &call.nodes.arguments) && let StaticType::Function { parameters: Some(_), .. @@ -1564,6 +1540,20 @@ impl Analyser { } binding => binding, }; + // `a op= b` with no operator that takes the operands calls an in-place + // overload if one does, as a set has `-=` but no `-`, which keeps `a`. + if matches!(binding, Binding::None) + && let CallSite::OpAssignment(id) = call.site + { + let in_place = self.resolver.augmented_candidates(id).in_place.clone(); + let in_place = self + .resolve_overloads(&in_place, arguments, call.kind) + .binding; + if !matches!(in_place, Binding::None) { + self.record_augmented_pick(id, in_place); + return arguments[0].clone(); + } + } if matches!(binding, Binding::None) { self.report_failed_call(call, arguments); self.record_pick(call.site, binding, arguments); @@ -2054,7 +2044,7 @@ impl Analyser { ); let compatible = Self::augmented_rhs_is_compatible(&arguments[0], &arguments[1]); let binding = match (in_place.binding, binding) { - (Binding::Resolved(candidate), _) if compatible => Binding::Resolved(candidate), + (Binding::Resolved(candidate), _) => Binding::Resolved(candidate), // Which one applies is left to the program as it runs, // which mutates in place where it can. (Binding::Dynamic(in_place), operators) if compatible => { diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 9cdde341..191ee237 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -223,7 +223,7 @@ fn an_in_place_operator_assignment_cannot_change_the_target_type() { ("++=".to_string(), concat.clone()), ("++".to_string(), concat.clone()), ], - "mismatched types: found List but expected List", + "No function called '++' found that matches the arguments 'List, List'", ); assert_analysis_error( "let values: List = [1]; values ++= [\"two\"];", @@ -231,7 +231,7 @@ fn an_in_place_operator_assignment_cannot_change_the_target_type() { ("++=".to_string(), concat.clone()), ("++".to_string(), concat), ], - "mismatched types: found List but expected List", + "No function called '++' found that matches the arguments 'List, List'", ); } @@ -1055,16 +1055,22 @@ fn a_cast_settles_what_isnt_known_about_its_value() { #[test] fn a_waiting_call_does_not_commit_to_an_overload_taking_any_instead() { - // Only the catch-all `[]=` takes a list of unknown elements as an Int - // list's element, but once the elements are known the element-wise - // `[]=` is the one that fits, and it doesn't take a list. + // While the list's elements aren't known, both `[]=` might take the + // write, so it waits. Once they're Ints, only the catch-all takes a list + // as an element: the element-wise `[]=` would need its `T` to be both an + // Int and a list. let globals = [ natives(&[("[]=", "(List, Any, Any)")]), index_natives(), ] .concat(); assert_eq!( - errors("let xs = [1]; xs[0] = [];", globals), + errors("let xs = [1]; xs[0] = [];", globals.clone()), + Vec::::new() + ); + // Without the catch-all, nothing takes it. + assert_eq!( + errors("let xs = [1]; xs[0] = [];", index_natives()), vec!["mismatched types: found List but expected Int"] ); } diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 3d4b17de..24522744 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -66,6 +66,7 @@ fn could_take(typ: &StaticType, sig: &[StaticType]) -> bool { .iter() .zip(sig) .all(|(param, arg)| arg.may_fit(param)) + && StaticType::type_parameters_agree(parameters, sig) } StaticType::Function { parameters: None, .. diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 17bbd617..4b7dbd8a 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -620,6 +620,121 @@ impl StaticType { .iter() .zip(parameters) .all(|(argument, parameter)| argument.fits(parameter)) + && Self::type_parameters_agree(parameters, types) + } + + /// Whether each type parameter of `parameters` stands for one type in + /// `arguments`: `(Int, String)` doesn't fit `(T, T)`, as `T` would have to + /// be both. Inside a container a type parameter is the type there, as a + /// `List` makes `T` `Any`, and a value passed for `T` itself only has + /// to fit it: `push(List, String)` fits `push(List, T)`. What isn't + /// known yet, and an argument that's `Any` as a whole, which is checked as + /// the program runs, agree with anything. + pub fn type_parameters_agree(parameters: &[Self], arguments: &[Self]) -> bool { + let mut bound = Vec::new(); + for (parameter, argument) in parameters.iter().zip(arguments) { + argument.bind_type_parameters(parameter, false, &mut bound); + } + bound.iter().enumerate().all(|(i, (name, typ, inside))| { + let exact = bound + .iter() + .find(|(other, _, other_inside)| other == name && *other_inside); + match exact { + // What a container says it is decides, and a value passed + // for it fits it. + Some((_, exact, _)) if *inside => exact.agrees_with(typ), + Some((_, exact, _)) => { + *typ == Self::Any || typ.fits(exact) || typ.agrees_with(exact) + } + // Values passed for it are what it is, so they agree. + None => bound[..i] + .iter() + .filter(|(earlier, _, _)| earlier == name) + .all(|(_, earlier, _)| { + *typ == Self::Any || *earlier == Self::Any || earlier.agrees_with(typ) + }), + } + }) + } + + /// Adds to `bound` the part of this type that each type parameter in + /// `parameter` is matched against, as [`Self::matches`] matches them, + /// with whether it's inside a container, where it has to be that type. + fn bind_type_parameters<'p>( + &self, + parameter: &'p Self, + inside: bool, + bound: &mut Vec<(&'p str, Self, bool)>, + ) { + match (self, parameter) { + (_, Self::Generic(name)) => bound.push((name, self.clone(), inside)), + // A sequence passed for an `Iterable` holds values passed for `T`. + (Self::Iterable(a), Self::Iterable(p)) => a.bind_type_parameters(p, inside, bound), + (argument, Self::Iterable(p)) if Self::is_sequence(argument) => { + if let Some(element) = argument.sequence_element_type() { + element.bind_type_parameters(p, inside, bound); + } + } + (Self::Option(a), Self::Option(p)) + | (Self::List(a), Self::List(p)) + | (Self::Iterator(a), Self::Iterator(p)) + | (Self::MinHeap(a), Self::MinHeap(p)) + | (Self::MaxHeap(a), Self::MaxHeap(p)) + | (Self::Deque(a), Self::Deque(p)) => a.bind_type_parameters(p, true, bound), + (Self::Tuple(a), Self::Tuple(p)) if a.len() == p.len() => { + for (a, p) in a.iter().zip(p) { + a.bind_type_parameters(p, true, bound); + } + } + (Self::Map { key: ak, value: av }, Self::Map { key: pk, value: pv }) => { + ak.bind_type_parameters(pk, true, bound); + av.bind_type_parameters(pv, true, bound); + } + _ => {} + } + } + + /// Whether this type and `other` could be one type, where what isn't + /// known yet in either could be anything. + fn agrees_with(&self, other: &Self) -> bool { + let unknown = |typ: &Self| matches!(typ, Self::Var(_) | Self::Generic(_) | Self::Never); + if unknown(self) || unknown(other) { + return true; + } + match (self, other) { + (Self::Option(a), Self::Option(b)) + | (Self::List(a), Self::List(b)) + | (Self::Iterator(a), Self::Iterator(b)) + | (Self::Iterable(a), Self::Iterable(b)) + | (Self::MinHeap(a), Self::MinHeap(b)) + | (Self::MaxHeap(a), Self::MaxHeap(b)) + | (Self::Deque(a), Self::Deque(b)) => a.agrees_with(b), + (Self::Tuple(a), Self::Tuple(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.agrees_with(b)) + } + (Self::Map { key: ak, value: av }, Self::Map { key: bk, value: bv }) => { + ak.agrees_with(bk) && av.agrees_with(bv) + } + ( + Self::Function { + parameters: ap, + return_type: ar, + }, + Self::Function { + parameters: bp, + return_type: br, + }, + ) => { + let parameters = match (ap, bp) { + (Some(a), Some(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.agrees_with(b)) + } + _ => true, + }; + parameters && ar.agrees_with(br) + } + (a, b) => a == b, + } } pub fn unpack(&self) -> Option + '_>> { diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index 2ecc80e0..76e09ead 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -708,7 +708,10 @@ mod inner { } /// Transposes a sequence of strings, turning rows of characters into columns. - #[function(name = "transposed", signature = "(Iterable) -> List>")] + #[function( + name = "transposed", + signature = "(Iterable) -> List>" + )] pub fn transposed_strings(seq: SeqValue) -> anyhow::Result { transpose(seq) } diff --git a/tests/functional/programs/004_basic/065_incompatible_specialized_op_assignment.ndc b/tests/functional/programs/004_basic/065_incompatible_specialized_op_assignment.ndc index cd551592..45ba0b26 100644 --- a/tests/functional/programs/004_basic/065_incompatible_specialized_op_assignment.ndc +++ b/tests/functional/programs/004_basic/065_incompatible_specialized_op_assignment.ndc @@ -1,3 +1,3 @@ -// expect-error: mismatched types: found List but expected List +// expect-error: No function called '++' found that matches the arguments 'List, List' let values = [1]; values ++= ["two"]; diff --git a/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc b/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc index c19f68a6..e521249d 100644 --- a/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc +++ b/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc @@ -1,8 +1,7 @@ -// A specialized `op=` copies values into the left operand without inspecting -// them, so an `Any` right operand is rejected rather than silently accepted: -// nothing downstream would check the elements. Cast the operand to state what -// it holds. -// expect-error: mismatched types: found Any but expected List +// `a op= b` with an `Any` right operand is `a = a op b`, checked as the +// program runs: nothing says the operand fits, so `++` makes a new list, +// which is checked where it's written back. +// expect-output: [1,2,3] fn opaque(x) -> Any => x; let values = [1]; values ++= opaque([2, 3]); diff --git a/tests/functional/programs/004_basic/067_op_assignment_supertype_rhs.ndc b/tests/functional/programs/004_basic/067_op_assignment_supertype_rhs.ndc index 69667af4..964ace69 100644 --- a/tests/functional/programs/004_basic/067_op_assignment_supertype_rhs.ndc +++ b/tests/functional/programs/004_basic/067_op_assignment_supertype_rhs.ndc @@ -1,8 +1,8 @@ // A specialized `op=` mutates the left operand in place and keeps its type, so // a right operand whose element type is merely a *supertype* is not enough: // appending a Float here would leave a List holding a non-Int. -// expect-error: mismatched types: found List but expected List +// expect-error: No function called '++' found that matches the arguments 'List, List' let values: List = [1]; -let rhs: List = [0.5]; +let rhs: List = []; values ++= rhs; print(values) diff --git a/tests/functional/programs/004_basic/075_op_assignment_takes_one_type_for_a_type_parameter.ndc b/tests/functional/programs/004_basic/075_op_assignment_takes_one_type_for_a_type_parameter.ndc new file mode 100644 index 00000000..82c4da8f --- /dev/null +++ b/tests/functional/programs/004_basic/075_op_assignment_takes_one_type_for_a_type_parameter.ndc @@ -0,0 +1,6 @@ +// `max` of two values takes two of one type, in `a max= b` as in +// `max(a, b)`: a Float and an Int aren't one type. +// expect-error: No function called 'max' found that matches the arguments 'Float, Int' +let best = -Inf; +let x: Int = 3; +best max= x; diff --git a/tests/functional/programs/004_basic/076_op_assignment_checks_what_an_any_operand_makes.ndc b/tests/functional/programs/004_basic/076_op_assignment_checks_what_an_any_operand_makes.ndc new file mode 100644 index 00000000..1ccc82a9 --- /dev/null +++ b/tests/functional/programs/004_basic/076_op_assignment_checks_what_an_any_operand_makes.ndc @@ -0,0 +1,6 @@ +// What `a op= b` makes from an `Any` operand is checked where it's written +// back, so a value that doesn't fit never reaches the target. +// expect-error: expected List but found List +fn opaque(x) -> Any => x; +let values = [1]; +values ++= opaque([0.5]); diff --git a/tests/functional/programs/004_basic/077_op_assignment_checks_an_any_target_before_the_call.ndc b/tests/functional/programs/004_basic/077_op_assignment_checks_an_any_target_before_the_call.ndc new file mode 100644 index 00000000..dd0609b7 --- /dev/null +++ b/tests/functional/programs/004_basic/077_op_assignment_checks_an_any_target_before_the_call.ndc @@ -0,0 +1,7 @@ +// The target of `a op= b` is passed to the operator, so a target of type +// `Any` is checked against what the operator takes before it runs. +// expect-error: no function called 'choose' found matches the arguments: (String, Int) +fn choose(a: Int, b: Int) => a; +let x: Any = "wrong"; +x choose= 1; +print(x); From 419a012a00ab0a5a497c8c62055ae7a542320111 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 17:39:55 +0100 Subject: [PATCH 30/39] =?UTF-8?q?refactor(analyser):=20drop=20user-written?= =?UTF-8?q?=20Iterable=20annotations=20=F0=9F=A7=B9=20(#333)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parameter annotated `Iterable` took any kind of sequence of `T`s, but an unannotated parameter already takes any sequence its body can iterate, and only the feature's own tests used it. Supporting it split each function's type in two (its declared type and the "shape" calls saw), with a separate iteration constraint per such parameter. A review of the type checker flagged it as costing far more code than it gave users. `Iterable` can no longer be written in a type annotation, for a parameter as for a value: "`Iterable` can't be written in a type annotation", with help to leave the parameter unannotated or name the kind of sequence. Built-in signatures keep it. This deletes the shape table and `callable_type` (every caller now reads the declared type), the parameter-annotation lowering that split off the element type, and the per-parameter iteration constraints and runtime types for such parameters. The `Iterable` tests that only covered the annotation go; the others now use unannotated parameters. The manual's section on it now describes unannotated parameters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types.md | 8 +-- ndc_analyser/src/analyser/calls.rs | 2 +- ndc_analyser/src/analyser/declarations.rs | 56 +++---------------- ndc_analyser/src/analyser/function_values.rs | 6 +- ndc_analyser/src/analyser/generics.rs | 4 +- ndc_analyser/src/analyser/mod.rs | 49 ++-------------- ndc_analyser/src/analyser/solver.rs | 17 +++--- ndc_analyser/src/analyser/tests.rs | 5 +- ndc_analyser/src/error.rs | 6 +- ndc_analyser/src/overloads.rs | 6 +- ndc_analyser/src/types.rs | 46 --------------- ...rable_parameter_checks_an_any_argument.ndc | 5 -- ..._a_list_fits_one_returning_an_iterable.ndc | 6 +- ...annotation_takes_any_kind_of_sequence.ndc} | 5 +- ...ements_the_body_cannot_use_is_an_error.ndc | 10 ++++ ...iterable_parameter_checks_its_elements.ndc | 9 --- ...terable_can_only_be_a_parameter_s_type.ndc | 3 - ...ble_cannot_be_written_in_an_annotation.ndc | 3 + .../004_a_cast_cannot_be_to_an_iterable.ndc | 2 +- ...parameter_does_not_take_a_non_sequence.ndc | 3 - ...le_parameter_is_more_specific_than_any.ndc | 5 -- ...ameter_may_return_any_kind_of_sequence.ndc | 3 +- ...pick_a_typed_iterable_overload_for_any.ndc | 8 --- ...ter_cannot_be_annotated_as_an_iterable.ndc | 3 + 24 files changed, 64 insertions(+), 206 deletions(-) delete mode 100644 tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc rename tests/functional/programs/019_iterable/{001_an_iterable_parameter_takes_any_kind_of_sequence.ndc => 001_a_parameter_without_an_annotation_takes_any_kind_of_sequence.ndc} (51%) create mode 100644 tests/functional/programs/019_iterable/002_a_sequence_of_elements_the_body_cannot_use_is_an_error.ndc delete mode 100644 tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc delete mode 100644 tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc create mode 100644 tests/functional/programs/019_iterable/003_an_iterable_cannot_be_written_in_an_annotation.ndc delete mode 100644 tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc delete mode 100644 tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc delete mode 100644 tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc create mode 100644 tests/functional/programs/019_iterable/016_a_parameter_cannot_be_annotated_as_an_iterable.ndc diff --git a/manual/src/reference/types.md b/manual/src/reference/types.md index e0323d42..7b6b43fb 100644 --- a/manual/src/reference/types.md +++ b/manual/src/reference/types.md @@ -41,12 +41,12 @@ let grid: List> = [[1, 2], [3, 4]]; print(grid); // [[1,2],[3,4]] ``` -### `Iterable` parameters +### Any kind of sequence -A parameter of type `Iterable` takes any kind of sequence of `T`s. The function is compiled once for each kind it's called with: +A parameter without an annotation takes any kind of sequence its body can iterate. The function is compiled once for each kind it's called with: ```ndc -fn total(xs: Iterable) { +fn total(xs) { let sum = 0; for x in xs { sum += x; @@ -56,7 +56,7 @@ fn total(xs: Iterable) { print(total([1, 2]), total((3, 4)), total(1..4)); // 3 7 6 ``` -A value has a kind of sequence, so `Iterable` can only be a parameter's type: `let xs: Iterable = [1]` is an error. A tuple whose elements have different types iterates as `Any`. +Built-in functions say this with `Iterable` in their signatures, as `sum(Iterable)` does, but an annotation can't: a value has a kind of sequence, which its annotation names, as in `List`. A tuple whose elements have different types iterates as `Any`. ### Element types are exact diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index ef006db8..52be8bf1 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -243,7 +243,7 @@ impl Analyser { known: &[Option], position: usize, ) -> Expects { - let function = self.vars.resolve(&self.types.callable_type(symbol)); + let function = self.vars.resolve(self.types.get_type(symbol)); let first = self.vars.count(); let Some((parameters, _)) = self.vars.instantiate_signature(&function) else { return Expects::Anything; diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index fe170e93..d504e2d0 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -1,4 +1,3 @@ -use super::solver::Constraint; use super::{Analyser, Returns}; use crate::error::AnalysisError; use itertools::izip; @@ -51,10 +50,10 @@ impl Analyser { let first_use = self.uses.len(); let first_literal = self.number_literals.len(); let first_check = self.checks.len(); - let (annotations, iterates): (Vec<_>, Vec<_>) = parameters + let annotations = parameters .iter() - .map(|param| self.lower_parameter_annotation(param.annotation.as_ref())) - .unzip(); + .map(|param| self.lower_annotation(param.annotation.as_ref())) + .collect::>(); let expected = self.expected_parameters.remove(&id).unwrap_or_default(); let param_types = annotations .iter() @@ -74,14 +73,7 @@ impl Analyser { Some(_) => Some( annotations .iter() - .zip(&iterates) - .map(|(annotation, iterates)| match (annotation, iterates) { - (Some(annotation), _) => annotation.clone(), - // What it iterates as is checked too, so a call the - // program picks for doesn't take another kind. - (None, Some(element)) => StaticType::Iterable(Box::new(element.clone())), - (None, None) => StaticType::Any, - }) + .map(|annotation| annotation.clone().unwrap_or(StaticType::Any)) .collect(), ), None => Some(param_types.clone()), @@ -103,23 +95,7 @@ impl Analyser { _ => None, }; if let Some((name, symbol)) = declared { - // An `Iterable` parameter takes what each call settles, but - // only sequences of `T`s, which tells it from its other overloads. - let shape = iterates - .iter() - .map(|element| { - element - .as_ref() - .map(|element| StaticType::Iterable(Box::new(element.clone()))) - }) - .collect::>(); - let shaped = param_types - .iter() - .zip(&shape) - .map(|(parameter, shape)| shape.clone().unwrap_or_else(|| parameter.clone())) - .collect::>(); - self.types.declare_shape(symbol, shape); - self.check_overlap(symbol, name, Some(&shaped), span); + self.check_overlap(symbol, name, Some(¶m_types), span); let placeholder = StaticType::Function { parameters: Some(param_types.clone()), return_type: Box::new( @@ -147,19 +123,6 @@ impl Analyser { .variable_types .insert(param.lvalue.id, typ.clone()); } - // An `Iterable` parameter is what each call passes, which has to - // iterate as `T`: `fn total(xs: Iterable)` takes a list or a - // tuple of Ints. - for ((param, typ), element) in parameters.iter().zip(¶m_types).zip(&iterates) { - if let Some(element) = element { - let span = param.annotation.as_ref().map_or(span, TypeExpr::span); - self.wait(Constraint::Iterate { - sequence: typ.clone(), - element: element.clone(), - span, - }); - } - } let implicit_return = self.analyse_expr(body); let mut returns = self.return_type_stack.pop().unwrap(); @@ -240,13 +203,10 @@ impl Analyser { .. }) = self.result.analysis.function_types.get_mut(&id) { - for (((runtime, parameter), annotation), iterates) in runtime - .iter_mut() - .zip(parameters) - .zip(&annotations) - .zip(&iterates) + for ((runtime, parameter), annotation) in + runtime.iter_mut().zip(parameters).zip(&annotations) { - if annotation.is_none() && iterates.is_none() { + if annotation.is_none() { *runtime = checkable_as_the_program_runs(¶meter); } } diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index 5120b62b..f28e3084 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -116,7 +116,7 @@ impl Analyser { /// `symbol`'s type with fresh variables for its type parameters. fn instantiated_type(&mut self, symbol: SymbolId) -> StaticType { - let typ = self.vars.resolve(&self.types.callable_type(symbol)); + let typ = self.vars.resolve(self.types.get_type(symbol)); self.vars.instantiate(&typ) } @@ -237,7 +237,7 @@ impl Analyser { /// `Fn(Int, Int) -> Int` isn't. fn might_be_expected(&self, symbol: SymbolId, expected: &StaticType) -> bool { self.vars - .resolve(&self.types.callable_type(symbol)) + .resolve(self.types.get_type(symbol)) .may_fit(&self.vars.resolve(expected)) } @@ -314,7 +314,7 @@ impl Analyser { }) .unwrap_or_else(|| self.resolver.innermost_reference(value.id)); self.vars - .shown(&self.types.callable_type(symbol)) + .shown(self.types.get_type(symbol)) .replace(&|part| matches!(part, StaticType::Generic(_)).then_some(StaticType::Any)) } diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 21a49095..577f3a63 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -539,7 +539,7 @@ impl Analyser { .reachable_symbols(call.candidates()) .into_iter() .filter( - |&symbol| match self.vars.resolve(&self.types.callable_type(symbol)) { + |&symbol| match self.vars.resolve(self.types.get_type(symbol)) { StaticType::Function { parameters: Some(parameters), .. @@ -583,7 +583,7 @@ impl Analyser { .iter() .filter(|&&earlier| self.resolver.symbol_name(earlier) == name) .filter_map( - |&earlier| match self.vars.resolve(&self.types.callable_type(earlier)) { + |&earlier| match self.vars.resolve(self.types.get_type(earlier)) { StaticType::Function { parameters, .. } => Some(parameters), _ => None, }, diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 02956525..0f75087f 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -337,63 +337,22 @@ impl Analyser { } /// [`Self::lower_type_expr`] for the type of a value, which an - /// `Iterable` is only part of as a parameter's type. + /// `Iterable` is never part of: only a built-in function takes any + /// kind of sequence of a type. fn lower_value_type(&mut self, expr: &TypeExpr) -> StaticType { let typ = self.lower_type_expr(expr); self.without_iterables(typ, expr.span()) } - /// `typ`, reporting each `Iterable` in it, which only a parameter's - /// type can be, as `Any`. + /// `typ`, reporting each `Iterable` in it as `Any`. fn without_iterables(&mut self, typ: StaticType, span: Span) -> StaticType { if !typ.contains(&|part| matches!(part, StaticType::Iterable(_))) { return typ; } - self.emit(AnalysisError::iterable_outside_a_parameter(span)); + self.emit(AnalysisError::iterable_in_an_annotation(span)); typ.replace(&|part| matches!(part, StaticType::Iterable(_)).then_some(StaticType::Any)) } - /// Lowers a parameter's annotation, returning its type, and the element - /// type for an `Iterable`, which takes any kind of sequence of `T`s: - /// the parameter's type is then what each call passes, and only has to - /// iterate as `T`. - fn lower_parameter_annotation( - &mut self, - annotation: Option<&TypeExpr>, - ) -> (Option, Option) { - let Some(expr) = annotation else { - return (None, None); - }; - match self.lower_type_expr(expr) { - StaticType::Iterable(element) => { - (None, Some(self.without_iterables(*element, expr.span()))) - } - // A function passed for the parameter may return any kind of - // sequence, as `flat_map`'s callback does. - StaticType::Function { - parameters, - return_type, - } if matches!(*return_type, StaticType::Iterable(_)) => { - let StaticType::Iterable(element) = *return_type else { - unreachable!("checked by the guard"); - }; - let parameters = parameters.map(|parameters| { - parameters - .into_iter() - .map(|parameter| self.without_iterables(parameter, expr.span())) - .collect() - }); - let element = self.without_iterables(*element, expr.span()); - let callback = StaticType::Function { - parameters, - return_type: Box::new(StaticType::Iterable(Box::new(element))), - }; - (Some(callback), None) - } - typ => (Some(self.without_iterables(typ, expr.span())), None), - } - } - /// Analyses a top-level statement. Names resolve first; a statement with /// unresolved names isn't analysed further, which would only repeat its errors. /// The names it still declared are bound as `Any`, so in diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 564417be..d339669b 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -467,11 +467,10 @@ impl Analyser { { Binding::Dynamic(only) if let [Candidate::Scalar(only)] = only[..] - && !self.types.has_shape(only) && let StaticType::Function { parameters: Some(_), .. - } = self.vars.resolve(&self.types.callable_type(only)) => + } = self.vars.resolve(self.types.get_type(only)) => { Binding::Resolved(Candidate::Scalar(only)) } @@ -990,7 +989,7 @@ impl Analyser { arguments: &[StaticType], result: &StaticType, ) -> bool { - let function = self.vars.resolve(&self.types.callable_type(symbol)); + let function = self.vars.resolve(self.types.get_type(symbol)); let Some((parameters, return_type)) = self.vars.instantiate_signature(&function) else { return true; }; @@ -1473,7 +1472,7 @@ impl Analyser { if literals.len() < 2 { return false; } - let function = self.vars.resolve(&self.types.callable_type(symbol)); + let function = self.vars.resolve(self.types.get_type(symbol)); let snapshot = self.vars.snapshot(); let Some((parameters, _)) = self.vars.instantiate_signature(&function) else { return false; @@ -1522,19 +1521,17 @@ impl Analyser { } = self.resolve_overloads(&call.candidates, arguments, call.kind); // A pick left to the program among one function with known // parameters is a call of it, whose `Any` arguments are checked where - // they're passed. A variable of type `Any` may hold any function, an - // `Iterable` parameter's kind isn't in its declared type, and an - // `Any` without a node to check it at, as the target of `a op= b`, + // they're passed. A variable of type `Any` may hold any function, and + // an `Any` without a node to check it at, as the target of `a op= b`, // isn't checked where it's passed, so the program checks those itself. let binding = match binding { Binding::Dynamic(only) if let [Candidate::Scalar(only)] = only[..] - && !self.types.has_shape(only) && Self::checks_every_any(arguments, &call.nodes.arguments) && let StaticType::Function { parameters: Some(_), .. - } = self.vars.resolve(&self.types.callable_type(only)) => + } = self.vars.resolve(self.types.get_type(only)) => { Binding::Resolved(Candidate::Scalar(only)) } @@ -1984,7 +1981,7 @@ impl Analyser { /// Whether `symbol` can accept arguments of these types, where each part /// not known yet fits anything. fn could_accept(&mut self, symbol: SymbolId, arguments: &[StaticType]) -> bool { - let function = self.vars.resolve(&self.types.callable_type(symbol)); + let function = self.vars.resolve(self.types.get_type(symbol)); let snapshot = self.vars.snapshot(); let Some((parameters, _)) = self.vars.instantiate_signature(&function) else { return matches!(function, StaticType::Function { .. }) || function.is_unknown(); diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 191ee237..80a50a53 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1107,7 +1107,10 @@ fn a_write_passes_its_value_like_an_argument() { // Any sequence goes where an `Iterable` is expected, and settles its // elements as Ints. assert_eq!( - analyse_last_type("fn total(xs: Iterable) => xs; let ys = []; total(ys); ys"), + analyse_last_type_with_globals( + "let ys = []; total(ys); ys", + natives(&[("total", "(Iterable) -> Int")]) + ), StaticType::List(Box::new(StaticType::Int)) ); } diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index e90df755..926cc88c 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -145,12 +145,12 @@ impl AnalysisError { } } - pub(crate) fn iterable_outside_a_parameter(span: Span) -> Self { + pub(crate) fn iterable_in_an_annotation(span: Span) -> Self { Self { - text: "`Iterable` can only be the type of a parameter".to_string(), + text: "`Iterable` can't be written in a type annotation".to_string(), span, help_text: Some( - "It takes any kind of sequence. Say which kind a value is, as in `List`" + "A parameter without an annotation takes any kind of sequence. Say which kind a value is, as in `List`" .to_string(), ), notes: Vec::new(), diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 24522744..6be43783 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -170,9 +170,9 @@ impl<'a> Overloads<'a> { /// The type of `symbol` as far as it's known: a parameter `g` whose type /// a call such as `g(1)` settled is a function from then on. fn type_of(self, symbol: SymbolId) -> Cow<'a, StaticType> { - match self.types.callable_type(symbol) { - typ if typ.has_vars() => Cow::Owned(self.vars.resolve(&typ)), - typ => typ, + match self.types.get_type(symbol) { + typ if typ.has_vars() => Cow::Owned(self.vars.resolve(typ)), + typ => Cow::Borrowed(typ), } } diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index 881d3afb..f283faa0 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -8,10 +8,6 @@ use ndc_resolver::SymbolId; #[derive(Debug, Default)] pub(crate) struct TypeTable { types: AHashMap, - /// The parameter types calls see, by function, where they differ from - /// the declared type's: a parameter annotated `Iterable` has a type - /// each call settles, but only takes sequences of Ints. - shapes: AHashMap>>, } impl TypeTable { @@ -27,48 +23,6 @@ impl TypeTable { .expect("internal error: a value is analysed after its declaration") } - /// Records the parameter types calls of the function `symbol` see, for - /// the parameters whose declared type doesn't say what they take. - pub(crate) fn declare_shape(&mut self, symbol: SymbolId, shape: Vec>) { - if shape.iter().any(Option::is_some) { - self.shapes.insert(symbol, shape); - } - } - - /// The type of `symbol` as a call sees it, which picks the overload a - /// call means: `fn total(xs: Iterable)` takes an `Iterable`, - /// although its declared type takes a type parameter each call settles. - /// Whether a parameter of `symbol` takes any sequence of a type, as - /// `xs: Iterable` does, which its declared type leaves to an - /// iteration constraint. - pub(crate) fn has_shape(&self, symbol: SymbolId) -> bool { - self.shapes.contains_key(&symbol) - } - - pub(crate) fn callable_type(&self, symbol: SymbolId) -> std::borrow::Cow<'_, StaticType> { - let typ = self.get_type(symbol); - let ( - Some(shape), - StaticType::Function { - parameters: Some(parameters), - return_type, - }, - ) = (self.shapes.get(&symbol), typ) - else { - return std::borrow::Cow::Borrowed(typ); - }; - std::borrow::Cow::Owned(StaticType::Function { - parameters: Some( - parameters - .iter() - .zip(shape) - .map(|(parameter, shape)| shape.clone().unwrap_or_else(|| parameter.clone())) - .collect(), - ), - return_type: return_type.clone(), - }) - } - /// Replaces the settled type variables in every value's type with what /// they stand for. pub(crate) fn resolve_all(&mut self, vars: &TypeVars) { diff --git a/tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc b/tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc deleted file mode 100644 index 646ea57b..00000000 --- a/tests/functional/programs/017_dynamic_any/021_an_iterable_parameter_checks_an_any_argument.ndc +++ /dev/null @@ -1,5 +0,0 @@ -// An `Any` passed for an `Iterable` parameter is checked to be a sequence. -// expect-error: no function called 'consume' found matches the arguments: (Bool) -fn consume(xs: Iterable) => 0; -let a: Any = true; -print(consume(a)); diff --git a/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc b/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc index a37cdb65..e81571e4 100644 --- a/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc +++ b/tests/functional/programs/017_dynamic_any/022_a_function_returning_a_list_fits_one_returning_an_iterable.ndc @@ -1,7 +1,7 @@ -// A function whose own type says it returns a list fits a function type that -// returns a sequence, without checking its calls. +// A function whose own type says it returns a list of Ints fits a function +// type that returns one, without checking its calls. // expect-output: 1 -fn h(f: Fn(Int) -> Iterable) => 1; +fn h(f: Fn(Int) -> List) => 1; fn h(x: Int) => 0; let a: Any = fn(x: Int) => [x]; print(h(a)); diff --git a/tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc b/tests/functional/programs/019_iterable/001_a_parameter_without_an_annotation_takes_any_kind_of_sequence.ndc similarity index 51% rename from tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc rename to tests/functional/programs/019_iterable/001_a_parameter_without_an_annotation_takes_any_kind_of_sequence.ndc index d164834a..aa5cd486 100644 --- a/tests/functional/programs/019_iterable/001_an_iterable_parameter_takes_any_kind_of_sequence.ndc +++ b/tests/functional/programs/019_iterable/001_a_parameter_without_an_annotation_takes_any_kind_of_sequence.ndc @@ -1,6 +1,7 @@ -// `total` is generic: each kind of sequence gets its own copy. +// A parameter without an annotation takes any kind of sequence its body can +// iterate, and each kind gets its own copy. // expect-output: 3 7 6 -fn total(xs: Iterable) { +fn total(xs) { let sum = 0; for x in xs { sum += x; diff --git a/tests/functional/programs/019_iterable/002_a_sequence_of_elements_the_body_cannot_use_is_an_error.ndc b/tests/functional/programs/019_iterable/002_a_sequence_of_elements_the_body_cannot_use_is_an_error.ndc new file mode 100644 index 00000000..27d9f0a9 --- /dev/null +++ b/tests/functional/programs/019_iterable/002_a_sequence_of_elements_the_body_cannot_use_is_an_error.ndc @@ -0,0 +1,10 @@ +// What a sequence holds has to work in the body. +// expect-error: No function called '+' found that matches the arguments 'Int, String' +fn total(xs) { + let sum = 0; + for x in xs { + sum += x; + } + sum +} +total(["a"]); diff --git a/tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc b/tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc deleted file mode 100644 index aaeeeef4..00000000 --- a/tests/functional/programs/019_iterable/002_an_iterable_parameter_checks_its_elements.ndc +++ /dev/null @@ -1,9 +0,0 @@ -// expect-error: No function called 'total' found that matches the arguments 'List' -fn total(xs: Iterable) { - let sum = 0; - for x in xs { - sum += x; - } - sum -} -total(["a"]); diff --git a/tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc b/tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc deleted file mode 100644 index 4165e653..00000000 --- a/tests/functional/programs/019_iterable/003_an_iterable_can_only_be_a_parameter_s_type.ndc +++ /dev/null @@ -1,3 +0,0 @@ -// A value has a kind of sequence, which the annotation has to say. -// expect-error: `Iterable` can only be the type of a parameter -let xs: Iterable = [1]; diff --git a/tests/functional/programs/019_iterable/003_an_iterable_cannot_be_written_in_an_annotation.ndc b/tests/functional/programs/019_iterable/003_an_iterable_cannot_be_written_in_an_annotation.ndc new file mode 100644 index 00000000..0873088a --- /dev/null +++ b/tests/functional/programs/019_iterable/003_an_iterable_cannot_be_written_in_an_annotation.ndc @@ -0,0 +1,3 @@ +// A value has a kind of sequence, which an annotation has to say. +// expect-error: `Iterable` can't be written in a type annotation +let xs: Iterable = [1]; diff --git a/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc b/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc index ded1e7fd..0fcbecc6 100644 --- a/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc +++ b/tests/functional/programs/019_iterable/004_a_cast_cannot_be_to_an_iterable.ndc @@ -1,2 +1,2 @@ -// expect-error: `Iterable` can only be the type of a parameter +// expect-error: `Iterable` can't be written in a type annotation print([1] as Iterable); diff --git a/tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc b/tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc deleted file mode 100644 index 86ecf0a7..00000000 --- a/tests/functional/programs/019_iterable/009_an_iterable_parameter_does_not_take_a_non_sequence.ndc +++ /dev/null @@ -1,3 +0,0 @@ -// expect-error: No function called 'consume' found that matches the arguments 'Bool' -fn consume(xs: Iterable) => 0; -consume(true); diff --git a/tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc b/tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc deleted file mode 100644 index 0943f3af..00000000 --- a/tests/functional/programs/019_iterable/010_an_iterable_parameter_is_more_specific_than_any.ndc +++ /dev/null @@ -1,5 +0,0 @@ -// The two are different overloads, and a sequence of Ints takes the first. -// expect-output: 1 2 2 -fn describe(xs: Iterable) => 1; -fn describe(x: Any) => 2; -print(describe([1]), describe(true), describe("a")); diff --git a/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc b/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc index 8c8f1d23..0198fe69 100644 --- a/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc +++ b/tests/functional/programs/019_iterable/011_a_callback_parameter_may_return_any_kind_of_sequence.ndc @@ -1,3 +1,4 @@ +// A callback passed on to `flat_map` may return any kind of sequence. // expect-output: [1,1,2,2] [1,2] -fn apply(f: Fn(Int) -> Iterable) => [1, 2].flat_map(f); +fn apply(f) => [1, 2].flat_map(f); print(apply(fn(x) => [x, x]), apply(fn(x) => (x,))); diff --git a/tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc b/tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc deleted file mode 100644 index 62f574ab..00000000 --- a/tests/functional/programs/019_iterable/015_the_program_does_not_pick_a_typed_iterable_overload_for_any.ndc +++ /dev/null @@ -1,8 +0,0 @@ -// When the program picks the overload as it runs, an `Iterable` -// parameter isn't scanned to see what it holds, as a `List` one isn't, -// so a value of type `Any` goes to the overload that takes anything. -// expect-output: 2 1 -fn describe(xs: Iterable) => 1; -fn describe(x: Any) => 2; -let x: Any = ["a"]; -print(describe(x), describe([1])); diff --git a/tests/functional/programs/019_iterable/016_a_parameter_cannot_be_annotated_as_an_iterable.ndc b/tests/functional/programs/019_iterable/016_a_parameter_cannot_be_annotated_as_an_iterable.ndc new file mode 100644 index 00000000..e7b0d454 --- /dev/null +++ b/tests/functional/programs/019_iterable/016_a_parameter_cannot_be_annotated_as_an_iterable.ndc @@ -0,0 +1,3 @@ +// A parameter without an annotation takes any kind of sequence already. +// expect-error: `Iterable` can't be written in a type annotation +fn total(xs: Iterable) => 0; From 0192f2a46600c9f6853b33edb67ef3307426219a Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 17:42:09 +0100 Subject: [PATCH 31/39] =?UTF-8?q?fix(analyser):=20report=20a=20generic=20f?= =?UTF-8?q?unction=20whose=20type=20grows=20too=20large=20=F0=9F=93=8F=20(?= =?UTF-8?q?#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each generic function's type can be twice the size of the type of the one it calls, so a chain of them grows exponentially, the known worst case of Hindley–Milner inference. With `fn f0(x) => (x, x)` and `fn fi(x) => f{i-1}(f{i-1}(x))`, analysis ran out of memory at `f6`, even with the call behind `if false`. A generic function whose type has more than 10,000 parts is now reported ("The type of `f4` has more than 10000 parts, too many to work with", with help to annotate a parameter or the return type with `Any`), like rustc's type length limit, and is analysed from there on as taking and returning `Any`, so the functions that call it don't grow it further. The size is counted once per generic function, stopping at the limit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/declarations.rs | 42 ++++++++++++++++++- ndc_analyser/src/error.rs | 14 +++++++ ..._type_that_grows_too_large_is_an_error.ndc | 12 ++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/functional/programs/005_functions/097_a_type_that_grows_too_large_is_an_error.ndc diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index d504e2d0..77be885b 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -8,6 +8,7 @@ use ndc_parser::{ BindingPattern, BindingPatternLocation, ExpressionLocation, FunctionParameter, StructField, TypeExpr, }; +use ndc_resolver::SymbolId; use std::rc::Rc; /// What a function declaration consists of, named like `fn f(x) { … }` or @@ -164,12 +165,13 @@ impl Analyser { self.vars.leave_level(); self.declaring = None; self.declared_in_batch.insert(symbol); - self.generalize( + let typ = self.generalize( symbol, &signature, id, [first_use, first_literal, first_check], - ) + ); + self.within_size_limit(symbol, name, typ, span) } _ => signature, }; @@ -224,6 +226,42 @@ impl Analyser { } } + /// `typ`, the type of the generic function `symbol`, unless it has more + /// parts than the analyser can work with, as when each function in a + /// chain doubles what the previous one returns: `fn f1(x) => f0(f0(x))` + /// with `fn f0(x) => (x, x)`. Then it's reported, and the function takes + /// and returns `Any`, so what calls it isn't built from its type. + fn within_size_limit( + &mut self, + symbol: SymbolId, + name: Option<&str>, + typ: StaticType, + span: Span, + ) -> StaticType { + const LIMIT: usize = 10_000; + let parts = std::cell::Cell::new(0_usize); + let too_large = typ.contains(&|_| { + parts.set(parts.get() + 1); + parts.get() > LIMIT + }); + if !too_large { + return typ; + } + self.emit(AnalysisError::type_too_large( + name.unwrap_or("?"), + LIMIT, + span, + )); + self.schemes.remove(&symbol); + let StaticType::Function { parameters, .. } = typ else { + return StaticType::Any; + }; + StaticType::Function { + parameters: parameters.map(|parameters| vec![StaticType::Any; parameters.len()]), + return_type: Box::new(StaticType::Any), + } + } + /// Declares a struct's type, constructor and field accessors. pub(super) fn analyse_struct_declaration( &mut self, diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index 926cc88c..1a465119 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -136,6 +136,20 @@ impl AnalysisError { } } + pub(crate) fn type_too_large(function: &str, limit: usize, span: Span) -> Self { + Self { + text: format!( + "The type of `{function}` has more than {limit} parts, too many to work with" + ), + span, + help_text: Some( + "Each call through it makes a bigger type. Annotate a parameter or the return type with `Any` to stop it growing" + .to_string(), + ), + notes: Vec::new(), + } + } + pub(crate) fn tuple_index_out_of_bounds(index: i64, length: usize, span: Span) -> Self { Self { text: format!("Index {index} is out of bounds for a tuple of {length} elements"), diff --git a/tests/functional/programs/005_functions/097_a_type_that_grows_too_large_is_an_error.ndc b/tests/functional/programs/005_functions/097_a_type_that_grows_too_large_is_an_error.ndc new file mode 100644 index 00000000..334a9872 --- /dev/null +++ b/tests/functional/programs/005_functions/097_a_type_that_grows_too_large_is_an_error.ndc @@ -0,0 +1,12 @@ +// Each function doubles the type the one before it returns, so the types +// grow too large to work with, which is reported instead of running out of +// memory. +// expect-error: The type of `f4` has more than 10000 parts +fn f0(x) => (x, x); +fn f1(x) => f0(f0(x)); +fn f2(x) => f1(f1(x)); +fn f3(x) => f2(f2(x)); +fn f4(x) => f3(f3(x)); +fn f5(x) => f4(f4(x)); +fn f6(x) => f5(f5(x)); +if false { print(f6(1)); } From 98f60de3aa543128f12190b79503930df4f82475 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 17:44:00 +0100 Subject: [PATCH 32/39] =?UTF-8?q?fix(analyser):=20say=20what=20a=20functio?= =?UTF-8?q?n=20takes=20when=20no=20overload=20fits=20a=20call=20?= =?UTF-8?q?=F0=9F=92=AC=20(#335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call that no overload takes only said which arguments it was given. When a function's parameter type comes from something other than its annotations, that left the reader guessing. In `let r = []; fn put(x) { r.push(fn() => x); 0 } put(1); put("a");`, the first call makes `r` hold functions returning Ints, so `put` takes an Int from then on, and `put("a")` reported only "No function called 'put' found that matches the arguments 'String'". The error now has help saying what the overloads it could reach take, when there are at most three: "`put` takes (Int)", or "The overloads of `f` take (Int), (String)". Larger sets, like the built-in operators, are left out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/calls.rs | 25 +++++++++++++++++++++++++ ndc_analyser/src/analyser/tests.rs | 20 ++++++++++++++++++++ ndc_analyser/src/error.rs | 14 ++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 52be8bf1..18c9b38c 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -2,6 +2,7 @@ use super::Analyser; use super::solver::{CallNodes, CallSite, Constraint}; use crate::error::AnalysisError; use crate::overloads::{CallKind, Overloads}; +use itertools::Itertools; use ndc_core::{NodeId, StaticType}; use ndc_lexer::{NumericLiteral, Span}; use ndc_parser::{Expression, ExpressionLocation}; @@ -32,9 +33,33 @@ impl Analyser { AnalysisError::function_not_found_suggest_cast(name, types, span) } else { AnalysisError::function_not_found(name, types, span) + .with_overloads(name, &self.what_the_overloads_take(candidates)) } } + /// What each overload `candidates` can reach takes, as `(Int, String)`, + /// when there are few enough to list: why `put("a")` doesn't fit a `put` + /// whose body made its parameter an Int. + fn what_the_overloads_take(&self, candidates: &Candidates) -> Vec { + const MOST: usize = 3; + let symbols = self.overloads().reachable_symbols(candidates); + if symbols.len() > MOST { + return Vec::new(); + } + symbols + .into_iter() + .filter_map( + |symbol| match self.vars.shown(self.types.get_type(symbol)) { + StaticType::Function { + parameters: Some(parameters), + .. + } => Some(format!("({})", parameters.iter().join(", "))), + _ => None, + }, + ) + .collect() + } + /// Overload resolution over the types declared so far. pub(super) fn overloads(&self) -> Overloads<'_> { Overloads::new(&self.types, &self.vars, self.resolver.symbols()) diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index 80a50a53..b75ae679 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1721,3 +1721,23 @@ fn a_list_literal_holding_the_list_it_is_pushed_to_is_analysed() { assert!(result.errors.len() <= 1, "{source}: {:?}", result.errors); } } + +#[test] +fn a_call_no_overload_takes_says_what_the_overloads_take() { + // `put`'s body puts `x` in `r`, which the first call settles as holding + // Ints, so `put` takes an Int from then on. + let (_, result) = analyse_with_globals( + "let r = []; fn put(x) { r.push(x); 0 } put(1); put(\"a\");", + natives(&[("push", "(List, T)")]), + ); + let help = result + .errors + .iter() + .find_map(|error| error.help_text().map(str::to_string)); + assert_eq!( + help.as_deref(), + Some("`put` takes (Int)"), + "{:?}", + result.errors + ); +} diff --git a/ndc_analyser/src/error.rs b/ndc_analyser/src/error.rs index 1a465119..43390f4a 100644 --- a/ndc_analyser/src/error.rs +++ b/ndc_analyser/src/error.rs @@ -278,6 +278,20 @@ impl AnalysisError { } } + /// This error with help saying what the overloads of `name` take, given + /// as `(Int, String)` each, if any. + pub(crate) fn with_overloads(self, name: &str, overloads: &[String]) -> Self { + let help_text = match overloads { + [] => return self, + [only] => format!("`{name}` takes {only}"), + several => format!("The overloads of `{name}` take {}", several.join(", ")), + }; + Self { + help_text: Some(help_text), + ..self + } + } + /// Same as [`Self::function_not_found`], for a call an overload would /// accept if the arguments were known more precisely. pub(crate) fn function_not_found_suggest_cast( From 475535bb12c56bf036250c0b9a494af0558b7c6e Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 17:59:51 +0100 Subject: [PATCH 33/39] =?UTF-8?q?fix(analyser):=20give=20a=20while=20true?= =?UTF-8?q?=20loop=20no=20break=20leaves=20no=20value=20=F0=9F=94=81=20(#3?= =?UTF-8?q?37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `while` loop always had the unit type, even `while true` with no `break`, which only ends by returning. A function whose body ends in one therefore returned the join of its `return`s and `()`, which is `Any`, and everything computed from its result was `Any` too. Found in an Advent of Code solution: `fn merge_ranges(ranges) { … while true { … if done { return rc; } … } }` made `rr` `Any`, so `[(hi - lo + 1) for (lo, hi) in rr].sum` failed to find a `sum` for `List`. A `while true` loop that no `break` leaves now has type `Never`, like a `return` or Rust's `loop` without a `break`, so a function ending in one returns what its `return`s do. A `break` inside a nested loop leaves that loop, not this one. To tell, the analyser's loop depth became a stack of loops that records whether a `break` leaves each. The manual's while-loop page describes it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- manual/src/reference/while-loop.md | 15 ++++++++ ndc_analyser/src/analyser/declarations.rs | 4 +-- ndc_analyser/src/analyser/mod.rs | 34 ++++++++++++------- ...078_an_endless_while_loop_has_no_value.ndc | 12 +++++++ ..._while_true_loop_left_by_break_is_unit.ndc | 10 ++++++ 5 files changed, 61 insertions(+), 14 deletions(-) create mode 100644 tests/functional/programs/004_basic/078_an_endless_while_loop_has_no_value.ndc create mode 100644 tests/functional/programs/004_basic/079_a_while_true_loop_left_by_break_is_unit.ndc diff --git a/manual/src/reference/while-loop.md b/manual/src/reference/while-loop.md index 0984c591..16a6a0aa 100644 --- a/manual/src/reference/while-loop.md +++ b/manual/src/reference/while-loop.md @@ -20,3 +20,18 @@ while n <= 100 { n += 1; } ``` + +A `while true` loop that no `break` leaves never ends, so it has no value at all, like a `return`. A function can end in one and leave it with `return`, and it returns what its `return`s do: + +```ndc +fn first_square_above(n) { + let i = 1; + while true { + if i * i > n { + return i * i; + } + i += 1; + } +} +print(first_square_above(50) + 1); // 65 +``` diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 77be885b..f37144b6 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -115,7 +115,7 @@ impl Analyser { }); // A function body starts outside of any loop: `break` can't // leave the function to end a loop around its declaration. - let outer_loop_depth = std::mem::take(&mut self.loop_depth); + let outer_loops = std::mem::take(&mut self.loops); for (param, typ) in parameters.iter().zip(¶m_types) { let symbol = self.resolver.declared_symbol(param.lvalue.id); self.types.declare(symbol, typ.clone()); @@ -127,7 +127,7 @@ impl Analyser { let implicit_return = self.analyse_expr(body); let mut returns = self.return_type_stack.pop().unwrap(); - self.loop_depth = outer_loop_depth; + self.loops = outer_loops; // The body's value is returned like the values of `return`. An // annotated return type is what each of them returns, which can diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 0f75087f..ddb59a70 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -113,9 +113,11 @@ pub struct Analyser { /// What each enclosing function returns with `return`, innermost last. /// Pushed on function entry, popped on exit. return_type_stack: Vec, - /// How many loops enclose the expression being analysed in the current - /// function, so `break` and `continue` outside of one are reported. - loop_depth: usize, + /// The loops that enclose the expression being analysed in the current + /// function, innermost last, each with whether a `break` leaves it: a + /// `while true` that none leaves never ends. `break` and `continue` + /// outside of one are reported. + loops: Vec, /// What the current batch's analysis has found so far. result: AnalysisResult, /// The symbols the current batch's uses pick, which the layout turns into @@ -173,7 +175,7 @@ impl Analyser { struct_registry: StructRegistry::default(), struct_types: AHashMap::new(), return_type_stack: Vec::new(), - loop_depth: 0, + loops: Vec::new(), result: AnalysisResult::default(), picks: Picks::default(), laid_out_scopes: 0, @@ -411,13 +413,14 @@ impl Analyser { | NumericLiteral::Complex(_), ) => StaticType::Number, Expression::Break => { - if self.loop_depth == 0 { - self.emit(AnalysisError::break_outside_loop(*span)); + match self.loops.last_mut() { + Some(broken) => *broken = true, + None => self.emit(AnalysisError::break_outside_loop(*span)), } StaticType::Never } Expression::Continue => { - if self.loop_depth == 0 { + if self.loops.is_empty() { self.emit(AnalysisError::continue_outside_loop(*span)); } StaticType::Never @@ -533,11 +536,18 @@ impl Analyser { } => { // The condition is part of the loop too, as it runs on every // iteration. - self.loop_depth += 1; + self.loops.push(false); self.analyse_expr(expression); self.analyse_expr(loop_body); - self.loop_depth -= 1; - StaticType::unit() + let broken = self.loops.pop().expect("the loop was pushed"); + // A `while true` that no `break` leaves only ends by + // returning, so it has no value, like a `return`: a function + // whose body ends in one returns what its `return`s do. + if !broken && matches!(expression.expression, Expression::BoolLiteral(true)) { + StaticType::Never + } else { + StaticType::unit() + } } Expression::For { iterations, body } => { // A map comprehension's default is evaluated once, before the @@ -700,7 +710,7 @@ impl Analyser { let found_type = self.constrain_iteration(sequence_type, sequence_span); - self.loop_depth += 1; + self.loops.push(false); // A `for` binder has no type annotation. self.declare_binding_pattern(l_value, None, found_type, sequence_span); } @@ -734,7 +744,7 @@ impl Analyser { } }; if matches!(iteration, ForIteration::Iteration { .. }) { - self.loop_depth -= 1; + self.loops.pop(); } typ } diff --git a/tests/functional/programs/004_basic/078_an_endless_while_loop_has_no_value.ndc b/tests/functional/programs/004_basic/078_an_endless_while_loop_has_no_value.ndc new file mode 100644 index 00000000..08d76f2e --- /dev/null +++ b/tests/functional/programs/004_basic/078_an_endless_while_loop_has_no_value.ndc @@ -0,0 +1,12 @@ +// A `while true` that no `break` leaves never ends, so a function ending in +// one returns what its `return`s do: a list of pairs, whose sum is typed. +// expect-output: 4 +fn grow(pairs) { + while true { + if pairs.len > 2 { + return pairs; + } + pairs.push((1, 1)); + } +} +print([hi - lo + 1 for (lo, hi) in grow([(1, 2)])].sum); diff --git a/tests/functional/programs/004_basic/079_a_while_true_loop_left_by_break_is_unit.ndc b/tests/functional/programs/004_basic/079_a_while_true_loop_left_by_break_is_unit.ndc new file mode 100644 index 00000000..2211cd3e --- /dev/null +++ b/tests/functional/programs/004_basic/079_a_while_true_loop_left_by_break_is_unit.ndc @@ -0,0 +1,10 @@ +// A `while true` that a `break` leaves ends normally, with the unit value. +// expect-output: true +let i = 0; +let value = while true { + i += 1; + if i > 3 { + break; + } +}; +print(value == ()); From 665b9c1b78dfcc236524ae227d6b385d87cc92fa Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 18:21:51 +0100 Subject: [PATCH 34/39] =?UTF-8?q?refactor(analyser):=20type=20the=20litera?= =?UTF-8?q?ls=20in=20a=20generic=20body=20by=20the=20body=20alone=20?= =?UTF-8?q?=E2=9C=82=EF=B8=8F=20(#340)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A number literal in a generic function's body that the function's parameters reached became an extra type parameter, which each call settled: `fn pair(x) => [x, 1]` made `pair(2.5)` `[2.5, 1.0]`. That needed its own machinery: literal type parameters with bounds in each scheme, per-instance literal types read by the compiler, and special Int rules for calls from an earlier REPL line and for function values. As a result the same code typed differently in a file and in the REPL. A review of the type checker found that no realistic program used it. A generic body's literals are now decided by the body alone, before the function is generalised, with the same look-ahead as literals anywhere else. They're never type parameters, so a file and the REPL type a program the same way. Behaviour changes: - `fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }` called as `fact(5.0)` is an analysis error pointing into the body. Writing `1.0` makes it work for Ints and Floats alike. - `fn pair(x) => [x, 1]` only takes Ints, `fn k(x) => [x, "b"]` only Strings, and `fn f(xs) { xs.push(1); xs }` only `List`. - `fn inc(x) => x + 1` still takes Floats, since `+(Float, Int)` exists, and `fib(10.0)` still works. A literal at the call still adapts: `let r: Float = g(3)` makes the `3` a Float. - Next to `fn f(xs: List)`, `fn f(x) => f([x])` now makes `x` an `Any`, so it calls the `List` overload instead of reporting growing recursion. Removed: `Scheme.literals` and `literal_nodes`, `LiteralBounds`, `instantiate_literals`, `resolve_bounds`, `with_literals_named`, `literals_tied_to_parameters`, `number_literals`, the Int rules for earlier batches and function values, the per-instance `literal_types` in the analysis, layout and compiler, and the bound de-duplication only copied literals needed (−353 lines of Rust). A second commit sorts the unsettled literals by a key computed once, which was quadratic for a list of 20,000 values. The manual's function and augmented-assignment pages are updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/augmented-assignment.md | 2 +- manual/src/reference/types/function.md | 15 +- ndc_analyser/src/analyser/declarations.rs | 8 +- ndc_analyser/src/analyser/function_values.rs | 4 +- ndc_analyser/src/analyser/generics.rs | 171 +++--------------- ndc_analyser/src/analyser/instances.rs | 35 +--- ndc_analyser/src/analyser/mod.rs | 11 +- ndc_analyser/src/analyser/solver.rs | 29 +-- ndc_analyser/src/types.rs | 160 +--------------- ndc_analysis/src/lib.rs | 5 - ndc_layout/src/lib.rs | 5 - ndc_vm/src/compiler.rs | 5 +- ...in_a_generic_body_is_typed_by_the_body.ndc | 7 + ...n_a_generic_body_takes_the_call_s_type.ndc | 6 - ...ter_and_a_literal_is_typed_by_the_body.ndc | 5 + ...ding_a_parameter_takes_the_call_s_type.ndc | 7 - ...c_function_as_a_value_has_int_literals.ndc | 5 - ...tion_makes_a_list_of_what_it_is_given.ndc} | 0 ...ll_through_another_overload_can_t_grow.ndc | 4 +- ...st_of_a_parameter_is_typed_by_the_body.ndc | 7 + ...n_takes_what_its_body_s_literals_allow.ndc | 8 + 21 files changed, 80 insertions(+), 419 deletions(-) create mode 100644 tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_is_typed_by_the_body.ndc delete mode 100644 tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc create mode 100644 tests/functional/programs/005_functions/088_a_list_of_a_parameter_and_a_literal_is_typed_by_the_body.ndc delete mode 100644 tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc delete mode 100644 tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc rename tests/functional/programs/005_functions/{090_copies_of_a_literal_keep_what_each_holds.ndc => 090_each_call_of_a_generic_function_makes_a_list_of_what_it_is_given.ndc} (100%) create mode 100644 tests/functional/programs/005_functions/097_a_list_of_a_parameter_is_typed_by_the_body.ndc create mode 100644 tests/functional/programs/005_functions/098_a_generic_function_takes_what_its_body_s_literals_allow.ndc diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index 16c85646..c9d269eb 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -132,7 +132,7 @@ print(x); // 8 One common use case is tracking the highest or lowest value in a loop: ```ndc -fn g(x) => (x - 40) ^ 2; +fn g(x) => (x - 40.0) ^ 2; let lowest, highest = Inf, -Inf; for x in 1..100 { diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index 9e3fdf07..c2e8004b 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -242,13 +242,18 @@ print(a, b, i); // 2 3.5 55 The body is compiled for each set of types it's called with, so the calls in it are picked when the program is compiled, not as it runs. Called with a value of type `Any`, the function picks them as it runs. -A number literal in the body that meets a parameter takes its type from each call, as a literal written at the call would: +A number literal in the body is typed by the body alone, as one anywhere else is, and never by a call: the `1` in `inc` is an `Int` whatever `inc` is given. So a literal that a result is made of fixes that result's type. `fact` returns the `1` or a product, which can only be one type, an `Int`, so it doesn't take a `Float`: -```ndc +```ndc,fails fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }; -fn pair(x) => [x, 1]; -print(fact(5), fact(5.0)); // 120 120.0 -print(pair(2), pair(2.5)); // [2,1] [2.5,1.0] +let r = fact(5.0); // error: mismatched types: found Float but expected Int +``` + +Write `1.0` for a `fact` of `Float`s, which works for `Int`s too, as a `Float` times an `Int` is a `Float`: + +```ndc +fn fact(n) => if n < 2 { 1.0 } else { n * fact(n - 1) }; +print(fact(5), fact(5.0)); // 120.0 120.0 ``` A call with an argument the body can't use is an error at the call, with a note on the line in the body that can't take it: diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index f37144b6..295c3a24 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -49,7 +49,6 @@ impl Analyser { self.declaring = declared.map(|(_, symbol)| symbol); } let first_use = self.uses.len(); - let first_literal = self.number_literals.len(); let first_check = self.checks.len(); let annotations = parameters .iter() @@ -165,12 +164,7 @@ impl Analyser { self.vars.leave_level(); self.declaring = None; self.declared_in_batch.insert(symbol); - let typ = self.generalize( - symbol, - &signature, - id, - [first_use, first_literal, first_check], - ); + let typ = self.generalize(symbol, &signature, id, [first_use, first_check]); self.within_size_limit(symbol, name, typ, span) } _ => signature, diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index f28e3084..5fede382 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -89,7 +89,7 @@ impl Analyser { .insert(id, Binding::Resolved(Candidate::Scalar(symbol))); // A generic function used as a value, as in `let p = push`, has one // type there, which its uses settle. - let typ = self.instantiate_function(symbol, false); + let typ = self.instantiate_function(symbol); self.flush_copies(None, span); typ } @@ -319,7 +319,7 @@ impl Analyser { } fn commit_function_value(&mut self, value: &FunctionValue, symbol: SymbolId, early: bool) { - let typ = self.instantiate_function(symbol, false); + let typ = self.instantiate_function(symbol); self.vars.bind_argument(&value.expected, &typ); self.flush_copies(None, value.span); if early && cfg!(debug_assertions) { diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 577f3a63..5782a8fd 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -10,8 +10,8 @@ use super::instances::Scheme; use super::solver::{CallConstraint, Constraint, Waiting}; use crate::error::AnalysisError; use crate::overloads::is_strictly_more_specific; -use crate::types::{LiteralBounds, TypeVars}; -use ahash::{AHashMap, AHashSet}; +use crate::types::TypeVars; +use ahash::AHashSet; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType, TypeVar}; use ndc_lexer::Span; @@ -29,24 +29,22 @@ impl Analyser { /// `signature`, and returns its type with its type parameters: each /// variable its body made that nothing outside it refers to becomes /// one, and the constraints still waiting on them become the function's. - /// A variable those constraints wait on but that the parameters don't - /// decide, like the element type of `[]` in `fn f(x) { [].push(x) }`, - /// can't be decided by a call either, so it's settled here first, as at - /// the end of the program. + /// The body's literals are typed by the body alone, as at the end of + /// the program: the `1` in `fn inc(x) => x + 1` is an Int, whatever + /// `x` is. A variable the constraints wait on but that the parameters + /// don't decide, like the element type of `[]` in `fn f(x) { + /// [].push(x) }`, can't be decided by a call either, so it's settled + /// here too. pub(super) fn generalize( &mut self, symbol: SymbolId, signature: &StaticType, declaration: NodeId, - [first_use, first_literal, first_check]: [usize; 3], + [first_use, first_check]: [usize; 2], ) -> StaticType { self.solve_waiting(); - // A literal in the body that its type parameters don't reach has - // one type, which a call can't change. The others are type - // parameters, which each call settles. - let tied = self.literals_tied_to_parameters(signature); - self.default_literals(|vars, var| vars.is_generalizable(var) && !tied.contains(&var)); - self.settle_ambiguous(signature, &tied); + self.default_literals(TypeVars::is_generalizable); + self.settle_ambiguous(signature); self.merge_copies(); let scheme = self @@ -92,68 +90,12 @@ impl Analyser { } } let mut named = 0; - let mut literals: Vec<(Box, LiteralBounds)> = Vec::new(); - let mut seen = parameters.iter().copied().collect::>(); - // The integer literals one literal holds have one type, `[x, 1, 2]` - // holds Ints or Floats, so they're one type parameter, named for - // the first of them. - let mut first_number = AHashMap::::new(); - let mut names = AHashMap::>::new(); - let mut next = 0; - while let Some(&var) = parameters.get(next) { - next += 1; - // What a literal holds is a type parameter too: the `1` in - // `fn g(x) => [x, 1]` is a Float when `x` is. - let mut first = None; - for held in self.vars.held_vars(var) { - if seen.insert(held) { - parameters.push(held); - } - if self.vars.is_literal(held) && self.vars.is_number(held) { - match first { - None => first = Some(held), - Some(first) if first != held => { - first_number.entry(held).or_insert(first); - } - Some(_) => {} - } - } - } - if !self.vars.is_generalizable(var) { - continue; + for var in parameters { + if self.vars.is_generalizable(var) { + self.vars.generalize(var, &type_parameter_name(named)); + named += 1; } - if let Some(name) = first_number.get(&var).and_then(|first| names.get(first)) { - let name = name.clone(); - if let Some(bounds) = self.vars.generalize(var, &name) - && let Some((_, first_bounds)) = - literals.iter_mut().find(|(literal, _)| *literal == name) - { - first_bounds.extend(bounds); - } - continue; - } - let name: Box = type_parameter_name(named).into(); - if let Some(bounds) = self.vars.generalize(var, &name) { - literals.push((name.clone(), bounds)); - } - names.insert(var, name); - named += 1; } - let literals = literals - .into_iter() - .map(|(name, bounds)| (name, self.vars.resolve_bounds(&bounds))) - .collect::>(); - // A literal that's tied to a type parameter but that nothing outside - // the body sees, like the list in `fn f(x) { let xs = [x]; 0 }`, - // takes its default. - self.default_literals(TypeVars::is_generalizable); - let literal_nodes = self - .number_literals - .split_off(first_literal) - .into_iter() - .map(|(node, typ)| (node, self.vars.resolve(&typ))) - .filter(|(_, typ)| matches!(typ, StaticType::Generic(_))) - .collect::>(); // A type only the body makes, as of a list nothing else uses, gets // its fallback. @@ -206,13 +148,12 @@ impl Analyser { .collect::>(); // An instance is for the types of the type parameters that what it // compiles differently depends on: the constraints and uses it - // picks for, the literals typed by a type parameter, and the checks. + // picks for, and the checks. let mut key: Vec> = Vec::new(); for typ in scheme .iter() .flat_map(|waiting| waiting.constraint.inputs()) .chain(uses.iter().flat_map(|generic_use| &generic_use.key)) - .chain(literal_nodes.iter().map(|(_, typ)| typ)) .chain(checks.iter().map(|(_, _, expected)| expected)) { for name in typ.type_parameters() { @@ -229,77 +170,12 @@ impl Analyser { uses: uses.into(), declaration, recursive_calls: std::mem::take(&mut self.recursive_calls).into(), - literals: literals.into(), - literal_nodes: literal_nodes.into(), checks: checks.into(), }, ); self.vars.resolve(signature) } - /// The literals in the body of the generic function whose type is - /// `signature` that its parameters' types reach, through a waiting - /// constraint or what a literal holds: the `1` in `fn fact(n) => if n < - /// 2 { 1 } else { n * fact(n - 1) }` is what `fact` returns, like `n * - /// fact(n - 1)`, and the list in `fn k(x) => [x, "b"]` holds `x`. - fn literals_tied_to_parameters(&self, signature: &StaticType) -> AHashSet { - let open = |var: &TypeVar| self.vars.is_generalizable(*var); - let literals = self - .vars - .unsettled_literals(TypeVars::is_generalizable) - .into_iter() - .collect::>(); - // The variables that are the same or decide each other: those of one - // constraint, and a literal with what it holds. - let mut groups = self - .generic_constraints() - .into_iter() - .map(|serial| { - let constraint = &self.waiting[&serial].constraint; - constraint - .inputs() - .iter() - .chain(constraint.outputs()) - .flat_map(|typ| self.vars.unsettled(typ)) - .filter(open) - .collect::>() - }) - .collect::>(); - for &literal in &literals { - let mut group = self.vars.held_vars(literal); - group.retain(open); - group.push(literal); - groups.push(group); - } - let parameters = match signature { - StaticType::Function { - parameters: Some(parameters), - .. - } => parameters.as_slice(), - _ => &[], - }; - let mut reached = parameters - .iter() - .flat_map(|typ| self.vars.unsettled(typ)) - .filter(open) - .collect::>(); - loop { - let mut changed = false; - for group in &groups { - if group.iter().any(|var| reached.contains(var)) { - for &var in group { - changed |= reached.insert(var); - } - } - } - if !changed { - break; - } - } - reached.retain(|var| literals.contains(var)); - reached - } - /// The waiting constraints that wait on or settle a variable of the /// generic function whose body just ended, oldest first. fn generic_constraints(&self) -> Vec { @@ -358,17 +234,12 @@ impl Analyser { /// a constraint settles is decided when what it waits on is. An /// overloaded function's name used as a value has to be decided in the /// body, which is compiled once, so what it waits on counts too. - fn ambiguous_vars( - &self, - parameters: &[StaticType], - tied: &AHashSet, - ) -> Vec<(u64, Vec)> { + fn ambiguous_vars(&self, parameters: &[StaticType]) -> Vec<(u64, Vec)> { let serials = self.generic_constraints(); let mut decided = parameters .iter() .flat_map(|typ| self.vars.unsettled(typ)) .filter(|&var| self.vars.is_generalizable(var)) - .chain(tied.iter().copied()) .collect::>(); loop { let mut changed = false; @@ -411,7 +282,7 @@ impl Analyser { /// that its parameters don't decide, like the end of the program /// settles its own: a call that a default rule decides is decided by it, /// and what's left gets its fallbacks, oldest constraint first. - fn settle_ambiguous(&mut self, signature: &StaticType, tied: &AHashSet) { + fn settle_ambiguous(&mut self, signature: &StaticType) { let parameters = match signature { StaticType::Function { parameters: Some(parameters), @@ -420,7 +291,7 @@ impl Analyser { _ => Vec::new(), }; loop { - let ambiguous = self.ambiguous_vars(¶meters, tied); + let ambiguous = self.ambiguous_vars(¶meters); let Some((_, vars)) = ambiguous.first().cloned() else { return; }; @@ -475,8 +346,8 @@ impl Analyser { fn merge_copies(&mut self) { let serials = self.generic_constraints(); let mut kept: Vec = Vec::new(); - // A call's arguments and result, as one type, so copies of one - // literal in them become one, as they're settled the same way. + // A call's arguments and result, as one type, so the two calls' + // results become one. let call_types = |call: &CallConstraint| { StaticType::Tuple( call.arguments() diff --git a/ndc_analyser/src/analyser/instances.rs b/ndc_analyser/src/analyser/instances.rs index ffc46f32..2035d790 100644 --- a/ndc_analyser/src/analyser/instances.rs +++ b/ndc_analyser/src/analyser/instances.rs @@ -9,7 +9,6 @@ use super::Analyser; use super::solver::{CallSite, Constraint, Origin, Waiting}; use crate::error::AnalysisError; -use crate::types::LiteralBounds; use ahash::AHashMap; use ndc_analysis::{Binding, Candidate}; use ndc_core::{NodeId, StaticType}; @@ -34,15 +33,6 @@ pub(super) struct Scheme { pub(super) declaration: NodeId, /// The calls its body makes of itself, which have its own types. pub(super) recursive_calls: Rc<[NodeId]>, - /// Its type parameters that are the types of literals in its body, - /// with their bounds, which each call settles as a literal there would - /// be: the `1` in `fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) - /// }` is a Float for `fact(5.0)`. - pub(super) literals: Rc<[(Box, LiteralBounds)]>, - /// The integer literals in its body whose type is one of its type - /// parameters, with that type, which an instance records its own types - /// for. - pub(super) literal_nodes: Rc<[(NodeId, StaticType)]>, /// The values its body checks as the program runs against a type that /// has its type parameters in it, with their types and that type, which /// an instance checks against what its types make it. @@ -237,23 +227,14 @@ impl Analyser { /// The instance of `function` for these types of its key: the function /// itself when they're all `Any`, as its own body picks its calls as the /// program runs, or when it was declared by an earlier batch, which - /// compiled it already. Its own body's integer literals are Ints, so an - /// integer literal's type parameter may be one. + /// compiled it already. fn instance_for( &mut self, function: SymbolId, key: Vec, new: &mut Vec<(SymbolId, SymbolId, Vec)>, ) -> SymbolId { - let scheme = &self.schemes[&function]; - let is_own = scheme.key.iter().zip(&key).all(|(name, typ)| { - *typ == StaticType::Any - || (*typ == StaticType::Int - && scheme - .literals - .iter() - .any(|(literal, bounds)| literal == name && bounds.is_number())) - }); + let is_own = key.iter().all(|typ| *typ == StaticType::Any); if is_own || !self.declared_in_batch.contains(&function) { return function; } @@ -292,7 +273,6 @@ impl Analyser { .collect::>(); let outer = self.recording.replace(instance); let first = self.vars.count(); - self.vars.instantiate_literals(&scheme.literals, &mut made); for waiting in scheme.constraints.iter() { let mut copy = Waiting { constraint: waiting @@ -330,17 +310,6 @@ impl Analyser { }); } self.settle_waiting(first); - let literal_types = scheme - .literal_nodes - .iter() - .map(|(node, typ)| { - let typ = self.vars.instantiate_with(typ, &mut made); - (*node, self.vars.resolve(&typ)) - }) - .collect::>(); - if let Some(picks) = self.picks.instances.get_mut(&instance) { - picks.literal_types.extend(literal_types); - } self.recording = outer; } } diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index ddb59a70..b708765c 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -75,9 +75,6 @@ pub struct Analyser { declaring: Option, /// The calls its body makes of itself. recursive_calls: Vec, - /// The integer literals in the batch, with their types, which a - /// generic function's instances may give types of their own. - number_literals: Vec<(NodeId, StaticType)>, /// The nodes of overloaded function values that waited for the type /// expected of them, as `abs` in `[len, abs]` does. waiting_function_values: AHashSet, @@ -162,7 +159,6 @@ impl Analyser { declared_in_batch: AHashSet::new(), declaring: None, recursive_calls: Vec::new(), - number_literals: Vec::new(), waiting_function_values: AHashSet::new(), expected_parameters: AHashMap::new(), copied_through: None, @@ -198,7 +194,6 @@ impl Analyser { self.check_early_picks(); } self.failed_reads.clear(); - self.number_literals.clear(); self.waiting_function_values.clear(); self.types.resolve_all(&self.vars); for (instance, node, typ, expected) in std::mem::take(&mut self.checks) { @@ -401,11 +396,7 @@ impl Analyser { StaticType::Option(Box::new(self.vars.fresh(StaticType::Any))) } Expression::StringLiteral(_) => StaticType::String, - Expression::NumericLiteral(NumericLiteral::Int64(_)) => { - let typ = self.vars.fresh_number(*span); - self.number_literals.push((*id, typ.clone())); - typ - } + Expression::NumericLiteral(NumericLiteral::Int64(_)) => self.vars.fresh_number(*span), Expression::NumericLiteral(NumericLiteral::Float64(_)) => StaticType::Float, Expression::NumericLiteral( NumericLiteral::NumberInt(_) diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index d339669b..fa6d63b5 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -244,9 +244,7 @@ impl CallConstraint { } /// Whether `other` calls what this calls, the same way, with arguments - /// of the same types, so it returns the same type. Copies of one - /// literal count as the same type, as the same call settles them the - /// same way. + /// of the same types, so it returns the same type. pub(super) fn is_same_call(&self, other: &Self, vars: &TypeVars) -> bool { std::mem::discriminant(&self.site) == std::mem::discriminant(&other.site) && self.kind == other.kind @@ -257,7 +255,7 @@ impl CallConstraint { .arguments .iter() .zip(&other.arguments) - .all(|(a, b)| vars.with_literals_named(a) == vars.with_literals_named(b)) + .all(|(a, b)| vars.resolve(a) == vars.resolve(b)) } } @@ -1613,7 +1611,7 @@ impl Analyser { Binding::Dynamic(_) | Binding::None => None, }; let pending = self.pending.len(); - let signature = picked.map(|symbol| self.instantiate_function(symbol, true)); + let signature = picked.map(|symbol| self.instantiate_function(symbol)); let Some( signature @ StaticType::Function { parameters: Some(_), @@ -1640,27 +1638,10 @@ impl Analyser { /// `symbol`'s type with new variables for its type parameters. For a /// generic function declared in the program, its constraints get the /// same variables, and wait in [`Self::pending`] until - /// [`Self::flush_copies`] knows which call they're for. `call` says - /// whether it's for a call, which may get an instance of its own. - pub(super) fn instantiate_function(&mut self, symbol: SymbolId, call: bool) -> StaticType { + /// [`Self::flush_copies`] knows which call they're for. + pub(super) fn instantiate_function(&mut self, symbol: SymbolId) -> StaticType { let declared = self.vars.resolve(self.types.get_type(symbol)); let mut made = Vec::new(); - if let Some(scheme) = self.schemes.get(&symbol) { - let literals = Rc::clone(&scheme.literals); - // Only a call in the batch that declares the function gets an - // instance. A value, as in `let f = pair`, or a call of a - // function an earlier batch declared, runs the function's own - // body, whose integer literals are Ints. - if !call || !self.declared_in_batch.contains(&symbol) { - made.extend( - literals - .iter() - .filter(|(_, bounds)| bounds.is_number()) - .map(|(name, _)| (name.clone(), StaticType::Int)), - ); - } - self.vars.instantiate_literals(&literals, &mut made); - } let typ = self.vars.instantiate_with(&declared, &mut made); // A copy of a function's own constraint that calls the function // again, as a recursive call its body couldn't decide does, is diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index f283faa0..f0d64b39 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -84,52 +84,6 @@ enum Bound { Holds(StaticType, TypeVar, Span), } -/// The bounds of a literal's type that became a type parameter of a generic -/// function, which each call's copy of the parameter gets: the `1` in -/// `fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }` is an Int or a -/// Float, whichever the call needs. -#[derive(Debug, Clone)] -pub(crate) struct LiteralBounds(Vec); - -impl Bound { - /// The type of the value it holds, if it holds one. - fn held(&self) -> Option<&StaticType> { - match self { - Self::Holds(held, _, _) => Some(held), - Self::Number(..) => None, - } - } - - /// Which bound of which literal it is: the value's kind and where it is - /// in the source. - fn position(&self) -> (bool, usize, usize, usize) { - let (is_number, span) = match self { - Self::Number(_, span) => (true, span), - Self::Holds(_, _, span) => (false, span), - }; - ( - is_number, - span.source_id().index(), - span.offset(), - span.end(), - ) - } -} - -impl LiteralBounds { - /// Adds the bounds of another literal of the same type. - pub(crate) fn extend(&mut self, other: Self) { - self.0.extend(other.0); - } - - /// Whether they're an integer literal's. - pub(crate) fn is_number(&self) -> bool { - self.0 - .iter() - .any(|bound| matches!(bound, Bound::Number(..))) - } -} - /// The state of the variables at one point, which [`TypeVars::rollback`] /// returns to: overload resolution tries each candidate this way. #[derive(Debug, Clone, Copy)] @@ -299,7 +253,7 @@ impl TypeVars { Bound::Number(..) => None, }) .collect::>(); - literals.sort_by_key(|&var| (self.is_number(var), held.contains(&var))); + literals.sort_by_cached_key(|&var| (self.is_number(var), held.contains(&var))); literals } @@ -499,93 +453,13 @@ impl TypeVars { } /// Settles `var` as the type parameter `name` of the generic function - /// whose body made it, and returns its bounds if it's a literal's type, - /// which the parameter keeps. - pub(crate) fn generalize(&mut self, var: TypeVar, name: &str) -> Option { - let bounds = std::mem::take(&mut self.bounds[var.0 as usize]); + /// whose body made it. + pub(crate) fn generalize(&mut self, var: TypeVar, name: &str) { + debug_assert!( + !self.is_literal(var), + "a literal's type is a type parameter" + ); self.settle(var, StaticType::Generic(name.into())); - (!bounds.is_empty()).then_some(LiteralBounds(bounds)) - } - - /// `bounds` with the types the values held have now, which are type - /// parameters once the generic function's body has ended. - pub(crate) fn resolve_bounds(&self, bounds: &LiteralBounds) -> LiteralBounds { - LiteralBounds( - bounds - .0 - .iter() - .map(|bound| match bound { - Bound::Holds(held, literal, span) => { - Bound::Holds(self.resolve(held), *literal, *span) - } - number @ Bound::Number(..) => number.clone(), - }) - .collect(), - ) - } - - /// Adds a new literal type to `made` for each of a generic function's - /// literal type parameters `literals` it doesn't have yet, with the - /// parameter's bounds, so each call settles its own: `fact(5.0)` makes - /// the `1` in `fact` a Float. - pub(crate) fn instantiate_literals( - &mut self, - literals: &[(Box, LiteralBounds)], - made: &mut Vec<(Box, StaticType)>, - ) { - let mut new = Vec::new(); - for (name, bounds) in literals { - if made.iter().any(|(generic, _)| generic == name) { - continue; - } - let typ = self.fresh(if bounds.is_number() { - StaticType::Int - } else { - StaticType::Any - }); - let StaticType::Var(var) = typ else { - unreachable!("a fresh type is a variable"); - }; - made.push((name.clone(), typ)); - new.push((var, bounds)); - } - for (var, bounds) in new { - let bounds = bounds - .0 - .iter() - .map(|bound| match bound { - Bound::Number(_, span) => Bound::Number(var, *span), - Bound::Holds(held, _, span) => { - Bound::Holds(self.instantiate_with(held, made), var, *span) - } - }) - .collect(); - self.bounds[var.0 as usize] = bounds; - self.literals.push(var); - } - } - - /// `typ` with each unsettled literal type in it replaced by a type that - /// says which literals it's the type of, so two copies of one generic - /// function's literal type parameter look the same: each call of `f0` - /// in `fn f1(x) => f0(x) + f0(x)` copies the `1` of `fn f0(x) => x + 1`. - pub(crate) fn with_literals_named(&self, typ: &StaticType) -> StaticType { - self.resolve(typ).replace(&|part| match part { - StaticType::Var(var) if self.is_literal(*var) => { - let mut spans = self.bounds[var.0 as usize] - .iter() - .map(|bound| match bound { - Bound::Number(_, span) | Bound::Holds(_, _, span) => { - (span.source_id().index(), span.offset(), span.end()) - } - }) - .collect::>(); - spans.sort_unstable(); - spans.dedup(); - Some(StaticType::Generic(format!("literal{spans:?}").into())) - } - _ => None, - }) } /// Whether `typ` is made from the unsettled variable `var`, directly or @@ -650,18 +524,6 @@ impl TypeVars { } match shallow { StaticType::Var(other) => { - // Copies of one literal, as two calls of a generic function - // make, have the same bounds, which it needs once. Copies - // may hold values of different types, as `wrap(1)` and - // `wrap("s")` of `fn wrap(x) => [x]` do, which it needs both. - let mut known = AHashMap::<_, Vec>::new(); - for bound in &self.bounds[other.0 as usize] { - known.entry(bound.position()).or_default().push( - bound - .held() - .map_or(StaticType::Int, |held| self.resolve(held)), - ); - } for bound in bounds { // A literal holding a value of the type the other // variable already is says nothing more. @@ -670,14 +532,6 @@ impl TypeVars { { continue; } - let held = bound - .held() - .map_or(StaticType::Int, |held| self.resolve(held)); - let same = known.entry(bound.position()).or_default(); - if same.contains(&held) { - continue; - } - same.push(held); self.bounds[other.0 as usize].push(bound); self.bounded.push(other); self.literals.push(other); diff --git a/ndc_analysis/src/lib.rs b/ndc_analysis/src/lib.rs index b468e078..4dbff040 100644 --- a/ndc_analysis/src/lib.rs +++ b/ndc_analysis/src/lib.rs @@ -93,11 +93,6 @@ pub struct Instance { pub augmented_assignments: AHashMap, /// As [`Analysis::type_checks`], for the instance's body. pub type_checks: AHashMap, - /// The types of the integer literals in the instance's body whose type - /// is one of the function's type parameters, in place of the ones in - /// [`Analysis::expr_types`]: the `1` in `fn fact(n) => if n < 2 { 1 } - /// else { n * fact(n - 1) }` is a Float in the instance for Floats. - pub literal_types: AHashMap, } impl Analysis { diff --git a/ndc_layout/src/lib.rs b/ndc_layout/src/lib.rs index 9659bd2f..d0794941 100644 --- a/ndc_layout/src/lib.rs +++ b/ndc_layout/src/lib.rs @@ -51,9 +51,6 @@ pub struct InstancePicks { pub augmented_assignments: AHashMap>, /// As [`Analysis::type_checks`], for the instance's body. pub type_checks: AHashMap, - /// The types of the integer literals in the instance's body whose type - /// is one of the function's type parameters. - pub literal_types: AHashMap, } impl InstancePicks { @@ -65,7 +62,6 @@ impl InstancePicks { setters: AHashMap::new(), augmented_assignments: AHashMap::new(), type_checks: AHashMap::new(), - literal_types: AHashMap::new(), } } } @@ -166,7 +162,6 @@ impl<'a> Layout<'a> { .map(locate_binding) .collect(), type_checks: picks.type_checks, - literal_types: picks.literal_types, }; analysis .instances diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index 310b6e35..cdf272f6 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -215,10 +215,7 @@ impl Compiler { let value = match literal { // An integer literal is what its use made it: `1` in // `[1.5, 1]` is a Float. - NumericLiteral::Int64(i) => match self - .instance_pick(|instance| instance.literal_types.get(&id)) - .or_else(|| self.analysis.expr_types.get(&id)) - { + NumericLiteral::Int64(i) => match self.analysis.expr_types.get(&id) { Some(StaticType::Float) => Value::float(i as f64), Some(StaticType::Number) => { Value::number(crate::value::AdvancedNumber::Int(i.into())) diff --git a/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_is_typed_by_the_body.ndc b/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_is_typed_by_the_body.ndc new file mode 100644 index 00000000..d9eb286a --- /dev/null +++ b/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_is_typed_by_the_body.ndc @@ -0,0 +1,7 @@ +// Nothing in `fact`'s body needs the `1` to be anything else, so it's an +// Int, which `fact` returns. Called with a Float, `n * fact(n - 1)` is a +// Float, and the two branches can't both be what `fact` returns: write +// `1.0` for a `fact` of Floats. +// expect-error: mismatched types: found Float but expected Int +fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }; +print(fact(5.0)); diff --git a/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc b/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc deleted file mode 100644 index ad76228e..00000000 --- a/tests/functional/programs/005_functions/087_a_literal_in_a_generic_body_takes_the_call_s_type.ndc +++ /dev/null @@ -1,6 +0,0 @@ -// The `1` in `fact` is what `fact` returns, so it's a Float when `fact` is -// called with one: each call settles the literal as a literal there would be. -// expect-output: 120 120.0 1.0 55.0 -fn fact(n) => if n < 2 { 1 } else { n * fact(n - 1) }; -fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; -print(fact(5), fact(5.0), fact(1.0), fib(10.0)); diff --git a/tests/functional/programs/005_functions/088_a_list_of_a_parameter_and_a_literal_is_typed_by_the_body.ndc b/tests/functional/programs/005_functions/088_a_list_of_a_parameter_and_a_literal_is_typed_by_the_body.ndc new file mode 100644 index 00000000..1a9b56e1 --- /dev/null +++ b/tests/functional/programs/005_functions/088_a_list_of_a_parameter_and_a_literal_is_typed_by_the_body.ndc @@ -0,0 +1,5 @@ +// The `1` in `[x, 1]` is an Int, so the list holds Ints and `x` is one: +// `g` doesn't take a Float. +// expect-error: No function called 'g' found that matches the arguments 'Float' +fn g(x) => [x, 1]; +print(g(2), g(2.5)); diff --git a/tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc b/tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc deleted file mode 100644 index 4a9ec6ad..00000000 --- a/tests/functional/programs/005_functions/088_a_literal_holding_a_parameter_takes_the_call_s_type.ndc +++ /dev/null @@ -1,7 +0,0 @@ -// A literal that holds a parameter, or is passed with one, is as generic -// as the parameter: `[x, 1]` is a list of Floats when `x` is a Float. -// expect-output: [2.5,1.0] [2,1] [1,"b"] [2.5,1.0] -fn g(x) => [x, 1]; -fn k(x) => [x, "b"]; -fn f(xs) { xs.push(1); xs } -print(g(2.5), g(2), k(1), f([2.5])); diff --git a/tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc b/tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc deleted file mode 100644 index f60c6ab9..00000000 --- a/tests/functional/programs/005_functions/089_a_generic_function_as_a_value_has_int_literals.ndc +++ /dev/null @@ -1,5 +0,0 @@ -// A generic function used as a value runs its own body, whose integer -// literals are Ints, so `pair` can't be a function returning Floats. -// expect-error: mismatched types -fn pair(x) => [x, 1]; -let f: Fn(Float) -> List = pair; diff --git a/tests/functional/programs/005_functions/090_copies_of_a_literal_keep_what_each_holds.ndc b/tests/functional/programs/005_functions/090_each_call_of_a_generic_function_makes_a_list_of_what_it_is_given.ndc similarity index 100% rename from tests/functional/programs/005_functions/090_copies_of_a_literal_keep_what_each_holds.ndc rename to tests/functional/programs/005_functions/090_each_call_of_a_generic_function_makes_a_list_of_what_it_is_given.ndc diff --git a/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc b/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc index 03472edf..272018bc 100644 --- a/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc +++ b/tests/functional/programs/005_functions/094_a_recursive_call_through_another_overload_can_t_grow.ndc @@ -1,8 +1,8 @@ -// `[x]` with an Int `x` is a `List`, which isn't the `List` the +// `[-x]` with an Int `x` is a `List`, which isn't the `List` the // first `f` takes, so `f(1)` would need `f` for `List`, then for // `List>`, without end. Another overload taking the call only // keeps the error from being reported in the body. // expect-error: `f` calls itself with an argument built from what it was given fn f(xs: List) => 42; -fn f(x) => f([x]); +fn f(x) => f([-x]); print(f(1)); diff --git a/tests/functional/programs/005_functions/097_a_list_of_a_parameter_is_typed_by_the_body.ndc b/tests/functional/programs/005_functions/097_a_list_of_a_parameter_is_typed_by_the_body.ndc new file mode 100644 index 00000000..7215c286 --- /dev/null +++ b/tests/functional/programs/005_functions/097_a_list_of_a_parameter_is_typed_by_the_body.ndc @@ -0,0 +1,7 @@ +// `[x]` is a list of what `x` is, and `f` can't take a list of its own +// parameter's type, so only the first `f` takes it, which makes `x` an +// `Any`: `f(1)` calls `f([1])` with a `List`. +// expect-output: 42 +fn f(xs: List) => 42; +fn f(x) => f([x]); +print(f(1)); diff --git a/tests/functional/programs/005_functions/098_a_generic_function_takes_what_its_body_s_literals_allow.ndc b/tests/functional/programs/005_functions/098_a_generic_function_takes_what_its_body_s_literals_allow.ndc new file mode 100644 index 00000000..7e7fc927 --- /dev/null +++ b/tests/functional/programs/005_functions/098_a_generic_function_takes_what_its_body_s_literals_allow.ndc @@ -0,0 +1,8 @@ +// The literals in a generic function's body are Ints, and an Int works +// with a Float in `+`, `-` and `<`, so these take Floats too. `fact` with a +// `1.0` returns what it's given multiplied. +// expect-output: 3.5 3 55.0 120.0 120.0 +fn inc(x) => x + 1; +fn fib(n) => if n < 2 { n } else { fib(n - 1) + fib(n - 2) }; +fn fact(n) => if n < 2 { 1.0 } else { n * fact(n - 1) }; +print(inc(2.5), inc(2), fib(10.0), fact(5.0), fact(5)); From 53d79c42cc920fe9a1fb06e2504a16e55419bcf7 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 19:33:51 +0100 Subject: [PATCH 35/39] =?UTF-8?q?fix:=20bugs=20found=20analysing=20the=20a?= =?UTF-8?q?dvent-of-code-ndc=20solutions=20=F0=9F=8E=84=20(#342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analysing every program in the advent-of-code-ndc repository turned up three things the type checker got wrong. Five programs that failed analysis now pass, including one master rejected too. - **A literal passed for an `Iterable` became an `Iterable` itself.** Binding a literal's still-open type to an `Iterable` parameter unified the two, so the value's type became `Iterable`, which only a parameter may be. In `let cur, path = ("A", ["A"]); path.len; path ++ [cur]` the `++` then found no overload. Such a literal is now settled as the sequence it holds (a `List` here), and its elements are bound to `T`. - **An overloaded function used as a value in a generic function was decided once for the body.** `fn signs(xs) => xs.map(signum)` reported that nothing says which `signum` is meant, though each call's types do. A function value now waits like a call: it's part of the function's scheme, each instance picks the overload its types call for, and the body compiled for `Any` arguments leaves the pick to the program. There the value is a new kind of function, `Function::Overloads`, made by a new `MakeOverloads` instruction from the overloads looked up where the value is made, most specific first; each call calls the first that accepts its arguments. Being an ordinary function, it can be passed to built-ins like `map`, captured, cached by a `pure fn`, and checked against a function type. Copying the scheme to a call now gives the value the call's types, and picking it counts as settling what it returns. - **`first` and `last` only took lists.** Their implementation takes any slice, and programs call them on pairs: `.map(first)` over `(point, flag)` tuples. They now also take tuples of one to twelve elements, typed by the element they return, and `zip` gives typed tuples for up to twelve sequences (it went up to four). A signature can't say "a tuple of any length", so each length gets its own overload; twelve is where Rust's standard library stops for tuples. The manual's section on overloaded function values covers generic functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- manual/src/reference/types/function.md | 7 ++ ndc_analyser/src/analyser/function_values.rs | 52 ++++++++++-- ndc_analyser/src/analyser/generics.rs | 76 +++++++++++------ ndc_analyser/src/analyser/solver.rs | 8 +- ndc_analyser/src/analyser/tests.rs | 16 ++-- ndc_analyser/src/overloads.rs | 2 +- ndc_analyser/src/types.rs | 12 +++ ndc_stdlib/src/sequence.rs | 84 ++++++++++++++++--- ndc_vm/src/chunk.rs | 9 +- ndc_vm/src/compiler.rs | 24 +++++- ndc_vm/src/value/function.rs | 41 ++++++++- ndc_vm/src/value/mod.rs | 5 ++ ndc_vm/src/vm.rs | 61 ++++++++++++++ ...neric_function_is_picked_for_each_call.ndc | 8 ++ ...ogram_takes_the_most_specific_overload.ndc | 9 ++ ...eft_to_the_program_works_as_a_callback.ndc | 7 ++ ..._the_program_keeps_its_local_overloads.ndc | 12 +++ ..._program_is_checked_as_a_function_type.ndc | 12 +++ ...rogram_can_be_a_pure_function_argument.ndc | 7 ++ ...r_an_iterable_is_the_sequence_it_holds.ndc | 7 ++ .../028_first_and_last_take_a_tuple.ndc | 4 + ...tuples_up_to_twelve_elements_are_typed.ndc | 5 ++ 22 files changed, 407 insertions(+), 61 deletions(-) create mode 100644 tests/functional/programs/005_functions/099_an_overloaded_function_value_in_a_generic_function_is_picked_for_each_call.ndc create mode 100644 tests/functional/programs/005_functions/100_a_function_value_left_to_the_program_takes_the_most_specific_overload.ndc create mode 100644 tests/functional/programs/005_functions/101_a_function_value_left_to_the_program_works_as_a_callback.ndc create mode 100644 tests/functional/programs/005_functions/102_a_function_value_left_to_the_program_keeps_its_local_overloads.ndc create mode 100644 tests/functional/programs/005_functions/103_a_function_value_left_to_the_program_is_checked_as_a_function_type.ndc create mode 100644 tests/functional/programs/005_functions/104_a_function_value_left_to_the_program_can_be_a_pure_function_argument.ndc create mode 100644 tests/functional/programs/019_iterable/017_a_literal_passed_for_an_iterable_is_the_sequence_it_holds.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/028_first_and_last_take_a_tuple.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/029_tuples_up_to_twelve_elements_are_typed.ndc diff --git a/manual/src/reference/types/function.md b/manual/src/reference/types/function.md index c2e8004b..b5c10a48 100644 --- a/manual/src/reference/types/function.md +++ b/manual/src/reference/types/function.md @@ -374,6 +374,13 @@ let g = split; // error: 'split' has several overloads, and nothing here sa Like a call, a value uses the nearest scope with an overload that fits. +In a generic function, each call's types pick the overload, as they pick the calls in its body: + +```ndc +fn signs(xs) => xs.map(signum); +print(signs([3, -2]), signs([0.5, -1.5])); // [1,-1] [1.0,-1.0] +``` + ## How a call finds its function A call such as `foo(x)` looks for `foo` in the scope it's written in first, then in each enclosing scope in turn, and finally among the built-in functions. diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index 5fede382..5254a646 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -4,7 +4,7 @@ //! `split(String, String)`. use super::Analyser; -use super::solver::{Constraint, EarlyOverload}; +use super::solver::{CallSite, Constraint, EarlyOverload}; use crate::error::AnalysisError; use crate::overloads::CallKind; use ndc_analysis::{Binding, Candidate}; @@ -35,6 +35,15 @@ impl FunctionValue { &self.expected } + /// This value with `expected` as the type expected of it, as a copy of + /// a generic function's constraints gets the types of its call. + pub(super) fn with_expected(&self, expected: StaticType) -> Self { + Self { + expected, + ..self.clone() + } + } + pub(super) fn id(&self) -> NodeId { self.id } @@ -42,6 +51,11 @@ impl FunctionValue { pub(super) fn mark_in_literal(&mut self) { self.in_literal = true; } + + /// The functions the name can mean, grouped by scope, nearest first. + pub(super) fn groups(&self) -> &[Vec] { + &self.groups + } } /// Which overload a function value means, given what's known of the type @@ -250,6 +264,21 @@ impl Analyser { fits } + /// Every overload `value` can mean, for the program to pick from as it + /// calls it: nearest scope first, and in each the most specific first, + /// as for a call the program picks for, since it takes the first that + /// accepts the arguments. + pub(super) fn every_overload(&self, value: &FunctionValue) -> Binding { + Binding::Dynamic( + value + .groups() + .iter() + .flat_map(|group| self.overloads().most_specific_first(group.clone())) + .map(Candidate::Scalar) + .collect(), + ) + } + /// Picks the overload `value` means if what's known of the type expected /// of it decides it, and returns whether it did. pub(super) fn try_function_value(&mut self, value: &FunctionValue) -> bool { @@ -325,9 +354,20 @@ impl Analyser { if early && cfg!(debug_assertions) { self.remember_early_function_value(value, symbol); } - self.picks - .bindings - .insert(value.id, Binding::Resolved(Candidate::Scalar(symbol))); + self.record_function_value(value, symbol); + } + + /// Records that `value` means `symbol`, in the instance whose + /// constraints are being solved, if any. A copy of a generic function's + /// constraint records nothing, as the value is in that function's body, + /// whose instance for the call records it. + fn record_function_value(&mut self, value: &FunctionValue, symbol: SymbolId) { + if self.copied_through.is_none() { + self.pick_tables(self.recording).insert( + CallSite::Call(value.id), + Binding::Resolved(Candidate::Scalar(symbol)), + ); + } } /// Picks the nearest overload after an error, so analysis carries on @@ -336,8 +376,6 @@ impl Analyser { let symbol = self.resolver.innermost_reference(value.id); let typ = self.instantiated_type(symbol); self.vars.try_unify(&value.expected, &typ); - self.picks - .bindings - .insert(value.id, Binding::Resolved(Candidate::Scalar(symbol))); + self.record_function_value(value, symbol); } } diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index 5782a8fd..a8b50549 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -126,10 +126,18 @@ impl Analyser { }) .collect::>(); for waiting in &scheme { - if waiting.origin.is_none() - && let Constraint::Call(call) = &waiting.constraint - { - self.pick_for_any_arguments(call); + match &waiting.constraint { + Constraint::Call(call) if waiting.origin.is_none() => { + self.pick_for_any_arguments(call); + } + // The body compiled for arguments of type `Any` can't tell + // which overload a function value means, so the program + // picks as it calls it. + Constraint::FunctionValue(value) if waiting.origin.is_none() => { + let binding = self.every_overload(value); + self.picks.bindings.insert(value.id(), binding); + } + _ => {} } } // A value of type `Any` that the body passes where a type parameter @@ -209,15 +217,35 @@ impl Analyser { /// The generic function's variables that `constraint` settles once it's /// solved. A function passed to a call gets the types the call passes /// it, as `fn(a, x) => a + x` in `xs.fold(0, fn(a, x) => a + x)`, so - /// they count as settled by the call too. + /// they count as settled by the call too, and so does an overloaded + /// function's name passed to one, as `signum` in `xs.map(signum)`, + /// whose type is what the call expects of it. fn generic_outputs(&self, constraint: &Constraint) -> Vec { let passed_functions = match constraint { - Constraint::Call(call) => call - .arguments() - .iter() - .map(|argument| self.vars.resolve(argument)) - .filter(|argument| matches!(argument, StaticType::Function { .. })) - .collect(), + Constraint::Call(call) => { + let values = self + .waiting + .values() + .filter_map(|waiting| match &waiting.constraint { + Constraint::FunctionValue(value) => { + Some(self.vars.resolve(value.expected())) + } + _ => None, + }) + .collect::>(); + call.arguments() + .iter() + .map(|argument| self.vars.resolve(argument)) + .filter(|argument| { + matches!(argument, StaticType::Function { .. }) || values.contains(argument) + }) + .collect() + } + // Picking the overload settles what the value returns. + Constraint::FunctionValue(value) => match self.vars.resolve(value.expected()) { + StaticType::Function { return_type, .. } => vec![*return_type], + _ => Vec::new(), + }, _ => Vec::new(), }; constraint @@ -232,8 +260,8 @@ impl Analyser { /// The generic function's variables that its constraints wait on but /// that its parameters don't decide, by constraint, oldest first. What /// a constraint settles is decided when what it waits on is. An - /// overloaded function's name used as a value has to be decided in the - /// body, which is compiled once, so what it waits on counts too. + /// overloaded function's name used as a value is picked like a call: + /// for each instance, from the types its parameters decide. fn ambiguous_vars(&self, parameters: &[StaticType]) -> Vec<(u64, Vec)> { let serials = self.generic_constraints(); let mut decided = parameters @@ -245,11 +273,10 @@ impl Analyser { let mut changed = false; for serial in &serials { let constraint = &self.waiting[serial].constraint; - if matches!(constraint, Constraint::FunctionValue(_)) - || !self - .generic_inputs(constraint) - .iter() - .all(|var| decided.contains(var)) + if !self + .generic_inputs(constraint) + .iter() + .all(|var| decided.contains(var)) { continue; } @@ -265,14 +292,11 @@ impl Analyser { .into_iter() .filter_map(|serial| { let constraint = &self.waiting[&serial].constraint; - let ambiguous = match constraint { - Constraint::FunctionValue(_) => self.generic_inputs(constraint), - _ => self - .generic_inputs(constraint) - .into_iter() - .filter(|var| !decided.contains(var)) - .collect(), - }; + let ambiguous = self + .generic_inputs(constraint) + .into_iter() + .filter(|var| !decided.contains(var)) + .collect::>(); (!ambiguous.is_empty()).then_some((serial, ambiguous)) }) .collect() diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index fa6d63b5..99ac6708 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -118,7 +118,9 @@ impl Constraint { result: replace(&call.result), ..call.clone() }), - Self::FunctionValue(value) => Self::FunctionValue(value.clone()), + Self::FunctionValue(value) => { + Self::FunctionValue(value.with_expected(replace(value.expected()))) + } Self::Iterate { sequence, element, @@ -1776,7 +1778,9 @@ impl Analyser { _ => return None, }; match sequence { - StaticType::Var(var) if !self.vars.is_literal(var) => Some((sequence, element)), + // A literal whose type isn't known yet either, as the + // element of `[1][0]` is, waits like any other value. + StaticType::Var(_) => Some((sequence, element)), _ => None, } }) diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index b75ae679..d3183046 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1557,15 +1557,17 @@ fn a_lambda_a_generic_function_passes_on_takes_what_each_call_gives_it() { } #[test] -fn an_overloaded_function_value_in_a_generic_function_is_decided_by_its_body() { - // The body is compiled once, so `g` has to be one `abs`, whatever `f` - // is called with. +fn an_overloaded_function_value_in_a_generic_function_is_picked_for_each_call() { + // Each instance of `f` picks the `abs` its argument's type calls for. let globals = natives(&[("abs", "(Int) -> Int"), ("abs", "(Float) -> Float")]); + let source = "fn f(x) { let g = abs; g(x) }"; assert_eq!( - errors("fn f(x) { let g = abs; g(x) } f(1);", globals.clone()), - vec![ - "'abs' has several overloads that are a Fn(Any) -> Any, and nothing here says which one is meant" - ] + analyse_last_type_with_globals(&format!("{source} f(1)"), globals.clone()), + StaticType::Int + ); + assert_eq!( + analyse_last_type_with_globals(&format!("{source} f(2.5)"), globals.clone()), + StaticType::Float ); assert_eq!( errors( diff --git a/ndc_analyser/src/overloads.rs b/ndc_analyser/src/overloads.rs index 6be43783..dbb5acea 100644 --- a/ndc_analyser/src/overloads.rs +++ b/ndc_analyser/src/overloads.rs @@ -533,7 +533,7 @@ impl<'a> Overloads<'a> { /// accepts the arguments. Overloads in one scope don't tie, so of those /// that accept them, one is more specific than the others, and it's the /// first. - fn most_specific_first(self, mut symbols: Vec) -> Vec { + pub(crate) fn most_specific_first(self, mut symbols: Vec) -> Vec { let mut ordered = Vec::with_capacity(symbols.len()); while !symbols.is_empty() { let next = symbols diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index f0d64b39..e49612ce 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -817,6 +817,18 @@ impl TypeVars { // n = a` is still an Int. (StaticType::Var(var), StaticType::Any) if self.is_literal(*var) => true, (StaticType::Iterable(_), StaticType::Var(var)) if !self.is_literal(*var) => true, + // A literal passed for an `Iterable` is the sequence it holds, + // as `["a"]` passed to `len` is a `List`: only a + // parameter is ever an `Iterable` itself. + (StaticType::Iterable(_), StaticType::Var(var)) => { + let Some(held) = self + .literal_defaults(*var) + .and_then(|defaults| defaults.into_iter().next()) + else { + return true; + }; + self.settle_literal(*var, &held) && self.bind_argument(¶meter, &held) + } (StaticType::Var(_), _) | (_, StaticType::Var(_)) => self.unify(¶meter, &argument), (_, StaticType::Any) => true, ( diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index 76e09ead..c6210fe7 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -997,22 +997,84 @@ pub mod extra { use std::rc::Rc; pub fn register(env: &mut FunctionRegistry>) { - for signature in [ - // Two sequences zip into pairs of their elements, three into - // triples, and four into quadruples. - "(Iterable, Iterable) -> List<(A, B)>", - "(Iterable, Iterable, Iterable) -> List<(A, B, C)>", - "(Iterable, Iterable, Iterable, Iterable) -> List<(A, B, C, D)>", - // More make tuples as long as the number of arguments. - "(*) -> List", - ] { + // A tuple's elements have their own types, and a signature can't say + // "a tuple of any length", so each length up to twelve, as far as + // Rust's standard library goes for tuples, gets one that says which. + const LONGEST: usize = 12; + let names = |count: usize| { + ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"][..count].to_vec() + }; + for length in 1..=LONGEST { + let names = names(length); + let parameters = names.join(", "); + let tuple = if length == 1 { + "(A,)".to_string() + } else { + format!("({parameters})") + }; + for (name, returns, documentation) in [ + ("first", names[0], "Returns the first element of a tuple."), + ( + "last", + names[length - 1], + "Returns the last element of a tuple.", + ), + ] { + env.declare_global_fn(Rc::new(NativeFunction { + name: name.to_string(), + documentation: Some(documentation.to_string()), + static_type: StaticType::signature(&format!( + "<{parameters}>({tuple}) -> {returns}" + )) + .expect("valid signature"), + func: NativeFunc::Simple(Box::new(move |args| { + tuple_end(args, name == "first") + })), + })); + } + } + // Two or more sequences zip into tuples of their elements. + for length in 2..=LONGEST { + let names = names(length); + let sequences = names + .iter() + .map(|name| format!("Iterable<{name}>")) + .collect::>() + .join(", "); env.declare_global_fn(Rc::new(NativeFunction { name: "zip".to_string(), - documentation: Some("Zips two or more sequences together into a list of tuples. The result length is the minimum length of the inputs.".to_string()), - static_type: StaticType::signature(signature).expect("valid signature"), + documentation: Some(ZIP_DOCUMENTATION.to_string()), + static_type: StaticType::signature(&format!( + "<{}>({sequences}) -> List<({})>", + names.join(", "), + names.join(", ") + )) + .expect("valid signature"), func: NativeFunc::Simple(Box::new(zip)), })); } + // More make tuples as long as the number of arguments. + env.declare_global_fn(Rc::new(NativeFunction { + name: "zip".to_string(), + documentation: Some(ZIP_DOCUMENTATION.to_string()), + static_type: StaticType::signature("(*) -> List").expect("valid signature"), + func: NativeFunc::Simple(Box::new(zip)), + })); + } + + const ZIP_DOCUMENTATION: &str = "Zips two or more sequences together into a list of tuples. The result length is the minimum length of the inputs."; + + /// The first or last element of the tuple in `args`. + fn tuple_end(args: &[Value], first: bool) -> Result { + let [Value::Object(object)] = args else { + return Err(VmError::native("expected one tuple".to_string())); + }; + let Object::Tuple(values) = object.as_ref() else { + return Err(VmError::native("expected a tuple".to_string())); + }; + let end = if first { values.first() } else { values.last() }; + end.cloned() + .ok_or_else(|| VmError::native("the tuple is empty".to_string())) } fn zip(args: &[Value]) -> Result { diff --git a/ndc_vm/src/chunk.rs b/ndc_vm/src/chunk.rs index e01e00b2..97878caf 100644 --- a/ndc_vm/src/chunk.rs +++ b/ndc_vm/src/chunk.rs @@ -64,6 +64,7 @@ impl std::fmt::Debug for JumpTarget { /// | `JumpIfFalse` | `[… bool → … bool]` | 0 (peeks, jumps if false) | /// | `MakeList` | `[… v1…vn → … list]` | −(n−1) | /// | `MakeTuple` | `[… v1…vn → … tuple]` | −(n−1) | +/// | `MakeOverloads` | `[… f1…fn → … function]` | −(n−1) | /// | `MakeMap` | `[… k1 v1…kn vn (default?) → … map]` | −(2n−1) or −2n if `has_default` | /// | `MakeRange` | `[… start (end?) → … iter]` | −1 if bounded, 0 if unbounded | /// | `Closure` | `[… → … closure]` | +1 | @@ -119,6 +120,10 @@ pub enum OpCode { MakeList(usize), /// Pops `n` values, pushes a tuple. `[… v1…vn → … tuple]` MakeTuple(usize), + /// Pops `n` functions, most specific first, and pushes a function that + /// calls the first of them that accepts each call's arguments. + /// `[… f1…fn → … function]` + MakeOverloads(usize), /// Pops `2n` values (+ optional default), pushes a map. MakeMap { pairs: usize, has_default: bool }, /// Pushes a closure, capturing values from locals/upvalues. `[… → … closure]` @@ -175,6 +180,7 @@ impl OpCode { Self::SetGlobal(_) => "SetGlobal", Self::MakeList(_) => "MakeList", Self::MakeTuple(_) => "MakeTuple", + Self::MakeOverloads(_) => "MakeOverloads", Self::MakeMap { .. } => "MakeMap", Self::Closure { .. } => "Closure", Self::GetIterator => "GetIterator", @@ -216,7 +222,7 @@ impl OpCode { | Self::GetGlobal(_) => 1, Self::SetLocal(_) | Self::SetCell(_) | Self::SetUpvalue(_) | Self::SetGlobal(_) => -1, // Pops `n` elements, pushes the collection: -n+1. - Self::MakeList(n) | Self::MakeTuple(n) => 1 - *n as isize, + Self::MakeList(n) | Self::MakeTuple(n) | Self::MakeOverloads(n) => 1 - *n as isize, // Pops a key and a value per pair plus the optional default, // pushes the map: -(2*pairs + has_default) + 1. Self::MakeMap { pairs, has_default } => { @@ -270,6 +276,7 @@ impl std::fmt::Debug for OpCode { Self::SetUpvalue(n) => write!(f, "SetUpvalue({n})"), Self::MakeList(n) => write!(f, "MakeList({n})"), Self::MakeTuple(n) => write!(f, "MakeTuple({n})"), + Self::MakeOverloads(n) => write!(f, "MakeOverloads({n})"), Self::MakeMap { pairs, has_default } => { write!(f, "MakeMap({pairs}, default={has_default})") } diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index cdf272f6..de8ad145 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -234,9 +234,18 @@ impl Compiler { let idx = self.ir.add_constant(value); self.ir.write(OpCode::Constant(idx), span); } - Expression::Identifier { .. } => { - self.compile_binding(&self.binding(id), span)?; - } + Expression::Identifier { .. } => match self.binding(id) { + // A function's name used as a value whose overload is picked + // as it's called: each overload is looked up now, where the + // value is made, so the value works wherever it's called. + Binding::Dynamic(candidates) => { + for candidate in &candidates { + self.emit_get_var(candidate.var(), span); + } + self.ir.write(OpCode::MakeOverloads(candidates.len()), span); + } + binding => self.compile_binding(&binding, span)?, + }, Expression::Cast { value, .. } => { self.compile_expr(*value)?; match self.analysis.cast_checks.get(&id) { @@ -433,7 +442,14 @@ impl Compiler { } _ => OpCode::Call(arguments.len()), }; - self.compile_expr(*function)?; + // A named callee is looked up as a call picks it, not as a + // value: the overloads the program picks from stay an + // overload set, which a call resolves from its frame. + if let Expression::Identifier { .. } = function.expression { + self.compile_binding(&self.binding(function.id), function_span)?; + } else { + self.compile_expr(*function)?; + } for argument in arguments { self.compile_expr(argument)?; diff --git a/ndc_vm/src/value/function.rs b/ndc_vm/src/value/function.rs index a6eea7c2..21919eb5 100644 --- a/ndc_vm/src/value/function.rs +++ b/ndc_vm/src/value/function.rs @@ -32,8 +32,23 @@ pub enum Function { /// every function value bigger. typ: Box, }, + + /// The overloads of a function, most specific first, of which each call + /// calls the first that accepts its arguments: an overloaded function's + /// name used as a value where the analyser couldn't tell which one is + /// meant, as `signum` in the body of `fn signs(xs) => xs.map(signum)` + /// compiled for arguments of any type. + Overloads(Rc<[Self]>), } +/// The type of a [`Function::Overloads`], which takes what one of its +/// overloads takes and returns what it returns. +static ANY_FUNCTION: std::sync::LazyLock = + std::sync::LazyLock::new(|| StaticType::Function { + parameters: None, + return_type: Box::new(StaticType::Any), + }); + /// How a function fits a function type, from the function's own type, /// which leaves out what isn't known as the program runs. #[derive(Debug, PartialEq, Eq)] @@ -208,7 +223,7 @@ impl Function { Self::Memoized { function, .. } | Self::Checked { function, .. } => { function.prototype() } - Self::Native(_) => None, + Self::Native(_) | Self::Overloads(_) => None, } } @@ -228,6 +243,7 @@ impl Function { Self::Native(f) => Some(&f.name), Self::Closure(c) => c.prototype.name.as_deref(), Self::Memoized { function, .. } | Self::Checked { function, .. } => function.name(), + Self::Overloads(candidates) => candidates.first().and_then(Self::name), } } @@ -244,6 +260,7 @@ impl Function { Self::Closure(c) => &c.prototype.static_type, Self::Memoized { function, .. } => function.signature(), Self::Checked { typ, .. } => typ, + Self::Overloads(_) => &ANY_FUNCTION, } } @@ -315,6 +332,11 @@ impl Function { /// element of a container argument against a parameter like /// `List`, which [`Self::matches_value_args`] doesn't. pub fn conforms_value_args(&self, args: &[Value]) -> bool { + if let Self::Overloads(candidates) = self { + return candidates + .iter() + .any(|candidate| candidate.conforms_value_args(args)); + } match self.signature() { StaticType::Function { parameters: Some(params), @@ -336,6 +358,11 @@ impl Function { /// Returns true if this function accepts the given arguments, without /// allocating a `StaticType` for each of them. pub fn matches_value_args(&self, args: &[Value]) -> bool { + if let Self::Overloads(candidates) = self { + return candidates + .iter() + .any(|candidate| candidate.matches_value_args(args)); + } let StaticType::Function { parameters, .. } = self.signature() else { return false; }; @@ -360,6 +387,7 @@ impl fmt::Debug for Function { Self::Closure(closure) => write!(f, "", closure.prototype.name), Self::Memoized { function, .. } => write!(f, "", function), Self::Checked { function, typ } => write!(f, "<{function:?} checked as {typ}>"), + Self::Overloads(candidates) => write!(f, ""), } } } @@ -375,6 +403,10 @@ impl fmt::Display for Function { Self::Closure(closure) => write!(f, "", closure.prototype.name), Self::Memoized { function, .. } => write!(f, ""), Self::Checked { function, .. } => write!(f, "{function}"), + Self::Overloads(candidates) => match candidates.first().and_then(Self::name) { + Some(name) => write!(f, ""), + None => write!(f, ""), + }, } } } @@ -444,7 +476,12 @@ fn could_be(found: &StaticType, expected: &StaticType) -> bool { /// takes `Any` takes anything, and one that returns a `List` returns an /// `Iterable`. fn proves(found: &StaticType, expected: &StaticType) -> bool { - *expected == StaticType::Any || (!has_unknowns(found) && found.fits(expected)) + // A type parameter of a built-in's signature, as `U` in `map`'s + // `Fn(T) -> U`, is whatever the function gives it. + matches!( + expected, + StaticType::Any | StaticType::Generic(_) | StaticType::Var(_) + ) || (!has_unknowns(found) && found.fits(expected)) } /// Whether `typ` leaves something unknown: `Any` or a type parameter. diff --git a/ndc_vm/src/value/mod.rs b/ndc_vm/src/value/mod.rs index 479d70f3..57089961 100644 --- a/ndc_vm/src/value/mod.rs +++ b/ndc_vm/src/value/mod.rs @@ -1126,6 +1126,7 @@ impl PartialEq for Object { (Function::Memoized { cache: a, .. }, Function::Memoized { cache: b, .. }) => { Rc::ptr_eq(a, b) } + (Function::Overloads(a), Function::Overloads(b)) => Rc::ptr_eq(a, b), _ => false, } } @@ -1220,6 +1221,10 @@ impl Hash for Object { Rc::as_ptr(cache).hash(state); } Function::Checked { .. } => unreachable!("`unchecked` strips the checks"), + Function::Overloads(candidates) => { + state.write_u8(5); + Rc::as_ptr(candidates).cast::<()>().hash(state); + } } } Self::OverloadSet { .. } => { diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index 51b5b0b8..f5675b50 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -426,6 +426,10 @@ impl Vm { let data = self.stack.split_off(self.stack.len() - size); self.stack.push(Value::Object(Rc::new(Object::Tuple(data)))); } + OpCode::MakeOverloads(size) => { + let size = *size; + self.make_overloads(size, span)?; + } OpCode::MakeMap { pairs, has_default } => { let (pairs, has_default) = (*pairs, *has_default); let default = if has_default { @@ -726,6 +730,62 @@ impl Vm { } } + /// Replaces the `size` functions on top of the stack with a + /// [`Function::Overloads`] of them. + #[cold] + fn make_overloads(&mut self, size: usize, span: Span) -> Result<(), VmError> { + let candidates = self + .stack + .split_off(self.stack.len() - size) + .into_iter() + .map(|candidate| match candidate { + Value::Object(object) => match object.as_ref() { + Object::Function(function) => Ok(function.clone()), + other => Err(other.static_type()), + }, + other => Err(other.static_type()), + }) + .collect::, _>>() + .map_err(|found| { + VmError::new(format!("Unable to invoke {found} as a function."), span) + })?; + self.stack + .push(Value::function(Function::Overloads(candidates))); + Ok(()) + } + + /// Calls the first of `candidates`, a [`Function::Overloads`], that + /// accepts the arguments on the stack. + #[cold] + fn call_overloads( + &mut self, + candidates: &[Function], + args: usize, + ) -> Result { + let arguments = &self.stack[self.stack.len() - args..]; + let picked = candidates + .iter() + .find(|candidate| candidate.matches_value_args(arguments)) + .or_else(|| { + candidates + .iter() + .find(|candidate| candidate.conforms_value_args(arguments)) + }) + .cloned(); + let Some(picked) = picked else { + let types = arguments + .iter() + .map(|argument| argument.static_type().to_string()) + .collect::>() + .join(", "); + let name = candidates.first().and_then(Function::name).unwrap_or("?"); + return Err(VmError::native(format!( + "no function called '{name}' found matches the arguments: ({types})" + ))); + }; + self.dispatch_call(picked, args) + } + /// Inner helper that pushes a new call frame. `memo` is `Some` when the /// call originates from a memoized function and the return value should be /// stored in the cache. @@ -765,6 +825,7 @@ impl Vm { upvalues: Vec::new(), }, Function::Checked { function, typ } => return self.call_checked(function, *typ, args), + Function::Overloads(candidates) => return self.call_overloads(&candidates, args), Function::Memoized { .. } => { // Already unwrapped in dispatch_call; this branch is unreachable. unreachable!( diff --git a/tests/functional/programs/005_functions/099_an_overloaded_function_value_in_a_generic_function_is_picked_for_each_call.ndc b/tests/functional/programs/005_functions/099_an_overloaded_function_value_in_a_generic_function_is_picked_for_each_call.ndc new file mode 100644 index 00000000..f502cfd1 --- /dev/null +++ b/tests/functional/programs/005_functions/099_an_overloaded_function_value_in_a_generic_function_is_picked_for_each_call.ndc @@ -0,0 +1,8 @@ +// Each call of a generic function picks the overload a function value in its +// body means, from that call's types. +// expect-output: [1,-1] [1.0,-1.0] +fn signs(ns) { + let diffs = ns.pairwise(fn(a, b) { b - a }); + diffs.map(signum) +} +print(signs([1, 3, 2]), signs([1.0, 2.5, 0.5])); diff --git a/tests/functional/programs/005_functions/100_a_function_value_left_to_the_program_takes_the_most_specific_overload.ndc b/tests/functional/programs/005_functions/100_a_function_value_left_to_the_program_takes_the_most_specific_overload.ndc new file mode 100644 index 00000000..a13d6245 --- /dev/null +++ b/tests/functional/programs/005_functions/100_a_function_value_left_to_the_program_takes_the_most_specific_overload.ndc @@ -0,0 +1,9 @@ +// A generic function used as a value runs its body compiled for any +// arguments, which leaves the function value in it to the program: it takes +// the most specific overload that accepts the argument, as a call would. +// expect-output: int +fn choose(x: Int) => "int"; +fn choose(x: Any) => "any"; +fn apply(x) { let g = choose; g(x) } +let h: Fn(Int) -> String = apply; +print(h(1)); diff --git a/tests/functional/programs/005_functions/101_a_function_value_left_to_the_program_works_as_a_callback.ndc b/tests/functional/programs/005_functions/101_a_function_value_left_to_the_program_works_as_a_callback.ndc new file mode 100644 index 00000000..c3dd7d5e --- /dev/null +++ b/tests/functional/programs/005_functions/101_a_function_value_left_to_the_program_works_as_a_callback.ndc @@ -0,0 +1,7 @@ +// A generic function used as a value runs its body compiled for any +// arguments, where `signum` is left to the program to pick for each element +// `map` passes it. +// expect-output: [1,-1] +fn signs(xs) => xs.map(signum); +let f: Fn(List) -> List = signs; +print(f([1, -2])); diff --git a/tests/functional/programs/005_functions/102_a_function_value_left_to_the_program_keeps_its_local_overloads.ndc b/tests/functional/programs/005_functions/102_a_function_value_left_to_the_program_keeps_its_local_overloads.ndc new file mode 100644 index 00000000..826db386 --- /dev/null +++ b/tests/functional/programs/005_functions/102_a_function_value_left_to_the_program_keeps_its_local_overloads.ndc @@ -0,0 +1,12 @@ +// A function value whose overload the program picks holds the overloads it +// was made from, so it works where it's called, outside their scope. +// expect-output: int +fn make(x) { + fn choose(y: Int) => "int"; + fn choose(y: Float) => "float"; + let g = choose; + fn() => g(x) +} +let m: Fn(Int) -> Fn() -> String = make; +let h = m(1); +print(h()); diff --git a/tests/functional/programs/005_functions/103_a_function_value_left_to_the_program_is_checked_as_a_function_type.ndc b/tests/functional/programs/005_functions/103_a_function_value_left_to_the_program_is_checked_as_a_function_type.ndc new file mode 100644 index 00000000..cb958cbc --- /dev/null +++ b/tests/functional/programs/005_functions/103_a_function_value_left_to_the_program_is_checked_as_a_function_type.ndc @@ -0,0 +1,12 @@ +// A function value whose overload the program picks, used as a function +// type through `Any`, gets its calls checked like any function. +// expect-error: expected the function to return String but it returned Int +fn bad(x) { + let g = abs; + g(x); + let a: Any = g; + let b: Fn(Int) -> String = a; + b(1) +} +let h: Fn(Int) -> String = bad; +print(h(1)); diff --git a/tests/functional/programs/005_functions/104_a_function_value_left_to_the_program_can_be_a_pure_function_argument.ndc b/tests/functional/programs/005_functions/104_a_function_value_left_to_the_program_can_be_a_pure_function_argument.ndc new file mode 100644 index 00000000..8884fc78 --- /dev/null +++ b/tests/functional/programs/005_functions/104_a_function_value_left_to_the_program_can_be_a_pure_function_argument.ndc @@ -0,0 +1,7 @@ +// A `pure fn` caches by its arguments, which can be a function value whose +// overload the program picks. +// expect-output: 1 +pure fn invoke(g, x) => g(x); +fn apply(x) => invoke(abs, x); +let h: Fn(Int) -> Int = apply; +print(h(1)); diff --git a/tests/functional/programs/019_iterable/017_a_literal_passed_for_an_iterable_is_the_sequence_it_holds.ndc b/tests/functional/programs/019_iterable/017_a_literal_passed_for_an_iterable_is_the_sequence_it_holds.ndc new file mode 100644 index 00000000..ab4e08fb --- /dev/null +++ b/tests/functional/programs/019_iterable/017_a_literal_passed_for_an_iterable_is_the_sequence_it_holds.ndc @@ -0,0 +1,7 @@ +// A list literal passed where any sequence is expected is still a list, so +// `++` takes it afterwards. +// expect-output: ["A","A"] +let t = ("A", ["A"]); +let cur, path = t; +let n = path.len; +print(path ++ [cur]); diff --git a/tests/functional/programs/603_stdlib_seq/028_first_and_last_take_a_tuple.ndc b/tests/functional/programs/603_stdlib_seq/028_first_and_last_take_a_tuple.ndc new file mode 100644 index 00000000..e4b348cc --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/028_first_and_last_take_a_tuple.ndc @@ -0,0 +1,4 @@ +// `first` and `last` take a tuple, and give its first or last element's type. +// expect-output: 2 3.0 [(-1,0)] +let t = (1, "a", 2.5); +print(t.first + 1, t.last + 0.5, [((-1, 0), true), ((1, 0), false)].filter(fn(x) => x[1]).map(first)); diff --git a/tests/functional/programs/603_stdlib_seq/029_tuples_up_to_twelve_elements_are_typed.ndc b/tests/functional/programs/603_stdlib_seq/029_tuples_up_to_twelve_elements_are_typed.ndc new file mode 100644 index 00000000..73625026 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/029_tuples_up_to_twelve_elements_are_typed.ndc @@ -0,0 +1,5 @@ +// `first`, `last` and `zip` are typed for tuples of up to twelve elements. +// expect-output: 2 z! [(1,2,3,4,5)] +let t = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, "z"); +let zipped: List<(Int, Int, Int, Int, Int)> = zip([1], [2], [3], [4], [5]); +print(t.first + 1, t.last ++ "!", zipped); From 56086aaa207c4ff5a5c933e24718c81f1a9fad15 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 22:04:52 +0100 Subject: [PATCH 36/39] =?UTF-8?q?fix:=20review=20findings=20that=20don't?= =?UTF-8?q?=20involve=20Any,=20and=20simplifications=20=F0=9F=A7=B9=20(#34?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the type-checker branch (PR #291) found these, all independent of how `Any` works (which is being redesigned separately, #331). Bug fixes: - A `pure fn` shared one cache between `1` and `1.0`, which are equal, so `same(1.0)` returned the cached Int: each set of argument types of a `pure fn` now gets an instance, and a cache, of its own. - A tuple whose elements still waited on a literal iterated as `Any`, so `(x - 1, y + 1).map(abs)` was rejected while the list form worked. A single-overload call now waits while a sequence argument waits to iterate. - A later call could take a nested function away from an earlier one: in `fn run2() { fn d(x) => "inner"; let r = d(1); d(2.5); r }`, `d(1)` ended up calling an outer `fn d(x: Int)`. When the nearest scope that takes a call has one overload whose parameters take whatever its literals become, the call commits to it. - A range's bounds weren't required to be Ints, so `let xs = [0.5, n]` could turn `n` into a Float and `0..n` failed as the program ran. The bounds are now Ints, and a Float or Number bound is an analysis error. - `xs[1..3] = 5..7` passed analysis and failed as the program ran: a list slice now takes any sequence, as its signature says. Simplifications (from the review, −84 lines): delete the unused `StaticType::with_element_type` and a slice branch of `validate_assignment_write` that could never run, replace `TypeVars::try_settle` with `try_unify`, define `held_literal` as the first of `held_literals`, and give a value callee the same checks path as other calls. Analysis of the large benchmark files is 2–3% slower, from the tuple fix's extra check per sequence parameter; the VM is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/assignments.rs | 23 +-------- ndc_analyser/src/analyser/calls.rs | 21 +++----- ndc_analyser/src/analyser/declarations.rs | 5 +- ndc_analyser/src/analyser/generics.rs | 12 +++++ ndc_analyser/src/analyser/mod.rs | 12 +++-- ndc_analyser/src/analyser/solver.rs | 49 ++++++++++++++++++- ndc_analyser/src/types.rs | 36 +++----------- ndc_core/src/static_type.rs | 19 ------- ndc_stdlib/src/index.rs | 29 +++++------ ...es_a_literal_call_from_one_further_out.ndc | 7 +++ ...r_other_arguments_before_taking_a_call.ndc | 9 ++++ .../008_iterators/007_range_bound_types.ndc | 6 +-- .../008_a_range_bound_is_an_int.ndc | 10 ++++ .../013_a_list_slice_takes_any_sequence.ndc | 7 +++ ...ure_fn_caches_each_argument_type_apart.ndc | 7 +++ ...ting_on_a_literal_iterates_as_its_type.ndc | 6 +++ .../998_not_desired/big_int_ranges.ndc | 2 +- 17 files changed, 150 insertions(+), 110 deletions(-) create mode 100644 tests/functional/programs/005_functions/105_a_nested_function_takes_a_literal_call_from_one_further_out.ndc create mode 100644 tests/functional/programs/005_functions/106_a_nested_function_waits_for_other_arguments_before_taking_a_call.ndc create mode 100644 tests/functional/programs/008_iterators/008_a_range_bound_is_an_int.ndc create mode 100644 tests/functional/programs/009_slicing/013_a_list_slice_takes_any_sequence.ndc create mode 100644 tests/functional/programs/010_memoization/006_a_pure_fn_caches_each_argument_type_apart.ndc create mode 100644 tests/functional/programs/020_tuples/004_a_tuple_of_values_still_waiting_on_a_literal_iterates_as_its_type.ndc diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index a2f2b0a1..13d0abb8 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -296,30 +296,9 @@ impl Analyser { { self.check_write(stored_type, value_type, span, Some(lvalue.id)); } - AssignmentTarget::Member { .. } => { + AssignmentTarget::Member { .. } | AssignmentTarget::Index { .. } => { self.check_write(stored_type, value_type, span, Some(lvalue.id)); } - AssignmentTarget::Index { index, .. } => { - if self.accepts_write(stored_type, value_type, Some(lvalue.id)) { - return; - } - // `xs[0..2] = (1, 2)` stores the tuple's elements. - let is_slice = self - .result - .analysis - .expr_types - .get(&index.id) - .is_some_and(Self::index_type_is_slice); - if is_slice - && let Some(stored_element) = - self.vars.resolve(stored_type).index_element_type() - && let Some(value_element) = value_type.sequence_element_type() - && self.accepts_write(&stored_element, &value_element, None) - { - return; - } - self.emit_rejected_write(stored_type, value_type, span); - } // Each target gets the element unpacked for it, as in a `let`. AssignmentTarget::Sequence(targets) => { let StaticType::Tuple(stored_types) = stored_type else { diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 18c9b38c..1623e6db 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -1,5 +1,5 @@ use super::Analyser; -use super::solver::{CallNodes, CallSite, Constraint}; +use super::solver::{CallNodes, CallSite}; use crate::error::AnalysisError; use crate::overloads::{CallKind, Overloads}; use itertools::Itertools; @@ -124,20 +124,11 @@ impl Analyser { .. } => { if let Some((typ, parameters)) = self.bind_signature(&callee, &arguments) { - for (node, expected) in - self.expected_of_any(¶meters, &arguments, &nodes) - { - self.expect(node, expected); - } - for (sequence, element) in - self.iterations_waited_for(¶meters, &arguments) - { - self.wait(Constraint::Iterate { - sequence, - element, - span, - }); - } + let checks = self.expected_of_any(¶meters, &arguments, &nodes); + self.pending.checks.extend(checks); + let iterations = self.iterations_waited_for(¶meters, &arguments); + self.pending.iterations.extend(iterations); + self.flush_copies(None, span); typ } else { let arguments = arguments diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 295c3a24..8d205185 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -20,6 +20,8 @@ pub(super) struct FunctionDeclaration<'a> { pub parameters: &'a [FunctionParameter], pub body: &'a ExpressionLocation, pub return_annotation: Option<&'a TypeExpr>, + /// Whether it's a `pure fn`, whose results are cached. + pub pure: bool, pub span: Span, } @@ -40,6 +42,7 @@ impl Analyser { parameters, body, return_annotation, + pure, span, } = function; let declared = name.map(|name| (name, self.resolver.declared_symbol(id))); @@ -164,7 +167,7 @@ impl Analyser { self.vars.leave_level(); self.declaring = None; self.declared_in_batch.insert(symbol); - let typ = self.generalize(symbol, &signature, id, [first_use, first_check]); + let typ = self.generalize(symbol, &signature, id, pure, [first_use, first_check]); self.within_size_limit(symbol, name, typ, span) } _ => signature, diff --git a/ndc_analyser/src/analyser/generics.rs b/ndc_analyser/src/analyser/generics.rs index a8b50549..638ced00 100644 --- a/ndc_analyser/src/analyser/generics.rs +++ b/ndc_analyser/src/analyser/generics.rs @@ -40,6 +40,7 @@ impl Analyser { symbol: SymbolId, signature: &StaticType, declaration: NodeId, + pure: bool, [first_use, first_check]: [usize; 2], ) -> StaticType { self.solve_waiting(); @@ -157,12 +158,23 @@ impl Analyser { // An instance is for the types of the type parameters that what it // compiles differently depends on: the constraints and uses it // picks for, and the checks. + // A `pure fn` caches its results by its arguments' values, which can + // be equal across types, as `1` and `1.0` are, so each set of types + // gets an instance, and a cache, of its own. + let parameters = match self.vars.resolve(signature) { + StaticType::Function { + parameters: Some(parameters), + .. + } if pure => parameters, + _ => Vec::new(), + }; let mut key: Vec> = Vec::new(); for typ in scheme .iter() .flat_map(|waiting| waiting.constraint.inputs()) .chain(uses.iter().flat_map(|generic_use| &generic_use.key)) .chain(checks.iter().map(|(_, _, expected)| expected)) + .chain(¶meters) { for name in typ.type_parameters() { if !key.contains(&name) { diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index b708765c..73577587 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -489,6 +489,7 @@ impl Analyser { parameters, body, return_annotation, + pure, .. } => self.analyse_function_declaration(FunctionDeclaration { id: *id, @@ -496,6 +497,7 @@ impl Analyser { parameters, body, return_annotation: return_annotation.as_ref(), + pure: *pure, span: *span, }), Expression::Block { statements } => { @@ -667,11 +669,11 @@ impl Analyser { } Expression::RangeInclusive { start, end } | Expression::RangeExclusive { start, end } => { - if let Some(start) = start { - self.analyse_expr(start); - } - if let Some(end) = end { - self.analyse_expr(end); + // A range counts in Ints, so its bounds are Ints: in `0..n`, + // `n` is one, whatever else it's used for. + for bound in [start, end].into_iter().flatten() { + let typ = self.analyse_expr(bound); + self.check_write(&StaticType::Int, &typ, bound.span, Some(bound.id)); } StaticType::Iterator(Box::new(StaticType::Int)) diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 99ac6708..1ff42c29 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -1356,6 +1356,26 @@ impl Analyser { .flat_map(|argument| self.vars.unsettled(argument)) .filter(|&var| self.vars.is_literal(var)) .collect::>(); + // The nearest scope with an overload that takes the call hides those + // further out. When that scope has one overload, whose parameters + // take whatever the literals turn out to be, as a nested `fn d(x)` + // does, the call is its, though a literal isn't decided yet. + if !literals.is_empty() + && !Self::operand_could_be_a_tuple(kind, arguments) + && let Some(nearest) = self + .overloads() + .reachable_groups(candidates) + .into_iter() + .find(|group| { + group + .iter() + .any(|&symbol| self.could_accept(symbol, arguments)) + }) + && let [only] = nearest[..] + && self.takes_any_literal(only, arguments) + { + return EarlyOverload::Only(only); + } let viable = reachable .iter() .copied() @@ -1396,6 +1416,31 @@ impl Analyser { } } + /// Whether `symbol`'s parameter for each argument with a literal in it + /// takes any type, as an unannotated one does, and the other arguments + /// are known, so nothing still to come can change whether `symbol` takes + /// the call. + fn takes_any_literal(&self, symbol: SymbolId, arguments: &[StaticType]) -> bool { + let Some(parameters) = self.overloads().parameters_of(symbol) else { + return false; + }; + parameters.len() == arguments.len() + && parameters + .iter() + .zip(arguments) + .all(|(parameter, argument)| { + let unsettled = self.vars.unsettled(argument); + if unsettled.iter().all(|&var| !self.vars.is_literal(var)) { + return unsettled.is_empty(); + } + match self.vars.resolve(parameter) { + StaticType::Var(var) => !self.vars.is_literal(var), + StaticType::Generic(_) => true, + _ => false, + } + }) + } + /// Whether one of `reachable` other than `except` takes `arguments` /// once each literal among them is `Any`. fn fits_once_any( @@ -1955,8 +2000,8 @@ impl Analyser { .zip(arguments) .all(|(parameter, argument)| match (parameter, argument) { (StaticType::Any, argument) => *argument == StaticType::Any, - (StaticType::Iterable(_), StaticType::Var(var)) => { - self.vars.is_literal(*var) + (StaticType::Iterable(_), argument) => { + !self.vars.waits_to_iterate(argument) } _ => true, }) diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index e49612ce..e62efb8c 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -135,15 +135,7 @@ impl TypeVars { /// A literal whose type the literal type `var` holds values of and that /// isn't settled yet, as `1`'s is in `[1]`. pub(crate) fn held_literal(&self, var: TypeVar) -> Option { - self.bounds[var.0 as usize] - .iter() - .find_map(|bound| match bound { - Bound::Holds(held, _, _) => match self.resolve(held) { - StaticType::Var(held) if held != var && self.is_literal(held) => Some(held), - _ => None, - }, - Bound::Number(..) => None, - }) + self.held_literals(var).into_iter().next() } /// The unsettled literal types that each unsettled literal type is @@ -366,28 +358,13 @@ impl TypeVars { return true; }; let snapshot = self.snapshot(); - let fits = defaults.iter().any(|default| self.try_settle(var, default)); + let fits = defaults + .iter() + .any(|default| self.try_unify(&StaticType::Var(var), default)); self.rollback(snapshot); fits } - /// Settles `var` as `typ` if `typ` is within its bounds, and returns - /// whether it did. A variable can't stand for a type that contains it. - fn try_settle(&mut self, var: TypeVar, typ: &StaticType) -> bool { - if self - .resolve(typ) - .contains(&|part| *part == StaticType::Var(var)) - { - return false; - } - let snapshot = self.snapshot(); - let settled = self.settle(var, typ.clone()); - if !settled { - self.rollback(snapshot); - } - settled - } - /// What the unsettled literal type `var` is shown as: what it would be /// if nothing said otherwise, as far as that's known. pub(crate) fn preview(&self, var: TypeVar) -> StaticType { @@ -641,7 +618,10 @@ impl TypeVars { Some(defaults) if !self.bounds[var.0 as usize].is_empty() => defaults, _ => vec![self.fallbacks[var.0 as usize].clone()], }; - if !defaults.iter().any(|default| self.try_settle(var, default)) { + if !defaults + .iter() + .any(|default| self.try_unify(&StaticType::Var(var), default)) + { // `Any` takes anything a literal holds. self.settle(var, StaticType::Any); } diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 4b7dbd8a..ec20a299 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -570,25 +570,6 @@ impl StaticType { } } - /// Returns a new type with the element type replaced. For container types - /// like `List`, this returns `List`. Returns `None` if the - /// type does not have a replaceable element type. - pub fn with_element_type(&self, new_elem: Self) -> Self { - match self { - Self::List(_) => Self::List(Box::new(new_elem)), - Self::Iterable(_) => Self::Iterable(Box::new(new_elem)), - Self::Iterator(_) => Self::Iterator(Box::new(new_elem)), - Self::MinHeap(_) => Self::MinHeap(Box::new(new_elem)), - Self::MaxHeap(_) => Self::MaxHeap(Box::new(new_elem)), - Self::Deque(_) => Self::Deque(Box::new(new_elem)), - Self::Map { key, .. } => Self::Map { - key: key.clone(), - value: Box::new(new_elem), - }, - _ => self.clone(), - } - } - pub fn index_element_type(&self) -> Option { if let Self::Map { value, .. } = self { return Some(value.as_ref().clone()); diff --git a/ndc_stdlib/src/index.rs b/ndc_stdlib/src/index.rs index f0381901..98b29ae6 100644 --- a/ndc_stdlib/src/index.rs +++ b/ndc_stdlib/src/index.rs @@ -455,21 +455,22 @@ fn vm_set_at_index(container: &Value, index_value: &Value, rhs: Value) -> Result list[idx] = rhs; } VmOffset::Range(from, to) => { - let rhs_vec = match rhs { - Value::Object(o) => match o.as_ref() { - Object::List(l) => l.borrow().clone(), - Object::Tuple(t) => t.clone(), - _ => { - return Err(VmError::native( - "cannot assign non-list to list slice", - )); - } - }, - _ => { - return Err(VmError::native( - "cannot assign non-list to list slice", - )); + // Any sequence goes in a slice: a list, a tuple, a + // range or another iterator. + let rhs_vec = match &rhs { + Value::Object(o) if matches!(o.as_ref(), Object::List(_)) => { + let Object::List(l) = o.as_ref() else { + unreachable!("matched a list"); + }; + l.borrow().clone() } + _ => rhs + .clone() + .try_into_iter() + .ok_or_else(|| { + VmError::native("cannot assign non-sequence to list slice") + })? + .collect(), }; let tail: Vec = list.drain(from..).collect(); list.extend(rhs_vec); diff --git a/tests/functional/programs/005_functions/105_a_nested_function_takes_a_literal_call_from_one_further_out.ndc b/tests/functional/programs/005_functions/105_a_nested_function_takes_a_literal_call_from_one_further_out.ndc new file mode 100644 index 00000000..7e528d86 --- /dev/null +++ b/tests/functional/programs/005_functions/105_a_nested_function_takes_a_literal_call_from_one_further_out.ndc @@ -0,0 +1,7 @@ +// The nearest scope with a function that takes a call is the call's, so a +// later call with another type can't hand an earlier one to an outer `d`. +// expect-output: inner inner +fn d(x: Int) => "outer"; +fn run() { fn d(x) => "inner"; d(1) } +fn run2() { fn d(x) => "inner"; let r = d(1); d(2.5); r } +print(run(), run2()); diff --git a/tests/functional/programs/005_functions/106_a_nested_function_waits_for_other_arguments_before_taking_a_call.ndc b/tests/functional/programs/005_functions/106_a_nested_function_waits_for_other_arguments_before_taking_a_call.ndc new file mode 100644 index 00000000..66d32244 --- /dev/null +++ b/tests/functional/programs/005_functions/106_a_nested_function_waits_for_other_arguments_before_taking_a_call.ndc @@ -0,0 +1,9 @@ +// A call whose other arguments aren't known yet waits for them before the +// nearest scope takes it: `y` may make the outer `d` the one that fits. +// expect-output: outer +fn d(x: Int, y: String) => "outer"; +fn run(y) { + fn d(x, y: Int) => "inner"; + d(1, y) +} +print(run("s")); diff --git a/tests/functional/programs/008_iterators/007_range_bound_types.ndc b/tests/functional/programs/008_iterators/007_range_bound_types.ndc index 7cf89390..cc74bbb0 100644 --- a/tests/functional/programs/008_iterators/007_range_bound_types.ndc +++ b/tests/functional/programs/008_iterators/007_range_bound_types.ndc @@ -1,4 +1,4 @@ -// Every non-Int bound used to claim the integer was too large. Only an -// integer that genuinely does not fit is a size problem. -// expect-error: range bounds must be Int, got Number +// Every non-Int bound used to claim the integer was too large. A range +// counts in Ints, so a Number bound is reported before the program runs. +// expect-error: mismatched types: found Number but expected Int for i in 0n..3n { print(i); } diff --git a/tests/functional/programs/008_iterators/008_a_range_bound_is_an_int.ndc b/tests/functional/programs/008_iterators/008_a_range_bound_is_an_int.ndc new file mode 100644 index 00000000..e4b80020 --- /dev/null +++ b/tests/functional/programs/008_iterators/008_a_range_bound_is_an_int.ndc @@ -0,0 +1,10 @@ +// A range counts in Ints, so a literal used as its bound is an Int, and a +// list mixing it with a Float holds `Any`s. +// expect-output: 3 [0.5,3] +let n = 3; +let xs = [0.5, n]; +let total = 0; +for i in 0..n { + total += i; +} +print(total, xs); diff --git a/tests/functional/programs/009_slicing/013_a_list_slice_takes_any_sequence.ndc b/tests/functional/programs/009_slicing/013_a_list_slice_takes_any_sequence.ndc new file mode 100644 index 00000000..98288bb9 --- /dev/null +++ b/tests/functional/programs/009_slicing/013_a_list_slice_takes_any_sequence.ndc @@ -0,0 +1,7 @@ +// A list slice can be assigned any sequence of its element type. +// expect-output: [1,5,6] [8,9,3] +let xs = [1, 2, 3]; +xs[1..3] = 5..7; +let ys = [1, 2, 3]; +ys[0..2] = (8, 9); +print(xs, ys); diff --git a/tests/functional/programs/010_memoization/006_a_pure_fn_caches_each_argument_type_apart.ndc b/tests/functional/programs/010_memoization/006_a_pure_fn_caches_each_argument_type_apart.ndc new file mode 100644 index 00000000..2e0ea672 --- /dev/null +++ b/tests/functional/programs/010_memoization/006_a_pure_fn_caches_each_argument_type_apart.ndc @@ -0,0 +1,7 @@ +// A `pure fn` caches by its arguments, and `1` and `1.0` are equal, so each +// argument type gets a cache of its own. +// expect-output: 1 1.5 +pure fn same(x) => x; +let n = same(1); +let f = same(1.0); +print(n, f + 0.5); diff --git a/tests/functional/programs/020_tuples/004_a_tuple_of_values_still_waiting_on_a_literal_iterates_as_its_type.ndc b/tests/functional/programs/020_tuples/004_a_tuple_of_values_still_waiting_on_a_literal_iterates_as_its_type.ndc new file mode 100644 index 00000000..01d972de --- /dev/null +++ b/tests/functional/programs/020_tuples/004_a_tuple_of_values_still_waiting_on_a_literal_iterates_as_its_type.ndc @@ -0,0 +1,6 @@ +// A tuple whose elements wait for a literal's type iterates as that type +// once it's known, as a list does. +// expect-output: [2,5] [2,5] +let x = 3; +let y = 4; +print((x - 1, y + 1).map(abs), [x - 1, y + 1].map(abs)); diff --git a/tests/functional/programs/998_not_desired/big_int_ranges.ndc b/tests/functional/programs/998_not_desired/big_int_ranges.ndc index 75c9a00c..f75e7949 100644 --- a/tests/functional/programs/998_not_desired/big_int_ranges.ndc +++ b/tests/functional/programs/998_not_desired/big_int_ranges.ndc @@ -1,4 +1,4 @@ -// expect-error: Integer too large for range bounds +// expect-error: mismatched types: found Number but expected Int // Not sure what to think of this one, but right now you cannot construct ranges with ints outside of the i64 range // it does make type analysis a tiny bit easier though (2n^1024n)..((2n^1024n)+1n) From 57592fd08b68f1a8410d86d05fda0c3c94349b11 Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 22:13:16 +0100 Subject: [PATCH 37/39] =?UTF-8?q?feat(stdlib):=20add=20panic,=20and=20type?= =?UTF-8?q?=20combinations,=20permutations=20and=20find=5Fsubsequence=20?= =?UTF-8?q?=F0=9F=93=90=20(#346)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for making `Any` explicit (#331): these built-ins gave `Any` that nobody wrote, which would need a cast at every use once `Any` only comes out through `as`. - `panic(message)` stops the program with the message and has type `Never`, so it can end a branch whose other branches give a value: `if c { 1 } else { panic("no such thing") }` is an `Int`. `assert(false)` can't do this, as `assert` returns when its condition holds. - `combinations(xs, k)` and `permutations(xs, k)` now give lists rather than tuples, as `k` is only known when the program runs: `Iterator>` and `List>` instead of `Any`. Destructuring them works as before; they print as `[1,2]` rather than `(1,2)`. On the advent-of-brian day 8 solution, nothing in the pair loop is picked as the program runs any more, which made part 2 about a third faster. - `find_subsequence` returned the index or `()`, typed `Any`. It now returns an `Int` and fails when the subsequence isn't there, and the new `find_subsequence?` returns an `Option`, like `first`/`first?`. An empty subsequence is found at 0 instead of panicking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- ndc_stdlib/src/cmp.rs | 7 ++++ ndc_stdlib/src/list.rs | 39 +++++++++++++------ ndc_stdlib/src/sequence.rs | 10 ++--- ndc_vm/src/iterator.rs | 4 +- .../005_functions/107_panic_never_returns.ndc | 8 ++++ .../programs/008_iterators/005_take.ndc | 2 +- .../603_stdlib_seq/011_combinations.ndc | 8 ++-- ...tions_and_permutations_are_typed_lists.ndc | 4 ++ ..._find_subsequence_has_an_optional_form.ndc | 4 ++ .../bug0022_combinations_lazy_source.ndc | 18 ++++----- .../900_bugs/bug0027_combinations_huge_k.ndc | 2 +- 11 files changed, 72 insertions(+), 34 deletions(-) create mode 100644 tests/functional/programs/005_functions/107_panic_never_returns.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/030_combinations_and_permutations_are_typed_lists.ndc create mode 100644 tests/functional/programs/603_stdlib_seq/031_find_subsequence_has_an_optional_form.ndc diff --git a/ndc_stdlib/src/cmp.rs b/ndc_stdlib/src/cmp.rs index 0f66e369..8d1e2c86 100644 --- a/ndc_stdlib/src/cmp.rs +++ b/ndc_stdlib/src/cmp.rs @@ -16,6 +16,13 @@ mod inner { } } + /// Stops the program with the error `message`. It never returns, so it + /// can end a branch whose other branches give a value. + #[function(signature = "(String) -> Never")] + pub fn panic(message: &str) -> anyhow::Result { + Err(anyhow!(message.to_string())) + } + /// Produces an error if the arguments aren't equal to each other. #[function(signature = "(A, B)")] pub fn assert_eq(left: Value, right: Value) -> anyhow::Result<()> { diff --git a/ndc_stdlib/src/list.rs b/ndc_stdlib/src/list.rs index 6d847b30..eff81f22 100644 --- a/ndc_stdlib/src/list.rs +++ b/ndc_stdlib/src/list.rs @@ -30,18 +30,23 @@ mod inner { list.windows(subsequence.len()).contains(&subsequence) } - /// Returns the starting index of the first occurrence of `subsequence` in `list`, or unit if not found. - #[function(signature = "(List, List) -> Any")] - pub fn find_subsequence(list: &[Value], subsequence: &[Value]) -> Value { - let result = list - .windows(subsequence.len()) - .enumerate() - .find(|(_, seq)| *seq == subsequence) - .map(|(idx, _)| idx); - if let Some(result) = result { - Value::Int(result as i64) - } else { - Value::unit() + /// Returns the starting index of the first occurrence of `subsequence` in `list`, or produces an error if there is none. + #[function(signature = "(List, List) -> Int")] + pub fn find_subsequence(list: &[Value], subsequence: &[Value]) -> anyhow::Result { + subsequence_start(list, subsequence) + .map(|idx| Value::Int(idx as i64)) + .ok_or_else(|| anyhow!("the list doesn't contain the subsequence")) + } + + /// Returns the starting index of the first occurrence of `subsequence` in `list`, or `None` if there is none. + #[function( + name = "find_subsequence?", + signature = "(List, List) -> Option" + )] + pub fn maybe_find_subsequence(list: &[Value], subsequence: &[Value]) -> Value { + match subsequence_start(list, subsequence) { + None => Value::None, + Some(idx) => Value::Object(Rc::new(Object::Some(Value::Int(idx as i64)))), } } @@ -390,3 +395,13 @@ pub mod ops { env.declare_global_fn(native); } } + +/// Where `subsequence` first starts in `list`, if it's in it. +fn subsequence_start(list: &[Value], subsequence: &[Value]) -> Option { + // An empty subsequence starts anywhere, and `windows` can't be empty. + if subsequence.is_empty() { + return Some(0); + } + list.windows(subsequence.len()) + .position(|window| window == subsequence) +} diff --git a/ndc_stdlib/src/sequence.rs b/ndc_stdlib/src/sequence.rs index c6210fe7..6d5c74aa 100644 --- a/ndc_stdlib/src/sequence.rs +++ b/ndc_stdlib/src/sequence.rs @@ -603,8 +603,8 @@ mod inner { default.call(&[]).map_err(|e| anyhow!(e)) } - /// Returns the `k` sized combinations of the given sequence `seq` as a lazy iterator of tuples. - #[function(signature = "(Iterable, Int) -> Iterator")] + /// Returns the `k` sized combinations of the given sequence `seq` as a lazy iterator of lists. + #[function(signature = "(Iterable, Int) -> Iterator>")] pub fn combinations(seq: SeqValue, k: usize) -> anyhow::Result { let iter = CombinationsIter::new(seq, k) .ok_or_else(|| anyhow!("combinations requires a sequence"))?; @@ -618,14 +618,14 @@ mod inner { Ok(Value::iterator(iter.into_shared())) } - /// Returns the `k` sized permutations of the given sequence `seq` as a list of tuples. - #[function(signature = "(Iterable, Int) -> List")] + /// Returns the `k` sized permutations of the given sequence `seq` as a list of lists. + #[function(signature = "(Iterable, Int) -> List>")] pub fn permutations(seq: SeqValue, k: usize) -> anyhow::Result { Ok(Value::list( seq.try_into_iter() .ok_or_else(|| anyhow!("permutations requires a sequence"))? .permutations(k) - .map(Value::tuple) + .map(Value::list) .collect::>(), )) } diff --git a/ndc_vm/src/iterator.rs b/ndc_vm/src/iterator.rs index 5ba66c7c..e2a3e20f 100644 --- a/ndc_vm/src/iterator.rs +++ b/ndc_vm/src/iterator.rs @@ -457,7 +457,7 @@ impl VmIterator for CombinationsIter { if k == 0 { return if self.first { self.first = false; - Some(Value::tuple(vec![])) + Some(Value::list(vec![])) } else { None }; @@ -504,7 +504,7 @@ impl VmIterator for CombinationsIter { } // All indices are in-bounds: direct indexing is safe. - Some(Value::tuple( + Some(Value::list( self.indices .iter() .map(|&i| self.buffer[i].clone()) diff --git a/tests/functional/programs/005_functions/107_panic_never_returns.ndc b/tests/functional/programs/005_functions/107_panic_never_returns.ndc new file mode 100644 index 00000000..7e895a75 --- /dev/null +++ b/tests/functional/programs/005_functions/107_panic_never_returns.ndc @@ -0,0 +1,8 @@ +// `panic` stops the program and never returns, so it can end a branch whose +// other branch gives a value. +// expect-error: no such thing +fn pick(c) { + if c { 1 } else { panic("no such thing") } +} +let n: Int = pick(true); +print(n + pick(false)); diff --git a/tests/functional/programs/008_iterators/005_take.ndc b/tests/functional/programs/008_iterators/005_take.ndc index 918946fc..f51ae21c 100644 --- a/tests/functional/programs/008_iterators/005_take.ndc +++ b/tests/functional/programs/008_iterators/005_take.ndc @@ -11,4 +11,4 @@ assert_eq([1, 2].take(10).list(), [1, 2]); assert_eq([1, 2, 3].take(0).list(), []); // Chained: take from a combinations iterator. -assert_eq(combinations([1, 2, 3, 4], 2).take(3).list(), [(1, 2), (1, 3), (1, 4)]); +assert_eq(combinations([1, 2, 3, 4], 2).take(3).list(), [[1, 2], [1, 3], [1, 4]]); diff --git a/tests/functional/programs/603_stdlib_seq/011_combinations.ndc b/tests/functional/programs/603_stdlib_seq/011_combinations.ndc index 8364eb41..63730ab9 100644 --- a/tests/functional/programs/603_stdlib_seq/011_combinations.ndc +++ b/tests/functional/programs/603_stdlib_seq/011_combinations.ndc @@ -1,18 +1,18 @@ // Basic 2-combinations of a list. -assert_eq(combinations([1, 2, 3], 2).list(), [(1, 2), (1, 3), (2, 3)]); +assert_eq(combinations([1, 2, 3], 2).list(), [[1, 2], [1, 3], [2, 3]]); // k=0 yields exactly one empty tuple. -assert_eq(combinations([1, 2], 0).list(), [()]); +assert_eq(combinations([1, 2], 0).list(), [[]]); // k > length yields nothing. assert_eq(combinations([1, 2], 5).list(), []); // Works on a tuple source. -assert_eq(combinations((10, 20, 30), 2).list(), [(10, 20), (10, 30), (20, 30)]); +assert_eq(combinations((10, 20, 30), 2).list(), [[10, 20], [10, 30], [20, 30]]); // Works on a map source (order is unspecified, test length only). assert_eq(combinations(%{"a": 1, "b": 2, "c": 3}, 2).list().len(), 3); // Lazy: take only the first combination from a large range iterator — the pool // is never fully materialised. -assert_eq(combinations(1..100, 2).take(1).list(), [(1, 2)]); +assert_eq(combinations(1..100, 2).take(1).list(), [[1, 2]]); diff --git a/tests/functional/programs/603_stdlib_seq/030_combinations_and_permutations_are_typed_lists.ndc b/tests/functional/programs/603_stdlib_seq/030_combinations_and_permutations_are_typed_lists.ndc new file mode 100644 index 00000000..fad7ecf3 --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/030_combinations_and_permutations_are_typed_lists.ndc @@ -0,0 +1,4 @@ +// Combinations and permutations are lists of the elements, so what they hold +// is typed. +// expect-output: [3,4,5] [3,4,3,5,4,5] +print([a + b for [a, b] in [1, 2, 3].combinations(2)], [p.sum() for p in [1, 2, 3].permutations(2)]); diff --git a/tests/functional/programs/603_stdlib_seq/031_find_subsequence_has_an_optional_form.ndc b/tests/functional/programs/603_stdlib_seq/031_find_subsequence_has_an_optional_form.ndc new file mode 100644 index 00000000..9a7f7b4b --- /dev/null +++ b/tests/functional/programs/603_stdlib_seq/031_find_subsequence_has_an_optional_form.ndc @@ -0,0 +1,4 @@ +// `find_subsequence` gives where a subsequence starts, and `find_subsequence?` +// gives `None` when it isn't there. +// expect-output: 2 Some(0) None +print([1, 2, 3, 4].find_subsequence([3, 4]), [1, 2].find_subsequence?([1]), [1, 2].find_subsequence?([5])); diff --git a/tests/functional/programs/900_bugs/bug0022_combinations_lazy_source.ndc b/tests/functional/programs/900_bugs/bug0022_combinations_lazy_source.ndc index 788304d0..ee98490b 100644 --- a/tests/functional/programs/900_bugs/bug0022_combinations_lazy_source.ndc +++ b/tests/functional/programs/900_bugs/bug0022_combinations_lazy_source.ndc @@ -6,23 +6,23 @@ // some entirely. Fix: pull `indices[k-1] + 1` before computing pivot. // Eager (list) reference — correct for both pre- and post-fix. -assert_eq([1, 2, 3, 4].combinations(2).list(), [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)]); +assert_eq([1, 2, 3, 4].combinations(2).list(), [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]); // Lazy (iterator) source — this is the path the bug lived in. assert_eq( [1, 2, 3, 4].enumerate().combinations(2).list(), [ - ((0, 1), (1, 2)), - ((0, 1), (2, 3)), - ((0, 1), (3, 4)), - ((1, 2), (2, 3)), - ((1, 2), (3, 4)), - ((2, 3), (3, 4)) + [(0, 1), (1, 2)], + [(0, 1), (2, 3)], + [(0, 1), (3, 4)], + [(1, 2), (2, 3)], + [(1, 2), (3, 4)], + [(2, 3), (3, 4)] ] ); // Larger k — exercise the multi-pull-per-pivot path. let triples = [10, 20, 30, 40, 50].enumerate().combinations(3).list(); assert_eq(triples.len, 10); -assert_eq(triples[0], ((0, 10), (1, 20), (2, 30))); -assert_eq(triples[-1], ((2, 30), (3, 40), (4, 50))); +assert_eq(triples[0], [(0, 10), (1, 20), (2, 30)]); +assert_eq(triples[-1], [(2, 30), (3, 40), (4, 50)]); diff --git a/tests/functional/programs/900_bugs/bug0027_combinations_huge_k.ndc b/tests/functional/programs/900_bugs/bug0027_combinations_huge_k.ndc index 44fb00d0..778dc9c4 100644 --- a/tests/functional/programs/900_bugs/bug0027_combinations_huge_k.ndc +++ b/tests/functional/programs/900_bugs/bug0027_combinations_huge_k.ndc @@ -20,4 +20,4 @@ assert_eq(lazy.list(), []); assert_eq(lazy.list(), []); // Valid combinations are still produced correctly. -assert_eq([1, 2, 3, 4].combinations(2).list(), [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)]); +assert_eq([1, 2, 3, 4].combinations(2).list(), [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]); From 1f70dd0501e964d3ec781d52cc6e77730d782a5d Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 22:15:22 +0100 Subject: [PATCH 38/39] =?UTF-8?q?feat:=20test=20a=20value's=20type=20with?= =?UTF-8?q?=20x=20as=3F=20T=20=F0=9F=94=8D=20(#347)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making `Any` explicit (#331) leaves a cast as the only way to get a value out of `Any`, and there was no way to test a value's type: `x as T` stops the program when `x` isn't a `T`. `x as? T` is that test: `Some(x)`, as a `T`, when the value is a `T`, and `None` when it isn't. It has type `Option`. ```ndc let v: Any = 3; if (v as? Int).is_some() { print("an int"); } print([1, "a"] as? List); // None ``` - Lexer, parser, formatter, highlighter and LSP completion know `as?`, which has the precedence of `as` (so `(x as? Int).is_some()` needs its parentheses) and needs a space after it, as `?` can be part of a name. - The analyser checks it as it checks `as`: a cast that can never succeed is an error (it would always be `None`), and one it can prove needs no check. Unlike `as`, it doesn't settle what isn't known about the value: testing `xs as? List` leaves an empty list free to hold Strings. A number written there still takes the type: `1 as? Float` is `Some(1.0)`. - A new `TryCast` instruction converts the value as `as` does, functions included (they get their calls checked), and gives `None` instead of failing. Its body is out of the VM's main loop, so calls run exactly as before (`fib(27)` executes the same number of instructions). The manual's casts page has a section on it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- manual/src/features/casts.md | 58 ++++++++++++++++++- ndc_analyser/src/analyser/mod.rs | 22 ++++++- ndc_fmt/src/format.rs | 12 +++- ndc_fmt/tests/style.rs | 8 +++ ndc_highlight/src/lib.rs | 1 + ndc_lexer/src/token.rs | 3 + ndc_lsp/src/features/completion.rs | 4 +- ndc_parser/src/expression.rs | 3 + ndc_parser/src/parser.rs | 3 +- ndc_vm/src/chunk.rs | 7 +++ ndc_vm/src/compiler.rs | 23 ++++++-- ndc_vm/src/vm.rs | 13 +++++ tests/compiler/tests/compiler.rs | 31 ++++++++++ .../016_casts/026_optional_cast_some.ndc | 4 ++ .../016_casts/027_optional_cast_none.ndc | 4 ++ .../028_optional_cast_containers.ndc | 5 ++ .../016_casts/029_optional_cast_functions.ndc | 6 ++ ...0_optional_cast_checked_function_error.ndc | 6 ++ .../016_casts/031_optional_cast_type_test.ndc | 12 ++++ .../032_impossible_optional_cast_error.ndc | 3 + .../016_casts/033_proven_optional_cast.ndc | 4 ++ ...onal_cast_leaves_the_value_s_type_open.ndc | 7 +++ 22 files changed, 223 insertions(+), 16 deletions(-) create mode 100644 tests/functional/programs/016_casts/026_optional_cast_some.ndc create mode 100644 tests/functional/programs/016_casts/027_optional_cast_none.ndc create mode 100644 tests/functional/programs/016_casts/028_optional_cast_containers.ndc create mode 100644 tests/functional/programs/016_casts/029_optional_cast_functions.ndc create mode 100644 tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc create mode 100644 tests/functional/programs/016_casts/031_optional_cast_type_test.ndc create mode 100644 tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc create mode 100644 tests/functional/programs/016_casts/033_proven_optional_cast.ndc create mode 100644 tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc diff --git a/manual/src/features/casts.md b/manual/src/features/casts.md index 2293ab35..3d7391dd 100644 --- a/manual/src/features/casts.md +++ b/manual/src/features/casts.md @@ -62,12 +62,66 @@ The analyser rejects casts between types that cannot share a value: // error[analysis]: invalid cast: String can never be Int ``` +## Testing a value's type with `as?` + +`value as? T` is the cast that doesn't fail: it is `Some(value)`, as a `T`, +when the value is a `T`, and `None` when it isn't. Its type is `Option`. + +```ndc +let value: Any = 5; +print(value as? Int); // Some(5) +print(value as? String); // None +``` + +It checks a value the way `as` does, so a container is checked element by +element, and a function that may fit a function type comes out checking its +calls: + +```ndc +let mixed: Any = [1, "a"]; +print(mixed as? List); // None +print(mixed as? List); // Some([1,"a"]) + +fn inc(x: Int) => x + 1; +let f: Any = inc; +print((f as? Fn(Int) -> Int).unwrap()(2)); // 3 +print((f as? Fn(String) -> Int).is_some()); // false +``` + +That makes it the way to ask what a value is: + +```ndc +fn describe(value: Any) { + if (value as? Int).is_some() { + "a number" + } else if (value as? String).is_some() { + "some text" + } else { + "something else" + } +} +print(describe(1)); // a number +print(describe("a")); // some text +print(describe([1])); // something else +``` + +An `as?` that can never succeed is rejected like the `as` it stands for, +since it would always be `None`: + +```ndc,fails +"foo" as? Int +// error[analysis]: invalid cast: String can never be Int +``` + ## Precedence -`as` binds tighter than binary operators and looser than unary operators +`as` and `as?` bind tighter than binary operators and looser than unary operators and calls, so `a + b as Int` means `a + (b as Int)`. Method calls bind tighter than the cast: recovering an element type before a method call -needs parentheses, as in `(values.keys as List).max()`. +needs parentheses, as in `(values.keys as List).max()`. An `as?` cast +needs them too before a method call on the option: `(value as? Int).is_some()`. +A name can contain `?`, so `as?` needs a space after it: `x as?Int` reads as +`x` followed by a name `as?Int`. A `<` after the cast type is read as a type argument list when the tokens that follow form one, and as a less-than comparison otherwise, so diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index 73577587..ee638d07 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -433,7 +433,11 @@ impl Analyser { StaticType::Bool } Expression::Grouping(expr) => self.analyse_expr(expr), - Expression::Cast { value, annotation } => { + Expression::Cast { + value, + annotation, + optional, + } => { let target = self.lower_value_type(annotation); let found = self.analyse_expr(value); // What isn't known about the value yet is what the cast says @@ -441,9 +445,14 @@ impl Analyser { // needs no check as the program runs, and `1 as Float` is a // Float. Known parts are checked, and so is a value nothing // is known about, like what a call that waits returns. + // `as?` only tests the value, which stays what it is: in `fn + // f(xs) { (xs as? List).is_some() }`, `xs` can still be a + // list of Strings. A number written there is what it's cast + // to, as with `as`: `1 as? Float` is `Some(1.0)`. let found = self.vars.resolve(&found); let unknown = matches!(found, StaticType::Var(var) if !self.vars.is_literal(var)); - if found.has_vars() && !unknown { + let literal = matches!(found, StaticType::Var(var) if self.vars.is_literal(var)); + if found.has_vars() && !unknown && (!*optional || literal) { self.vars.try_unify(&found, &target); } let found = self.vars.resolve(&found); @@ -453,7 +462,14 @@ impl Analyser { } let check = (!found.fits(&target)).then(|| target.clone()); self.result.analysis.cast_checks.insert(*id, check); - target + // `as?` is the same cast, with `None` where `as` would fail. + // One that can never succeed is an error like `as`: it would + // always be `None`. + if *optional { + StaticType::Option(Box::new(target)) + } else { + target + } } Expression::VariableDeclaration { l_value, diff --git a/ndc_fmt/src/format.rs b/ndc_fmt/src/format.rs index 014483c7..7c13d076 100644 --- a/ndc_fmt/src/format.rs +++ b/ndc_fmt/src/format.rs @@ -208,9 +208,15 @@ impl<'a> Formatter<'a> { } Expression::Logical { .. } => self.operator_chain(expression), Expression::Grouping(inner) => concat([text("("), self.expr(inner), text(")")]), - Expression::Cast { value, annotation } => { - concat([self.expr(value), text(" as "), type_expr(annotation)]) - } + Expression::Cast { + value, + annotation, + optional, + } => concat([ + self.expr(value), + text(if *optional { " as? " } else { " as " }), + type_expr(annotation), + ]), Expression::VariableDeclaration { l_value, annotated_type, diff --git a/ndc_fmt/tests/style.rs b/ndc_fmt/tests/style.rs index 03dab4f2..3aefcdc4 100644 --- a/ndc_fmt/tests/style.rs +++ b/ndc_fmt/tests/style.rs @@ -24,6 +24,14 @@ fn spaces_go_around_operators_and_after_commas() { ); } +#[test] +fn a_cast_keeps_its_operator() { + assert_formats( + "let a = x as Int;let b = x as? List;", + "let a = x as Int;\nlet b = x as? List;\n", + ); +} + #[test] fn statements_are_indented_by_four_spaces() { assert_formats( diff --git a/ndc_highlight/src/lib.rs b/ndc_highlight/src/lib.rs index 9609b229..9385c3a2 100644 --- a/ndc_highlight/src/lib.rs +++ b/ndc_highlight/src/lib.rs @@ -151,6 +151,7 @@ fn kind( | Token::While | Token::Pure | Token::As + | Token::AsOptional | Token::LogicAnd | Token::LogicOr | Token::LogicNot => Kind::Keyword, diff --git a/ndc_lexer/src/token.rs b/ndc_lexer/src/token.rs index 116b779e..439554e1 100644 --- a/ndc_lexer/src/token.rs +++ b/ndc_lexer/src/token.rs @@ -83,6 +83,7 @@ pub enum Token { FatArrow, // => // Keywords As, + AsOptional, // as? Break, Continue, Else, @@ -155,6 +156,7 @@ impl fmt::Display for Token { Self::GreaterGreater => ">>", Self::Bang => "!", Self::As => "as", + Self::AsOptional => "as?", Self::Break => "break", Self::Continue => "continue", Self::Else => "else", @@ -332,6 +334,7 @@ impl From for Token { // Normal keywords "and" => Self::LogicAnd, "as" => Self::As, + "as?" => Self::AsOptional, "break" => Self::Break, "continue" => Self::Continue, "else" => Self::Else, diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index 38153874..8d005f9a 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -212,8 +212,8 @@ fn merged_type_display(candidates: &[&StaticType]) -> String { /// Language keywords offered in general (non-dot) completion. const KEYWORDS: &[&str] = &[ - "let", "fn", "pure", "struct", "if", "else", "while", "for", "in", "as", "return", "break", - "continue", "and", "or", "not", "true", "false", "None", + "let", "fn", "pure", "struct", "if", "else", "while", "for", "in", "as", "as?", "return", + "break", "continue", "and", "or", "not", "true", "false", "None", ]; fn keyword_completions() -> impl Iterator { diff --git a/ndc_parser/src/expression.rs b/ndc_parser/src/expression.rs index fa24d990..56147bec 100644 --- a/ndc_parser/src/expression.rs +++ b/ndc_parser/src/expression.rs @@ -35,9 +35,12 @@ pub enum Expression { }, Grouping(Box), /// `value as Type` — asserts the value has the target type; never converts. + /// `value as? Type` (`optional`) — `Some(value)` when the value has the + /// target type, `None` when it doesn't. Cast { value: Box, annotation: TypeExpr, + optional: bool, }, VariableDeclaration { l_value: BindingPatternLocation, diff --git a/ndc_parser/src/parser.rs b/ndc_parser/src/parser.rs index d5042142..0775d419 100644 --- a/ndc_parser/src/parser.rs +++ b/ndc_parser/src/parser.rs @@ -650,12 +650,13 @@ impl Parser { fn cast(&mut self) -> Result { let mut expression = self.tight_unary()?; - while self.consume_token_if(&[Token::As]).is_some() { + while let Some(operator) = self.consume_token_if(&[Token::As, Token::AsOptional]) { let annotation = self.cast_type_annotation()?; let span = expression.span.merge(annotation.span()); expression = Expression::Cast { value: Box::new(expression), annotation, + optional: operator.token == Token::AsOptional, } .to_location(span); } diff --git a/ndc_vm/src/chunk.rs b/ndc_vm/src/chunk.rs index 97878caf..c93e80e8 100644 --- a/ndc_vm/src/chunk.rs +++ b/ndc_vm/src/chunk.rs @@ -154,6 +154,9 @@ pub enum OpCode { Memoize, /// Peeks top; errors if the value does not conform to the type. `[… value → … value]` CheckType(Rc), + /// Pops a value, pushes `Some(value)` if it conforms to the type and + /// `None` if it doesn't. `[… value → … option]` + TryCast(Rc), /// Peeks top, a value of type `Any` where code expects the type; errors /// if it isn't one. `[… value → … value]` ExpectType(Rc), @@ -194,6 +197,7 @@ impl OpCode { Self::NewCell(_) => "NewCell", Self::Memoize => "Memoize", Self::CheckType(_) => "CheckType", + Self::TryCast(_) => "TryCast", Self::ExpectType(_) => "ExpectType", } } @@ -251,6 +255,8 @@ impl OpCode { Self::Memoize => 0, // Peeks the value without popping it. Self::CheckType(_) | Self::ExpectType(_) => 0, + // Replaces the value with the option holding it. + Self::TryCast(_) => 0, } } } @@ -305,6 +311,7 @@ impl std::fmt::Debug for OpCode { Self::Unpack(n) => write!(f, "Unpack({n})"), Self::Memoize => write!(f, "Memoize"), Self::CheckType(typ) => write!(f, "CheckType({typ})"), + Self::TryCast(typ) => write!(f, "TryCast({typ})"), Self::ExpectType(typ) => write!(f, "ExpectType({typ})"), } } diff --git a/ndc_vm/src/compiler.rs b/ndc_vm/src/compiler.rs index de8ad145..27db14f8 100644 --- a/ndc_vm/src/compiler.rs +++ b/ndc_vm/src/compiler.rs @@ -246,15 +246,28 @@ impl Compiler { } binding => self.compile_binding(&binding, span)?, }, - Expression::Cast { value, .. } => { + Expression::Cast { + value, optional, .. + } => { self.compile_expr(*value)?; - match self.analysis.cast_checks.get(&id) { + let check = match self.analysis.cast_checks.get(&id) { None => return Err(CompileError::unresolved_cast(span)), - Some(None) => {} - Some(Some(target)) => { - let target = Rc::new(target.clone()); + Some(check) => check.clone().map(Rc::new), + }; + match (check, optional) { + (None, false) => {} + (Some(target), false) => { self.ir.write(OpCode::CheckType(target), span); } + // A value proven to be the type is always `Some`, as + // every value is an `Any`. + (None, true) => { + self.ir + .write(OpCode::TryCast(Rc::new(StaticType::Any)), span); + } + (Some(target), true) => { + self.ir.write(OpCode::TryCast(target), span); + } } } Expression::Statement(stm) => { diff --git a/ndc_vm/src/vm.rs b/ndc_vm/src/vm.rs index f5675b50..732d5c1d 100644 --- a/ndc_vm/src/vm.rs +++ b/ndc_vm/src/vm.rs @@ -239,6 +239,7 @@ impl Vm { )); } } + OpCode::TryCast(target) => try_cast(&mut self.stack, target), OpCode::ExpectType(target) => { if !convert_top(&mut self.stack, target) { let value = self.stack.last().expect("stack underflow"); @@ -1292,6 +1293,18 @@ fn bad_range_bound(value: &Value) -> String { format!("range bounds must be Int, got {}", value.diagnostic_type()) } +/// Replaces the value on top of `stack` with `Some` of it, made the +/// `target` as [`Value::converted_to`] does, or with `None` when it isn't +/// one. +#[cold] +fn try_cast(stack: &mut [Value], target: &StaticType) { + let value = stack.last_mut().expect("stack underflow"); + *value = match value.converted_to(target) { + Some(converted) => Value::Object(Rc::new(Object::Some(converted))), + None => Value::None, + }; +} + /// Checks that the value on top of `stack` is a `target`, and makes it one: /// a function that may be a function `target` gets its calls checked (see /// [`Value::converted_to`]). diff --git a/tests/compiler/tests/compiler.rs b/tests/compiler/tests/compiler.rs index 709417e5..8969dd50 100644 --- a/tests/compiler/tests/compiler.rs +++ b/tests/compiler/tests/compiler.rs @@ -534,6 +534,37 @@ fn a_proven_cast_needs_no_runtime_check() { ); } +// let x: Any = 1; x as? Int +// +// An optional cast tests the value with a TryCast, which gives an option +// instead of a CheckType guard that fails. +#[test] +fn an_unproven_optional_cast_tries_the_type_at_runtime() { + let ops = compile_with_analysis("let x: Any = 1; x as? Int"); + assert!( + ops.iter() + .any(|op| matches!(op, TryCast(t) if **t == ndc_core::StaticType::Int)), + "expected a TryCast(Int), got: {ops:?}", + ); + assert!( + ops.iter().all(|op| !matches!(op, CheckType(_))), + "an optional cast must not emit CheckType, got: {ops:?}", + ); +} + +// 1 as? Int +// +// A proven optional cast is always `Some`, which a TryCast to `Any` makes. +#[test] +fn a_proven_optional_cast_is_always_some() { + let ops = compile_with_analysis("1 as? Int"); + assert!( + ops.iter() + .any(|op| matches!(op, TryCast(t) if **t == ndc_core::StaticType::Any)), + "expected a TryCast(Any), got: {ops:?}", + ); +} + // let a: Any = 1; let n: Int = a; // // An `Any` stored where an Int is expected is checked before it's stored. diff --git a/tests/functional/programs/016_casts/026_optional_cast_some.ndc b/tests/functional/programs/016_casts/026_optional_cast_some.ndc new file mode 100644 index 00000000..2c578e78 --- /dev/null +++ b/tests/functional/programs/016_casts/026_optional_cast_some.ndc @@ -0,0 +1,4 @@ +// `as?` gives Some of the value when it is the type. +// expect-output: Some(5) +let value: Any = 5; +print(value as? Int); diff --git a/tests/functional/programs/016_casts/027_optional_cast_none.ndc b/tests/functional/programs/016_casts/027_optional_cast_none.ndc new file mode 100644 index 00000000..ceeeeb32 --- /dev/null +++ b/tests/functional/programs/016_casts/027_optional_cast_none.ndc @@ -0,0 +1,4 @@ +// `as?` gives None when the value is not the type. +// expect-output: None +let value: Any = "five"; +print(value as? Int); diff --git a/tests/functional/programs/016_casts/028_optional_cast_containers.ndc b/tests/functional/programs/016_casts/028_optional_cast_containers.ndc new file mode 100644 index 00000000..a2cec4c5 --- /dev/null +++ b/tests/functional/programs/016_casts/028_optional_cast_containers.ndc @@ -0,0 +1,5 @@ +// A container is checked element by element, as `as` does. +// expect-output: None Some([1,"a"]) Some([1,2]) +let mixed: Any = [1, "a"]; +let ints: Any = [1, 2]; +print(mixed as? List, mixed as? List, ints as? List); diff --git a/tests/functional/programs/016_casts/029_optional_cast_functions.ndc b/tests/functional/programs/016_casts/029_optional_cast_functions.ndc new file mode 100644 index 00000000..40463844 --- /dev/null +++ b/tests/functional/programs/016_casts/029_optional_cast_functions.ndc @@ -0,0 +1,6 @@ +// A function that may be the function type comes out checking its calls. +// expect-output: 3 false +fn inc(x: Int) => x + 1; +let a: Any = inc; +let f = (a as? Fn(Int) -> Int).unwrap(); +print(f(2), (a as? Fn(String) -> Int).is_some()); diff --git a/tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc b/tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc new file mode 100644 index 00000000..8ba5a879 --- /dev/null +++ b/tests/functional/programs/016_casts/030_optional_cast_checked_function_error.ndc @@ -0,0 +1,6 @@ +// A function whose result is checked as it is called. +// expect-error: expected the function to return Int but it returned String +fn text(x) => "s"; +let a: Any = text; +let f = (a as? Fn(Int) -> Int).unwrap(); +print(f(1)); diff --git a/tests/functional/programs/016_casts/031_optional_cast_type_test.ndc b/tests/functional/programs/016_casts/031_optional_cast_type_test.ndc new file mode 100644 index 00000000..ae52dfe0 --- /dev/null +++ b/tests/functional/programs/016_casts/031_optional_cast_type_test.ndc @@ -0,0 +1,12 @@ +// `as?` tests the type of a value. +// expect-output: int string other +fn describe(value: Any) { + if (value as? Int).is_some() { + "int" + } else if (value as? String).is_some() { + "string" + } else { + "other" + } +} +print(describe(1), describe("a"), describe([1])); diff --git a/tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc b/tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc new file mode 100644 index 00000000..ab9cae4b --- /dev/null +++ b/tests/functional/programs/016_casts/032_impossible_optional_cast_error.ndc @@ -0,0 +1,3 @@ +// An optional cast that can never succeed would always be None. +// expect-error: invalid cast: String can never be Int +print("foo" as? Int); diff --git a/tests/functional/programs/016_casts/033_proven_optional_cast.ndc b/tests/functional/programs/016_casts/033_proven_optional_cast.ndc new file mode 100644 index 00000000..d63a1f90 --- /dev/null +++ b/tests/functional/programs/016_casts/033_proven_optional_cast.ndc @@ -0,0 +1,4 @@ +// A proven optional cast is Some, and its type is an Option of the target. +// expect-output: 3 +let n: Option = 3 as? Int; +print(n.unwrap()); diff --git a/tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc b/tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc new file mode 100644 index 00000000..3edf1c60 --- /dev/null +++ b/tests/functional/programs/016_casts/034_an_optional_cast_leaves_the_value_s_type_open.ndc @@ -0,0 +1,7 @@ +// `as?` only tests a value: a list whose elements aren't known yet stays +// open, so it can still hold Strings. The empty list is a `List` then. +// expect-output: true ["a"] +let xs = []; +let ints = (xs as? List).is_some(); +xs.push("a"); +print(ints, xs); From 65795588a14689fb641f00db4dba5f0fd786ecef Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Fri, 13 Nov 2026 22:22:00 +0100 Subject: [PATCH 39/39] =?UTF-8?q?fix(analyser):=20don't=20report=20errors?= =?UTF-8?q?=20that=20follow=20from=20an=20earlier=20one=20=F0=9F=A7=AF=20(?= =?UTF-8?q?#348)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #326. When a check failed, the analyser gave the failed expression the type `Any`, and everything built from it could fail again, so one mistake gave several errors whose help pointed the wrong way: ```ndc struct Player { name: String, score: Int } let players = [Player("ann", 3), Player("bob", 5)]; print(players.map(fn(p) => p.len).sum()); ``` reported the missing `len` and then "No function called 'sum' found that matches the arguments 'List'", with a hint to cast. It now reports only the first. This is also groundwork for making `Any` explicit (#331), where an error-recovery `Any` would cause even more such errors. - A failed check gives the new `StaticType::Error`, which fits anything both ways, joins with anything as itself, and iterates and unpacks as itself. It's only used by the analyser: the types it hands to the compiler, VM and LSP turn it into `Any`, which is what hover and inlay hints showed before. - A call whose arguments (or callee) contain it is skipped without a report, and returns it; the unknowns it would have settled become it too, so a lambda passed to it stays quiet. The places that reported a failure and returned `Any` (no overload, a committed overload that didn't fit, tuple index out of bounds, growing recursion, not iterable, a failed unpack, bad annotations, a type that grows too large, names in a statement with name errors) now return it. - A literal keeps its own type when an error is written into it, and the debug-build check of early picks skips picks whose arguments now contain it. As with rustc and TypeScript, a call passed a failed value reports nothing about its other arguments either. Errors in a value's declared type are still reported: after `let s: String = missing();`, `let n: Int = s;` is still an error. Every existing test program reports the same errors as before; analysis time is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- ndc_analyser/src/analyser/assignments.rs | 6 +- ndc_analyser/src/analyser/calls.rs | 11 ++- ndc_analyser/src/analyser/declarations.rs | 27 +++--- ndc_analyser/src/analyser/function_values.rs | 6 ++ ndc_analyser/src/analyser/mod.rs | 27 +++--- ndc_analyser/src/analyser/solver.rs | 70 +++++++++++----- ndc_analyser/src/analyser/tests.rs | 82 +++++++++++++++++++ ndc_analyser/src/types.rs | 42 +++++++++- ndc_core/src/static_type.rs | 49 +++++++++-- ndc_lsp/src/features/inlay_hints.rs | 8 ++ ...failed_call_in_a_list_is_reported_once.ndc | 5 ++ ...erating_a_failed_call_is_reported_once.ndc | 8 ++ ...9_a_failed_field_read_is_reported_once.ndc | 5 ++ 13 files changed, 289 insertions(+), 57 deletions(-) create mode 100644 tests/functional/programs/006_lists/059_a_failed_call_in_a_list_is_reported_once.ndc create mode 100644 tests/functional/programs/008_iterators/009_iterating_a_failed_call_is_reported_once.ndc create mode 100644 tests/functional/programs/015_struct/039_a_failed_field_read_is_reported_once.ndc diff --git a/ndc_analyser/src/analyser/assignments.rs b/ndc_analyser/src/analyser/assignments.rs index 13d0abb8..c87bfaad 100644 --- a/ndc_analyser/src/analyser/assignments.rs +++ b/ndc_analyser/src/analyser/assignments.rs @@ -68,6 +68,10 @@ impl Analyser { span: Span, ) -> StaticType { let arguments = [left_type.clone(), right_type.clone()]; + // An operand whose check failed was reported there. + if arguments.iter().any(StaticType::has_errors) { + return StaticType::Error; + } let ResolvedCall { binding, .. } = self.overloads().resolve_call( &self.resolver.augmented_candidates(id).in_place, &arguments, @@ -77,7 +81,7 @@ impl Analyser { self.emit(AnalysisError::function_not_found( operation, &arguments, span, )); - return StaticType::Any; + return StaticType::Error; } self.record_augmented_pick(id, binding); left_type.clone() diff --git a/ndc_analyser/src/analyser/calls.rs b/ndc_analyser/src/analyser/calls.rs index 1623e6db..fef46dc0 100644 --- a/ndc_analyser/src/analyser/calls.rs +++ b/ndc_analyser/src/analyser/calls.rs @@ -140,10 +140,17 @@ impl Analyser { &arguments, span, )); - StaticType::Any + StaticType::Error } } callee @ StaticType::Var(_) => self.call_unknown_function(&callee, arguments), + // The callee's check failed, which was reported there. + StaticType::Error => { + for argument in &arguments { + self.vars.settle_unknowns_as_errors(argument); + } + StaticType::Error + } // Nothing tells what the callee does with its arguments. callee if callee.could_be_callable() => { for argument in &arguments { @@ -156,7 +163,7 @@ impl Analyser { } other => { self.emit(AnalysisError::not_callable(&self.vars.shown(&other), span)); - StaticType::Any + StaticType::Error } }; }; diff --git a/ndc_analyser/src/analyser/declarations.rs b/ndc_analyser/src/analyser/declarations.rs index 8d205185..a5fe79ce 100644 --- a/ndc_analyser/src/analyser/declarations.rs +++ b/ndc_analyser/src/analyser/declarations.rs @@ -226,8 +226,9 @@ impl Analyser { /// `typ`, the type of the generic function `symbol`, unless it has more /// parts than the analyser can work with, as when each function in a /// chain doubles what the previous one returns: `fn f1(x) => f0(f0(x))` - /// with `fn f0(x) => (x, x)`. Then it's reported, and the function takes - /// and returns `Any`, so what calls it isn't built from its type. + /// with `fn f0(x) => (x, x)`. Then it's reported, and what the function + /// takes and returns are failed checks, so what calls it isn't built + /// from its type, and isn't reported again. fn within_size_limit( &mut self, symbol: SymbolId, @@ -251,11 +252,11 @@ impl Analyser { )); self.schemes.remove(&symbol); let StaticType::Function { parameters, .. } = typ else { - return StaticType::Any; + return StaticType::Error; }; StaticType::Function { - parameters: parameters.map(|parameters| vec![StaticType::Any; parameters.len()]), - return_type: Box::new(StaticType::Any), + parameters: parameters.map(|parameters| vec![StaticType::Error; parameters.len()]), + return_type: Box::new(StaticType::Error), } } @@ -336,7 +337,7 @@ impl Analyser { return; } // If the type is a fixed-length Tuple whose arity doesn't match - // the number of bindings, fall back to Any for each element. This + // the number of bindings, bind each name as a failed check. This // can happen when a variable is declared with one type (e.g. ()) // and later reassigned to a tuple of a different arity — the // analyser doesn't track reassignment types. @@ -351,7 +352,7 @@ impl Analyser { elems.len(), span, )); - self.bind_patterns_as_any(seq, span); + self.bind_patterns_as_errors(seq, span); return; } else { Box::new(elems.iter()) @@ -360,7 +361,7 @@ impl Analyser { iter } else { self.emit(AnalysisError::unable_to_unpack_type(&resolved_type, span)); - self.bind_patterns_as_any(seq, span); + self.bind_patterns_as_errors(seq, span); return; }; @@ -390,18 +391,18 @@ impl Analyser { if desired_length != actual_len { self.emit(AnalysisError::unable_to_unpack_type(&found_type, span)); - self.bind_patterns_as_any(&seq[actual_len..], span); + self.bind_patterns_as_errors(&seq[actual_len..], span); } } } } - /// Binds every name in `patterns` as `Any`, so a pattern that failed to - /// type-check still declares its names. In `let (a, b, c) = (1, 2)`, `c` + /// Binds every name in `patterns` as a failed check, so a pattern that + /// failed to type-check still declares its names. In `let (a, b, c) = (1, 2)`, `c` /// is bound and a later `print(c)` reports nothing further. - fn bind_patterns_as_any(&mut self, patterns: &[BindingPatternLocation], span: Span) { + fn bind_patterns_as_errors(&mut self, patterns: &[BindingPatternLocation], span: Span) { for pattern in patterns { - self.declare_binding_pattern(pattern, None, StaticType::Any, span); + self.declare_binding_pattern(pattern, None, StaticType::Error, span); } } } diff --git a/ndc_analyser/src/analyser/function_values.rs b/ndc_analyser/src/analyser/function_values.rs index 5254a646..e05c6f8e 100644 --- a/ndc_analyser/src/analyser/function_values.rs +++ b/ndc_analyser/src/analyser/function_values.rs @@ -283,6 +283,12 @@ impl Analyser { /// of it decides it, and returns whether it did. pub(super) fn try_function_value(&mut self, value: &FunctionValue) -> bool { let expected = self.vars.resolve(&value.expected); + // What's expected of it comes from a check that failed, as in + // `missing().map(abs)`, which was reported there. + if expected.has_errors() { + self.give_up_on_function_value(value); + return true; + } match self.choose_function_value(value, &expected) { Choice::One(symbol) => { self.commit_function_value(value, symbol, expected.has_vars()); diff --git a/ndc_analyser/src/analyser/mod.rs b/ndc_analyser/src/analyser/mod.rs index ee638d07..9464dfe5 100644 --- a/ndc_analyser/src/analyser/mod.rs +++ b/ndc_analyser/src/analyser/mod.rs @@ -202,7 +202,7 @@ impl Analyser { } let expected = self .vars - .resolve(&expected) + .finish(&expected) .replace(&|part| matches!(part, StaticType::Generic(_)).then_some(StaticType::Any)); if expected == StaticType::Any { continue; @@ -222,10 +222,13 @@ impl Analyser { &mut analysis.function_types, ] { for typ in table.values_mut() { - *typ = self.vars.resolve(typ); + *typ = self.vars.finish(typ); debug_assert!(!typ.has_vars(), "unsettled type variable in {typ}"); } } + for typ in analysis.cast_checks.values_mut().flatten() { + *typ = self.vars.finish(typ); + } let mut result = std::mem::take(&mut self.result); Layout::new(&self.resolver).lay_out( @@ -256,7 +259,8 @@ impl Analyser { } /// Records an analysis error. The analyser continues with a fallback type - /// (usually `Any`) so that subsequent code is still checked. An error in + /// (usually [`StaticType::Error`], which nothing reports again) so that + /// the rest of the program is still checked. An error in /// a copy of a generic function's constraint is the call's: it's /// reported there, with notes pointing into the function. fn emit(&mut self, err: AnalysisError) { @@ -279,7 +283,7 @@ impl Analyser { /// and a struct name becomes that struct's `StaticType::Struct`. The /// resolver has already matched each name to its type symbol. A wrong /// generic-argument count is reported as an analysis error at the - /// annotation's span, and the annotation falls back to `Any` so the rest + /// annotation's span, and the annotation is a failed check so the rest /// of the program is still checked. fn lower_type_expr(&mut self, expr: &TypeExpr) -> StaticType { match expr { @@ -310,7 +314,7 @@ impl Analyser { match self.struct_types.get(&symbol) { Some(_) if has_args => { self.emit(AnalysisError::type_does_not_take_generic_args(name, *span)); - StaticType::Any + StaticType::Error } Some(info) => info.static_type(), // A built-in type: `from_name_and_args` checks its generic arity. @@ -318,7 +322,7 @@ impl Analyser { Ok(typ) => typ, Err(err) => { self.emit(AnalysisError::invalid_type_annotation(&err, *span)); - StaticType::Any + StaticType::Error } }, } @@ -341,18 +345,19 @@ impl Analyser { self.without_iterables(typ, expr.span()) } - /// `typ`, reporting each `Iterable` in it as `Any`. + /// `typ`, reporting each `Iterable` in it, which becomes the type of + /// a failed check. fn without_iterables(&mut self, typ: StaticType, span: Span) -> StaticType { if !typ.contains(&|part| matches!(part, StaticType::Iterable(_))) { return typ; } self.emit(AnalysisError::iterable_in_an_annotation(span)); - typ.replace(&|part| matches!(part, StaticType::Iterable(_)).then_some(StaticType::Any)) + typ.replace(&|part| matches!(part, StaticType::Iterable(_)).then_some(StaticType::Error)) } /// Analyses a top-level statement. Names resolve first; a statement with /// unresolved names isn't analysed further, which would only repeat its errors. - /// The names it still declared are bound as `Any`, so in + /// The names it still declared are bound as failed checks, so in /// `fn f() { missing } let g = f`, only `missing` is reported. pub fn analyse(&mut self, expr_loc: &ExpressionLocation) -> StaticType { let bound_before = self.resolver.current_bindings().len(); @@ -364,9 +369,9 @@ impl Analyser { .extend(name_errors.into_iter().map(AnalysisError::from)); let declared = self.resolver.current_bindings()[bound_before..].to_vec(); for symbol in declared { - self.types.declare(symbol, StaticType::Any); + self.types.declare(symbol, StaticType::Error); } - return StaticType::Any; + return StaticType::Error; } self.analyse_expr(expr_loc) } diff --git a/ndc_analyser/src/analyser/solver.rs b/ndc_analyser/src/analyser/solver.rs index 1ff42c29..91907052 100644 --- a/ndc_analyser/src/analyser/solver.rs +++ b/ndc_analyser/src/analyser/solver.rs @@ -333,7 +333,7 @@ impl Analyser { } let error = AnalysisError::not_iterable(&self.vars.shown(sequence), span); self.emit(error); - StaticType::Any + StaticType::Error } /// The types `let (a, b, …) = value` gives each name when the kind of @@ -459,6 +459,11 @@ impl Analyser { .iter() .map(|argument| self.vars.resolve(argument)) .collect::>(); + // A call passed a value whose check failed later picks + // nothing, and the program doesn't run. + if arguments.iter().any(StaticType::has_errors) { + continue; + } // A pick left to the program among one overload is a // call of it, with its `Any` arguments checked. let binding = match self @@ -489,6 +494,9 @@ impl Analyser { } EarlyPick::FunctionValue { value, picked } => { let expected = self.vars.resolve(value.expected()); + if expected.has_errors() { + continue; + } let choice = self.choose_function_value(&value, &expected); assert!( choice == Choice::One(picked), @@ -1109,7 +1117,7 @@ impl Analyser { found } }; - let found = found.unwrap_or_else(|| vec![StaticType::Any; parts.len()]); + let found = found.unwrap_or_else(|| vec![StaticType::Error; parts.len()]); for (part, found) in parts.iter().zip(found) { self.check_write(part, &found, *span, None); } @@ -1173,6 +1181,9 @@ impl Analyser { .iter() .map(|argument| self.vars.resolve(argument)) .collect::>(); + if arguments.iter().any(StaticType::has_errors) { + return Some(self.skip_failed_call(call, &arguments)); + } // A tuple indexed by a number written in the code gives that // element, of its own type: `(1, "a")[1]` is a String, and // `(1, "a")[-2]` an Int. @@ -1196,7 +1207,7 @@ impl Analyser { elements.len(), call.span, )); - return Some(StaticType::Any); + return Some(StaticType::Error); }; return Some(element); } @@ -1236,22 +1247,28 @@ impl Analyser { Binding::Resolved(Candidate::Scalar(function)), &call.arguments, ); - return Some(StaticType::Any); + return Some(StaticType::Error); } - // A variable whose type isn't known yet, like the parameter `f` in - // `fn(f) => f(1)`, holds a function that takes these arguments. - if let [only] = reachable[..] - && let callee @ StaticType::Var(var) = self.vars.resolve(self.types.get_type(only)) - && !self.vars.is_literal(var) - { - let typ = self.call_unknown_function(&callee, arguments); - self.record_pick( - call.site, - Binding::Resolved(Candidate::Scalar(only)), - &call.arguments, - ); - return Some(typ); + if let [only] = reachable[..] { + match self.vars.resolve(self.types.get_type(only)) { + // A variable whose type isn't known yet, like the parameter + // `f` in `fn(f) => f(1)`, holds a function that takes these + // arguments. + callee @ StaticType::Var(var) if !self.vars.is_literal(var) => { + let typ = self.call_unknown_function(&callee, arguments); + self.record_pick( + call.site, + Binding::Resolved(Candidate::Scalar(only)), + &call.arguments, + ); + return Some(typ); + } + // A variable whose value's check failed, like `f` in `let f + // = missing(); f(1)`, was reported there. + StaticType::Error => return Some(self.skip_failed_call(call, &arguments)), + _ => {} + } } // A variable holding a literal, like `n` in `let n = 1; n(2)`, can't @@ -1318,7 +1335,7 @@ impl Analyser { EarlyOverload::Only(only) => { let Some(typ) = self.commit_early(call, &arguments, only) else { self.report_failed_call(call, &arguments); - return Some(StaticType::Any); + return Some(StaticType::Error); }; Some(typ) } @@ -1326,6 +1343,19 @@ impl Analyser { } } + /// Skips a call that's passed a value whose check failed, or that calls + /// one, as that was reported where it failed: the call picks nothing, + /// settles what only it could have said about its arguments as failed + /// too, and gives a value whose check failed. In `missing().len()`, only + /// `missing` is reported. + fn skip_failed_call(&mut self, call: &CallConstraint, arguments: &[StaticType]) -> StaticType { + for argument in arguments { + self.vars.settle_unknowns_as_errors(argument); + } + self.record_pick(call.site, Binding::None, arguments); + StaticType::Error + } + /// Whether an operator might still apply to each element of a tuple: an /// operand that isn't known yet, or is `Any`, can be one. fn operand_could_be_a_tuple(kind: CallKind, arguments: &[StaticType]) -> bool { @@ -1599,7 +1629,7 @@ impl Analyser { if matches!(binding, Binding::None) { self.report_failed_call(call, arguments); self.record_pick(call.site, binding, arguments); - return StaticType::Any; + return StaticType::Error; } let typ = self.commit_call(&binding, arguments, &call.nodes.arguments, return_type); self.record_pick(call.site, binding, arguments); @@ -1609,7 +1639,7 @@ impl Analyser { } else { self.pending.clear(); self.report_failed_call(call, arguments); - StaticType::Any + StaticType::Error } } diff --git a/ndc_analyser/src/analyser/tests.rs b/ndc_analyser/src/analyser/tests.rs index d3183046..f7d95e92 100644 --- a/ndc_analyser/src/analyser/tests.rs +++ b/ndc_analyser/src/analyser/tests.rs @@ -1743,3 +1743,85 @@ fn a_call_no_overload_takes_says_what_the_overloads_take() { result.errors ); } + +/// Natives for the tests of what's built from a failed check. +fn failed_check_natives() -> Vec<(String, StaticType)> { + let mut globals = natives(&[ + ("len", "(Iterable) -> Int"), + ("map", "(Iterable, Fn(T) -> U) -> List"), + ("sum", "(Iterable) -> Int"), + ("sum", "(Iterable) -> Float"), + ("abs", "(Int) -> Int"), + ("abs", "(Float) -> Float"), + ("++", "(List, List) -> List"), + ("++", "(String, String) -> String"), + ]); + globals.extend(index_natives()); + globals +} + +#[test] +fn what_is_built_from_a_failed_call_is_not_reported_again() { + let globals = failed_check_natives(); + assert_eq!( + errors( + "struct Player { name: String, score: Int } + let players = [Player(\"ann\", 3), Player(\"bob\", 5)]; + players.map(fn(p) => p.len).sum();", + globals, + ), + vec!["No function called 'len' found that matches the arguments 'Player'"] + ); +} + +#[test] +fn a_failed_call_is_reported_once_wherever_its_value_goes() { + let globals = failed_check_natives(); + let failed = "No function called 'len' found that matches the arguments 'Bool'"; + for source in [ + // Indexed. + "let x = len(true)[0]; [x].sum();", + // Iterated. + "for x in len(true) { [x].sum(); }", + "let xs: List = [x for x in len(true)];", + // Passed on. + "[len(true)].sum();", + "len(true).map(abs);", + "len(true).map(fn(x) => [x]).sum();", + "abs(len(true)) ++ \"a\";", + // Assigned to a typed variable. + "let xs: List = [len(true)];", + "let s: String = len(true);", + // Called. + "let f = len(true); [f(1)].sum();", + // Unpacked. + "let (a, b) = len(true); [a, b].sum();", + ] { + assert_eq!(errors(source, globals.clone()), vec![failed], "{source}"); + } +} + +#[test] +fn an_error_after_a_failed_call_is_still_reported() { + let globals = failed_check_natives(); + assert_eq!( + errors( + "let n = len(true); let s: String = 1; [\"a\"].sum();", + globals, + ), + vec![ + "No function called 'len' found that matches the arguments 'Bool'", + "mismatched types: found Int but expected String", + "No function called 'sum' found that matches the arguments 'List'", + ] + ); +} + +#[test] +fn a_failed_annotation_is_not_reported_again() { + let globals = failed_check_natives(); + assert_eq!( + errors("let x: Int = 1; [x].sum();", globals), + vec!["type `Int` does not take generic arguments"] + ); +} diff --git a/ndc_analyser/src/types.rs b/ndc_analyser/src/types.rs index e62efb8c..a7e68ed7 100644 --- a/ndc_analyser/src/types.rs +++ b/ndc_analyser/src/types.rs @@ -520,7 +520,11 @@ impl TypeVars { typ => bounds.iter().all(|bound| match bound { Bound::Number(..) => matches!( typ, - StaticType::Int | StaticType::Float | StaticType::Number | StaticType::Any + StaticType::Int + | StaticType::Float + | StaticType::Number + | StaticType::Any + | StaticType::Error ), Bound::Holds(held, _, _) => self.bind_argument(&typ, held), }), @@ -636,6 +640,17 @@ impl TypeVars { }) } + /// `typ` as the analysis hands it on, once every variable in it is + /// settled: [`Self::resolve`]d, and with each failed check's type shown + /// as `Any`, which is what's known about it. + pub(crate) fn finish(&self, typ: &StaticType) -> StaticType { + typ.replace(&|part| match part { + StaticType::Var(var) => self.solution(*var).map(|solution| self.finish(solution)), + StaticType::Error => Some(StaticType::Any), + _ => None, + }) + } + /// `typ`, or what it stands for if it's a settled variable. fn shallow(&self, typ: &StaticType) -> StaticType { match typ { @@ -668,6 +683,9 @@ impl TypeVars { } self.settle(*var, other.clone()) } + // A failed check's type is every type, so nothing built from it + // fails again. + (StaticType::Error, _) | (_, StaticType::Error) => true, (StaticType::Option(x), StaticType::Option(y)) | (StaticType::Iterable(x), StaticType::Iterable(y)) | (StaticType::List(x), StaticType::List(y)) @@ -794,8 +812,12 @@ impl TypeVars { (_, StaticType::Never) | (StaticType::Any, _) => true, // A value of type `Any` is checked where it goes, so it leaves a // literal's type what the literal says: `n` in `let n = 1; - // n = a` is still an Int. - (StaticType::Var(var), StaticType::Any) if self.is_literal(*var) => true, + // n = a` is still an Int. So does a value whose check failed. + (StaticType::Var(var), StaticType::Any | StaticType::Error) + if self.is_literal(*var) => + { + true + } (StaticType::Iterable(_), StaticType::Var(var)) if !self.is_literal(*var) => true, // A literal passed for an `Iterable` is the sequence it holds, // as `["a"]` passed to `len` is a `List`: only a @@ -810,7 +832,7 @@ impl TypeVars { self.settle_literal(*var, &held) && self.bind_argument(¶meter, &held) } (StaticType::Var(_), _) | (_, StaticType::Var(_)) => self.unify(¶meter, &argument), - (_, StaticType::Any) => true, + (_, StaticType::Any | StaticType::Error) | (StaticType::Error, _) => true, ( StaticType::Function { parameters: expected, @@ -915,6 +937,18 @@ impl TypeVars { } } + /// Settles every unsettled variable in `typ` but a literal's as the + /// type of a failed check: in `missing(xs).map(fn(x) => x.size)`, the + /// call of `map` says nothing about `x`, which then isn't checked. + /// Literals take their defaults as usual. + pub(crate) fn settle_unknowns_as_errors(&mut self, typ: &StaticType) { + for var in self.unsettled(typ) { + if self.solution(var).is_none() && !self.is_literal(var) { + self.settle(var, StaticType::Error); + } + } + } + /// Settles every variable made since `first` that nothing settled with /// its fallback. pub(crate) fn settle_from(&mut self, first: usize) { diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index ec20a299..a7211aee 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -47,6 +47,13 @@ pub enum StaticType { /// it, which the analyser works out from the arguments. Every method /// here treats one as `Any`. Generic(Box), + /// The type of an expression whose check failed, which was reported. + /// Nothing built from it is reported again: it fits anything, anything + /// fits it, and a call it's passed to fails silently. In `[p].map(fn(p) + /// => p.size).sum()`, only the missing `size` is reported, not `sum` of + /// a list of what it gave. Only the analyser makes it, and it shows as + /// `Any`. + Error, /// Bottom type for diverging expressions (`return`, `break`, `continue`). /// A value of it fits anywhere, and it joins with any type as that type. Never, @@ -216,6 +223,7 @@ impl StaticType { pub fn join(&self, other: &Self) -> Self { match (self, other) { (Self::Never, other) | (other, Self::Never) => other.clone(), + (Self::Error, _) | (_, Self::Error) => Self::Error, _ if self == other => self.clone(), _ => Self::Any, } @@ -267,6 +275,7 @@ impl StaticType { key.as_ref().clone(), value.as_ref().clone(), ])), + Self::Error => Some(Self::Error), unknown if unknown.is_unknown() => Some(Self::Any), _ => None, } @@ -299,7 +308,11 @@ impl StaticType { /// this type too if `unknowns_match`. fn matches(&self, parameter: &Self, unknowns_match: bool) -> bool { let wildcard = |typ: &Self| matches!(typ, Self::Var(_) | Self::Generic(_)); - if wildcard(parameter) || (unknowns_match && wildcard(self)) { + if wildcard(parameter) + || (unknowns_match && wildcard(self)) + || self.is_error() + || parameter.is_error() + { return true; } let matches = |a: &Self, p: &Self| a.matches(p, unknowns_match); @@ -357,10 +370,25 @@ impl StaticType { } } - /// Whether this type stands for every type: `Any`, a type variable or - /// a type parameter, which are all `Any` to the methods here. + /// Whether this type stands for every type: `Any`, a type variable, a + /// type parameter or a failed check's type, which are all `Any` to the + /// methods here. pub fn is_unknown(&self) -> bool { - matches!(self, Self::Any | Self::Var(_) | Self::Generic(_)) + matches!( + self, + Self::Any | Self::Var(_) | Self::Generic(_) | Self::Error + ) + } + + /// Whether this is the type of an expression whose check failed. + pub fn is_error(&self) -> bool { + matches!(self, Self::Error) + } + + /// Whether a failed check's type is part of this type: `List` + /// is the type of a list holding a value whose check failed. + pub fn has_errors(&self) -> bool { + self.contains(&Self::is_error) } /// This type with each part that `replace` gives a type for replaced by @@ -399,6 +427,7 @@ impl StaticType { Self::Any | Self::Var(_) | Self::Generic(_) + | Self::Error | Self::Never | Self::Bool | Self::Struct { .. } @@ -437,6 +466,7 @@ impl StaticType { Self::Any | Self::Var(_) | Self::Generic(_) + | Self::Error | Self::Never | Self::Bool | Self::Struct { .. } @@ -678,7 +708,12 @@ impl StaticType { /// Whether this type and `other` could be one type, where what isn't /// known yet in either could be anything. fn agrees_with(&self, other: &Self) -> bool { - let unknown = |typ: &Self| matches!(typ, Self::Var(_) | Self::Generic(_) | Self::Never); + let unknown = |typ: &Self| { + matches!( + typ, + Self::Var(_) | Self::Generic(_) | Self::Never | Self::Error + ) + }; if unknown(self) || unknown(other) { return true; } @@ -725,6 +760,7 @@ impl StaticType { Self::Any | Self::Var(_) | Self::Generic(_) => { Some(Box::new(std::iter::repeat(&Self::Any))) } + Self::Error => Some(Box::new(std::iter::repeat(&Self::Error))), Self::List(elem) | Self::Iterable(elem) | Self::Iterator(elem) @@ -749,7 +785,8 @@ impl StaticType { impl fmt::Display for StaticType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Any => write!(f, "Any"), + // A failed check's type shows as what it was before there was one. + Self::Any | Self::Error => write!(f, "Any"), Self::Var(_) => write!(f, "_"), Self::Never => write!(f, "Never"), Self::Bool => write!(f, "Bool"), diff --git a/ndc_lsp/src/features/inlay_hints.rs b/ndc_lsp/src/features/inlay_hints.rs index 0d54b612..c252d209 100644 --- a/ndc_lsp/src/features/inlay_hints.rs +++ b/ndc_lsp/src/features/inlay_hints.rs @@ -110,6 +110,14 @@ mod tests { assert!(has_label(&hints, |l| l == ": Int")); } + #[test] + fn a_failed_check_is_shown_as_any() { + // `len(true)` fails, and what it gives is shown as `Any`. + let hints = collect_hints("let n = len(true);\nlet m = [n, n];"); + assert!(has_label(&hints, |l| l == ": Any"), "{hints:?}"); + assert!(has_label(&hints, |l| l == ": List"), "{hints:?}"); + } + #[test] fn annotated_let_binding_skips_type_inlay() { let hints = collect_hints("let value: Int = 1;"); diff --git a/tests/functional/programs/006_lists/059_a_failed_call_in_a_list_is_reported_once.ndc b/tests/functional/programs/006_lists/059_a_failed_call_in_a_list_is_reported_once.ndc new file mode 100644 index 00000000..de8396a6 --- /dev/null +++ b/tests/functional/programs/006_lists/059_a_failed_call_in_a_list_is_reported_once.ndc @@ -0,0 +1,5 @@ +// expect-error: No function called 'len' found that matches the arguments 'Bool' +// The list holds what a failed call gave, so `sum` isn't reported as well. +let x = len(true)[0]; +let xs = [x, x]; +print(xs.sum()); diff --git a/tests/functional/programs/008_iterators/009_iterating_a_failed_call_is_reported_once.ndc b/tests/functional/programs/008_iterators/009_iterating_a_failed_call_is_reported_once.ndc new file mode 100644 index 00000000..4e2bc254 --- /dev/null +++ b/tests/functional/programs/008_iterators/009_iterating_a_failed_call_is_reported_once.ndc @@ -0,0 +1,8 @@ +// expect-error: No function called 'len' found that matches the arguments 'Bool' +// What iterating gives comes from the failed call, so nothing built from it +// is reported. +let n = len(true); +let words: List = [x for x in n]; +for x in n.map(fn(i) => [i]) { + print(x.sum()); +} diff --git a/tests/functional/programs/015_struct/039_a_failed_field_read_is_reported_once.ndc b/tests/functional/programs/015_struct/039_a_failed_field_read_is_reported_once.ndc new file mode 100644 index 00000000..9848eaf0 --- /dev/null +++ b/tests/functional/programs/015_struct/039_a_failed_field_read_is_reported_once.ndc @@ -0,0 +1,5 @@ +// expect-error: No function called 'len' found that matches the arguments 'Player' +// Only the missing `len` is reported, not `sum` of the list of what it gave. +struct Player { name: String, score: Int } +let players = [Player("ann", 3), Player("bob", 5)]; +print(players.map(fn(p) => p.len).sum());