Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions manual/src/features/augmented-assignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,24 @@ let values = [1];
values ++= ["two"]; // error: mismatched types: found List<String> but expected List<Int>
```

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<Int> = [1];
let rhs: List<Number> = [0.5];
values ++= rhs; // error: mismatched types: found List<Number> but expected List<Int>
```

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<Int>
```

Annotate the target with `Any` to opt into heterogeneous contents:

```ndc
Expand Down
7 changes: 6 additions & 1 deletion ndc_analyser/src/analyser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> ++= List<Number>` 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 {
Expand Down
25 changes: 23 additions & 2 deletions ndc_analyser/src/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>` argument may hold a `List<Int>`
/// at runtime, which satisfies a `List<Any>` 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
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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, ..
Expand Down
138 changes: 136 additions & 2 deletions ndc_core/src/static_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,9 +581,116 @@ impl StaticType {
)
}

// BRUH
/// Checks whether some runtime value could satisfy both `self` and `other`.
///
/// # Examples
/// - `Sequence<String>` overlaps `List<Any>`: a `List<String>` inhabits
/// both, even though neither type is a subtype of the other
/// - `Any` overlaps every type
/// - `List<Int>` does not overlap `List<String>`
/// - `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<Int>` and `List<String>`
/// 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<T> 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support newly overlapping typed containers at runtime

For a widened Sequence<Int> argument and a List<Number> parameter, this branch now creates a dynamic candidate because a runtime List<Int> could satisfy both. However, Value::matches_param explicitly returns false for every typed List, Sequence, Map, or Tuple parameter during dynamic dispatch. Thus a value widened from an iterator and then assigned [1], for example, is accepted by the analyser when passed to fn consume(xs: List<Number>) but is guaranteed to fail overload resolution even though its runtime value matches. The runtime matcher must be able to verify the typed-container cases enabled here, or this overlap must not advertise them as dispatchable.

Useful? React with 👍 / 👎.

(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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty tuples in sequence overlap

When elems is empty, all is vacuously true, so Sequence<Int> is considered to overlap (). A call such as fn consume(x: ()) => x; let xs: Sequence<Int> = [1]; consume(xs) is therefore retained for dynamic dispatch, although no Sequence<Int> value can satisfy the unit parameter and Value::matches_param rejects every Tuple parameter. This turns a statically provable mismatch into a guaranteed runtime “no matching overload” error; special-case the empty tuple before applying the element-wise test.

Useful? React with 👍 / 👎.

}
(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<Any>` is checkable; `List<Int>` 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat typed iterators and heaps as uncheckable

The newly added runtime-checkability guard still falls through to true for Iterator<T>, MinHeap<T>, and MaxHeap<T> with concrete T. For example, let xs: Sequence<Int> = 1..3; consume(xs) with an Iterator<Number> parameter is retained as a dynamic candidate because the types overlap, but Value::matches_param falls back to the runtime type Iterator<Any>, which cannot match Iterator<Number>, so the call is guaranteed to fail at runtime rather than being rejected by the analyser. The heap runtime types similarly erase their elements to Any; these variants should only be considered checkable when their element type is Any.

Useful? React with 👍 / 👎.

}
}

/// Returns a new type with the element type replaced. For container types
Expand Down Expand Up @@ -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<String> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>
fn opaque(x) => x;
let values = [1];
values ++= opaque([2, 3]);
print(values)
Original file line number Diff line number Diff line change
@@ -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<Int> holding a non-Int.
// expect-error: mismatched types: found List<Number> but expected List<Int>
let values: List<Int> = [1];
let rhs: List<Number> = [0.5];
values ++= rhs;
print(values)
Original file line number Diff line number Diff line change
@@ -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<Int>'
fn consume(xs: List<Number>) => xs;
let xs: Sequence<Int> = [1];
consume(xs)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// A variable widened to Sequence<String> 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)
Loading