Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d6adf44
feat(analyser): keep each variable's type fixed 📌 (#286)
timfennis Nov 11, 2026
b618881
feat(analyser): give built-ins generic signatures 🧬 (#287)
timfennis Nov 11, 2026
c48cd32
feat(stdlib): give the standard library generic signatures 🧬 (#288)
timfennis Nov 11, 2026
7412408
feat(analyser): add function types to annotations 🎯 (#289)
timfennis Nov 11, 2026
aae3152
feat(analyser): give lambdas the function type expected of them 🎯 (#290)
timfennis Nov 11, 2026
dc58b62
feat(analyser): let calls wait until their arguments pick an overload…
timfennis Nov 11, 2026
87ade7c
feat(analyser): let the expected type pick an overloaded function use…
timfennis Nov 11, 2026
e562510
test(proptest): fuzz the analyser's early overload choices 🔍 (#301)
timfennis Nov 11, 2026
107fe8c
feat(analyser): make functions declared in the program generic 🧬 (#304)
timfennis Nov 12, 2026
5267951
feat(compiler): compile a generic function once for each set of types…
timfennis Nov 12, 2026
d14b1ac
feat(analyser): check a value of type Any where code expects another …
timfennis Nov 12, 2026
1650dcc
feat(analyser): make literals adapt to their use and containers invar…
timfennis Nov 12, 2026
e3027c5
feat(analyser): join types without subtyping and delete is_subtype 🪓 …
timfennis Nov 12, 2026
7c4747f
feat(analyser): make Iterable<T> a parameter type for any sequence 🔁 …
timfennis Nov 13, 2026
accf481
feat(vm): check container elements before runtime dispatch gives up 🔎…
timfennis Nov 13, 2026
1b4b44f
fix(analyser): keep promised types true for pops, maps and Any indexe…
timfennis Nov 13, 2026
6784af1
Merge origin/master into feat/type-checker
timfennis Nov 13, 2026
9522a59
fix(analyser): let a list literal adapt to uses of its elements 🧮 (#317)
timfennis Nov 13, 2026
49cfc7c
fix: address small findings from the type checker review 🧹 (#319)
timfennis Nov 13, 2026
a9532aa
fix(analyser): let each call settle a generic body's literals 🧬 (#320)
timfennis Nov 13, 2026
8eea77b
fix(analyser): explain growing recursion and overloaded values in lis…
timfennis Nov 13, 2026
f2cadd9
fix: bugs and slowdowns found running Advent of Code solutions 🎄 (#322)
timfennis Nov 13, 2026
fbced07
feat(analyser): give a tuple element picked by a literal index its ty…
timfennis Nov 13, 2026
68643dc
fix(stdlib): give min and max of two values the values' type 📏 (#324)
timfennis Nov 13, 2026
261ab90
feat(analyser): give a lambda's parameters the types the call passes …
timfennis Nov 13, 2026
4a2c55c
refactor(analyser): merge duplicated helpers and drop leftovers 🧹 (#327)
timfennis Nov 13, 2026
2744adf
fix(analyser): check Any values a generic body passes for its type pa…
timfennis Nov 13, 2026
4619a52
feat(stdlib): give transposed, multi_cartesian_product and zip typed …
timfennis Nov 13, 2026
1aebf24
fix(vm): check a function from Any against the function type it's use…
timfennis Nov 13, 2026
acce12d
fix(analyser): make a type parameter one type across a call, and op= …
timfennis Nov 13, 2026
419a012
refactor(analyser): drop user-written Iterable<T> annotations 🧹 (#333)
timfennis Nov 13, 2026
0192f2a
fix(analyser): report a generic function whose type grows too large 📏…
timfennis Nov 13, 2026
98f60de
fix(analyser): say what a function takes when no overload fits a call…
timfennis Nov 13, 2026
475535b
fix(analyser): give a while true loop no break leaves no value 🔁 (#337)
timfennis Nov 13, 2026
665b9c1
refactor(analyser): type the literals in a generic body by the body a…
timfennis Nov 13, 2026
53d79c4
fix: bugs found analysing the advent-of-code-ndc solutions 🎄 (#342)
timfennis Nov 13, 2026
56086aa
fix: review findings that don't involve Any, and simplifications 🧹 (#…
timfennis Nov 13, 2026
57592fd
feat(stdlib): add panic, and type combinations, permutations and find…
timfennis Nov 13, 2026
1f70dd0
feat: test a value's type with x as? T 🔍 (#347)
timfennis Nov 13, 2026
6579558
fix(analyser): don't report errors that follow from an earlier one 🧯 …
timfennis Nov 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 22 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 41 additions & 30 deletions manual/src/features/augmented-assignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> but expected List<Int>
values ++= ["two"]; // error: No function called '++' found that matches the arguments 'List<Int>, List<String>'
```

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<Int> = [1];
let rhs: List<Number> = [0.5n];
values ++= rhs; // error: mismatched types: found List<Number> but expected List<Int>
values ++= rhs; // error: No function called '++' found that matches the arguments 'List<Int>, List<Number>'
```

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
fn opaque(x) => x;
```ndc
fn opaque(x: Any) => x;
let values = [1];
values ++= opaque([2, 3]); // error: mismatched types: found Any but expected List<Int>
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
fn opaque(x) => x;
```ndc,fails
fn opaque(x: Any) => x;
let values = [1];
values ++= opaque([2, 3]) as List<Int>;
print(values); // [1,2,3]
values ++= opaque([0.5]); // error: expected List<Int> but found List<Any>
```

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
fn opaque(x) => x;
```ndc
fn opaque(x: Any) => x;
let values = [1];
values ++= opaque([0.5]) as List<Int>; // error: cannot cast List<Float> to List<Int>
let same = values;
values ++= opaque([2, 3]) as List<Int>;
print(values, same); // [1,2,3] [1,2,3]
```

Annotate the target with `Any` to opt into heterogeneous contents:
Expand All @@ -92,13 +96,20 @@ mixed ++= ["two"];
print(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,fails
let numbers = ["a".len()]; // a List<Int>
numbers[0] += 0.5; // error: Int + Float is a Float, which doesn't fit in List<Int>
```

A list of number literals takes the type its uses need, so there the numbers are Floats:

```ndc
let numbers = [1];
numbers[0] += 0.5; // fine: the element type widens from Int to Any
print(numbers); // [1.5]
let numbers = [1, 2];
numbers[0] += 0.5;
print(numbers); // [1.5,2.0]
```

## Optimization
Expand All @@ -121,13 +132,13 @@ 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 {
lowest min= g(x);
highest max= g(x);
}

print(lowest, highest); // 0 3481
print(lowest, highest); // 0.0 3481.0
```
116 changes: 92 additions & 24 deletions manual/src/features/casts.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,18 @@
The `as` operator asserts that a value has the given type:

```ndc
let values = %{};
values.insert(1);
values.insert(2);
let values: List<Any> = [1, 2];

// keys returns List<Any>; the cast recovers the element type so the
// values is a List<Any>; the cast recovers the element type so the
// result works with functions that expect a List<Int>.
let keys = values.keys as List<Int>;
print(keys.max()); // 2
let ints = values as List<Int>;
print(ints.sum()); // 3
```

A cast checks the value without converting it. `5 as Float` is an error
because `5` is an `Int`; use conversion functions like `float(5)` to change
a value's type.
A cast checks the value without converting it. `n as Float` is an error
when `n` is an `Int`; use conversion functions like `float(n)` to change a
value's type. A number literal takes the type the cast says, so `5 as Float`
is the Float `5.0`.

## Checking

Expand All @@ -38,19 +37,91 @@ 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
"foo" as Int
// 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<T>`.

```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<Int>); // None
print(mixed as? List<Any>); // 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<Int>).max()`.
needs parentheses, as in `(values.keys as List<Int>).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
Expand All @@ -61,27 +132,24 @@ 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,fails
let line = "a b c";
line = line.split(" "); // line is now Sequence<String>, not List<String>
line.remove(0); // error: no `remove` matches Sequence<String>, Int
let values: List<Any> = [1, 2, 3];
values.sum(); // error: no `sum` matches List<Any>
```

A cast states that it is still a list:
A cast states what the list holds:

```ndc
let line = "a b c";
line = line.split(" ");
print((line as List<String>).remove(0)); // a
let values: List<Any> = [1, 2, 3];
print((values as List<Int>).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;
Expand Down
4 changes: 2 additions & 2 deletions manual/src/reference/memory-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ There are two common ways to create cycles:

```ndc
{
let m = %{};
let n = %{};
let m: Map<String, Any> = %{};
let n: Map<String, Any> = %{};
m["n"] = n;
n["m"] = m;
print(m["n"]["m"] == m); // true
Expand Down
Loading
Loading