Short answers with pointers into the reference. See LANGUAGE.md for the rules and ARCHITECTURE.md for the machinery.
Because every Nish value has exactly one fixed LLVM type and memory
layout, decided at compile time. any (and unknown, unions other than
T | null, typeof, instanceof, prototypes, eval, ...) would need
runtime type tags, boxing, and dynamic dispatch, which is what a JavaScript
engine provides and what Nish deliberately leaves out. The full list,
with the guarantee each construct would break, is in
LANGUAGE.md: Forbidden constructs
and wp0-validator.md. If it compiles, the layout is
known.
Loop and index code is the common case in the programs Nish targets,
and i32 is what C and Rust use for it: one machine word, exact, vectorisable,
and the natural type for array indices and exit codes. Treating every
number as a double would make i % 2, a[i], and for (let i ...)
carry conversions and lose exactness above 2^53. Measured on the benchmark
suite, compiling the same program --number-mode f64 costs 1.36x on fib,
1.80x on sieve and 3.4x on an array reduction (14x when the array is
cache-resident, where a float accumulator cannot be split into parallel
partial sums), plus about 12 KB of binary for the double formatter that
console.log then needs:
wp9-optimisation.md has the
tables and the three mechanisms. The cost is that number
does not behave like JavaScript's number: signed integer overflow panics
rather than wrapping (see below), and 1.5 is rejected in i32 mode
(LANGUAGE.md: Semantics decisions). The
explicit types i32, i64, and f64 are always available whatever the
mode.
Compile with --number-mode f64: number becomes double, literals may be
fractional, x / 0 is Infinity, and Math.sqrt(n) works directly on a
number. Array indices are truncated toward zero, .length is a double, and
main must be declared main(): i32 because the exit code is an integer
(tests/cases/f64_mode, arr_f64, math_intrinsics). In i32 mode you can
mix: const x: f64 = 1.5 and Math.sqrt(toF64(n)). Two things are still
not JavaScript even in f64 mode: toI32 saturates instead of wrapping
modulo 2^32, and Math.min/max with a NaN operand return the other operand
(LANGUAGE.md: Builtins).
A collector needs a runtime that knows where every pointer is, and it costs
binary size, memory, and unpredictable pauses; the whole point of compiling
ahead of time is to avoid that. Nish uses one bump-allocated arena
(runtime/runtime.c, the core half of the C runtime, about 3.5 KB of machine
code): allocation is a load, an add, a compare and a store inlined into the
caller; nothing is freed individually; the entry wrapper frees everything when
main returns, and a C or Node host can call nish_reset_arena() between
batches to recycle memory in O(1). Objects, arrays, and strings built at run
time live there; string literals are constant data. On top of that the compiler
places memory statically (LANGUAGE.md: Memory model,
wp6-memory.md): an object, object literal, or array
literal that provably never leaves its function is an alloca, and a
function whose arena temporaries all die with it marks the arena on entry
and releases it before returning, so a hot loop keeps the arena flat.
using a = arena() frees a batch explicitly, and Arena.mark() /
Arena.used() read the arena (Arena.release(m) and Arena.reset() still
work but are deprecated). Optional reference counting for objects that must
outlive an arena reset is still on the plan; it has not been built.
Usually by doing nothing: a temporary new, [...], or { ... } that does
not escape is a stack slot reused every iteration, and a helper function
whose strings or dynamic arrays die with it gets an automatic arena scope.
Two things defeat that: returning or storing the object (the caller now
owns arena memory), and calling the deprecated Arena.reset /
Arena.release inside the callee (the compiler then never scopes it). If a
batch does accumulate, open a block with using a = arena() around it: the
block releases everything allocated after that line on every exit, and the
compiler refuses the block if anything allocated in it could outlive it
(LANGUAGE.md: using a = arena()).
Arena.used() tells you whether it worked
(tests/cases/mem_scope_dynamic_array prints it before and after 100000
calls). --no-stack-alloc shows the arena-only IR for comparison.
Yes, when its arguments are all scalars, and in every build profile —
--profile debug included, where no optimiser runs at all. return f(...)
as the whole of a return is emitted as a tail call, which tells LLVM the
callee cannot reach the caller's stack frame, so the frame is reused instead
of stacked. Measured on a recursion with one string temporary, built
--profile debug: 1,840 KB of peak RSS at a million levels and 1,836 KB at
a hundred million, against a segfault at a million without the marker.
tests/link/tail_call_depth_debug is the checked-in one: a million levels at
-O0 in a function with no arena scope at all, which is the shape the
release on its own never reached.
Two things go with it. A function with an automatic arena scope also moves
its nish_arena_release ahead of the call — a release between the call and
the ret is work after the call, and a call with work after it is not the
last thing the function does
(LANGUAGE.md: Memory model, item 2). And at -O1
and above LLVM's own pass rewrites the self-recursion as a loop before the
backend ever sees the marker; the marker is what makes debug behave like
the rest.
Two things do not follow. Nothing in the language guarantees any of this,
so a program that must iterate a hundred million times should say so with a
loop. And the shape that looks most like it should qualify is refused: a
string accumulator is memory the frame owns, so step(n - 1, acc + piece)
over strings is a plain call, one frame per level.
The program panics: attempt to divide by zero on stderr and exit status
1, and INT_MIN / -1 (also %) panics with attempt to divide with overflow, exactly as Rust does. JavaScript would give Infinity | 0,
that is 0, and C leaves it undefined. The check is two compares and a
branch to a cold block, and LLVM folds it away for constant divisors
(LANGUAGE.md: Checked integer division).
f64 division is IEEE: x / 0 is Infinity, 0 / 0 is NaN.
Not silently. A signed i32/i64 +, -, *, negation, ++, -- or
op= the compiler cannot prove fits is checked, and an overflow stops the
program with attempt to add with overflow (or its siblings) and exit 1, the
rule of a Rust debug build kept in every build. One the bounds walk proves fits
— a loop counter below a length, a value built from two bytes — is a plain
add nsw with no check, so the common loops cost nothing.
Code that overflows on purpose — a hash, a linear congruential generator, a wrap-around counter — must say so:
wrappingAdd,wrappingSubandwrappingMulfromnish:unsafewrap two's-complement at the one site that means it:wrappingAdd(2147483647, 1)is-2147483648.- Write it in an unsigned type.
u8,u16,u32andu64are defined to wrap and are never checked, which is what they are for; an FNV-1a round inu32needs nothing more. - The deprecated
--wrappingmakes every operator of your own package wrap (never a dependency's or the standard library's).
Earlier versions made signed overflow undefined behaviour, as C does; that is
gone, and no flag brings it back (--nsw is refused)
(LANGUAGE.md: Semantics decisions).
npm run test:diff compiles every whole program in tests/cases and a
71-program corpus, runs each binary, runs the same program's JavaScript
rewrite under Node with runtime/shim.mjs, and compares stdout and exit
status byte for byte. The rewrite was made from the compiler's own recorded
types ((a + b) | 0 for i32, BigInt for i64, a bounds-checked index
helper, byte lengths) by a rewriter that was deleted with stage0, so it is
frozen in tests/differential/goldens/. node tests/differential/fuzz.js --count 200
compiles random integer programs with the last release and with the current
compiler and compares the IR. Everything that still differs
is listed with a reproducer in
wp13-differential.md and
tests/differential/known-failures.txt: 1-ulp libm differences in
sin/cos/log/pow, Math.min/max with NaN, Math.round(-0.3),
and the division panics above.
Two ways, both generated from the same signatures as the IR (wp8-interop.md):
- WebAssembly:
scripts/build.sh a.ll -o a.wasm --profile wasmbuilds a freestanding module;WebAssembly.instantiateloads it and every exported scalar function is callable directly (examples/node-host.mjs).--emit-dts a.d.tswrites the typings anda.mjs, a loader that passesInt32Array/Float64Array/BigInt64Arrayarguments by copying them into the module's memory (linkruntime/runtime-wasm.c). Strings are not available in this profile because it has no WASI runtime. - Native addon:
--emit-napi a_napi.cwrites an N-API shim,scripts/build.sh a.ll runtime/runtime.c a_napi.c -o a.node --profile napibuilds the addon, andrequire("./a.node")loads it (examples/node-addon.mjs). Numbers, booleans,i64(bigint), strings and typed arrays are bridged with argument type checks; a typed array is borrowed zero-copy, so writes through it are visible in JS.
Design the boundary around batches: one call that processes a whole buffer,
not one call per element. node bench/ffi.mjs measures the difference
(about 30 ns per N-API call, 2 ns per wasm call, before any work is done;
0.5 ns per element when a 1M-element Float64Array crosses in one call).
Calling npm packages from an Nish binary would mean embedding a JS engine (QuickJS adds about 2 MB, V8 tens of MB), marshalling every value across the boundary at hundreds of cycles per call, and running a second, garbage-collected heap next to the arena. That erases the reasons to compile in the first place, so Nish deliberately has no such bridge. The supported direction is the reverse: Node does I/O, HTTP, and npm; Nish does math, parsing, data transforms, and hot loops; they meet once per batch.
Every rejection is one line file:line:col: error: message followed by the
source line and a caret marking the offending node, and the process exits
with status 1:
tests/cases/reject_type_mismatch.ts:1:40: error: Operator `+` requires two operands of the same numeric type or two strings, got i32 and boolean
1 | function f(a: number): number { return a + true; }
| ^~~~~~~~
Syntax errors use the same layout with syntax error:. The compiler reports
every error it finds, in source order, up to 20 before ...and N more errors,
and --json prints each as one object with a stable code. The message texts are catalogued in
LANGUAGE.md and the
format in wp10-ci.md.
0 success; 1 your program was rejected (or an input path is missing);
2 usage error (unknown flag, no inputs); 3 toolchain error (--link
found no clang, or scripts/build.sh failed; the .ll files are still
written and named); 70 internal compiler error. Details in
INSTALL.md and wp12-release.md.
- Exit code 70 means the compiler itself crashed, not that your program
is wrong. It prints
nish <version>: internal compiler error while compiling <files>and the exception; re-run withNISH_DEBUG=1for the stack trace, then open an issue at https://github.com/amritk/nish/issues with the input file, the command line, and that output. - Wrong IR or wrong native output for a program that compiles: attach
the
.ts, the.ll(-o out.ll), the flags, and what you expected.--plaingives the smallest IR to look at. Behaviour that is known to disagree with JavaScript or the design notes is listed in LANGUAGE.md: Known inconsistencies; check there first. - A rejection you think is wrong: quote the full three-line diagnostic. The reference marks every rule with the test case that fixes it, so a report can point at the rule.
By default the IR is target-neutral (no target triple line), so clang
fills in the host triple and warns. It is harmless; scripts/build.sh
passes -Wno-override-module. --target host (or a triple such as
aarch64-unknown-linux-gnu or wasm32-wasi) writes the target datalayout
and target triple lines and the warning goes away. Cross-compile with
--target <triple> plus clang --target=<triple>, or with llc -mtriple=....
Because a target-neutral module gives opt a generic data layout with no
vector registers. --link is unaffected (clang supplies the layout), but
when you inspect optimised IR by hand compile with --target host and
opt -O2 -S will vectorise the same loops the binary gets
(tests/cases/opt_target_triple is checked for exactly that).
Because the compiler could not prove it. A while loop, a for loop that
is not a counted loop, a checked a[i], an integer / or %
(the divisor check can panic), a signed +, - or * not proven to fit
(the overflow check can panic), or a call to process.exit drops
willreturn; any arena allocation, console.log, string concatenation,
field store through a non-stack object, integer division, a checked signed
operation, or Math.random makes the function impure. An object that lives in a stack slot is the
function's own memory, so reading and writing its fields keeps readnone
(tests/cases/mem_stack_struct, swapped). The
exact rules are in
ARCHITECTURE.md: Attribute soundness rules;
the attributes are guarantees, so the compiler never guesses.
Not natively yet: scripts/build.sh is bash and the runtime is built with a
POSIX clang. Use WSL with Ubuntu (INSTALL.md).