Mettle v0.16.0
Mettle v0.16.0
The next generation.
250 commits since v0.15.1. Mettle is now a single repository: the compiler, the backend, the linker, and the runtime build from one tree with no pinned dependency between them. This release adds a checked-access memory-safety mode, compile-time reflection, verified hot code swapping, and gives string a real library after fixing the reason it never had one.
Breaking changes
Narrowing conversions need an explicit cast
Widening still happens silently; narrowing reports M0119 and stops the build.
var n: int64 = 300
var a: int32 = n // M0119
var b: int32 = (int32)n // says the wrap is intended
This will touch existing code. The diagnostic names the file, the line, and the range the destination type holds.
print and println take string, not cstring
Call sites passing a cstring need print_cstr / println_cstr.
== and != on strings compare contents
They previously compared the 16-byte record as a scalar and were always false, including "ab" == "ab". Code that worked around this with streq still compiles; code that silently never matched now matches.
Other
- A parameter can no longer be shadowed by a local of the same name.
- Heap allocation is typed at the boundary. Allocators hand out
rawptr, which converts to and from every pointer type, sovar a: int32* = malloc(n)andfree(a)need no cast. - The editor extensions moved to their own project.
- libmtlc is no longer a pinned dependency.
libmtlc.versionis gone and the backend compiles fromsrc/. It still ships as a release asset and the fetchers still work; what changed is that its API version (mtlc_version(), nowlibmtlc 0.2.0) is independent of the toolchain version. See Is libmtlc still a separate artifact?
Memory safety: --safe
--safe checks every access the compiler cannot prove in bounds, and reports what survived.
The work went into making the checks cheap: indices resolved against the heap, globals, and stack locals; an index recognized as coeff * counter + invariant + const; a single whole-loop check hoisted through bodies that branch and rejoin; a surviving check lowered to a comparison rather than a call. A checked function keeps its register allocation.
What it does not cover is written down in docs/memory-safety.md.
Hot code swapping
A function can be replaced in a running process, at a point the program names.
policy(5); // 6
mettle_swap_stage(&policy, &policy_v2);
policy(5); // still 6, staged but not applied
quiesce;
policy(5); // 50
Staging records an intent; quiesce; is the only place it takes effect, so a replacement can never land mid-operation.
@swappable marks a function replaceable and keeps the call boundary a swap redirects: it implies @noinline, and @swappable @inline is refused, because an inlined body has no call to redirect.
The binding is a slot holding a function pointer, so applying a swap is one pointer-sized store. No code is modified, no page is made writable, and a thread already inside the old body finishes there.
| Tool | What it does |
|---|---|
mettle swap-check old new |
runs the differential harness over two functions; refuses a changed signature |
layoutof(T) |
layout digest, so a swap cannot silently reinterpret a value |
A program with no quiesce; never links the swap runtime.
Compile-time reflection
comptime for walks a type's fields, Type and Field queries answer at compile time, and at module scope it generates declarations.
New in this release: fieldof(T, name) reaches a Field through a compile-time string, which is the one way into the field table a metaprogram can compose. Every other spelling needs the name written in source.
comptime for f in typeof(Packet).fields {
total = total + (int32)fieldof(Packet, f.name).type.size;
}
Compile-time strings now compare, so a contract can span two declarations.
mettle expand prints the expansion as source, and mettle trace names the iteration behind each value instead of merging them:
21 | total = total + f.offset ... <- (field `kind`) total = 100; (field `seq`) total = 505
Strings
string had two representations at once: a value was a pointer to a {chars, length} record, a local was the record itself. Sites disagreed silently, and returning a string built in the callee wrote its fields through an uninitialized pointer. Nothing in the corpus returned a string, so nothing caught it.
string is now an aggregate and moves like the struct it is: copied whole, returned through a hidden pointer, stored inline.
That unblocked a real library. std/conv gains:
str_slice(s, start, len) |
the view at start, clamped to what s holds |
str_starts_with / str_ends_with |
1 or 0 |
str_eq_at(s, offset, needle) |
whether needle's bytes sit at offset |
str_find / str_find_byte |
first index, or -1 |
str_contains |
1 or 0 |
str_trim / str_trim_start / str_trim_end |
whitespace removed |
str_split_once(s, sep) |
(head, tail, found) |
str_to_i64(s) |
(value, ok); needs no terminator |
i64_to_str(n, buf, buf_len) |
writes into buf, returns a view of it |
Every returned string is a view into its input: nothing allocates, nothing copies.
var head: string = "";
var tail: string = "";
var found: int32 = 0;
(head, tail, found) = str_split_once("id=907", "=");
var value: int64 = 0;
var ok: int32 = 0;
(value, ok) = str_to_i64(tail); // 907, no allocation anywhere
atoi, atol, cstr_len, and streq keep cstring as the C boundary.
Optimizer and vectorizer
The vectorizer reads shapes it previously declined:
- an
ifthat only selects a value, read as a value - counting under a predicate, without a branch
- a comparison used as a value
- a scan seeded from the first element
- a global array's base hoisted above its loop
- byte maps run in int32 lanes
- a running maximum recognized as the operator it is
--explain reports what each loop and call became and why. Its fix suggestions are applied to a clone and re-checked before printing, and a guard that was discarding every proven inline fix is repaired.
Correctness
Each of these was a wrong answer, not a missed optimization:
==on strings was always false- returning a string built in the callee crashed, or returned garbage under
--release - a predicated accumulate read the branch it had just retired
- one arm of a select read the other arm's write
- a call with many arguments built the wrong frame
- three miscompiles around the integer range rule
deferdid not run onbreak,continue, labeled jumps, orswitchcases
Diagnostics
One mistake now produces one diagnostic, and the parser resyncs at block boundaries so a syntax error no longer cascades.
A GPU-only construct compiled for a CPU target used to report an internal compiler error naming an IR opcode number. It now names the construct and the flag:
error: 'tensor_mma' in function 'gemm_tile' runs on a GPU and has no CPU
translation. Compile the module that defines this kernel with --emit-ptx
(NVIDIA) or --emit-spirv (OpenCL), and keep it out of the host program
Runtime
Each optional component is independently excisable, and the absence is checked on every build rather than asserted. The swap and string runtimes are written in Mettle, compiled by the compiler that ships them.
Installer
The Windows installer is rebuilt in the language's own palette, with artwork generated from the brand mark at every display-scaling step, in light and dark. A guard fails the build if the version drawn on the banner is not the version being built. It reports the correct version and links to the right repository, both of which were stale.
Documentation
docs/ideology.md sets out the rules the language holds itself to and what each one costs. docs/memory-safety.md covers --safe, and the backend has its own reference under docs/libmtlc/.
The compiler-debugging pages documented four dump flags the compiler rejects; they now describe what exists. Eight working flags that appeared nowhere are documented, and every example presented as a complete program compiles.
Tests
The suite is at 927 checks, from 636 .mettle fixtures at v0.15.1 to 792.
Full Changelog: v0.15.1...v0.16.0