On this page
Toolchain and backend Syntax choices where the two documents were silent Semantics narrowed for the first release Second pass: the gaps Toward self-hosting nexium-gui Editors and releases Translations Language gaps closed before the parser The standard library Installers The interactive session The standard library, phase 1 Talking to the world (phase 2) Sharing code (phase 3) Syntax, second pass Compiler selection RepositoryDecisions made while building the first compiler
This log records the calls made during the initial implementation that the specification left open, or where the implementation deliberately narrows the design for a first release. Each one is reviewable; none is load-bearing for the architecture. "Spec" means nexium-spec.txt; "archived" means nexium-systems-spec.txt sections 4 through 9.
Toolchain and backend#
- Compiler written in Rust, C emitted as the backend, compiled with
zig cc. The archived design (section 19) picks C emission for portability;zig ccgives a C compiler, a static archiver, and cross-compilation to any target with no toolchain install (spec section 9).--ccoverrides the compiler. - One translation unit per build. The runtime header is embedded into the generated C. No separate runtime library to link, satisfying S6.
- Hidden context parameter. Every internal Nexium function takes
nx_ctx*(allocator, RNG state, argv, stdio). This is the "implicit context" of the archived section 15. Exported functions create a context on the stack per call, so S1 (no initialization) holds.for parallelwould need an executor in this context and is not implemented yet. - Thread-local panic boundary. The only static in the runtime is a
_Thread_localpointer to the current panic boundary. Two Nexium libraries in one process each carry their own copy (S2). The runtime has since gained state of its own, all of it per translation unit and out of a program's reach (116, 126); 126 rewords the constraint.
Syntax choices where the two documents were silent#
- Struct literals are
Point{ .x = 1 }and anonymous.{ .x = 1 }, following the archived 4.1 example. Enum variants are.Variant(args)with the type inferred, orEnum.Variant(args). - ~~Conditions take parentheses (
if (x),while (x),for (xs) |x|), matching every example in the archived document. This also makesName{unambiguous.~~ Superseded by 87 in 0.6. - Optionals:
nullis the empty value,.?unwraps (panics),x orelse d,if let v = x { }(wasif (x) |v| { }before 87). A bare binding in amatchon an optional binds the payload;nullmatches the empty case. - Numeric casts are
value as T, checked at runtime when narrowing (contributespanicsunless the range analysis proves it).@truncate(T, x)wraps. Distinct types convert withasin both directions. - Logical operators are
and,or,!. Bitwise are& | ^ ~ << >>. - Compile-time builtins use
@:@typeName,@sizeOf,@truncate,@errorName,@embedFile,@weak,@refCount. impl Type { }andimpl Trait for Type { }declare methods; the receiver is the first parameter namedselfwith typeSelf,*Self, or*mut Self. Generic impls areimpl(T) Pair(T) { }.- Statement termination is the newline. A line beginning with
|>,.method(,catch,orelse,and, ororcontinues the previous expression; a line ending with a binary operator continues too. - Error sets are declared
error Name { A, B };error.Namevalues belong to the global set unless a set is named in the return type (ParseError!T).
Semantics narrowed for the first release#
- Pointers
*T/*mut Tare safe references created with&x/&mut xand dereferenced with.*. The archived section 12 lists raw pointer dereference as unsafe; here only pointer casts, integer/pointer casts,.ptrof a slice, foreign calls, and mutable globals requireunsafe. Reason:self: *mut Selfreceivers would otherwise forceunsafeinto every method. - Regions are checked conservatively. Rule R1 is enforced for the case that matters most: a function may not return a slice or pointer into one of its own locals (arrays, Lists, Strings, structs); parameters are exempt because their storage belongs to the caller. Views stored into outer variables are not tracked (archived O1/O2 leave the notation open). Exported functions may not return slices at all.
- Ownership of collections (
List,String,Map, and structs that contain them):let y = xmoves; a later use ofxis a compile error; moving out of a field or element is an error (.clone()instead); parameters are borrowed, so moving a parameter into a new owner is an error. Owned values are released at scope exit; that release is the only automatic action at scope exit (H7). Codegen zeroes moved-from locals so the release is always safe. ref classvalues copy by retaining;?Nodeandweak Nodetoo. Passing to a function does not retain (borrow). Cycles leak, as the spec says;weakbreaks them.- Effects are inferred for every function (not only within a module); negative bounds are enforced with diagnostics naming the introducing site. Calling through a function value acquires every effect its type permits (all of them unless the type says
fn(...) !allocates). panicsrange analysis follows E6's five sources: type ranges, literals, dominatingifguards on locals,foriteration facts (for (xs) |x, i|provesxs[i];for (0..xs.len) |i|provesxs[i]on the same local), and constant loop bounds. Anything else contributespanics.- Records: a literal with compile-time-known field values is checked at compile time (a violation is a compile error); a literal with runtime values panics if violated;
Record.new(.{ ... })returnserror.InvalidRecordinstead. printlnhas theblockseffect (archived section 15: reaching for stdout blocks) and does not allocate.formatallocates and returns aString.- The export ABI depends on the effect signature. An export that returns a plain value and is proven
!panicsgets a direct C signature. Any other export returnsint32_tstatus and writes its value through an out pointer; a panic becomes thePanicstatus (S3). The Python wrapper raisesNexiumError/NexiumPanic. - Python artifact uses ctypes over the shared library rather than a CPython extension module; this is the "stable ABI by default" of 4.2 taken literally (one wheel per platform, any interpreter version).
abi = nativeis accepted and ignored for now. - Test blocks return
!voidsotryworks inside them.nx testcompiles a runner;comptime testis parsed but runs at runtime for now. - Integer literals default to
i64, floats tof64when nothing constrains them.for (0..n)takes the type ofn. mainmay returnvoid,!void, or an integer exit code. An error returned frommainprintserror: Nameand exits 1; a panic prints the location and exits 101.- Not implemented in this release (reported as errors or notes rather than silently ignored):
nx publishand a registry.nodeandinstallerartifacts exist since 0.5;soa,packed,poolandstackwere removed from the language in item 88. Everything else listed in the first draft of this item now exists; see items 31 to 44.
Second pass: the gaps#
comptime testruns in the compile-time interpreter duringnx check; a failing expectation is a compile error at theexpectline.nx refcountswalks the typed IR and lists every retain, release, weak creation, and upgrade site with the function it is in.nx leaksruns the program in debug mode with a tracking allocator whose counters live in the context (no globals); a nonzero live count at exit is reported with total and peak bytes. Every example is leak-free.using arena { ... }installs a bump allocator for the block and frees it all at once at the end. EveryList,String, andMapremembers the arena it was created in (one pointer per container), so a container created outside the block keeps its storage on the heap even when it grows inside the block, and the arena forwards frees and reallocs of memory it does not own to the parent allocator. What is still not checked: a value created inside the block that is stored outside it (same status as regions).nx fmtis token based and line preserving: indentation, spacing, trailing whitespace, and blank-line runs are canonical; lines are never joined or split.cargo testchecks that every example is canonical.nx docemits one HTML page per program with signatures, fields, and the inferred effect set of each public function.nx sizecompiles with one section per function and reads section sizes back from the object file (ELF and COFF; Mach-O is reported as unsupported), attributing bytes to declarations, glue, and runtime.dyn Traitis a fat pointer{data, vtable}created by coercing*T/*mut T; vtables are generated per (trait, type) with thunks that adapt the receiver.dyn Trait !allocateschecks every implementation's inferred effects at the coercion site.Selfmay only appear in receiver position for a trait used as an object.for parallel (items) |x, i| { }extracts the body into a worker function that reaches outer locals through an environment of pointers, and the runtime splits the index range across hardware threads. The checker rejectsshared_mutablein the body (directly or through calls), and rejectsreturnandbreakinside it; a panic in a worker is re-raised in the caller after all workers finish. Data races through captured mutable locals are the programmer's responsibility, exactly as the spec's "free ofshared_mutable" rule implies.@cImport("header.h")runs the C compiler's preprocessor and reads functions (including variadics), typedefs, structs of scalars/pointers/ arrays, enums, and integer/float/string macros.const T*maps to*T, other pointers to*mut T,void*to*mut u8, Clongto i32 on Windows and i64 elsewhere. A struct with fields that cannot be translated (function pointers, bit-fields, nested definitions) becomes an opaque type usable through pointers, because that is how Apple'sFILEis defined and every stdio function takesFILE*. Unsupported declarations (unions, function-pointer typedefs, function-like macros such asstdout) are imported as names that explain themselves when used. Imported structs keep their C spelling in the generated code, so the header stays the single source of truth for layout.@cstr("...")gives a NUL-terminated*u8.- Vendored C is declared with
artifact link { c_sources = [...], libs = [...], include = [...], lib_paths = [...] }(spec 17.1), and the same is available as--c-source,--link,--link-path,-I. rustlibartifact: a Cargo crate with a build script linking the static archive,extern "C"declarations,#[repr(C)]structs, and safe wrappers returningResult<T, NexiumError>(with aPanicvariant). On Windows the static archive is compiled for the MSVC ABI so it links into Rust and MSVC programs.nx lspis a stdio language server with diagnostics on open/change/ save and hover over functions (signature plus inferred effects). It has no dependencies; JSON handling is in-tree. When a file has errors, hover falls back to the parsed signature without effects.- Region rule R1 is enforced conservatively (item 15).
Toward self-hosting#
process.run(argv) -> !i32spawns without a shell (CreateProcess on Windows with CommandLineToArgv-compatible quoting,posix_spawnpelsewhere), waits, and returns the exit code. stdout/stderr are flushed first so parent and child output interleave deterministically. A signal death reports as 128 plus the signal number.nx tokensis the lexer oracle:start end KIND [payload]per token, with integers in decimal, floats as their source text, strings and byte strings as hex of the unescaped bytes, chars as code points. The format is frozen becauseself/lexer.nxreproduces it.- The compiler runs on a 512 MB thread. A long else-if chain in
self/lexer.nxoverflowed the default main-thread stack in the checker; tree-walking compilers recurse, so the work moves to a big-stack thread instead of rewriting every walk iteratively. - Program output is binary on Windows (
_setmode(_O_BINARY)on stdout and stderr) so the same program prints the same bytes on every platform. The oracle diff depends on it. - The AST will be an id-arena. Nodes live in a
Listand refer to each other by index (examples/tree.nx), which needs no recursive types, no pointers, and frees in one release. Language gaps found writing the lexer, to close before the parser: parameters cannot be taken by value (moving aStringinto a struct field costs a.clone()), there is no raw-byte push onString(append_charencodes), andreturnis a statement, soorelse returnis not available. All three are closed in items 61 to 63.
nexium-gui#
- The GUI is immediate mode over a software framebuffer. Widgets are function calls that return what happened this frame, state lives in the caller's variables (passed as
*mut), and drawing is Nexium code writing into aList(u32). No retained widget tree, no callbacks, no closures: the style that suits a language with explicit ownership, and the one egui proved. The cost is CPU rendering, fine at 640x440 and 60 Hz. - The platform layer is the only C, about 200 lines: open a window, pump events into a queue, blit a framebuffer, sleep, time. Win32 today; other platforms compile a stub whose
gp_openreturns 0, so the library, its tests, and offscreen rendering work everywhere and only the window is missing. X11 and Cocoa backends are the same eight functions. - Text is a bitmap font embedded with
@embedFile, generated once from Pillow's own free bitmap font intogui/font.bin(8x13 cells, ASCII). Non-ASCII is skipped. A vector font is a later problem. - Widget identity is call order. Each widget takes the next id in the frame; hot, active, and focus are ids. Conditional widgets shift the ids of everything after them, which is the standard immediate-mode trade-off.
libs_windows/libs_linux/libs_macosexist because zig's gnu target links user32 but not gdi32 by default,#pragma comment(lib)is ignored there, and a plainlibsentry would break the Linux build.
Editors and releases#
- One TextMate grammar, two spellings. VS Code takes JSON and Sublime takes YAML with the same regexes and scope names, kept in step by hand; a generator would be more machinery than the two files. Tree-sitter, which Neovim, Helix, and Zed want, is a separate grammar and a later job.
- The extension is thin. Highlighting is the grammar; everything semantic comes from
nx lsp, so the editor never disagrees with the compiler and the extension has one dependency (the LSP client library). - GitHub highlighting borrows Zig's grammar through
.gitattributesuntil Linguist accepts Nexium, which requires usage in public repositories first. Zig's syntax is the closest match. - Installers stay outside
nx..dmg,.msi, and.debare made by the platform tools in CI from the binarynx buildproduced; the release template carries those jobs, off by default. The spec'sinstallerartifact remains unimplemented on purpose.
Translations#
- Translations live in
docs/i18n/<lang>/and mirror the English files by name. English is the source of truth; a translated page that falls behind is still linked, and its header switcher always offers the English original. Code, command names, error messages, and identifiers are never translated, because they are what the reader will type and what the compiler will print; only the comments inside code blocks are. The README is translated into six languages; the two documents people read first, the language reference and the architecture tour, into three. The rest follows as the English text settles.
Language gaps closed before the parser#
ownparameters.fn f(own s: String)takes ownership: the caller's argument is moved (a local is zeroed after the copy so its scope-exit drop is a no-op; a temporary is handed over without a caller-side drop), and the callee owns it: mutable, movable into a struct or another call, dropped at return otherwise.ownis a contextual modifier, only recognized before a parameter name. It is rejected on receivers, on copy types (where it would mean nothing), on exported functions (the host cannot hand over ownership), and a function withownparameters cannot become a function value becausefn(String)says nothing about ownership. The spec is silent on the syntax;ownreads as the counterpart of "borrowed" and does not collide with any identifier in the examples.- Moves are branch-aware. The moved-set is snapshotted before an
iformatch, each branch or arm starts from that snapshot, and afterwards the union counts as moved. Before this, moving a String in thethenbranch made it unusable inelse, which the lexer hit at once. - Jumps in expression position, narrowly.
return,break, andcontinueare still statements, but the right-hand side oforelseandcatchaccepts one (wrapped as a diverging block), and an unbracedifbody orelsebody is parsed as a statement, so assignments and jumps work there without braces.String.push_byte(u8)appends a raw byte;append_charkeeps encoding code points. Integer and float literals coerce into?T.
The standard library#
std/is Nexium source embedded in the compiler.import std.stringsloadsstd/strings.nxfrom thenxbinary the way the runtime header is embedded: nothing to install, one artifact, and the library is versioned with the compiler that compiles it. Std modules load after the program's own files so the root stays module 0.- What stays builtin.
List,String,Map, slices, formatting, and the platform namespaces need the compiler for ownership, effects, and the C runtime, and stay in Rust. Everything that is plain Nexium over those (strings,lists,bytes,numtoday) lives instd/, readable and testable withnx test. Module names never collide with the builtin namespaces, soimport std.mathstill means the builtin. - Tuple types are legal type arguments (
List((A, B))), whichlists.zipneeded. A tuple literal in type-argument position is read as a tuple type. - Recursive types through
List.enum Json { Arr(List(Json)) }is legal: a List holds a pointer, so the C backend forward-declares a struct or enum reached through a List or slice and defines it after the current type. Definitions that are only ever forward-declared are emitted before the functions. This is whatstd.jsonneeded. - Matching through a pointer binds owning payloads by reference. In
match p.* { .Arr(items) => ... }withp: *mut Json,itemsis a*mut List(Json)aliasing the payload, so appending mutates the value in place and returning&items[i]is a pointer into the caller's data, not into a local. Scalars are still copied. The rule is only active formatch p.*; a match on a value binds values as before. fn string(...)may not becomenx_string. User function names are mangled withnx_, which the runtime also uses; the backend appends_fnto any user name that collides with a runtime identifier instead of reserving the names in the language. Struct and enum names outside the root module are prefixed with their module index for the same reason:std.jsonandstd.argseach have aParser.
Installers#
- The Windows installer bundles Zig. Python's installer ships its runtime; Nexium's ships its C toolchain, so "install and run" needs nothing else.
nxlooks forzignext to itself before the PATH, so the bundled copy wins without configuration, and the component can be unchecked by people who keep their own Zig. Inno Setup was chosen over WiX because it gives the license, overview, components, tasks, and finish pages with no XML and is preinstalled on GitHub's runners. - macOS and Linux get a script, not a package.
install.shverifies the checksum, installs under~/.nexium, and edits the shell startup files;.pkgand.debwould need signing or repositories to be worth more than that. Zig is downloaded only on Linux when no compiler exists, because on macOS the Xcode tools are the compiler that works. - Zig stays required by the design, not by the user. As long as C is the backend, a C compiler is needed to build programs; the installers make that invisible. Dropping the requirement entirely means a native backend, which is self-hosting stage 4 territory and not planned yet.
The interactive session#
- The REPL is the compile-time interpreter with a persistent program. Each line is appended to a synthetic module and the whole thing is re-checked, so the prompt and the compiler can never disagree; only the new statements run, against values kept by name from earlier lines. The interpreter is allowed I/O in this mode and nowhere else. Re-checking everything per line is cheap at REPL scale and simpler than an incremental checker; values are plain data, so they survive the type table being rebuilt. Compile-only features (
@cImport,for parallel,using arena) are reported, not emulated. - Interpreter pointers name a call frame. A
Value::Ptris (frame, local, path): the frame index says which suspended caller's environment holds the local, so a pointer passed into a function still points at the caller's variable, andmatch p.*binds owning payloads as pointers into that place, exactly as the compiled program does. Paths step into enum payloads, optionals and error unions (index 0) as well as fields and elements.Mapis an insertion-ordered list of pairs in the interpreter; it only needs to be correct, not fast. When a statement cannot be evaluated the message names the source position of the first expression that failed, because "cannot be evaluated" without a location was the most common dead end in the REPL.
The standard library, phase 1#
- File-system access is a thin set of
ioprimitives plus a Nexium module. The runtime knows only what needs the platform: kind, stat, make/remove a single directory, remove a file, rename, list one directory, cwd, temp dir, append. Everything else instd.fs(recursive create and remove, the walker, sorted listings, every path function) is Nexium, so it is portable by construction and readable by users. Primitives reporterror.NotFoundorerror.IoError, nothing finer, until a real program needs more. Paths are byte strings; both/and\separate components on every platform and results keep the separator the input used, because a tool written on Windows should produce paths its own users recognize. - Dates are arithmetic; the platform only supplies an offset.
std.timebreaks instants down with the civil-from-days algorithm in Nexium and carries a UTC offset in minutes on everyDateTime, so UTC, fixed-offset and local times are one type. The one primitive,time.utc_offset(ms), asks the C library for the local offset at that instant; there is no time zone database in the runtime and no zone names, because that is a large dependency for a rare need and@cImportcan bring one in. The interpreter reports offset 0, so local time at the REPL is UTC. - Regular expressions never backtrack.
std.regexcompiles to a small instruction set and runs a Pike VM: every search is linear in the pattern times the text, so a user-supplied pattern cannot hang a program. The price is no back-references and byte-level matching (.is one byte); both can come later behind the same API. The engine is written in Nexium on purpose: it exercises recursive enums, owning payloads through pointers, and moves in loops, and it found two compiler bugs on its first run (literal field order, reassignment after a move). - Text is bytes; code points are a library.
Stringand[]u8stay byte-oriented andstd.textdecodes UTF-8 on demand, so the core never pays for or argues about a character model. Case mapping covers only the alphabets whose upper/lower pairs are a fixed offset (ASCII, Latin-1, Latin Extended-A, Greek, Cyrillic); full Unicode tables would be larger than the rest of the standard library. Terminal width follows the wcwidth convention. A program'snx testruns its own tests and its file modules' tests, never the embedded std modules' tests, which the compiler's suite covers. - Streams are integer handles plus a Nexium buffer. The runtime keeps a small table of open
FILE*s and hands out integers (1, 2, 3 are the standard streams), because a handle is trivially copyable, printable and needs no destructor;std.streamputs the 64 KB buffers, line splitting and flushing on top in Nexium. Nothing closes or flushes a Writer automatically: with no destructors in the language yet, an explicitclose(ordefer w.flush()) is the honest rule, stated in the module's doc comment.
Talking to the world (phase 2)#
- Sockets are blocking, with a timeout on every wait. The runtime offers plain blocking TCP and UDP over Winsock and BSD sockets, and each call that can wait takes a timeout in milliseconds (0 = forever) that expires as
error.Timeout. No event loop, no async: a tool or a small service is simplest as straight-line code, and threads (phase 2, later) cover concurrency when it is needed. A socket is an integer handle like a file, sostd.streambuffers both with one Reader. Windows takes two seconds to refuse a loopback connection, which is why the tests give refusals a generous timeout. - HTTP is a library, not a runtime feature, and it is single-threaded.
std.httpis written onstd.netandstd.streamalone: a client that always sendsConnection: close(one connection per request keeps the parser trivial and the behaviour obvious), and a server that answers one request at a time. Handlers are plainfn(*Request) -> Responsevalues in aRouter, so a service is a set of functions and no framework. TLS stays outside the standard library until a vetted C binding exists. Concurrency arrives with threads later in the phase; the API will not change when it does, because each connection is already independent. - Threads, no async. The concurrency model is blocking threads with channels and mutexes, and nothing else: no event loop, no colored functions, no
await. The effect system already says which calls block, which is the information an async design would add, and a thread per connection or per job is what the programs this language targets need. The runtime primitive is deliberately untyped (thread.starttakes anyfn(*mut T)and a pointer);std.threadsupplies the typedThread(T, R),Worker(T),Channel(T)andMutex(T)in Nexium, keeping every thread's argument in a one-element List so its address is stable while the Thread value moves. Refcountedref classvalues are not thread-safe yet (their counts are not atomic); channels and mutexes are shared by pointer, and the spawner joins before the shared values go out of scope. Atomic counts and aMutexas aref classcan come when a program needs them.
Sharing code (phase 3)#
- Packages are git tags first, a registry later. A dependency is a repository at a tag or a directory on disk, checked out shallowly into
nexium_modules/and pinned by commit innexium.lock; that gives reproducible builds and vendoring with nothing to host. A package is a manifest plussrc/;import depissrc/lib.nxso the common case needs no module name, and a package's own imports resolve inside it (the loader tags each module with its package and the checker looks forpkg.namefirst), so two packages may both have autil. Names are claimed first-come across the graph and versions are not resolved: with no registry there is no version to compare, and a conflict is an error a person should see. The TOML reader is a small subset written in the compiler, because the crate takes no dependencies. - Editor navigation is syntactic. Go to definition, completion and rename resolve names from the token stream and the parsed modules with the language's own scoping rules (locals of the enclosing function first, then the file, then imports), not from the typed program. The typed program only exists when the file checks, and an editor needs answers most while the file does not. The cost is precision at the edges:
value.methodfinds a method of that name in anyimpl, and completion after a dot offers every field and method declared anywhere. Hover keeps using the typed program, because a signature with inferred effects is worth waiting for. When the file checks, the typed answers can replace the syntactic ones without changing the protocol. - Installers are generated scripts for the platform's own tool. The
installerartifact writes an Inno Setup script on Windows and a POSIX shell script elsewhere, and only runs Inno's compiler when it is present. Writing the script is always possible and inspectable; the setup program needs a tool this compiler should not embed. The Windows script is the same shape as Nexium's own installer (PATH handling included) so the two stay correct together, and the app id is a hash of the program name so upgrades replace the previous install. A.msior a.debare left to the release template's optional jobs. - The npm package is FFI, not N-API. The
nodeartifact generates plain JavaScript that loads the shared library through koffi, the same way the Python package uses ctypes: one shared library serves C, Python, Rust and Node, the consumer needs no compiler or node-gyp, and the generated code is readable. N-API would give a marginally faster call and no dependency, at the price of a C build on every install and a second copy of the export boundary to keep correct. 64-bit integers cross as BigInt, which is what JavaScript has for them.
Syntax, second pass#
- Control flow is
if c { },while c { },for x in items { }; bodies always take braces. Nexium is meant to read as a high-level language by default and a systems language only where it has to, and the C-family ceremony around a condition ((,), then{) carried no information: the grammar already knows a condition followsif, and the brace already marks the body. The line, not a semicolon, ends a statement (decision 12), so the brace stays as the one structural boundary and indentation never becomes syntax. Consequences:if c { return v }replaces the unbracedif (c) return v; a body is a block, always, so there is one form to read and no dangling-else question.for x, i in items { },for i in lo..hi step s { }, andfor parallel x in items { }replacefor (items) |x, i|. The capture spelling was Zig's;inis what the loop means, andfor items |x|would have collided with bit-or.if let v = opt { }replacesif (opt) |v| { }.catch |e|and closure parameters keep their bars: those are functions' parameters, not loop variables.- A condition is parsed without struct literals (Rust's rule), so
if p == Point{ .x = 1 } {needs parentheses around the literal; inside( ),[ ], and argument lists a literal is unambiguous again.Name{stays the literal spelling everywhere else, so decision 6's concern is answered by the parser rather than by parentheses. matcharms were already newline-separated with optional commas.- Migration is
nx fmt: the formatter rewrites 0.5 syntax by token span (comments and blank lines survive;--migrate-onlykeeps the layout) and is idempotent on new sources. The compiler does not accept the old forms; a mixed codebase would have meant two grammars forever. This is the last planned change to the surface syntax before 1.0.
- The specification promises only what is implemented and tested. Toward 1.0 (ROADMAP phase 5), the four features the spec still carried as "planned" are settled, all the same way:
- Regions: rule R1 stays the only region rule. R2 to R4 of the archived design would need a notation for the lifetime of every stored view, and nothing in the tree (the compiler in Nexium included) has needed it; the uncovered cases are listed in the spec (5.6), and a debug build fills freed storage with a fixed byte so they do not pass by luck.
packedandsoalayouts are removed.layout(c)is the one layout because the C ABI needs it; a packed struct is a binary pattern away (section 7.1), and a structure of arrays is separate lists. Both spellings were accepted and silently ignored, which item 27 had ruled out; they are errors now that name this decision.poolandstackallocation strategies are removed.using arenacovers the use the archived design had for them (a block's worth of allocations freed at once);using poolwas always an error. A feature wanted later comes back through the stability policy, as an addition in a minor version, not as a promise in the spec.
- Regions: rule R1 stays the only region rule. R2 to R4 of the archived design would need a notation for the lifetime of every stored view, and nothing in the tree (the compiler in Nexium included) has needed it; the uncovered cases are listed in the spec (5.6), and a debug build fills freed storage with a fixed byte so they do not pass by luck.
- Releases are named after places on a mountain. A major version is a mountain, in the order the fourteen 8000-metre peaks were first climbed (0.x and 1.x Annapurna, 2.x Everest, 3.x Nanga Parbat, 4.x K2, ...);
X.0.0isMountain: Summit; a minor version is a camp, route, face or feature of that mountain, chosen to fit the release with one line of why in the changelog; a patch is a member of the mountain's first-ascent expedition, in roster order, so nothing is forced onto a bug-fix release. The 0.x line is the approach and the camps of the first climb, so 1.0.0 is the top of the mountain the project has been on since 0.1.0. The mountain always comes first in the name (Annapurna: Camp V), so a name reads without knowing the scheme. After the 8000ers come the Seven Summits, then the great north faces of the Alps; a range beyond those is a new decision. The name appears under the version header inCHANGELOG.md, in the release title and tag message, and innx version;docs/release-names.mdis the reference and the ledger, with the pools of names per mountain. The 0.x releases were named retroactively. The name fits the release; the release is never shaped to fit a name. - The compiler in Nexium is the compiler; the Rust one is a frozen seed until 1.0, then gone. From 0.8, a language change is made in
self/and nowhere else. The Rust crate keeps 0.7 semantics, moves tobootstrap/rust/, stays buildable and in CI (it is still the binary users run until the tools are ported in 0.9), and is deleted at 1.0. The seed a fresh machine builds from is the C the Nexium compiler emits for itself,bootstrap/nx.c, regenerated at each release by the previous release's compiler: any C compiler buildsnx0from it,nx0buildsself/nx.nx, and the result must rebuild itself to the same C. The oracle comparisons that drove the port (nx tokens,sexp,tir,emit-cdiffed between the two compilers) retire with it; the fixed point, the examples, the spec suite, the compile-fail cases and fuzzing are the independent check, and they are written once. Reasons: two implementations meant every fix twice (0.7's last day mirrored five) and a class of bugs that were only disagreements between them; a compiler in the language is the proof of the language people look for, and the largest program that exercises it; and after the port, a contributor needs to know Nexium, not Nexium and Rust. The constraint this adds:self/*.nxmay implement a feature but may not use it until the next release's seed understands it.
Compiler selection#
- Native macOS builds use the system compiler. zig 0.14.1 cannot read the libSystem text stubs shipped with Xcode 16.3 and later, so
zig ccon a current Mac links a program with no libc at all (_putsundefined in a hello world). Rather than pin CI to an old runner and leave users stuck,nxpicksccfor native macOS builds andzig cceverywhere else and for every cross-compile.--ccand theNX_CCenvironment variable override both.
Repository#
- License: MIT, copyright Londopy.
- Changelog follows Keep a Changelog and is validated in CI with
patchnotes(strict mode). - CI runs on Windows, Linux, and macOS:
cargo testbuilds the compiler, runs every example against its recorded output, and checks the compile-fail cases. Zig is installed in CI to provide the C compiler. - Names for discovery: page titles and metadata say "Nexium language", since a search has other things called Nexium to get past; the compiler stays
nx. Where a registry already has an unrelatednexium(PyPI, npm), the language's own packages arenexium-lang. - Destructuring views a place and owns a temporary.
let (a, b) = eandfor (k, v) in pairsfollowif let(decision on bindings over a place): over a place the names are views of its elements, so the place keeps its value and a move out of a name is an error; over an owned value the names own the elements and the tuple itself, a hidden local, drops nothing. One rule for both, no partial moves to track, and each name is an ordinaryletin the typed IR, so the emitter and the interpreter learned nothing new. derive(Clone)is explicit for structs and enums; everything else clones by structure. A struct or enum gets.clone()only when it says so (a copy of a user type is a design decision, asEqis), and then only when every field and payload clones: numbers, strings, slices, containers, optionals, tuples, arrays, weak references, otherClonetypes, and reference classes, which clone by retaining. A pointer, a trait object and a function value do not clone (a pointer is a view; clone what it points to). The emitter already derived the copy field by field for containers, so the checker gates and the backend'sclone_fnserves; the compile-time interpreter copies the value it holds.- An iterator is a value with
next(self: *mut Self) -> ?T; no trait.for x in itowns the iterator (a hidden mutable local) and is thewhile true { if let x = it.next() { } else { break } }it stands for, so a yielded value is the binding's own and every loop rule (break,continue, labels, the per-pass drop) holds without a new statement kind. One iterator per loop, no index (count in the body), nofor parallel(a worker needs a slice to split). A map iterates its entries under a tuple binding,for (k, v) in m, the way it iterates keys: collected first, then walked. - Slice patterns match by shape; the rest is a view;
@names the whole.[a, b],[first, rest..]and[.., last]match slices and arrays (an array pattern names every element or ends in a rest, so the length is static). Elements bind as views of the slice's storage, never as owners, and the onerest..binds a[]Tslice of the middle. Exhaustiveness is by length: the rest-arm with the fewest fixed elements must leave no shorter length without an exact arm, which makes[]plus[x, rest..]total without a_.whole @ patternbinds the value (owned when the value is) while its parts match as views of it, so nothing is dropped twice. - A guard proves its facts where it holds, and a fact about a
varends where the variable may change. Facts flow into thethenof anif, the body of awhile(they hold at the top of every pass) and, negated, into theelseof either;andproves both sides,!flips,orproves nothing certain. Beyond ranges, a guard proves!= 0,!= null, a slice's least length and an index below a slice's length, which discharge division,.?, indexing and slicing. A fact about a mutable local is dropped at an assignment, at a mutable borrow, and at the entry of any loop (everyvarat once: the loop may change any of them on any pass, and the check runs once), which is the conservative side of a bug 1.0.3 shipped with, a guard fact surviving into a loop that changed the variable. Lengths are tracked for slices only, whose length cannot change. - Named format arguments are a mode of the literal, not of the placeholder.
.{a, b}is positional and{x}there is the hex spec as before;.{ .x = a }is named and every placeholder names an argument,{x}or{x:>w}, with a width taken from an integer argument by name. No mixing, and every named argument must be used. The checker rewrites either form to{#index:spec}for the emitter and the interpreter, which evaluate each argument once, in order, before the first write, so an argument written twice is computed once. @target()is a constant of the C build, not of the compile-time interpreter. The seed and every emitted program are one C file for every platform, so the operating system, the architecture and the pointer width are decided when the C compiles:@target()is a tuple built fromnx_host_os(),nx_host_arch()andsizeof(void*), anifon it compiles both branches and the C compiler folds the dead one, andcomptimecannot evaluate it.@bitCastreinterprets scalars of one size throughmemcpy(no pointers, no aggregates);@minand@maxare themin/maxmethods as intrinsics that also carry the range of their operands, so@min(i, n - 1)proves an index;@alignOfreads the layout the checker already computes.unbounded_stackstays the call-graph effect, and there is no depth proof. A recursive call on a strictly smaller argument bounds the depth by the argument, which is still a depth the caller cannot see: an exported function that carries the effect needs a stack sized for its input either way (section 15), and the discharge is what it has always been, a loop over an explicit stack. The roadmap's "reserved" was stale; the effect has been implemented and tested since 0.9 (spec cases9_unbounded_stack, two compile-fail cases), and the 1.1 theme leaves it as it is.- Views have origins, not lifetimes. The checker records, per local, the storage each view it holds points into (an origin: a local, a parameter's storage, a literal, a temporary, or the value's own heap storage) and when the view was taken. Origins flow through bindings, literals, calls (a result may point into any argument passed by reference or as a view, not into one moved to an
ownparameter), closures (their captures), and theif let, loop and pattern bindings, which view what they bind over. Anownparameter is the callee's storage, released at return: a view into it is a view into a local. There are no lifetime annotations and no interprocedural analysis: a signature says all a caller needs, because a returned view can only point into the caller's own arguments (V1). The rules are checked at the use that would read released storage, which is where the fault is, so a view that is never used again costs nothing (liveness, not lifetimes). What the model cannot see is taken conservatively: a call's result may point into every non-scalar argument. - Warnings first,
--strictfor those who want the errors now. The checker gained a warning channel beside its errors: rendered the same withwarning:in front, warning severity in the language server, and--strictorNX_STRICT=1turning them into errors. The view rules of 1.2 arrive this way, asdocs/stability.mdrequires (a warning in one release, an error in the next); 1.3 makes them errors. The compiler, the standard library, the examples, the Topo and the tests build without a warning, and the harness runs the compile-fail cases marked// STRICTunder--strict. - V2 compares scope depths; a temporary is a hidden local of its block. Every local knows the depth of the scope it was declared in, and a view of a local of a deeper scope stored into a shallower one is reported, as is a view of a temporary (the emitter releases temporaries at the end of the block, so a temporary is a hidden local of the current depth the rule compares like any other), a view stored into a global, and a view stored through a pointer parameter into the caller's storage. A view of a borrowed parameter may be stored anywhere but a global.
- V3 and V4 are events on the storage, matched against when the view was taken. A growth, a clearing or a reassignment of a container and a move of a value are recorded with their position, and a read of a view taken before the event is reported. Both sides carry the first field on their path from the local (
lv.rowsagainstlv.origins.append), and meet only when one is the whole value or both name the same field: sibling fields of a struct are separate storage, and a level compiler that walks one list while filling another is the common shape. Inside a loop, a change after a read of a view taken before the loop is reported at the change, because the next pass reads it. A move on the line of the use is not an event: a literal or a return that holds the value and the view together keeps the storage alive. A view into heap storage a value owns that the same literal moves in (Pair{ .first = name[..], .name = name }) is a view into the literal's value, which travels with it; whether a part is heap storage (aList,StringorMapbuffer) or inline (an array, a struct's fields) decides that, because inline storage does not move with a move. - V5 counts arena depth, and
@escapeis the one way out. Every local knows theusing arenanesting it was declared in and the nesting the value it holds was made in; a value made inside a block (a call's result, a literal, a clone, a container) kept in a place declared outside, handed to a global or to the caller, or returned, is reported.@escape(v)is a clone made by the allocator the block was entered with (the arena's parent context), so the copy survives the block; it takes a place, so what it copies is named and released as usual..clone()inside the block allocates from the arena and does not escape. A call inside the block that returns a value it took from outside counts as made inside, because the model does not look into callees;@escapeis the answer there, at the cost of a copy. A value taken out of a container (pop,remove) was made where the container's values were. - A struct literal evaluates its initializers in the order written. The checker checked moves in written order while the emitter and the interpreter evaluated in declaration order, so
Pair{ .first = name[..], .name = name }withnamedeclared first read a zeroedname; the view rules found it. The typed IR keeps the written order, with the field of each initializer beside it, and the defaults of the fields left out come last. - A
returnout ofusing arenaends the arena. The emitter ended an arena after its block's closing brace only, so areturn,breakorcontinueleaving the block leaked the arena's chunks (nx leaksshowed 64 KiB live). The arena belongs to the block's scope now, whose exit actions (defers, drops, then the arena's end) run on every way out. - An exercise is a file whose name says its kind, and one header serves the page and the terminal.
topo/exercises/<chapter>/holds a starter per exercise (fill_,fix_,write_,test_,predict_decide how it is checked), its recorded output where the output is the check, and its solution beside it; a chapter'squiz.txtholds questions with the right answer starred and a line of why. The starter's comment header (// topo: kind, the task,// hint:lines) is what bothsite/build.nxrenders andself/topo.nxprints, so the two cannot drift, and the harness runs every solution, checks that every starter fails the way its kind says, and that the compiler embeds exactly the files in the directory. A prediction is graded from a.guessfile rather than a prompt, so the terminal course needs no interaction beyond the quiz and the harness can walk it. Progress is a text file ofdone <chapter>/<exercise>lines in the terminal and a list in the browser's local storage on the site, and the two are not synchronized: nothing to sign into, and the site never sees the reader. The???hole is the marker because it is not Nexium: a starter with one cannot compile by accident. - The playground runs the compiler's interpreter, not its C backend, and never prints what a compiled program would not. A page on GitHub Pages has no server, so the compiler itself runs in the browser: the C of the current sources builds for
wasm32-wasiwith the runtime'sNX_WASMbranch (no processes, sockets, terminal orsetjmp; each fails as a refusing operating system would), andnx playchecks a program and runsmainin the interpreter the REPL already had. A C compiler in the page is 1.5's work. The rule that makes the playground trustworthy is parity: a program prints exactly what its compiled self prints, or is refused with the line that needs a compiled run; a different answer is a bug. The harness enforces it over every recorded program, and holding the interpreter to it found eight places it was wrong, all fixed rather than listed as limits. What the interpreter cannot know (@refCount, the machine's target) it refuses rather than guesses; what it models differently but equivalently (afor parallelloop in order, a[]mutargument copied in and written back,ref classobjects in a heap of their own) it keeps only where no program can tell. The page gives the interpreter a smaller call depth than a native thread, because the browser's stack holds fewer frames, so a deep recursion gets the interpreter's own message. - A quiz question can ask the compiler, and then the compiler grades it. A question in a chapter's
quiz.txtmay carry its program (|lines), what is asked of it (ask: effects NAME,ask: output,ask: check) and the compiler's own words for the answer (says:lines): for effects, the effect listnx effectsprints and, per effect, the first reasonnx explaingives; for a check, the line of the first diagnostic and its message; for output, what the program prints. The words are recorded rather than produced when the page is built, so the site needs no compiler to render a quiz;nx topo verifyasks the real compiler every such question and fails when the starred choice or the recorded words differ from what it says now, and the harness runs it, so a quiz cannot outlive the language it is about. A wrong answer is met with the compiler's words, in the terminal (nx topo quiz) and on the page, where a click on a choice is graded at once. A question withoutask:is an answer key as before; the two kinds mix in one quiz. - A method is visible wherever its type is, a type's
implblocks may sit in any module that sees it, and a type has one method of a name. Methods followed fields (visible to any code that can see the struct) in the implementation from the start, and animplof a type from another module worked, but neither was written down, and a method defined twice, in two blocks of one module or in two modules, was accepted with the first definition silently winning every call. The rules are now the specification's:pubon a method marks the type's documented interface (nx doclists it) rather than guarding it; the inherent methods of one type may be spread over blocks and modules, which is how a large type such as the compiler'sCheckeris split by responsibility without widening anything; and a second method of a name is an error at the second definition, a genericimpl(T)conflicting with every instance of its head andimpl Pair(i32)withimpl Pair(i32)but not withimpl Pair(f64). nx fixkeeps an edit only when the checker, run on the edited program, reports fewer errors and none it did not report before. With the view rules errors in 1.3, the promise of 1.2 was thatnx fixwould insert.clone()where that is the fix. Where it is is narrower than the old advice suggested: a view's clone is the same view, so the only clone that fixes a view is of the value it points into, and that changes the view's type unless it is made where the value moves (rule V4:take(s)becomestake(s.clone()), and every type stays). Rule V5 has the other type-preserving edit,@escape(...)around the value kept past its arena. The checker offers these edits with the errors (check.Fix), and rather than trust each offer,nx fixtries it on the program in memory and keeps it only when the error count falls and no new error appears, then takes the next offer from a fresh check of the edited program. An offer that would not type-check, a clone of a struct that does not deriveClonefor one, is dropped silently, and whatever is left is reported by count. V1, V2 and V3 get no edit: their fixes change a type or need a binding in the right scope, which is a decision for the programmer; the errors say what to do.- A trait impl's methods have the signatures the trait declares. Nothing checked it: impl and trait methods were paired by name only, so an impl could return
i32where the trait declaresi64, drop anown, or take*Selfwhere the trait takes*mut Self, and a call throughdynor through generic code bound by the trait then ran the impl with the trait's types (a wrong value, or a double free where anownwent missing). Now each method ofimpl Trait for Tmust have the trait's receiver, the same parameters with the sameown, and the same return type,Selfread asT; a generic impl is held to the receiver, the parameters' number andown(its types are checked where it is instantiated). This also lets the vtable type come from the trait's declaration alone, so a function overdyn Traitcompiles in a program that never coerces a value to it. Programs this rejects were already wrong (specification 8.3). nx fixmakes an edit only when it keeps what the program does;+%and+|are the programmer's to choose. The 1.3 plan hadnx fixapply every hint the compiler gives, and one of them names two operators: in a function declared!panics, arithmetic that may overflow is met with "use+%to wrap or+|to saturate". Either edit makes the error go, and so would pass decision 111's test, but each gives the overflowing case a different answer (a hash wants the wrap, a level or a count wants the clamp, and a program that wants neither wants a wider type or a range the checker can prove), so picking one would benx fixdeciding what the program computes. The edits it makes keep what the program does and only settle what the checker asks: V4's.clone()and V5's@escape(...)(111), and_ =in front of a value nothing uses, where the value is computed as before._ =is not offered for an error union (discarding an error is a decision too) nor inside anifwith noelsewhose value is wanted, where the missingelseis the mistake.if comptime Cchecks and builds only the branchCpicks. Platform code had one tool,@target()as a C constant, and with it both branches of anifwere checked and compiled: a call to an API that one platform lacks failed to link on the others, which is why every platform difference lived in the runtime's#ifdefs. Now the whole condition afterif comptimeis evaluated while checking (the compile-time interpreter knows@target():--target's triple, or the machine compiling, in the words the runtime uses), and the branch not taken is parsed but never checked or built. The cost is Zig's: an error in a branch no build takes goes unreported until a build takes it, which is why CI builds for the three platforms.comptimeafteriftakes the whole condition (if comptime a == b), where before it bound as a prefix operator and only foldeda; nothing in the tree or in the packages known to use Nexium wrote that.- When the program checks, the language server answers from the checker. Decision 84 made navigation syntactic so it would answer while code is half-written, at the price of guessing:
value.methodwent to a method of that name in anyimpl, a field access could not know its struct, and rename edited one file. Now, when the program checks, an index built from the typed IR (self/lsp_index.nx) says what each use of a local, function, method or field resolved to, with the span of the declaration's name: definition goes there, hover shows the local's or field's type or the function's signature and effects, and rename edits the declaration and every use in each of the program's own files (never std, a package or a C header). Where the program does not check, and for completion, types, enum variants and constants (which the IR does not keep by name), the syntactic answers of decision 84 stand. Rename sees what the checker checked: a name used only in anif comptimebranch this build does not take, or in a generic function nothing instantiates, is not reached. - A large program's debug build is a C file per module, each object reused while its C is unchanged. The compiler's own debug build was one 190,000-line C file, eight and a half seconds to compile after any change. Now a debug build of a program of about 200 KB of source or more (
NX_UNITS=1or0decides for any program) is generated as one C file per module (cgen.generate_units): each declares every function and the types it needs, defines its own module's functions with external linkage, and keeps its helpers and literals static; the root module's file defines the globals and holds the entry. The runtime's own state (the panic boundary, the file table, stdin's buffer, the console's modes) is declared by every file and defined by the root's (NX_STATEinnx_rt.h), where one file keeps it static. Each object is named by a hash of its C and of the command line that compiles it, so a build compiles again only the files whose C changed, on as many threads as there are cores (at most eight), and links. With a released (optimized)nx, a one-line change to the compiler rebuilds it in 0.99 seconds on Linux with gcc and 1.75 on Windows with zig cc, where the one C file took 7.8; of that, checking the whole program is 0.74, so "well under a second" everywhere waits for checking by module.nx emit-c, and so the seed, stays one file, as do optimized builds, whose optimizer sees across functions. - A mountain runs from a line's
.5to the next line's.4, and its summit is the major between. Decision 89 made a major a mountain and itsX.0.0the summit, which held for the first one only: the whole 0.x line was Annapurna's climb, so 1.0.0 stood on top. From Everest on, the first release on a mountain would have been its summit, and the plan then climbed the route from the bottom (2.1 at the Khumbu Icefall, the foot of the route, after 2.0 at the top). Now a mountain's climb starts at the.5of the line before, its minors go up the first-ascent route in order to the last place below the top for the release before the major,X.0.0is the summit, and the minors after it, up toX.4, take the mountain's other routes, faces, neighbours and descent by fit; patches take the first-ascent team of the mountain their minor is on. A late major means more camps; a major announced before the.5(its deprecations land earlier) starts the climb at that release, so none arrives without one. The mountain in a name then says which summit the line is climbing to or has just stood on, where before it said which major:Everest: Base Campis 1.5.0. Every released name already fits (0.x the climb, 1.0.0 the summit, 1.1 to 1.3 Annapurna's other routes); the names pencilled in for 1.5 to 1.8 go back to Annapurna's pool, and Everest's route moves from after 2.0 to before it. - A temporary a branch's value is made of belongs to the whole
if,matchor block expression. SPEC 5.2 releases owned values when their scope ends without saying whose scope a temporary is. A plain expression's are the enclosing block's:show(format(...))andlet t: []u8 = format(...)keep the String until the block ends. The temporaries a branch made for its value, and an ownedif letcapture or pattern binding, were the branch's, released when it closed, so a branch whose value was a view of one handed on freed memory:show(if c { format(...) } else { ... })read a released String, with no diagnostic, where the view rules promise that never happens. They are now the expression's: declared before it zeroed, so a branch that did not run releases nothing, and released with the block the expression is in, as a plain expression's are. A statement inside a branch keeps its own temporaries, released at the branch's end as before, so a loop in a branch does not pile them up. Found by a Discord app's tests against glibc (#15). random.secure(buf)fills a mutable byte slice from the operating system's generator, and returns!void. Keys, tokens, UUIDs and aMaphashed against flooding need bytes an attacker cannot predict;random.intis splitmix64 seeded from the clock, andrandom.seedmakes it repeatable on purpose. The secure source stays next to it inrandom, where people look, with the difference in its name. It fills a slice rather than returning a String, so code that must not allocate (a TLS record, a key schedule under!allocates) can use it; it returns an error rather than panicking because every other builtin that asks the operating system does, though it fails only where there is no generator at all (IoError). Each platform's own source: Windows BCryptGenRandom, looked up in bcrypt.dll at the first call so no program links another library; Linux the getrandom system call made directly, as glibc's wrapper would raise the floor past 2.17, falling back to/dev/urandom, checked to be a device, on kernels before 3.17; macOS and the BSDsarc4random_buf; WASIgetentropy. It isnondeterministic, so compile-time evaluation refuses it; the REPL's interpreter does not run it yet, as it cannot write through a slice argument.- HTTPS in std goes through a TLS slot: nxtls fills it first, the platform's TLS second.
std.http's client speaks HTTP over any stream, and one interface turns a TCP connection into an encrypted one: connect with a deadline, send, receive, close, and whether the server ended cleanly (close_notify) or the connection was cut. nxtls, the TLS 1.3 client written in Nexium, fills it first: no C, the same behaviour on every platform, tested byte for byte against Python'scryptographyand OpenSSL, and reviewed and fixed before this was decided (0.4.0). It speaks TLS 1.3 with ChaCha20-Poly1305 over X25519 only, which every large host tried accepts (Discord, GitHub, Google, Cloudflare, 1.1.1.1, 8.8.8.8), and it stays a package that a program hands to the client. The platform's TLS fills the same slot second and is std's ownhttps: SChannel, Security.framework, OpenSSL where the system has it. It reaches what nxtls cannot: www.echolink.org, probed on 2026-09-25, answers every TLS 1.3 ClientHello with handshake_failure and on TLS 1.2 takes only ECDHE-ECDSA-AES256-GCM-SHA384 over P-256. Neither alone: the platform alone puts C over three operating systems' APIs under every HTTPS call, with their certificate stores and their errors, and leaves nothing that behaves the same everywhere; nxtls alone would need TLS 1.2, constant-time AES-GCM and constant-time P-256 key exchange written in Nexium, weeks of cryptography for the old servers the platform already reaches. This replaces the plan of 1.4's first draft, the platform's TLS alone. - A
Mapkeeps its keys in the order they were first put, and hashes them under a key drawn once per process. A map hashed with a fixed function lets whoever chooses its keys (the names in a JSON object, the headers of a request) choose ones that collide, and every lookup then walks them all: hash flooding. Keying the hash with bytes from the operating system's generator, drawn at the first use of a map (SipHash-1-3, as Rust'sHashMap), ends that; but a hash that differs between runs would make the order offor k in mdiffer between runs too, and a program's output with it, as Go and Rust have it. So the order is no longer the hash's: the entries sit in the order their keys were first put, and the hash table beside them holds only where each entry is. A put of a key already there keeps its place; a removed key leaves no gap (the entries are packed when removed ones are half of them); a key put again after its removal goes to the end. It is the order Python's dicts have, and the one the interpreter's maps (nx play, the REPL, compile-time evaluation) already had, so a program prints the same thing compiled and interpreted. It costs nothing measurable: the words benchmark, all maps and strings, runs in the time it did. - 32-bit targets:
isizeandusizeare the target's word, the C is the same for every target, andi128is C23's_BitInt(128)where C has no__int128. The checker computes widths and sizes for the build's target, read from--targetor else the machine: ausizeliteral past 32 bits is an error fori686, and@sizeOfcounts a word as 4 bytes there, with each ABI's alignment (ani64in a struct sits at a multiple of 4 on 32-bit x86 Linux, of 8 on 32-bit Windows and ARM). The C does not change with the target:isizeandusizebounds areINTPTR_MIN,INTPTR_MAXandSIZE_MAX, so the seed, emitted on a 64-bit machine, still builds a correct 32-bit compiler. Every@sizeOfanswer becomes a_Static_assertin the C, so the C compiler confirms the checker's layout on every target it builds for, whether anything runs there or not (it found the 64-bit sizes ofString,ListandMapwrong).i128stays a type on every target, since the compiler itself computes in it: 32-bit targets have no__int128, and clang's_BitInt(128)does all the runtime does with one, arithmetic, division, the overflow builtins and float conversions, checked byte for byte against__int128on 32-bit x86 Windows and Linux; a C compiler with neither (gcc for a 32-bit target) is refused with a message naming zig.i686(andi386toi586) names 32-bit x86 as GCC, Clang and Rust do; zig calls itx86, and its baseline there is a Pentium 4 with SSE2, which keeps floating point the same as on x86-64, where-mcpu=i686would fall back to the x87's extended precision.armv7is zig'sarm(ARMv7-A with NEON at its baseline) andarmv6isarmon the ARM1176 of the Raspberry Pi 1 and Zero, as GCC, Rust and Debian name them. A 32-bitnxbuilds 32-bit programs unless told otherwise, as a 32-bit gcc does: under a 64-bit kernel (WOW64 on Windows,uname -mon Linux) it passes its own target to zig, whose default is the machine; on a 32-bit machine zig's default already is right, down to an ARMv6 Pi's CPU, and is left alone. - WebAssembly programs are
wasm32-wasimodules, a panic unwinds by WebAssembly's exception handling, andnx runruns a module under Node.js in a sandbox. WASI gives a program files, clocks, arguments and an environment, which is what the runtime needs; the rest of the machine (processes, sockets, threads, a terminal) fails as a system that refuses it would, as it already did for the playground's compiler. A panic is alongjmpto the nearest boundary (main, a test, a thread, an export), and WASI has nosetjmp: C compiled with-mexception-handling -mllvm -wasm-enable-sjljgets one from the exception-handling proposal, which every major browser and Node.js run, so a panic prints the same message and exit code as it does natively and a test runner goes on to the next test. Zig 0.14's WASI libc leaves out the three helpers that lowering calls; nx compiles them fromruntime/nx_wasm_sjlj.cbeside the program, in a file of their own, because LLVM 19 emits code no engine accepts when they share a translation unit with the calls. C compiled without the flags still builds (the playground's compiler is), and a panic there ends the program where it happens.nx runandnx testrun a module under Node.js's WASI, which every CI runner has, rather than a WASI runtime that may lack the proposal. The program sees the working directory and a/tmpof its own beside the module, nothing else of the machine, so a test's scratch files stay in the build's directory (the first version opened the machine's root to it, and on Windows a test run madeC:\tmp).wasm32-freestandingwaits for the embedded targets: without WASI the runtime needs a C library of its own. artifact wasmis a WASI reactor with a loader that brings its own WASI. A library for the page cannot count on a WASI runtime, and a freestanding module would need a C library of its own (decision 123): so the module is built forwasm32-wasiwith nomain(-mexec-model=reactor, its exports named at the link), and the generated<name>.jsanswers the few calls the runtime makes (a clock, randomness, the console; ENOSYS for the rest) in a part that is the same for every library (runtime/nx_wasm_lib.js). The loader crosses the boundary as wasm32's C ABI does rather than through wrappers in the C: a slice as a copy in the module's memory (taken with its exportedmalloc), a struct of one field as that field, a larger one by pointer, a struct result through a pointer passed first. The program is checked a second time forwasm32-wasi, since its C asserts each@sizeOffor the target it is compiled for. The package is an ES module with no dependencies and no build step, for Node.js 20 or later and every browser with exception handling; 128-bit integers at the boundary are refused until something needs them. Zig builds it on every platform, macOS too, whose own compiler (which links its native libraries) has no WebAssembly.m.get_or_put(key, default)is a map's one-lookup update: a pointer to the value, a view into the map. Counting withgetand thenputhashes the key twice, and when the key is there,putswaps the entry's key for the one passed and drops the old.get_or_puthashes once and returns*mut V, the value ofkey, put in withdefaultwhen the key is new; when it was there, the entry keeps its own key and value and the two passed in are dropped. A pointer rather than a callback, because the language already checks one: the pointer is a view into the map's storage, which the next put may move, so rule V3 reports a use after the map changes, as it does for&mut xs[i]into a list, and the put it makes itself comes before the view. Form.get_or_put(word, 0).* += 1to look the key up once and movewordonce, a compound assignment now checks and evaluates its target once, as a place read and then written (the checker typed it twice, and the interpreter ran its calls twice). Rust'sentry(k).or_insert(d)and Python'ssetdefaultare the same operation; the name keeps the two calls it replaces. The words benchmark, written this way, runs in two thirds of the time on Windows, where the allocator made each swapped key dear, and in the same time on Linux with gcc.- Small freed blocks are kept for reuse, per thread, instead of a small-string optimization. The roadmap's plan for short strings was to store them inside the
Stringvalue, as C++ and Rust's small-string types do, so a short string needs no block from the allocator. It does not fit the language: aStringis passed by copy, and a function may return a slice into the one it was given, which the view rules allow because the bytes live on the heap, shared by the original and the copy. With the bytes inside the value, the slice would point into the copy, released when the call returns; making that safe would change how every string, and every struct holding one, is passed and returned. What the short strings cost was the allocator: on Windows, whose C library caches nothing small,wordstook and returned a 16-byte block ten million times, and that was nearly half of its distance from C. So the runtime keeps freed blocks of up to 64 bytes and a little over, in four classes 16 bytes apart, up to 64 per class, on a list of the thread's own, and hands them out before askingmalloc; a block is always allocated at its class's size, so any cached block fits any request of its class, and areallocinto or out of the classes makes a whole block rather than shrinking one. The classes end where the C library's own steps do, measured on 64-bit Windows and 64-bit glibc: there a block carries an 8-byte header and is rounded up to 16, so a request of 24, 40, 56 or 72 bytes costs what one of 16, 32, 48 or 64 does, the classes end at those, and a cached block costs the C library no more than its request would have; elsewhere they end at the multiples of 16. The lists are thread-local and per translation unit, like the panic boundary, rather than fields of the context: a context is copied by value for an arena, a parallel worker and a thread, and a copied list head would be two owners of the same blocks, so lists in the context would need clearing at every copy, a drain wherever a copy dies, and the context's address in the allocator's state in every build; a thread-local list has one owner, needs no lock, and is returned tomallocby the thread that ends, and by an exported call before it returns, so a library leaves nothing on a host's thread. The header's S2 comment, which said the only static was the boundary, now says what is true. A free with a size of 0 (an export's panic path releases what it tracked that way) goes tomalloc, not to a list. Under AddressSanitizer the cache is off and every request has its exact size, so a use after free or an overflow stays detectable; the leak checker counts a block from the list as an allocation, one put on it as a free, and a realloc as a realloc whichever way it went, so its numbers are what they were. The cache is on everywhere but under AddressSanitizer: on Linux, whose C library caches small blocks itself, it changes nothing measurable, and on Windowswordsruns in three quarters of the time. - The fifth benchmark is binary trees of
ref classnodes, the classic, sized for a second in C, with a plainmallocandfreeper node there. None of the four benchmarks made aref class, so the ARC elision item had nothing to measure. Binary trees, the Benchmarks Game's program (for every depth, trees built, counted and dropped, a stretch tree first and a long-lived one kept throughout), is allocation and reference-count traffic and little else, has a reference program in every language, and needed no design; an interpreter over a reference-counted syntax tree, the other candidate, would have been a design of its own with no twin elsewhere. It is cut down to one output line, the trees and the nodes, as the table's rule wants. The C version takes a block frommallocfor every node and frees each, which is what a reader writes; the Game's fast C versions pool their nodes, and a pool would measure the pool. Rust boxes its nodes, Go lets its collector have them, Python makes objects. Every second depth from 4 to 16, 2^(21 − depth) trees each, with a stretch tree of depth 18 first and a long-lived one of depth 17 kept, give 174,754 trees and 29,971,806 nodes, 0.8 s in C with zig cc on the laptop, inside the table's second. Inmakethe compiler moves each fresh child into the literal's field, so building a node costs its allocation and nothing more; the reference traffic is incount, which binds each child withlet left = n.left orelse return 1, the form a reader reaches for to stop at a leaf: aletof a reference is a copy, retained there and released at the scope's exit, twice per inner node, which the emitted C shows andnx refcountslists. That is the pair the elision is for, a reference copied out of a borrowed parameter's field and used only within the function, and the program's number to move;if let, which borrows, makes none of it and runs about a tenth faster, the elision's ceiling.