From 1ab78d352d83c61114f0673a2095f184b566fa3e Mon Sep 17 00:00:00 2001 From: Tim Fennis Date: Thu, 27 Aug 2026 09:27:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(analyser):=20use=20type=20overlap=20instead?= =?UTF-8?q?=20of=20subtyping=20for=20dispatch=20feasibility=20=E2=9E=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- manual/src/features/augmented-assignment.md | 18 +++ ndc_analyser/src/analyser.rs | 7 +- ndc_analyser/src/scope.rs | 25 +++- ndc_core/src/static_type.rs | 138 +++++++++++++++++- .../004_basic/066_op_assignment_any_rhs.ndc | 9 ++ .../067_op_assignment_supertype_rhs.ndc | 8 + ...typed_container_param_not_dispatchable.ndc | 7 + .../050_remove_on_widened_sequence.ndc | 6 + 8 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc create mode 100644 tests/functional/programs/004_basic/067_op_assignment_supertype_rhs.ndc create mode 100644 tests/functional/programs/005_functions/042_typed_container_param_not_dispatchable.ndc create mode 100644 tests/functional/programs/006_lists/050_remove_on_widened_sequence.ndc diff --git a/manual/src/features/augmented-assignment.md b/manual/src/features/augmented-assignment.md index 1ef3a698..719625a3 100644 --- a/manual/src/features/augmented-assignment.md +++ b/manual/src/features/augmented-assignment.md @@ -49,6 +49,24 @@ let values = [1]; values ++= ["two"]; // error: mismatched types: found List but expected 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: + +```ndc +let values: List = [1]; +let rhs: List = [0.5]; +values ++= rhs; // error: mismatched types: found List but expected 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: + +```ndc +fn opaque(x) => x; +let values = [1]; +values ++= opaque([2, 3]); // error: mismatched types: found Any but expected List +``` + Annotate the target with `Any` to opt into heterogeneous contents: ```ndc diff --git a/ndc_analyser/src/analyser.rs b/ndc_analyser/src/analyser.rs index 7c27622f..9ace6b3b 100644 --- a/ndc_analyser/src/analyser.rs +++ b/ndc_analyser/src/analyser.rs @@ -968,7 +968,12 @@ impl Analyser { index_type.is_subtype(&StaticType::Iterator(Box::new(StaticType::Int))) } - /// Specialized `op=` implementations preserve the concrete left type. + /// Specialized `op=` implementations mutate the left operand in place and + /// keep its concrete type, so the right operand must provably fit it: the + /// operator copies values across without inspecting them, and nothing + /// downstream re-checks. A merely overlapping right operand is not enough — + /// `List ++= List` would leave the target holding a `Float`. + /// /// 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 { diff --git a/ndc_analyser/src/scope.rs b/ndc_analyser/src/scope.rs index 494e4975..dd7cfada 100644 --- a/ndc_analyser/src/scope.rs +++ b/ndc_analyser/src/scope.rs @@ -70,6 +70,24 @@ fn is_strictly_more_specific(more: &[StaticType], less: &[StaticType]) -> bool { any_strict } +/// Whether a call could still succeed at runtime for one argument position, +/// so the overload is worth keeping as a dynamic-dispatch candidate. +/// +/// The types must overlap — a `Sequence` argument may hold a `List` +/// at runtime, which satisfies a `List` parameter even though neither +/// static type is a subtype of the other. Overlap alone is not enough though: +/// runtime dispatch has to be able to tell whether the value matches, and it +/// cannot do that for a parameter with a concrete element type. Advertising +/// such a candidate would trade a clear compile-time error for a guaranteed +/// runtime one, so it is only kept when the argument already fits statically. +fn dispatch_is_feasible(param: &StaticType, arg: &StaticType) -> bool { + if !param.overlaps(arg) { + return false; + } + + arg.is_subtype(param) || param.is_runtime_checkable() +} + /// If every per-position candidate list contains exactly one entry and they /// all point to the same scalar overload, return it. This is the only case /// where `Binding::Resolved(Candidate::Vec)` is safe to emit: a single scalar @@ -237,7 +255,10 @@ impl Scope { }; let is_good = param_types.len() == find_types.len() - && param_types.iter().zip(find_types.iter()).all(|(typ_1, typ_2)| !typ_1.is_incompatible_with(typ_2)); + && param_types + .iter() + .zip(find_types.iter()) + .all(|(param, arg)| dispatch_is_feasible(param, arg)); is_good.then_some(slot) }) @@ -839,7 +860,7 @@ impl ScopeTree { && parameters .iter() .zip(sig) - .all(|(param, arg)| !param.is_incompatible_with(arg)) + .all(|(param, arg)| dispatch_is_feasible(param, arg)) } StaticType::Function { parameters: None, .. diff --git a/ndc_core/src/static_type.rs b/ndc_core/src/static_type.rs index 3803f69c..05b6d399 100644 --- a/ndc_core/src/static_type.rs +++ b/ndc_core/src/static_type.rs @@ -581,9 +581,116 @@ impl StaticType { ) } - // BRUH + /// 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 + /// - `Any` overlaps every type + /// - `List` does not overlap `List` + /// - `Int` does not overlap `String` + /// + /// This is the right question for runtime-dispatch feasibility: a call is + /// only *provably* impossible when an argument's static type is disjoint + /// from the corresponding parameter type of every overload. + /// + /// Element types are treated as inhabited: `List` and `List` + /// are considered disjoint even though the empty list technically inhabits + /// both. This keeps overlap useful for rejecting provably-mismatched calls. + pub fn overlaps(&self, other: &Self) -> bool { + // A subtype relation in either direction implies a common inhabitant. + // This also handles Any (supertype of everything) and Never (subtype + // of everything). + if self.is_subtype(other) || other.is_subtype(self) { + 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. + ( + Self::Sequence(t), + Self::List(u) + | Self::Iterator(u) + | Self::MinHeap(u) + | Self::MaxHeap(u) + | Self::Deque(u) + | Self::Sequence(u), + ) + | ( + Self::List(u) + | Self::Iterator(u) + | Self::MinHeap(u) + | Self::MaxHeap(u) + | Self::Deque(u), + Self::Sequence(t), + ) => t.overlaps(u), + (Self::Sequence(t), Self::String) | (Self::String, Self::Sequence(t)) => { + Self::String.overlaps(t) + } + (Self::Sequence(t), Self::Tuple(elems)) | (Self::Tuple(elems), Self::Sequence(t)) => { + elems.iter().all(|elem| elem.overlaps(t)) + } + (Self::Sequence(t), Self::Map { key, value }) + | (Self::Map { key, value }, Self::Sequence(t)) => { + Self::Tuple(vec![key.as_ref().clone(), value.as_ref().clone()]).overlaps(t) + } + + // Covariant containers of the same shape overlap when their + // element types do. + (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)) => s.overlaps(t), + (Self::Tuple(s_elems), Self::Tuple(t_elems)) => { + s_elems.len() == t_elems.len() + && s_elems.iter().zip(t_elems).all(|(s, t)| s.overlaps(t)) + } + (Self::Map { key: k1, value: v1 }, Self::Map { key: k2, value: v2 }) => { + k1.overlaps(k2) && v1.overlaps(v2) + } + + // A function value's parameter and return types are not checkable + // at runtime, so only a provable arity mismatch rules overlap out. + (Self::Function { parameters: p1, .. }, Self::Function { parameters: p2, .. }) => { + match (p1, p2) { + (Some(p1), Some(p2)) => p1.len() == p2.len(), + _ => true, + } + } + + _ => false, + } + } + + /// Checks whether `self` and `other` are provably disjoint: no runtime + /// value can satisfy both types. See [`Self::overlaps`]. pub fn is_incompatible_with(&self, other: &Self) -> bool { - !self.is_subtype(other) && !other.is_subtype(self) + !self.overlaps(other) + } + + /// Whether runtime dispatch can decide if a value matches this parameter + /// type. Scalars carry their type in the value itself, and a container of + /// `Any` is settled by the container's shape alone. A container with a + /// concrete element type is *not* checkable: deciding it would mean + /// scanning every element on each dispatch attempt. + /// + /// `List` is checkable; `List` is not. + /// + /// Must stay in agreement with `Value::matches_param` in `ndc_vm`, which + /// returns `false` for exactly the types this rejects. + pub fn is_runtime_checkable(&self) -> bool { + match self { + Self::List(t) | Self::Deque(t) | Self::Sequence(t) => matches!(t.as_ref(), Self::Any), + Self::Map { key, value } => { + matches!((key.as_ref(), value.as_ref()), (Self::Any, Self::Any)) + } + Self::Tuple(elements) => elements.is_empty(), + _ => true, + } } /// Returns a new type with the element type replaced. For container types @@ -749,6 +856,33 @@ mod test { assert!(fun.is_fn_and_matches(&[list_of_two_tuple_int, StaticType::Int])); } + #[test] + fn test_overlaps() { + let list_of = |elem: StaticType| StaticType::List(Box::new(elem)); + let seq_of = |elem: StaticType| StaticType::Sequence(Box::new(elem)); + + // Mixed-direction relationships overlap: a List inhabits both. + assert!(seq_of(StaticType::String).overlaps(&list_of(StaticType::Any))); + assert!(list_of(StaticType::Any).overlaps(&seq_of(StaticType::String))); + + // Any overlaps everything, including containers of concrete elements. + assert!(StaticType::Any.overlaps(&list_of(StaticType::Int))); + assert!(list_of(StaticType::Int).overlaps(&StaticType::Any)); + + // Element types are treated as inhabited, so concrete containers with + // disjoint element types are disjoint. + assert!(!list_of(StaticType::Int).overlaps(&list_of(StaticType::String))); + assert!(!seq_of(StaticType::Int).overlaps(&list_of(StaticType::String))); + + // Disjoint scalar constructors never overlap. + assert!(!StaticType::Int.overlaps(&StaticType::String)); + assert!(!StaticType::Bool.overlaps(&StaticType::Number)); + + // A string is a sequence of strings. + assert!(StaticType::String.overlaps(&seq_of(StaticType::Any))); + assert!(!StaticType::String.overlaps(&seq_of(StaticType::Int))); + } + // Every name in BUILTIN_TYPE_NAMES must be accepted by from_name_and_args // for some argument count; a name that is only rejected as "unknown type" // means the two lists have drifted apart. 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 new file mode 100644 index 00000000..2137c005 --- /dev/null +++ b/tests/functional/programs/004_basic/066_op_assignment_any_rhs.ndc @@ -0,0 +1,9 @@ +// 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 +fn opaque(x) => x; +let values = [1]; +values ++= opaque([2, 3]); +print(values) 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 new file mode 100644 index 00000000..69667af4 --- /dev/null +++ b/tests/functional/programs/004_basic/067_op_assignment_supertype_rhs.ndc @@ -0,0 +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 +let values: List = [1]; +let rhs: List = [0.5]; +values ++= rhs; +print(values) 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 new file mode 100644 index 00000000..1475d37c --- /dev/null +++ b/tests/functional/programs/005_functions/042_typed_container_param_not_dispatchable.ndc @@ -0,0 +1,7 @@ +// Runtime dispatch cannot verify a parameter with a concrete element type, so +// an argument that only *overlaps* it is rejected at compile time rather than +// deferred to a runtime match that could never succeed. +// expect-error: No function called 'consume' found that matches the arguments 'Sequence' +fn consume(xs: List) => xs; +let xs: Sequence = [1]; +consume(xs) 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 new file mode 100644 index 00000000..baea1054 --- /dev/null +++ b/tests/functional/programs/006_lists/050_remove_on_widened_sequence.ndc @@ -0,0 +1,6 @@ +// A variable widened to Sequence by reassignment can still call +// list functions like `remove` through runtime dispatch. +// expect-output: a ["b","c"] +let line = "a b c"; +line = line.split(" "); +print(line.remove(0), line)