Skip to main content
v0.13.0

Changelog

v0.13.0

Released 2026-09-06. The closure cluster lands — pipe notation, capture lists, the once / var qualifiers, and capture-by-move as the default — alongside move-only written function types, move-triggered row narrowing and widening, dyn Aspect existentials, transparent type aliases, struct pattern matching, and the := walrus for kept bindings. The Since v0.13.0 / Changed in v0.13.0 markers throughout the spec date to this release.

:= for kept bindings (RFC-0136):

  • A let / var binding, an associated-type definition, and a plain assignment now use := in place of =let x := 1;, var n := 0;, n := n + 1;. The bare = in those positions is a P0001 parse error: a hard switch, no transition alias. : still introduces a type annotation (let x: i64 := 1); = is kept for struct-field init (P { x = 1 }), keyword arguments, associated-type bindings in a bound (Deref<Target = Node>), the compound operators (+= -= *= /= %=), and ==.
  • The whole corpus migrated in the same change via an AST-driven rewriter that splices := at each target = token's byte span in the parse tree — never a text substitution: every .mtl fixture, stdlib/core.mtl, and the inline-Metel test strings (metel-core#804).

Pipe notation for closures and function types (RFC-0154):

  • A closure literal and a function type are now written with a pipe-delimited parameter list: |x, y| { … } and |A, B| -> C, replacing (x, y) -> { … } and (A, B) -> C. (...) is freed for grouping and (RFC-0151) tuples/records. It is a hard switch — the parenthesized spelling is a parse error.
  • On a closure literal the -> is written only when a return type is: |x| -> i64 { … } annotated, |x| { … } inferred, || { … } nullary. This supersedes RFC-0041's rule that -> precede every body — the |...| delimiters do that disambiguation now. Return-type inference from the body is unchanged.
  • In a written function type the -> and return type stay mandatory: |i64| -> String, nullary || -> String. once / var qualifiers and & / &var prefix it as before (&var once var |T| -> U).
  • -> is right-associative, so a nested function type (|A| -> |B| -> C) parses unambiguously; parenthesizing it is a style recommendation, not a rule.

Closure capture lists and qualifiers (RFC-0050, RFC-0134, RFC-0153, RFC-0157):

  • A closure that uses an outer binding captures it; a non-Copy capture, or one taken by reference, must be named in an explicit capture list before the pipes: [total] |n: i64| { … }, [&cfg] || { … }, [&var acc] var |x| { … }. A bare Copy binding needs no entry; the list is required the moment a move would otherwise occur (RFC-0050, metel-core#926).
  • [x] moves x in (or copies it if Copy), [x.clone()] takes an independent copy, [&x] / [&var x] capture a reference. Capture defaults to by move for a bare non-Copy binding (RFC-0157 D5); RFC-0006's per-call environment re-clone is gone — the environment is moved in once and read or mutated in place.
  • An optional qualifier before the pipes states how the closure uses its captures: var mutates one (assigns a by-value capture, takes &var of one, or calls a &var self method), once consumes one and may then be called only once, unqualified reads only. [&var x] implies var. There is no Fn / FnMut / FnOnce to reverse-engineer — the list and the qualifier are the contract (RFC-0134 / RFC-0153, metel-core#927 / #929).
  • Re-entering a var closure through the same value while a call on it is in progress is a runtime error (R0015). dyn Callable and a Callable bound are not in this release — deferred in full to RFC-0161 (v0.13.1).

Type aliases (RFC-0160):

  • public? type Name := T; gives an existing type a name — type Bytes := List<u8>;, type Handler := once var |Request, &Config| -> Response;. The := separator is RFC-0136's kept-binding form, matching an associated-type definition.
  • An alias is transparent: erased to its right-hand side before name resolution and type checking, with no nominal identity. Pair<i64, boolean> and (i64, boolean) are the same type, accepted in the same positions and satisfying the same bounds. An alias defines no impl, so there is no coherence concern.
  • May be parameterised (type Pair<A, B> := (A, B);) and may reference another alias. An alias use must supply exactly the declared number of type arguments — a mismatch is T0004. A recursive alias, direct or chained, is T0003: a transparent alias must expand to a finite type; use a struct / enum for a genuinely recursive shape.
  • type inside an aspect / extend block is still an associated-type definition — position, not a keyword, tells the two apart; type adds no new reserved word.
  • Allowed at module scope or inside a function / block body. A block-local alias is never exported, may name the enclosing function's generic parameters, and shadows an outer alias of the same name for the rest of its block.
  • A module-level public alias crosses module boundaries — imported by name, under a rename, through a glob, or re-exported by an intermediate module (export shapes::{Vec2};, one hop), and referenced with a qualified path (geometry::Vec2); every spelling resolves to the same erased type. Naming a non-public alias from another module is T0009, the same as any other private item.
  • Expansion reaches every type position, including annotations nested inside expressions — a closure parameter annotation, a cast, an ascription, a turbofish — and an alias for a plain named type also stands in for that name in value and pattern position: a struct literal (P { … }), a record projection (P.{ … }), an enum-variant path (D::Variant), a match pattern.

Written function types are move-only (RFC-0166):

  • Every syntactically written |T| -> U type node — in a parameter, a let / var annotation, an ascription, a declared return, a struct / enum field, a written aggregate element, a generic argument, an alias body, an aspect method signature — now has concrete move-only by-value use-multiplicity (RFC-0134's third axis, which the surface still cannot spell). A value of a written function type may be called (subject to its once / var) and moved, never duplicated by value.
  • A function value the compiler proved copyable — a named function, a capture-free closure, a closure whose captures are all Copy — is accepted into a written function-type slot by moving (map(add_one) still type-checks). Its copyability is not carried by the written type and is not re-derived downstream: a parameter, or a value returned through a written function return type, is move-only for the receiver regardless of what flowed in.
  • Below the first function-type level the by-value use axis now matches exactly, as once / var already do — the one nested_fun_axes_match exception that let a written nested function type reconcile with an inferred Copy one is removed.
  • This is a checked-mode change: a body that used a bare-typed callback by value more than once (let a := f; let b := f; for f: |T| -> U) is now a use-after-move under --move-check. The default evaluator still deep-clones, so such a body keeps running until move-checking is on. There is no copy |T| -> U spelling to opt back out — that, and a distinct "capability unknown" state, are deferred to RFC-0163 (v0.17.0), which refines this move-only state rather than replacing it. No keyword is reserved.

Move-triggered row narrowing and widening (RFC-0137 slice 2, RFC-0117):

  • Moving a non-Copy field out of a struct value now narrows that value's type to a residual of the same brand — let n := h.name; makes h a Handle.{ fd } — not just compiler-internal move-tracking state (RFC-0137 slice 1, metel-core#857, added the residual type and branded projection; this slice makes a partial move produce one). The residual an explicit projection h.{ fd } yields and the one a partial move yields are the same type, interchangeable at a Self.{ fd } parameter.
  • Anonymous records narrow too (RFC-0117, metel-core#789): let x := r.left; for r := { left = …, right = … } makes r : { right: i64 }. A whole-value use afterward is a plain T0001 at type-check time — the ordinary record-shape mismatch, since a narrowed record has no distinct type marker the way a struct residual's brand gives one. A Copy field read by value does not narrow.
  • Using a narrowed value where the whole brand (or a wider row of it) is required is a plain type error at inference time now, not only a --move-check finding: T0001 "a partially-moved Handle (now Handle.{ fd }) cannot be used where the whole Handle is required". Every still-present field stays readable; re-projecting a residual for a field it still holds works, and a full-width projection normalizes back to the plain struct type.
  • Reassigning a moved-out field of an owned binding (var h; …; h.name := "y";) widens the type back to the whole brand. Widening does not re-check any constructor invariant — ordinary field reassignment already bypasses one, independent of this feature.
  • Narrowing is path-sensitive across if / match arms (metel-core#958): each arm forks from the state before the construct, and the arms join afterward — a later arm never sees an earlier arm's partial move, and a move on any arm narrows the binding for the code after the construct (the join is the union of the arms' moves). A loop-carried use that only becomes invalid on a later iteration is still surfaced by --move-check rather than as a narrowing type error.
  • --move-check is narrowing-aware (metel-core#950): a whole-value use of a binding at its narrowed type — moving it, binding it, passing it where its row matches — is no longer flagged as a partial-move violation.
  • Drop-dispatch against a narrowed residual (RFC-0137 §5) is a separate v0.14.0 slice — it needs a narrowed drop receiver (RFC-0109 / RFC-0147), and until then a Drop type still cannot be partially moved at all.

Struct pattern matching:

  • A named struct can now be matched with a struct pattern (Point { x, y }, Token { kind, span, .. }) — RFC-0032 §4/§5 and RFC-0034 §5's own worked examples used this syntax, but no form of it actually worked: a bare { field } record pattern only ever unified against an anonymous/structural record type, never a named struct declaration, so matching one failed with T0001 regardless of field names (metel-core#753, metel-core#755). A struct pattern binds each named field to a local of the same name; a trailing .. omits the rest, and is required whenever the pattern doesn't name every field. Outside the struct's declaring module, naming a private field in the pattern is rejected with T0009, the same as construction — .. must be used to skip it instead. Field sub-patterns (Point { x: 0, y }) are not part of this — only bare field-name bindings are supported, matching what the grammar actually accepts.
  • A record pattern's trailing .. ({ x, .. }) now works against a row-bounded generic type parameter (<record T: { x: f64, .. }>), reading the fields it names and discarding the rest — closing the one gap #645 left open (metel-core#646). It's required to match an open bound at all, since the bound's full field set isn't known; for a closed bound it's optional sugar, but the pattern must still name every field the bound lists without it. Naming a field the bound doesn't list is still rejected, .. or not.

Aspect objects — dyn Aspect (RFC-0008):

  • dyn Aspect is an existential: some concrete type that implements Aspect, with the type erased. Written in type position (let s: dyn Shape), behind a reference (&dyn Shape / &var dyn Shape), and as a type argument (List<dyn Shape>). Method calls dispatch dynamically at runtime (metel-core#865 / #866).
  • Any value whose type implements Aspect coerces to dyn Aspect at an expected dyn position — an argument, a let with a dyn annotation, an array or List element. Coercion requires the aspect to be object-safe: no open associated type, no Self-by-value receiver or Self return, no generic method (metel-core#870).
  • A List<dyn Aspect> / dyn Aspect[] holds values of different concrete types at once; a heterogeneous array literal infers each element against the declared dyn element type rather than against the other elements (metel-core#864 / #872).

Error codes (breaking):

  • R0012 was a phantom — the ?-on-non-Result case it named is caught at compile time as T0001, so it could never be raised. It is removed and the runtime codes above it shift down one: R0013R0016 become R0012R0015 (assertion failed; yolo on None / Err; panic; re-entrant var closure). Code or fixtures citing the old numbers must be updated (metel-core#983 / #1000).

Syntax (breaking):

  • A match expression's scrutinee must now be parenthesized — match (x) { … } — the same as if / while / for / for-in, all of which already required it. The bare match x { … } form is a parse error (RFC-0156, metel-core#701). A tuple scrutinee's own parentheses satisfy the requirement (match (a, b) { … } is unchanged), as does the unit literal (match () { … }). Every match in the spec, tutorials, stdlib, and fixture corpus was migrated in the same change with an AST-driven rewriter; only the bare form was touched.

extends Aspect (RFC-0130):

  • The anonymous aspect-bound spelling in the two positions that permit it — a function parameter (RFC-0035) and a function return (RFC-0037) — is now extends Aspect, not impl Aspect: fun draw(s: extends Shape), fun make() -> extends Shape. A pure lexical rename with zero semantic change; it finishes the one spot the RFC-0098 keyword sweep missed, the sweep that also gave implextend, pubpublic, and mutvar (metel-core#801).

Fixes:

  • Self::AssocType and Self.{ field } now resolve inside an extend block's own method signature and body, everywhere an ordinary bound generic parameter's own projections already do — including a body-internal let x: Self::Item = ...;, which previously had no route to resolve Self at all, and a generic function returning a projection (fun unwrap<T: Container>(c: &T) -> T::Item), which previously only inferred correctly when the caller also checked the result against an expected type (metel-core#740, metel-core#774). Architecturally, Self is now bound as the impl-block method's own type parameter for the whole of its inference, pinned to the concrete target type, rather than re-resolved ad hoc at each position that happens to need it — the previous, narrower fix (which covered only param/return-type position) kept surfacing the same class of gap one call site at a time. Investigating this also found and fixed a real, Self-independent bug: let x: T::AssocType = ...; for an ordinary bound generic T inside a generic function's own body never resolved either, for the same underlying reason.
  • An array literal with no expected type now defaults to T[] (a borrowed view), not [T; N] (a sized array) — matching the reading recorded on RFC-0053's qualified status (2026-08-12) but not implemented until now. Fixes println([1, 2, 3]), which declarations.md's own worked example claimed compiled but didn't (metel-core#715). An array literal with an explicit [T; N] or T[] annotation, or one whose type is otherwise propagated (a let annotation, a struct field, a function parameter), is unaffected — this changes only the no-annotation fallback.
  • A generic method's own bound (fun describe<U: Aspect>(self, x: U)), declared inside an extend block, is now recognized and enforced. Previously it was silently dropped whenever the target itself had no generics of its own: calling a bound-required method on the parameter from inside the method's own body failed with a confusing cannot infer receiver type error even though the bound was declared right there, and — separately — an argument that didn't actually satisfy the bound was never rejected at the call site at all, only failing later as an internal error when the method body was reconstructed at call time (metel-core#746).
  • A call with an explicit turbofish type argument (identity::<i64>(x)) now rejects an argument that doesn't actually match the pinned type, the same way an inferred call already does. Previously the pinned type was never checked against the arguments at all — identity::<i64>("hello") compiled and ran, silently keeping the string value untouched instead of reporting a type error (metel-core#775). An unsuffixed numeric literal passed to a turbofish call is unaffected — it still adopts the pinned type (clamp::<i32>(5, 0, 10) still works); this changes only genuinely mismatched arguments.
  • A generic function can now be referenced as a value, not just called directly — bound with a bare, unannotated let (let alias = identity;, staying polymorphic across that binding's own later uses, the same guarantee an unannotated closure literal already had), and passed as a higher-order argument whose receiving parameter position is itself concrete (apply(identity, 3)). Works identically for a nested generic function, not just a top-level one. Previously any non-call reference to a generic function was rejected outright, contradicting functions.md's unqualified first-class-functions claim (metel-core#736, RFC-0138). Referencing one where nothing pins down a concrete instantiation — passed to a parameter that is itself still generic in the callee, or with no expected type at all outside a let — is still rejected; RFC-0138 tracks that remaining gap.

v0.12.1

Released 2026-08-09. Interpreter-only patch release — no spec changes.

Negative aspect bounds:

  • A negative bound (T: !Aspect) is now enforced for every structural type — tuples, fixed-size arrays, and references — not just named types and primitives. A Copy tuple, array, or reference previously satisfied T: !Copy silently, since the check never ran for anything without a nominal or primitive type name.

Aspect implementations:

  • An aspect implementation must contain exactly the methods its aspect declares; extra type-specific methods belong in an inherent extend block. Missing required aspect methods now report the aspect-bound error code instead of undefined-name.
  • An aspect implementation's methods must also match the aspect's declared signature — arity, parameter types, and return type. A mismatch is now rejected instead of silently accepted.

impl Aspect position:

  • impl Aspect written anywhere other than a function parameter or return type — a let/var annotation, a struct or enum field, a cast target — is now rejected instead of silently becoming a phantom nominal type and surfacing later as an unrelated, confusing unification failure.

Loop control flow:

  • break/continue with no enclosing loop is now rejected at compile time instead of reaching the evaluator as an internal error. This also closes a live miscompilation: a break/continue inside a closure body no longer escapes past the closure's own call boundary to terminate whatever loop happens to be running when the closure is called.

Nested function hoisting:

  • A nested fun declaration can now be called before its own textual declaration point within the same block, and two nested funs can call each other regardless of declaration order — matching the spec's documented hoisting semantics, which the typechecker already accepted but the evaluator did not enforce. Previously this failed at runtime with undefined variable naming the nested function itself, even on a program that typechecked cleanly; top-level functions were never affected, only ones nested inside a block.

Generic diagnostics:

  • A generic struct field's type, when recoverable from an unsuffixed numeric literal (e.g. Pair { first = 1, .. }.first), is now correctly propagated to method-call and string-concatenation dispatch. This previously failed with "cannot infer receiver type" even though the same value's type was already recoverable through arithmetic or an explicit annotation.
  • Diagnostics that named a raw internal type variable (?t18) now show the type's own declared generic parameter name where one exists — for struct, enum, function, and method generics alike — falling back to a stable, message-local placeholder (?1, ?2, ...) rather than an id that changed whenever unrelated code elsewhere in the file did.

Row bounds:

  • p.x now resolves through an abstract, row-bounded generic type parameter (<record T: { x: f64, .. }>) for any field the bound explicitly lists, open or closed, on both the read and write sides. This previously fell through to a generic "add a type annotation" error that no annotation could actually fix. Reading a field an open bound does not list is still unsupported.

Move checking:

  • --move-check now rejects moving a value out of a reference at every position, not just a by-value self method call, its original scope. General assignment (let x: B = *r;), by-value argument passing (f(*r)), and a plain field read through a reference receiver with no explicit * at all (self.field inside a &self method, r.field for any reference-typed r) are all covered now, uniformly. A reference only ever grants access, never ownership; a Copy pointee is unaffected. Reading a reference directly into a differently-typed local (let x: T = r;, with no field, index, or * at all) was a distinct gap, fixed separately — see "Read-copy" below.

Primitive extend targets:

  • Self as a return type now works on a primitive extend target (extend i64 { fun identity(&self) -> Self { ... } }) — previously this failed to compile with a confusing "cannot unify i64 with i64", since self's parameter type and the Self return annotation resolved to two different internal representations of the same primitive. String now implements Clone, the fix this was blocking.

Read-copy:

  • Reading a non-Copy value out of a reference via read-copy (let x: T = r;, return/break, a tail expression, or explicit ascription, where T differs from r's reference type) is now a hard, always-on type error (new code T0024) instead of silently duplicating the value. This Copy requirement was part of the reference design from the start; it was never actually implemented. Unlike the move-checking bullet above, this is enforced unconditionally — it isn't gated behind --move-check, since it's a type-safety question rather than an ownership-discipline migration.

Array views:

  • Index assignment through T[] (an immutable, non-owning array view) is now its own error code rather than being classified as "annotation required" — the type is already fully known at the point of assignment, and no annotation could have fixed it.

Module system:

  • A root/self/super-qualified path is now valid in type-annotation position, not just as an import path or a value/constructor expression — matching the spec's claim that fully-qualified paths are valid anywhere a name is expected. Previously such an annotation failed outright with unknown type.
  • A module reachable only through another module's export declaration — with no import anywhere pulling it in directly — is now actually loaded. The module graph builder previously followed only import declarations, even though export shares the identical path syntax, so a module reachable solely via re-export was never parsed at all; even a direct import bypassing the re-export failed with unknown struct/unknown enum/name does not exist.
  • A bare or self-rooted path written inside a non-root module (parser.mtl) now resolves against that module's own submodule directory (parser/) first, when a matching file exists there, falling back to the previous sibling-relative resolution otherwise. parser.mtl can now export/import its own parser/ast.mtl submodule with a bare ast::Ast or self::ast::Ast, not only a fully root-qualified path.
  • An import alias (import path::Name as Alias;) now works as a type annotation and as a struct-literal constructor, not just as an ordinary value — previously only the original, un-aliased name worked in either position, even though the alias worked everywhere else.
  • A glob-imported re-export (import facade::*;, where facade re-exports a name from elsewhere via export) is now correctly bound at runtime. It previously typechecked — visibility already counted a re-export as part of the providing module's public surface — but failed at runtime with undefined variable; an explicit, non-glob import of the identical re-export worked correctly. The same gap existed, and is fixed the same way, for a re-exported type referenced through a glob import.

String interpolation:

  • A string literal nested inside a ${...} interpolation (e.g. "${if (true) { "yes" } else { "no" }}") now parses correctly. The outer string's own lexing previously terminated at the first unescaped quote inside the interpolation, with no awareness that the quote belonged to a nested string, misparsing everything that followed it.

Diagnostic and value formatting:

  • A mutable reference now prints as &var in diagnostics and value output, not &mut — a spelling the language does not have.
  • A diagnostic message no longer embeds an issue-tracker number. The one place this happened — the extend rejection for a structural target — is fully actionable without it, and a tracking number in a string a user might paste into a bug report is exactly the kind of thing that goes stale silently once the number stops meaning what it meant when the message was written.

Documentation:

  • Corrected the public reference to distinguish implemented anonymous records and references from planned named records and linear values.
  • Corrected spec/types.md's claim that extend on an array target fails the same way it does for tuples and records — it doesn't; arrays support a local aspect extend, matching spec/declarations.md. Only the array claim was stale; the tuple and record claims were accurate.

v0.12.0

Released 2026-08-03. The spec's Since v0.12.0 / Changed in v0.12.0 markers refer to this entry.

Anonymous records:

  • Closed, anonymous, exact-shape, structurally typed product types: { x: f64, y: f64 } as a type, { x = 1.0, y = 2.0 } as a value, and Handle.{ fd } to project a nominal type's row.
  • Structural identity is order-insensitive — { x: i64, y: i64 } and { y: i64, x: i64 } are the same type.
  • Records are exact: unification requires the same label set, and there is no width subtyping. A record with an extra field is a different type, not a subtype.
  • Duplicate labels are a parse error.
  • A bare { x } or { x = e } where a block may appear is still a block. Write a record there in parentheses — ({ x = e }); the diagnostic says so at the point of use.
  • Records satisfy Send/Sync by field composition, but carry no impl-based aspect. Inherent methods, non-local aspect impls, and a custom Drop on a record are rejected.
  • Not yet available: chained and pattern projection, narrowing, record conversions, named records, and open rows.

Row bounds:

  • A bound may be a bare row, constraining a type parameter by the fields it carries rather than by an aspect:

    fun magnitude<record T: { x: f64, y: f64, .. }>(p: T) -> f64
    fun labels<record T>(x: T) -> Symbol[]
    fun f<record T: { x, y: f64, .. }>(p: T)
    fun h<T>(p: T) where record T: { x: f64, .. }
    fun send<record T: !{ token }>(t: T) -> i64
  • Only records satisfy a row bound. A struct never does, and the diagnostic says why.

  • A closed row (no ..) requires exactly that label set; an open row requires at least it.

  • A field may omit its type to constrain the label alone. There is no _ wildcard.

  • Negation is per listed field, not the complement of the whole row. !{ x: f64 } is satisfied by a record whose x is an i64; !{ x } rejects the label outright.

  • A row bound on a parameter that is record-kinded in neither the parameter list nor the where clause is an error that names the fix.

Ownership — partially available, and off by default:

  • The Copy and Drop aspects are declared in the standard library. Copy is implemented for the twelve numeric primitives plus boolean and Char.

  • Structural rules: a tuple is Copy iff every element is, a fixed array iff its element type is, &T is Copy, and &var T is not.

  • A struct may implement Copy only if every field is; an enum only if every payload in every variant is. The diagnostic names the offending field or payload.

  • Copy and Drop are mutually exclusive, rejected in either declaration order and also when two overlapping conditional impls would give one instantiation both.

  • Move checking is available but off by default. Pass --move-check to enable it. Nothing in the language moves without that flag; the default remains copy-on-assign.

    With it enabled:

    • Loop bodies are analysed to a fixed point, so a move inside a loop is visible to the next iteration. The diagnostic says which iteration it means: `s` was moved here on an earlier iteration.
    • A move on a path leaving through break, continue, or return no longer reaches the code that follows it — removing a class of false positives, in and out of loops.
    • Writing to a moved place makes it valid again rather than counting as a use of it, so let moved = s; s = "again"; is accepted. Assigning a field works the same way; a write whose base is gone is still an error.
    • A dereference is a place: *p and (*p).f can be named, and moving the same value out of a reference twice is caught rather than ignored.
    • Generic function bodies, generic impl methods, and named let-polymorphic closures are analysed rather than skipped. Bodies that still cannot be analysed produce a warning naming the reason instead of failing silently.
    • A by-value self method is rejected when called through a reference, and a &var self method is rejected through a shared reference, in every receiver form. A reference grants access, never ownership. &self methods and owned receivers are unaffected, as is a Copy pointee.
    • Consuming a non-Copy element out of a borrowed T[] is rejected.
  • Known limitation: closures are not tracked as owners. Every function type is treated as Copy, so a closure that captures a non-Copy value can be reused freely, and calling one never consumes what it captured:

    fun call(f: () -> String) -> String { f() }

    let s = "hello";
    let f = () -> String { s }; // captures a non-Copy value
    let a = call(f);
    let b = call(f); // accepted, though `f` is once-callable

    Treat --move-check in this release as checking ownership of values, not of closures. This is a checker gap, not memory unsafety — the runtime deep-clones a closure's environment at creation, so both calls produce a value. It is the reason move checking is not yet the default.

  • The Drop aspect is declared, but destructors do not run in this release, and writing a drop body is therefore rejected rather than compiling into a destructor that never fires:

    extend Handle: Drop {
    fun drop(self) { close(self.fd); } // rejected: this cleanup would never happen
    }

    extend Handle: Drop {
    fun drop(self) {} // fine — declares Drop, promises nothing
    }

    Declaring a type Drop still works and everything it means at the type level is available: the Copy/Drop exclusion, the eligibility rules, T: Drop and T: !Drop bounds, the ban on Drop for anonymous records, and the refusal to partially move a Drop value. Only invocation is missing. If you have cleanup to run today, put it in an ordinary method and call it. This applies to std::core::Drop specifically — a module's own unrelated Drop aspect is unaffected.

  • extend on a concrete structural target is now a diagnostic rather than an internal error. extend i64[]: Area { … }, extend (i64, i64): Area { … } and extend { w: i64 }: Area { … } report the target kind and the form that works:

    cannot `extend` a tuple type without type parameters: only the generic form is
    implemented, so this block's methods could never be found. Write it as
    `extend<A, B> (A, B): Aspect { … }`, or use a named struct

    A tuple or record target in the generic form is rejected too — it typechecked and then was invisible to both dispatch and bound satisfaction. Of the structural targets, the generic array form is the one that works: extend<T> T[]: Display { … } registers and dispatches as before.

  • Auto-deref now reaches through a reference in two places it previously missed: an array intrinsic resolves through &T[] and &[T; N] (arr.len() where arr: &i64[] needed (*arr).len() before), and an aspect method resolves through &T under a T: Aspect bound, so a read-only generic can borrow its parameter and still call methods on it.

  • impl Aspect is now lowered wherever it appears in a parameter annotation, not only as the annotation's outermost type. impl Printable[] previously bound the array type rather than its element, making the bound vacuous. Nested occurrences in generic arguments, tuples, records, references, function types, and associated-type projections are lowered too.

Arrays:

  • T[] is now a non-owning, immutable, unconditionally-Copy borrowed view. Produced only by borrowing a List<T>, a [T; N], or another slice; array literals with no expected type now default to [T; N]. The existing [T; N]T[] coercion means most code needs no change — it only differs at a genuinely unannotated literal.
  • List<T> gains .set(i, value) -> Perhaps<T>, overwriting in place and returning the replaced value, or None if out of bounds.

References:

  • & and &var <rvalue> no longer require binding the value to a name first (foo(&Vec::new()), foo(&var Vec::new())). A literal, call result, or construction is materialized into a fresh, independent cell and referenced directly. Nothing outside the expression can alias that cell, so a mutable reference to it is always sound.

Breaking changes:

  • Field initializers use =, not :Point { x = 1.0, y = 2.0 }. This completes the rule that : classifies and = defines. Field declarations (message: String), enum variant declarations, and patterns are unchanged and still use :.

    Migration is mechanical but must not be done with a regex. Declarations, patterns and literals share brace syntax and co-occur on one line — Perhaps::Some { value } => Perhaps::Some { value = f(value) } has a pattern and a literal in one expression, and only the literal changes. Rewrite over parsed field-initializer spans.

  • a[0] = 9 through a T[] no longer compiles. Mutate via List<T> or [T; N].

  • record is now a keyword and can no longer be used as an identifier.

  • String and List<T> read-only methods now take &self instead of selfString's entire method surface, and List<T>'s get/len/as_slice/map/filter/ fold/find/concat. Only observable under --move-check, where calling two such methods on one binding previously moved it on the first call.

Diagnostics:

  • New error code T0019, reported only under --move-check, with distinct wording per rule rather than one generic message: use after move, a partially moved value used as a whole, a partial move of a Drop type, a banned array-element move, a &var moved by a use that is not a reborrow, and a move out of a reference. Each names the binding and the location of the move.
  • Taking a &var reference through a shared reference reports T0006 in every lvalue form — &var r.field as well as &var *r — where one form previously reported a non-exhaustive-match code.
  • A malformed record projection is diagnosed directly, instead of against a synthesised type name that appears nowhere in the program.
  • A generic field type in a Copy eligibility error is rendered as written — Inner<T> — rather than leaking the inference variable behind it (Inner<?t16>).

Fixes:

  • Undeclared type and aspect names in annotations and bounds are rejected at their declaration, including unused function signatures, unused struct/enum fields, and extend clauses. Diagnostics name the unresolved type or aspect instead of blaming a later unification failure.
  • A record no longer satisfies an aspect bound vacuously; it must actually meet it.
  • .. on a negative row bound is rejected, since "at least these fields, negated" has no coherent reading.
  • Copy eligibility now sees conditional impls on generic field types, so extend<T: Copy> Outer<T>: Copy is accepted when Outer's field is an Inner<T> that is itself conditionally Copy.
  • return, break, and continue nested inside an ordinary expression position no longer crash the interpreter. 1 + (return 7), f(return 7), [return 7, 1], a struct-literal field, a match scrutinee, an if condition and a let initializer each aborted the process; the signal now propagates to the construct that owns it.
  • Taking a reference to a field reached through a reference now works. Both &r.a and &(*r).a, and the tuple, array, nested, and &var equivalents, previously raised an internal error. Reading such a field always worked — only taking a reference to one did not.
  • Clone exists as an aspect but has essentially no standard-library impls, so T: Clone is not yet usable as a bound for a primitive or String.

v0.11.0

Released 2026-07-24. The spec's Since v0.11.0 / Changed in v0.11.0 markers refer to this entry.

Enum variants:

  • A match arm may name a variant without its Enum:: prefix when the scrutinee's enum determines it: match c { Red => .., Green => .. }. Resolution is type-directed against the scrutinee's own type — not a lexical import — so two enums may both declare Red with no ambiguity.
  • The same applies in expression position, against the expected type: let c: Colour = Red;, paint(Blue), fun favourite() -> Colour { Green }.
  • None, Some, Ok and Err are ordinary variants, not literals. They have no special status in the grammar or the type system and resolve exactly as a user-declared variant does. Qualified forms remain valid everywhere.
  • A bare variant is a last resort: an in-scope binding wins, and so does a same-named unit struct. Where no expected type exists the bare form does not resolve — there is deliberately no search for "some enum, somewhere".

References:

  • Explicit *expr returns, for reading through a reference and, as an assignment target, for writing through a &var T. This reverses v0.10.0's removal of explicit dereference syntax.
  • Auto-deref is now confined to selectors — field access, field assignment, indexing, and method dispatch. Call arguments and operator operands are spelled explicitly: add(*p, *q), *p + *q.
  • Matching a &T/&var T scrutinee matches against the referent's own patterns.
  • &*p is a reborrow that shares the referent's storage; reborrowing a &var T as &T downgrades to shared.
  • Index-path write-through works through a reference: xs[0] = 9 for xs: &var i64[].
  • Tuple elements are assignable — t.0 = v, t.0 += v, and nested and chained forms — including through a &var reference.

Breaking changes:

  • Assignment to a reference-typed binding now rebinds it, like every other type. *p = v is the spelling that writes through. Previously a bare p = v wrote through the reference, which made repointing a &var T unrepresentable. Migration is mechanical: p = v becomes *p = v.
  • Write-through takes one * per reference layer. The previous rule peeled every layer at once, so pp = 5 on a &var &var i64 wrote the innermost value; it is now **pp = 5. In exchange, *pp = &var m repoints the inner reference, which the old rule could not express.
  • & applied to a field or element now aliases the original storage instead of snapshotting a copy, so later writes are visible through it. It remains read-only.

Diagnostics:

  • == and != on operand types the evaluator cannot compare — references, structs, enums, arrays, tuples, unit — are rejected at compile time (T0005) instead of aborting at run time with an internal error.
  • A binary operator whose operands disagree now names the operator: operator `==` cannot be applied to an integer literal and `String` rather than a bare cannot unify.
  • Address-of a non-addressable place — a literal, a call result, a struct or enum construction — is a compile-time error with a span, not a runtime internal error. The rule was always static-determinable.
  • &var *r on a shared reference is a compile-time error rather than a runtime failure.
  • Assigning to a tuple element out of range, or through an immutable binding, reports a type error instead of an internal error.

Fixes:

  • A closure with no declared return type is no longer typed () at the call site. Pass 1 inferred it correctly and pass 2 discarded it; let f = () -> { 42 }; let n = f(); n + 1 failed.
  • Type-directed read-copy decides whether to peel against the substituted type, so let n: i64 = g(); works for fun g() -> &i64 — previously only the syntactically-a-reference forms did, and a call returning &T did not.
  • Generic bodies constructed at call time use the argument and receiver types recorded at the call site, refined over the runtime-derived ones. An empty collection has no element to sample, and the resulting Never coerced without ever pinning a type parameter, so [].eq(&[]) failed with an error pointing inside std::core.
  • A bare variant that can never resolve is reported rather than silently accepted.

Dispatch and bounds:

  • Two aspects may register a same-named method against the same generic or structural targetT[], Wrapper<T> — without silently aliasing. The single-slot registries previously kept whichever impl was registered last, regardless of which one's bounds the concrete instantiation actually satisfied, so calls could dispatch to the wrong impl. Affects nominal generic structs identically, not only arrays.
  • A generic struct or array implementing Iterable<T> genericallyextend<T> Wrapper<T>: Iterable<T>, rather than a concrete extend Counter: Iterable<i64> — now derives its for-in element type correctly. Two separate paths were wrong: inference read the registry's recorded type arguments, which for a still-generic impl are the impl's own parameter names rather than types, and construction searched only the concrete method environment, never the polymorphic one.
  • An associated type's declared bound is registered on its projection. An aspect may write type Item: Display;, but the placeholder minted for Self::Item never carried that bound, so chaining directly onto a projection result — c.get().to_string() — failed with a spurious "cannot infer receiver type" (T0002).

v0.10.0

Released 2026-07-17.

Language surface:

  • public, var, and extend are now the canonical spellings. The old pub, mut, and impl declaration spellings are removed.
  • Empty aspect declarations may be written as aspect Name;.
  • Bodyless positive and negative aspect implementations are accepted: extend Type: Aspect; and extend Type: !Aspect;.
  • Zero-field structs and zero-field enum variants may be constructed with or without braces: Empty / Empty {} and Flag::On / Flag::On {}.
  • return, break, and continue are expressions of type !, so they work in braceless if arms, match arms, loop tails, and other expression positions.

References and control flow:

  • Reference types are now spelled &T and &var T.
  • Explicit dereference syntax is gone; field access, method calls, function calls through references, type-directed reads, and write-through assignment handle ordinary reference use.
  • Reference operations chain through multiple layers such as &&T and &&var T.
  • The bottom type ! is user-writable, coerces to any type, participates in exhaustiveness for uninhabited enum variants, and is checked for -> ! functions.

Aspect and type system:

  • Conditional aspect implementations are enforced, including where clauses and negative bounds.
  • Aspect implementation coherence is enforced with orphan-rule and overlap checks.
  • Negative bounds (T: !Aspect) and negative implementations (extend Type: !Aspect;) participate in bound checking and coherence.
  • Associated types are supported in aspects and implementations, including projections such as T::AssocType and equality-constrained bounds.
  • Return-position impl Aspect is supported as an opaque static return type.
  • Structural aspect implementations over built-in constructors such as arrays participate in aspect-bound satisfaction.
  • Bare-parameter blanket implementations such as extend<T> T: Aspect are allowed only when the aspect is local to the declaring module.
  • Coherence's disjoint-negation overlap check now recognizes structural targets (arrays, tuples, function types) the same way it already did for named types: two conditional implementations for the same structural target, distinguished only by a positive versus negative bound on the same type parameter, no longer incorrectly conflict.

Standard library:

  • Perhaps and Result gain .yolo().
  • Perhaps gains .ok_or(error).
  • Result gains .map_err(f) and .ok().

Breaking changes:

  • Replace pub with public.
  • Replace mut bindings with var bindings.
  • Replace impl blocks with extend blocks.
  • Replace *T / *mut T with &T / &var T.
  • Remove explicit *p dereference syntax.

Fixes and cleanup:

  • Generic method bodies now recover the receiver's own type parameters when reconstructing method dispatch.
  • Zero-argument generic calls can use the caller's expected type when arguments alone do not determine all type parameters.
  • Aspect dispatch, import resolution, and the runtime type registry now use stable symbol identities, avoiding same-name collisions across modules.
  • Generic bounds are preserved when a type variable is aliased to another type variable during inference, instead of being silently dropped.
  • The RFC/process documentation was reorganized around the current public docs and implementation state.

v0.9.1

Bug fixes.

Fixes:

  • print and println now print any value whose type implements Display, dispatching the user's to_string — previously a struct or enum with a Display implementation typechecked but panicked at runtime, and had to be printed via an explicit .to_string()
  • A tuple type now accepts an array suffix: (T, U)[] parses and typechecks in return, parameter, local-annotation, and struct-field positions (and a tuple is accepted as a generic type argument, e.g. List<(String, String)>)

Testing and tooling:

  • The integration harness now runs evaluator and typechecking fixtures through the same full module pipeline as the shipped binary, eliminating the old single-program shortcut that drifted from real std::core behavior

v0.9.0

The first presentable standard library.

Language:

  • Methods on generic types now work end-to-end. Generic structs and generic enums can carry methods with their own type parameters (fun map<U>(self, f: (T) -> U) -> Box<U>), closures, and match self, and they dispatch correctly across module boundaries. This unblocks the standard library's methods on Perhaps, Result, and List

Standard library — std::core (auto-imported):

  • Perhaps<T> combinators: is_some, is_none, map, and_then, unwrap_or, unwrap_or_else
  • Result<T, E> combinators: is_ok, is_err, map, and_then, unwrap_or, unwrap_or_else
  • List<T> ergonomics: map, filter, fold, find, concat (in addition to new/from/push/pop/len/get/as_slice)
  • String utilities: is_empty, to_upper, to_lower, trim, trim_start, trim_end, contains, starts_with, ends_with, index_of, split, replace, repeat, chars, char_at, substring, and the associated String::join. Index-based operations count Unicode scalars and are total (out-of-range clamps or returns None)
  • OsError — the error type for the host modules, with a Display implementation and a message() accessor

Standard library — host modules (explicit import):

  • std::envget(name) -> Perhaps<String>, vars() -> EnvVar[] (read-only)
  • std::fs — text-oriented file operations (read_to_string, write_string, append_string, exists, read_dir, create_dir, create_dir_all, remove_file, remove_dir, remove_dir_all), all returning Result<_, OsError>
  • std::processargs() and shell-free synchronous run(command, args) -> Result<ProcessOutput, OsError>

Known gaps (tracked):

  • std::math and the comparison-dependent List methods (sort, contains) await a forthcoming Ord/Eq aspect

(The print/println Display limitation and the tuple array-suffix parse gap noted here at release were fixed in v0.9.1.)

v0.8.3

Standard library expansion and module system clarifications.

New language features:

  • Function overloading — a module may declare multiple free functions with the same name, distinguished by parameter types. Resolution is exact-match only: argument types must equal a candidate's parameter types exactly, with no implicit numeric coercion participating in selection (bare numeric literals default before selection, so f(42) picks an i64 overload). Overloaded functions must be non-generic with every parameter annotated; calls with no matching candidate list all available signatures in the error
  • Aspects can now be implemented for primitive types — extend i64: Display { … } and the like typecheck and run; the standard library's Display and From implementations for the primitives are declared this way
  • native declaration syntax for stdlib-only host-backed implementations — free functions, methods, and aspect methods can be marked native with an explicit binding key. Reserved for the standard library; using it in user code is a compile error

Standard library (breaking):

  • print/println now require Display at compile time — passing a type with no Display implementation is a type error (T0012) instead of a runtime panic
  • A module's function overloads extend, rather than replace, a same-named standard-library function: if no overload matches exactly, the call falls back to the outer binding (e.g. overloading print for specific types keeps the generic print reachable for everything else)
  • assert is now overloaded: assert(cond) and assert(cond, msg). The separate assert_msg function is removed — replace assert_msg(c, m) with assert(c, m)
  • string_len(s) is removed in favour of a len method on String: use s.len()
  • string_concat(a, b) is removed — use the + operator: a + b

Module system:

  • Using std as a top-level module name is now a compile error. A file at std.mtl or anywhere under std/ in the project tree produces: error: module path std::… is reserved for the standard library. The std keyword was already reserved in the language syntax; the interpreter now enforces the same reservation at the module path level.

Internal improvements:

  • std::core is now a real module compiled into the interpreter binary and checked through the normal module pipeline, rather than a set of hand-registered builtins — the entire core surface (Perhaps, Result, Display/From/Iterable, List<T>, print/println/assert/…) is declared in standard library source. No user-visible behaviour change; import std::core::… works as before
  • Overloaded calls dispatch by stable symbol identity rather than by name throughout the pipeline
  • Symbol definition index — every declared symbol now has a stable definition site recorded during name resolution; used by diagnostics and future tooling
  • Error span accessor — all error variants that carry source location now expose it through a uniform interface

v0.8.2

Generic function recursion and forward-reference fix.

Bug fixes:

  • Generic self-recursion now type-checks correctly; a generic function can refer to itself inside its own body without triggering T0003 undefined name
  • Generic forward references now work the same way as monomorphic forward references; a generic function can call a later generic function declared in the same scope
  • Mutual recursion across generic functions now type-checks and evaluates correctly; the pre-inference hoist pass now registers generic function schemes before any body is inferred

Performance improvements:

  • Incremental constraint solvingInferContext::solve() now caches the solved substitution for the append-only prefix of the constraint list instead of re-solving the full set on every eager partial solve. This removes the dominant 0.8.2 baseline bottleneck in generic-heavy programs
  • Typechecker sub-phase profiling — the benchmark harness now reports registry, inference, solve, scheme-environment, construction, and finalize timings so optimization work can target the real hot paths rather than evaluator guesses
  • Benchmark/profiling workflowmetel-bench now benchmarks evaluator integration fixtures through the same parse → typecheck → evaluate path used by the test suite and emits machine-readable summaries plus call-graph artifacts
  • Measured impact on the release benchmark suite — representative total runtime improvements from the original 0.8.2 baseline:
    • int_04_generic_algorithms.mtl: 1662.887 ms160.724 ms
    • int_01_statistics.mtl: 675.241 ms87.376 ms
    • int_03_generic_option_chain.mtl: 431.502 ms76.467 ms
    • int_05_generic_data_pipeline.mtl: 357.644 ms66.298 ms
    • int_11_generic_sized.mtl: 157.804 ms27.107 ms

Internal improvements:

  • hoist_fun_decls now pre-registers generic function schemes and their aspect bounds, so generic visibility follows the same pre-pass architecture as monomorphic recursion instead of relying on per-function provisional bindings
  • Regression coverage added for generic self-recursion and generic mutual recursion in both the typechecking and evaluator integration suites

v0.8.1

Post-inference elaboration pipeline. No new language surface.

Internal (interpreter architecture):

  • Elaboration pass — a dedicated elaborator stage runs between the typechecker and evaluator and resolves every MethodDispatch call site to Inherent or Aspect { aspect_id } before evaluation begins. The evaluator now accepts ElaboratedModuleGraph (a newtype proof that elaboration has run) instead of TypedModuleGraph directly.
  • SymbolId infrastructure — every top-level declaration is assigned a stable SymbolId by the name resolver at declaration site, and every import binding carries the same SymbolId. Builtin types and aspects have reserved IDs (1–99); user-defined symbols start at 1000.
  • SymbolId-keyed aspect dispatchRuntimeAspectImpl carries aspect_id: Option<SymbolId> alongside its string name. RuntimeRegistry::get_aspect_method_by_id matches on aspect_id first, eliminating cross-module name collisions where two unrelated aspects share a method name.
  • Ambiguous same-type aspect methods rejected — if two distinct aspects define the same method name on the same receiver type, elaboration now rejects the call with T0013 instead of silently picking one impl by traversal order.
  • Environment documentationTypeDefinitionRegistry is annotated with its elaboration interface; ElaboratedModuleGraph carries a responsibilities table; architecture.md, typechecker.md, and evaluator.md are updated to reflect the new stage.
  • Regression suite — four new full-pipeline fixtures cover: polymorphic calls across modules, cross-module aspect dispatch, two aspects with the same method name on different receiver types, and inherent/aspect method coexistence.

v0.8.0

Sized numeric types, Char, List<T>, fixed-size arrays, turbofish, and fat-pointer &var.

New language features:

  • Sized numeric typesi8, i16, i32, i64, u8, u16, u32, u64, f32, f64. Sized literal suffixes: 42i32, 3.14f32, 255u8. All casts between sized types are explicit (as). Array indices must be u64.
  • Polymorphic numeric literals — unsuffixed integer and float literals unify with whatever numeric type the context demands (let annotation, function parameter, struct field, return type, or the other operand in a binary expression). Without context they default to i64 / f64. mut reassignment (m = 99 where m: i32) also propagates the declared type to the literal. Negative minimum literals (-128i8, -32768i16, -2147483648i32) are accepted at the lexer level.
  • Cross-sized numeric From impls — all 90 pairwise casts among the 10 numeric types are supported via as (i8 as u32, f32 as i64, etc.). Previously only i64 ↔ f64 was supported.
  • Char type — Unicode scalar value; single-quoted literals ('a', '\u{1F600}'); u32::from(c) and Char::from(n) conversions; implements Display
  • List<T> — standard growable-sequence type in std::core; replaces ad-hoc array_push usage; methods: new, from, push, pop, len, get, as_slice
  • Fixed-size array type [T; N] — compile-time-known length; repeat construction [v; N]; coerces to T[]; .len() method; array patterns on [T; N]
  • Turbofish — explicit type arguments at call sites: f::<T>(args), zip::<A, B>(as, bs)
  • &var for lvalue paths&var obj.field, &var arr[i], and chains thereof produce a *mut T that writes back to the original storage location

Bug fixes:

  • Generic functions with multiple independent type parameters (e.g. fold_left<T, A>) no longer have their type parameters collapsed when a module-level constraint solve follows a single-parameter generic function
  • Same-tier glob import conflicts (import a::* and import b::* both exporting the same name) no longer raise an error at import resolution; the error fires at the first use site of the ambiguous name
  • &var x on a non-var binding is now a type error (T0006); previously accepted silently, allowing immutable bindings to be mutated through a pointer
  • Field assignment (p.field = v) on a non-var binding is now a type error (T0006); previously the field mutability check was missing, allowing struct fields to be mutated through an immutable binding

Breaking changes:

  • array_push and array_len are removed as top-level built-in functions; use List<T> for mutation and .len() on arrays and lists
  • Code that previously relied on &var x or p.field = v with a non-var binding will now fail typechecking

v0.7.0

Language quality, pointer semantics, closure stabilisation, and aspect bounds.

Breaking changes:

  • Anonymous closure expressions now use (...) -> ... { ... }; fun(...) is no longer accepted in expression position, and function types are written as (T) -> U
  • Struct fields are module-private by default; cross-module field access and construction now require pub on each exposed field
  • Mutable bindings now use var; standalone var x = value; is no longer accepted, and for / for-in bindings use the same var form

New language features:

  • Explicit receiver semantics — methods may declare &self (shared read) or &var self (shared mutable) receivers; &var self mutations are visible to the caller without a writeback convention
  • Regular and mutable pointer types&expr and &var expr produce Pointer<T> and MutPointer<T> values; assignment through *ptr and function-pointer auto-deref are supported
  • Aspect bounds on generic type parameters — functions, structs, and enums may now declare aspect bounds on their type parameters; bounds are enforced by the typechecker and violation is error T0012:
    • Inline single bound: fun foo<T: Comparable>(x: T), struct SortedList<T: Comparable>
    • Inline multi-bound with +: fun foo<T: Comparable + Printable>(x: T)
    • where clause: fun foo<T>(x: T) where T: Comparable + Printable
    • impl Aspect anonymous parameters: fun foo(x: impl Display)
    • Aspect methods declared by a bound are available on the type parameter inside the function body
    • T0012 is emitted at the call/construction site with span on the offending argument
  • String interpolation (${expr}) — string literals may contain ${…} placeholders; each hole desugars to .to_string() concatenated with surrounding fragments via +
  • String concatenationString + String -> String
  • Aspect default methods — an aspect method may provide a default body; impl blocks may omit defaulted methods and inherit them automatically
  • Self in impl signaturesSelf may be used as a parameter or return type in impl method signatures
  • Match arm blocks — match arm bodies may be a block in addition to a bare expression

Bug fixes:

  • Computed index assignment (arr[i + 1] = v, s.data[offset * 2] = v) now works correctly; previously any computed index expression caused an internal error
  • &var self methods on nested struct fields now mutate in place
  • impl methods with T-typed parameters on generic structs now resolve correctly in Pass 2
  • Bounded type parameter method dispatch correctly enforces arity and argument types
  • ? (error propagation) — routed through From-based coercion; typechecker emits T0007 when no From impl exists
  • Generic functions returning an ascribed None : Perhaps<T> now correctly constrain the inferred return type

Tooling:

  • CLI version is derived from CARGO_PKG_VERSION rather than a hardcoded string
  • Source file extension corrected to .mtl throughout public docs
  • mod and use removed from the reserved keyword list

Spec clarifications:

  • pub is not valid on top-level let or mut bindings

v0.6.4

Module system technical debt.

Internal improvements:

  • TypeDefinitionRegistry is now used as the cross-module type accumulator in check_graph, replacing the Vec<Decl> approach that cloned raw AST nodes; cross-module struct field type references now resolve correctly even when the field type comes from an indirect dependency
  • InferContext::new accepts imported_schemes directly, enforcing the dual-registration invariant (inference + construction passes both see imported names) at the type level
  • declared_names map added to ResolvedNames during name resolution, replacing an O(n) AST scan in build_import_schemes for T0009/T0003 distinction
  • resolve_path_root extracted to src/module_paths.rs as a single shared implementation for both module_loader and name_resolver; fixed a regression where the Name path root incorrectly doubled the module name segment
  • StdPrelude::schemes() / evaluator builtin parity assertion added as a compile-time-checked test

Compatibility:

  • No language-visible changes.

v0.6.3

Module system — feature complete.

Bug fixes:

  • return and break are now valid as bare match arm bodies without enclosing braces: arm => return value
  • Diamond module dependencies (same physical file reachable via two different logical paths) no longer fail with T0003; the name resolver now dereferences path aliases to their canonical form

Internal improvements:

  • ? operator desugared in a pre-pass (path_normalizer::desugar_propagate_error) rather than carried through inference and construction; Expr::PropagateError no longer exists after normalization
  • Type::Perhaps and Type::Result convenience variants removed from the Type enum; both types are now represented uniformly as Type::Named("Perhaps", ...) and Type::Named("Result", ...)
  • Per-module isolated runtime environments validated with cross-module closure-capture and mutual-recursion tests
  • All aspect method dispatch key construction routed through ImplMethodKey::to_env_key(), eliminating ad-hoc format strings in the evaluator

Compatibility:

  • No language-visible changes except the match arm body fix, which is purely additive.

v0.6.2

Evaluator normalization.

Internal improvements:

  • Value::Perhaps and Value::Result dedicated variants removed; all Perhaps and Result values now use the general Value::Enum { name, variant, fields } representation, eliminating special-case dispatch throughout the evaluator
  • evaluate_graph now initialises each module in its own isolated Environment seeded with builtins and cross-linked via the imported_names table populated by check_graph; replaces the flat-merge strategy from v0.5.0

Compatibility:

  • No language-visible changes. All existing programs produce identical output.

v0.6.1

Type system cleanup and std::core virtual module.

Internal improvements:

  • Unified TypeDefinitionRegistry replaces four separate flat maps (struct_env, method_env, enum_env, aspect impls) in the type inference and construction passes; a single registry instance is now the source of truth for all type and impl data
  • ImplMethodKey enum replaces flat string concatenation for impl method dispatch keys in the evaluator
  • StdPrelude::default() is the single source of truth for all built-in function schemes, eliminating the previous divergence between the inference and construction registries

New language features:

  • std::core virtual module: Perhaps, Result, Display, Iterable, From, and all built-in functions are available in every module without any explicit import
  • Glob import tiers: the runtime auto-imports std::core at Std tier (lowest priority); user import path::* declarations use User tier and silently win over Std tier without a conflict error

Compatibility:

  • All existing programs are unaffected; std::core names that were previously available globally continue to work without import statements

v0.6.0

Module semantics.

Enforced module semantics (previously deferred from v0.5.0):

  • Visibility enforcement: pub is required for a declaration to be importable; private items produce a compile-time error (T0009) when referenced from another module
  • Import scoping: only names brought in scope by import are accessible; accessing an undeclared name is a compile-time error (T0003)
  • Alias resolution: import mod::name as alias makes alias callable and removes name from scope
  • Import conflict detection: two imports binding the same local name produce a compile-time error (T0011); explicit imports silently win over conflicting glob imports
  • Glob visibility filtering: import mod::* now includes only pub items from the source module; private items are excluded
  • Re-export propagation: names re-exported via export are part of the facade module's public API and importable by consumers without importing the underlying module directly
  • pub declarations require complete type annotations (T0010): every parameter and the return type must be annotated on a pub fun

Internal improvements:

  • Name resolver wired into the type-checking pipeline (load_root → resolve → normalize → check_graph → evaluate_graph)
  • Flat-merge compatibility shim and last-segment fallback removed
  • root::, self::, and super:: path roots now compute correct module paths in both the loader and name resolver

Compatibility:

  • Single-file programs and programs using only pub items across module boundaries are unaffected
  • Programs that imported private items or relied on global declaration visibility will need pub annotations added

v0.5.0

Module system.

New language features:

  • Multi-file programs: each .mtl file is a module; the module graph is built from import declarations
  • import path::Name; both loads the referenced file and brings Name into scope
  • Import forms: single name, alias (as), group ({A, B}), glob (*), module handle
  • export path::Name; re-exports a name from a submodule into the current module's public API
  • pub on fun, struct, enum, and aspect marks declarations as externally accessible
  • Absolute and relative path roots: root::, std::, self::, super::
  • Fully-qualified paths valid in type and expression position without a preceding import
  • Circular imports detected at load time with a full chain in the error message
  • Facade modules: parser.mtl alongside parser/ directory — no special mod.mtl file
  • File-to-module mapping via ::/ with no special cases

Shipped in v0.6.1:

  • std::core auto-import and standard library core types

Compatibility:

  • Single-file programs with no import or export declarations remain valid without modification

v0.4.2

Evaluator refactor, test restructure, and keyword cleanup.

Breaking changes:

  • Perhaps::Nope renamed to Perhaps::None; the standalone nope keyword is now None

v0.4.1

Technical debt, bug fixes, and internal cleanup.

Bug fixes:

  • TypeErrorCode::T0005 ("Invalid operand types") is now emitted for arithmetic operators (+, -, *, /, %) applied to non-numeric types (e.g. true + false is now a type error)
  • Unary negation (-) on non-numeric types is now a type error
  • Ordering comparisons (<, <=, >, >=) on non-comparable types (non-numeric, non-String) are now type errors
  • Pattern::Nope latent bug eliminated — nope values are now exclusively Value::Perhaps(None), so the pattern can no longer silently miss the Value::Enum { name: "Perhaps", variant: "Nope" } form

Internal improvements:

  • Value::YoloResult renamed to Value::Result; Perhaps and Result values are now first-class runtime variants — no longer stored as Value::Enum
  • Large enum variants boxed in Decl, Stmt, TypedDecl, TypedStmt (stack frame sizes reduced from 896–1040 bytes to 8 bytes)
  • Dead utility methods removed (Program::new, Type::is_numeric, Type::is_unit); reserved fields annotated with #[allow(dead_code)]
  • All clippy style/idiom warnings resolved

v0.4.0

Aspects and upgraded builtins.

New language features:

  • Aspect declarations — aspect Foo { fun method(self) -> T; }
  • extend Type: Aspect blocks with method dispatch via .method() syntax
  • Iterable<T> aspect — user-defined types usable in for-in loops
  • From<S> aspect — as cast desugars to T::from(value); user-defined casts for any type pair
  • Display aspect — .to_string() on i64, f64, boolean, String; print/println polymorphic via Display
  • ? operator now supports cross-type error coercion: if the function's error type E2 implements From<E1>, ? calls E2::from(e) automatically

Builtin changes:

  • print(v) and println(v) are now polymorphic (<T: Display>) — accept any Display type
  • i64::from(f: f64) and f64::from(n: i64) built-in From impls replace the hardcoded as special case
  • Deprecated: print_int, println_int, print_float, println_float, int_to_string, float_to_string, bool_to_string (use .to_string() and polymorphic print/println)

Bug fixes:

  • Keyword-prefix identifiers (break_sum, return_value, let_x) now parse correctly as identifiers
  • Multiple extend Y: From<X> blocks with different source types now dispatch independently

v0.3.0

Generics and type-inference improvements.

New language features:

  • User-defined generic functions — fun id<T>(x: T) -> T — monomorphised at each call site
  • User-defined generic structs — struct Box<T> { value: T }, struct Pair<A, B> { ... }
  • User-defined generic enums — enum Maybe<T> { Some { value: T }, None {} }
  • Let-polymorphism — unannotated let-bound closures are generalised to polymorphic schemes (let id = fun(x) { x } works at i64, boolean, and String in the same scope)
  • Braceless if body — if (c) expr and if (c) a else b
  • struct and enum declarations are allowed inside function bodies

Type-inference improvements:

  • expected_ty propagates into match arm bodies — bare [] and nope resolve without ascription when the surrounding return type is known
  • Callee parameter types propagate into argument construction — find(words, nope) resolves without ascription when the parameter type is Perhaps<String>
  • Lvalue path assignment — obj.field = val and arr[i] = val work on non-bare receivers (e.g. get_foo().bar = 1)

v0.2.0

Evaluator improvements, DX features, and language quality fixes.

New language features:

  • Type ascription operator :[] : i64[] guides type inference without runtime cost
  • Shorthand struct field initialisation — Point { x, y } desugars to Point { x: x, y: y }
  • Trailing commas allowed in function parameter lists and argument lists

New built-in functions:

  • assert(cond: boolean) — panics with "assertion failed" if cond is false
  • assert_msg(cond: boolean, msg: String) — panics with msg if cond is false
  • dbg<T>(v: T) -> T — prints [dbg] <value> to stderr and returns the value unchanged
  • print_int(n: i64), println_int(n: i64) — print an i64 without/with newline
  • print_float(f: f64), println_float(f: f64) — print a f64 without/with newline

Bug fixes:

  • Arrays now have value semantics — binding an array to a new variable produces an independent copy
  • Error spans now report file:line:col instead of raw byte offsets
  • Complex expressions (field access, calls) are now valid array index operands

Developer experience:

  • Runtime panics now include a call-stack trace showing function name and call site

v0.1.0

Initial language version. Implemented by the tree-walk interpreter.

Features included:

  • Primitive types: i64, f64, boolean, String, ()
  • Variables: let (immutable), mut (mutable), lexical scoping, fun/type hoisting
  • Functions: first-class values, closures with mutable capture, ? operator (exact error type match only)
  • Structs: literals, field access, methods (impl), var self, associated functions
  • Enums: unit and struct-like variants, impl blocks
  • Built-in generic types: Perhaps<T>, Result<T, E>, Array<T> / T[] (as special cases; user-defined generics are v0.3.0)
  • Exhaustive pattern matching: all pattern kinds (see Pattern Kinds)
  • Control flow: if/else, while, for, for-in (arrays and ranges only), loop, break/continue, return
  • Type casting: as for i64 ↔ f64
  • Never type (!)
  • Tuples
  • Built-in functions (see Built-in Functions)

Not included (v0.3.0+):

  • User-defined generic functions and types (see Generics)
  • User-defined aspects and extend Type: Aspect (see Aspects)
  • From-based ? coercion across different error types (see The ? Operator)
  • User-defined Iterable<T> implementations (see For-In)