feat: add struct definitions with field access and assignment ποΈ - #197
Merged
Conversation
timfennis
added a commit
that referenced
this pull request
Aug 25, 2026
## Context Working on PR #197's biggest TODO ("struct names are not usable as types") revealed the underlying problem: the parser eagerly constructs semantic `StaticType`s from annotations through a closed-world name table (`StaticType::from_name_and_args`). That makes user-declared type names unresolvable in principle (the parser only has tokens), turns a typo in one annotation into a fatal parse error that blanks every LSP feature for the document, loses the annotation's span, and blocks the planned generics rework β a type variable is a binder, not a name lookup, so it can't be represented by parse-time `StaticType` construction. This precursor separates type syntax from type semantics so struct names (and later, type variables) become a small addition to one lowering function instead of a special case. ## Changes - **`ndc_parser`**: new syntactic `TypeExpr` (`Name { name, args, span }` / `Tuple { elements, span }`) with `Display`. The type-annotation parser builds `TypeExpr` and no longer validates names; the `>>`/`>=`/`>>=` splitting is untouched. AST slots store the syntax plus an analyser-filled resolved field (the existing `Binding::Resolved`-style pattern): `FunctionParameter.resolved_type` and `FunctionDeclaration.resolved_return_type`. - **`ndc_analyser`**: `lower_type_expr` resolves `TypeExpr` β `StaticType`. Unknown names and generic-arity mismatches are emitted as span-precise analysis errors and degrade to `Any`, so the rest of the program is still checked. Lowering runs before the `TypeSignature` is built, so overload resolution and runtime dispatch see the resolved types. - **`ndc_vm` / `ndc_lsp`**: consume the resolved fields instead of parse-time types. ## Behaviour change `fn f(x: Unknown)` and `let a: Map<Int> = ...` now report `error[resolver]` diagnostics (with the annotation's exact span) instead of aborting the parse, and one bad annotation no longer suppresses other errors or LSP features. New functional and REPL tests cover each annotation position, the arity error, error recovery, and REPL state integrity. π€ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
timfennis
force-pushed
the
feature/structs
branch
from
August 25, 2026 15:17
0069c09 to
b683b4a
Compare
timfennis
force-pushed
the
feature/structs
branch
from
August 26, 2026 08:55
a4b620c to
543e5cc
Compare
timfennis
force-pushed
the
feature/structs
branch
2 times, most recently
from
August 26, 2026 09:43
dda1b7a to
3f3aea4
Compare
timfennis
added a commit
that referenced
this pull request
Aug 26, 2026
## Context While working on struct support in #197 (the `to_json` TODO), we decided JSON conversion should be perfect in both directions: `json_encode` must be the exact inverse of `json_decode` and error whenever that's impossible, instead of silently degrading values. Extracted as a precursor PR off master so #197 can rebase on it and add only the struct arm. ## Changes - **`json_encode` is now strict.** It only accepts values for which `json_decode(json_encode(v)) == v` holds: the unit value `()`, booleans, ints, big ints, finite floats, strings, lists, string-keyed sets, and maps with string keys. Everything else errors with a hint: rationals, complex numbers, non-finite floats, options (both `Some` and `None`), tuples, deques, iterators, heaps, functions, maps with non-string keys or a default value, and cyclic values. - **New `json_encode_lossy`** accepts the rejected values by degrading them: rationals β floats, complex β strings, `Some(x)` unwrapped to `x`, `None` β `null`, tuples/deques β arrays, heaps β arrays in priority order (deterministic, was arbitrary internal heap order), iterators drained, non-string keys stringified, map defaults dropped, non-finite floats β `null`. It has no decode counterpart because these conversions cannot be reversed. Functions and cyclic values still error. - **`json_decode` fixes:** integers beyond `i64` decode exactly to big ints via serde_json's `arbitrary_precision` (previously silently lossy through `f64`), and numbers that overflow a float (`1e999`) error. - **Cycle detection** replaces a stack-overflow abort: `json_encode([l where l contains l])` used to crash the process, now it's an error. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
timfennis
commented
Aug 26, 2026
Comment on lines
+10
to
+14
| The reverse also holds: when every argument type is known at compile time and **no** overload | ||
| can accept them β every candidate has a fully-annotated signature of the wrong arity or with | ||
| conflicting parameter types β the call is rejected at compile time with a | ||
| `No function called 'β¦' found that matches the arguments` error, instead of being deferred to | ||
| a guaranteed runtime failure. |
Owner
Author
There was a problem hiding this comment.
Is there a way to phrase this with shorter sentences. This is a little rough to read for my pea sized brain.
timfennis
added a commit
that referenced
this pull request
Aug 26, 2026
) ## Context Follow-up to #197, which noted that modelling constructors and accessors as three dedicated `Function` variants forced ~9 extra match arms, a `StaticType` allocation on every dispatch probe, and an ambiguous `Display` (getter and setter both printed `<fn Point.x>`). Stacked on `feature/structs` so it can be reviewed separately. ## What changed - **`Function::{Constructor, GetField, SetField}` are gone.** A struct declaration now builds three ordinary `NativeFunction` closures (`Function::struct_constructor/struct_getter/struct_setter`) that capture the `Rc<StructInfo>` and field index. Behaviour, runtime error messages, and the nominal `Rc::ptr_eq` type check are unchanged. - **All special-case match arms deleted** from dispatch (`dispatch_call_with_memo`), equality, hashing, `name()`, `static_type()`, `matches_arg_types`/`matches_value_args`, and `Display`/`Debug`. Equality and hashing now use the `Native` arm's pointer identity, which coincides with the old per-info-plus-index identity because each accessor is constructed exactly once, as a chunk constant. - **The per-dispatch `StaticType` allocation is gone** β the type is computed once at declaration and stored on the `NativeFunction`. Struct-heavy code got measurably faster (~8% on a constructor+getter+setter loop, hyperfine, pinned core); fib-style compiled-function dispatch is unchanged within noise. - **`Display` is now name-based and unambiguous**: `<fn Point>`, `<fn Point.x>`, `<fn Point.x=>`. Covered by a unit test. This also resolves the "two identical constants in `ndc disassemble`" item from #197. - **Removed `Function::is_native`** β unused, and its doc comment (natives bridge to the tree-walk interpreter) would have been wrong for accessors. ## Notes for reviewers - **Stdlib natives display differently now.** `print(len)` prints `<fn len>` instead of `<native fn Function { β¦ }>`, since `Display` for `Function::Native` uses the function's name. No test relied on the old string; the old type dump moved to `Debug`, which now also includes the name. - Accessor names are qualified (`Point.x`, `Point.x=`), so the rare runtime dispatch errors that print a callee name (vec dispatch) name the struct too. Compile-time messages are unaffected β the analyser resolves accessors by their binding names (`x`, `x=`) as before. - #197's "setter hash tag is untested" gap dissolves: there is no setter-specific hash arm any more. π€ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
`call_callback` assumed every dispatch pushed a frame, so passing a struct constructor or field accessor to a HOF ran the caller's bytecode again and then overran the chunk. Dispatch now reports whether it pushed a frame. Routing through `dispatch_call` also unwraps `Memoized`, fixing a `pure fn` passed as a callback hitting `unreachable!`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Hash for Object` left `GetField`/`SetField` as `todo!`, so using a getter as a map key aborted. Hash the `StructInfo` pointer plus the field index, with distinct tags for getter and setter, matching the `PartialEq` arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only `Constructor` wrote a discriminant, so a `Compiled` and a `Closure` sharing a prototype pointer hashed alike. Tag all seven variants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`p.x += 1` now lowers through `PreparedAssignmentTarget`, caching the receiver so it is evaluated once for the getter and the setter. Rejection of `+=` on a member is dropped now that the compiler can lower it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`let l[0] = 5` panicked in the compiler and `let p.x = 5` panicked in the analyser: both are write targets, not new bindings. Reject them while parsing. Destructuring declarations are unaffected; only Index and Member targets go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adapts the structs branch to the TypeExpr refactor from #198: struct fields store the syntactic annotation, and the analyser lowers it when registering the struct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Struct names now work as type annotations (parameters, return types, let bindings, struct fields, inside generics). Built-in names take precedence. Struct names are globally unique: redeclaration is an analysis error, and the analyser checkpoint now rolls the registry back so a failed REPL line frees its struct names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also rewrites the duplicate-parameter check with the same itertools duplicates_by/unique_by idiom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When every same-name candidate has a fully-annotated signature and none is compatible with the call, resolution now reports Binding::None instead of deferring to runtime dispatch. Covers constructor arity and argument type errors. Also fixes the map value type to include the default value's type, which the stricter resolution would otherwise falsely reject. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
let x: Map<Int, Int> = %{} was rejected because the literal inferred
Map<Any, ()>, which is not a subtype of the annotation. Declarations
and assignments now analyse the initialiser with the expected type,
letting empty (and nested empty) container literals adopt it.
Temporary until type parameters and unification land.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structs are rejected by the strict json_encode (a JSON object decodes back to a map, not a struct) and encode as objects via json_encode_lossy. The serde.rs arms landed while resolving the rebase onto #199. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
) ## Context Follow-up to #197, which noted that modelling constructors and accessors as three dedicated `Function` variants forced ~9 extra match arms, a `StaticType` allocation on every dispatch probe, and an ambiguous `Display` (getter and setter both printed `<fn Point.x>`). Stacked on `feature/structs` so it can be reviewed separately. ## What changed - **`Function::{Constructor, GetField, SetField}` are gone.** A struct declaration now builds three ordinary `NativeFunction` closures (`Function::struct_constructor/struct_getter/struct_setter`) that capture the `Rc<StructInfo>` and field index. Behaviour, runtime error messages, and the nominal `Rc::ptr_eq` type check are unchanged. - **All special-case match arms deleted** from dispatch (`dispatch_call_with_memo`), equality, hashing, `name()`, `static_type()`, `matches_arg_types`/`matches_value_args`, and `Display`/`Debug`. Equality and hashing now use the `Native` arm's pointer identity, which coincides with the old per-info-plus-index identity because each accessor is constructed exactly once, as a chunk constant. - **The per-dispatch `StaticType` allocation is gone** β the type is computed once at declaration and stored on the `NativeFunction`. Struct-heavy code got measurably faster (~8% on a constructor+getter+setter loop, hyperfine, pinned core); fib-style compiled-function dispatch is unchanged within noise. - **`Display` is now name-based and unambiguous**: `<fn Point>`, `<fn Point.x>`, `<fn Point.x=>`. Covered by a unit test. This also resolves the "two identical constants in `ndc disassemble`" item from #197. - **Removed `Function::is_native`** β unused, and its doc comment (natives bridge to the tree-walk interpreter) would have been wrong for accessors. ## Notes for reviewers - **Stdlib natives display differently now.** `print(len)` prints `<fn len>` instead of `<native fn Function { β¦ }>`, since `Display` for `Function::Native` uses the function's name. No test relied on the old string; the old type dump moved to `Debug`, which now also includes the name. - Accessor names are qualified (`Point.x`, `Point.x=`), so the rare runtime dispatch errors that print a callee name (vec dispatch) name the struct too. Compile-time messages are unaffected β the analyser resolves accessors by their binding names (`x`, `x=`) as before. - #197's "setter hash tag is untested" gap dissolves: there is no setter-specific hash arm any more. π€ Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
timfennis
force-pushed
the
feature/structs
branch
from
August 26, 2026 10:54
15bfed8 to
434543b
Compare
timfennis
marked this pull request as ready for review
August 26, 2026 11:12
There was a problem hiding this comment.
π‘ Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 434543b0bc
βΉοΈ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with π.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
This was referenced Aug 26, 2026
timfennis
added a commit
that referenced
this pull request
Aug 26, 2026
## Context A final adversarial review of #197 (after it merged) surfaced four real bugs and two smaller gaps. This PR fixes all six. Each bug was reproduced before fixing and has a regression test. ## Fixes - **Compiler panic:** `for (p.x) in [1, 2, 3]` hit `unreachable!("cannot declare into a field")`. For-loop iteration variables now go through the same `non_binding_target` check as `let`. The probe also uncovered that `fn f(p.x)` / `fn f(l[0])` panicked in `FunctionParameter::from_params` (the index case pre-dates structs) β parameters are now required to be plain identifiers at parse time. - **VM stack corruption:** a struct declaration in value position (`[struct R { x: Int }, 5]` printed `[,5]`) pushed no value. Struct declarations are now statements, parsed exactly like `let`, so value position is a parse error. - **`(s.f)()` silently called the getter method-style** instead of the function stored in the field. Parenthesized member access now keeps an `Expression::Grouping` wrapper so the postfix-call rewrite can tell it apart from `s.f()`. Grouping was never constructed by the parser before, so its (dead) lvalue arms were redefined as transparent: `(s.x) = 5`, `(s.x) += 2`, and `(l[0]) = 9` behave exactly as before. - **`clone()` aliased structs and `deepcopy()` shared nested state** β `Object::Struct` fell into both catch-all arms. Structs now copy like the other mutable containers: `clone` gives an independent instance (nested containers shared, like lists), `deepcopy` shares nothing. - The `struct` keyword was missing from the REPL highlighter and the LSP keyword completion list. - The analyser hand-built the constructor/getter/setter `StaticType`s inline; it now uses `StructInfo::constructor_type()/getter_type()/setter_type()` β the same source of truth the runtime functions use. π€ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
While attempting to implement an advent of code puzzle I needed access to linked lists because currently that can only be emulated with maps which is cumbersome to read and write. This PR is a step in the right direction, but since self references are not allowed yet it doesn't really solve the problem πͺ¦.
What this adds
structkeyword, field declarations with required typeannotations, trailing commas allowed.
StructRegistryinndc_coreβ ownsStructInfo; shared by the analyser andcompiler via
Rc<RefCell<β¦>>so the REPL keeps struct definitions across lines.getter and setter per field. Identity is nominal: two structs with an
xfield producetwo distinct getters, so overload resolution picks the right one.
Expression::MemberAccessβp.xis its own AST node rather than a desugared call,while
p.x()still lowers tox(p). This is what makes assignability decidable atparse time:
p.x = vis valid,p.x() = vis not.Object::StructplusFunction::{Constructor, GetField, SetField}, allcompleting inline without pushing a frame.
document symbols, inlay hints and go-to-definition.
fn dist(p: Point),let p: Point = ...,struct Line { a: Point }, andList<Point>all resolve (built on theTypeExprrefactor from refactor(parser): resolve type annotations in the analyser π§Β #198; built-in names take precedence). Struct names are globally
unique β redeclaration is an analysis error β and the analyser checkpoint rolls the
registry back, so a failed REPL line frees its struct names.
Bugs found while building this
Three panics, two of which predate the branch:
points.map(x)) corrupted the VM stack andoverran the chunk.
call_callbackassumed every dispatch pushed a frame; dispatch nowreports which happened. This also fixes
pure fnas a HOF callback, which hitunreachable!on master.let l[0] = 5panicked in the compiler (pre-existing) andlet p.x = 5in theanalyser. Both are write targets, not new bindings β now rejected while parsing.
todo!(), so%{x: 1}aborted.Notes for reviewers
Dispatchenum touches all call dispatch.dispatch_callnow returnsFramePushed/Completed. Shape follows Lua'sluaD_precall.+=,-=,*=,%=,&=,|=work;/=and^=on anIntfield are rejected because the operator's resulttype widens to
Numberβ identically to an annotated container(
let l: List<Int> = [10]; l[0] /= 2gives the same error). A field is a non-widenabletyped location, so it lands in the behaviour chore: augmented assignment review follow-ups π§ΉΒ #195 established. Deferred with the rest of
the subtyping rework, not a new regression. (An earlier revision of this note listed
-=/&=/|=as rejected too; theBinding::Noneoverload work fixed those.)feature/struct-oopsbranch, withauthorship preserved; that branch still holds the old history.
emit_set_varto bumpsource_localson every local store,and made
produces_valueexhaustive instead of_ => true.Remaining work
to the subtyping/unification rework: self-referential structs
(
struct Node { next: Option<Node> }errors withunknown type, since usablerecursion also needs
Noneto be assignable to anyOption<T>).manual/src/reference/types/struct.mdcovers declarationsyntax, positional constructors,
p.xβ‘x(p), accessors as function values,field assignment (including the augmented-assignment rules above), reference
semantics, nominal equality/hashing, JSON encoding, and current limitations. Every
code block was executed against this branch.
to_jsonon a struct is unreachable. Done β the call itself was already fixedby the
Binding::Noneoverload work. This branch is now stacked on feat(stdlib): make json_encode strict and add json_encode_lossy π§Β #199 (strictjson_encode+json_encode_lossy, targeted at master): the strictjson_encoderejects structs (a JSON object decodes back to a map, not a struct) and
json_encode_lossyencodes them as objects with field names as keys.(
Illegal redefinition of field 'a' in struct 'Dup'), reported at the duplicatefield's own span; the declaration is not registered, so the name stays free.
same-name candidate is a fully-annotated function and none is compatible with the
call,
resolve_callnow reportsBinding::Noneinstead of falling back to runtimedispatch β so
Point(1),Point("x", 2), anddist(Other(1, 2))all fail atcompile time. Exposed a latent analyser bug (a map default's type wasn't part of the
map's value type), fixed alongside.
Displayis indistinguishable from the getter β fixed in refactor(vm): model struct accessors as native function closures π§©Β #201 (stackedon this branch): the setter prints
<fn Point.x=>.struct Empty { }β decided: allowed. A one-flag parser change(
delimited_comma_separated(..., allow_empty: true)); the rest of the pipelinealready handled zero fields (construction, printing as
Empty {}, equality,hashing, type annotations β covered by
015_struct/024_empty_struct.ndc). Thetree-sitter grammar already accepted an empty body.
(Nested field assignment is now covered by
015_struct/012_nested_struct.ndc.)Moot after refactor(vm): model struct accessors as native function closures π§©Β #201: the setter-specific hash arm no longer exists.
NativeFunctionclosures instead ofthree
Functionvariants. Would delete ~9 match arms, the per-dispatchStaticTypeallocation, and the getter/setter
Displayambiguity. Done in refactor(vm): model struct accessors as native function closures π§©Β #201, stacked on thisbranch.
Unrelated and pre-existing:
cargo clippy --all-featuresfails to compile becausendc_vm/src/tracer.rsdoesn't coverOpCode::CallVec. CI only builds default features.π€