A native (C) backend for nimony that
compiles the post-hexer .c.nif IR to real C and links it with gcc — a
self-owned counterpart to nifjs (the
JavaScript backend), retargeted from JS to C.
You don't write a code generator. You write a printer.
By the time nimony's hexer pipeline has lowered a program to a .c.nif, every
genuinely hard piece of compiler work is already done and baked into the IR:
| hexer pass | what it did |
|---|---|
destroyer + duplifier + mover |
ARC — destructor calls, =copy/=destroy hooks, ref-count ops injected |
lambdalifting |
closures → plain functions + env structs |
iterinliner |
iterators inlined |
eraiser |
exceptions → error-code plumbing |
generic mono + dce + inliner |
generics monomorphised, dead code stripped, inlined |
What's left in a .c.nif is a C-shaped tree with sized types spelled out
((i 32)), an explicit result var, explicit everything. So:
A native backend is a
.c.nif → Cprinter.hexeralready did ARC, closures, exceptions and monomorphisation, so the printer is mechanical and GC is free (ARC was injected upstream). C / JS / WASM are all just printers over hexer's output.
This is easier than nifjs was: nifjs worked from the high-level .s.nif and had
to invent value mappings (int→number, seq→Array) and worry about int-wrapping.
aowlc works from the post-hexer .c.nif, which is already sized, already ARC'd,
already monomorphised — you transliterate S-expr-C → C syntax.
aowlc is faithful to Andreas Rumpf's own C generator (nimony/src/lengc) for the
computational core, verified end-to-end against .c.nif files produced by
nimony's real frontend + hexer:
- procs / funcs, parameters, recursion
- sized numeric /
char/bool/ pointer types (NI64,NU32,NF64,NC8, …) - typed arithmetic & bit-ops with the wrap-preserving cast —
(add (i 64) a b)→((NI64)(a + b)) - comparisons,
and/or/not,neg,bitnot if/elif/else,while,loop,scope,break/continuecase— single values, value lists, ranges (case 10 ... 20),else- labels &
goto,var/let/cursor/const/gvar,asgn/store,ret/discard - casts / convs, suffixed literals,
sizeof/alignof - objects / unions / enums / arrays / proc-types (type declarations)
- the real
mangleToCname mangling and theimportc/exportcextern-name rule - a self-contained C prelude (
NI/NU/NF/NC8/NB8/NIM_TRUE/…) — no nimony runtime needed for the core
The system runtime is lowered too — strings, seqs, echo, exceptions, GC objects
and method dispatch all run end to end. The e2e corpus below compiles and runs
programs using them and diffs the output against nimony's own binary. Anything
aowlc can't print still raises aowlc: unsupported …, so a gap is visible rather
than silently wrong.
Every nimony c in this repo goes through the machine-wide lock (nimlock).
It did not before: build.sh, test/e2e.sh, test/driver.sh, test/single.sh
and test/twoprinters.sh all invoked nimony directly — seven call sites, and
e2e.sh's reference build did not even pass a --nimcache:. Two nimony compiles
running at once corrupt each other's link through the shared nimcache_static,
which a private --nimcache: does not cover because the static object is
shared across caches. Unlocked, a red here could be another instance's compile
rather than anything about aowlc, and no run was repeatable. Verified with three
other instances queued on the same lock: test/e2e.sh examples/hello.nim waits
its turn and passes, where before it would have raced them.
# emit a C translation unit for the whole module
node bin/aowlc emit examples/fib.c.nif
# compile the whole module to a standalone native binary and run it
node bin/aowlc run examples/fib.c.nif
# build a native binary at a path
node bin/aowlc build examples/compute.c.nif -o /tmp/compute
# observe a single proc's result: build a harness that calls it and prints
node bin/aowlc exec examples/fib.c.nif --entry fib --arg 10 # -> 55
node bin/aowlc exec examples/compute.c.nif --entry gcd --arg 48 --arg 36 # -> 12
node bin/aowlc exec examples/mathf.c.nif --entry classify --arg 15 # -> 300
# just the linked C, no cc step (what aowli's mid-run JIT consumes)
node bin/aowlc link <nimcache>/<main>/*.c.nif --emit-only -o /tmp/program.cbin/aowlc-native emits a self-contained TU for any module (test/single-all.sh,
78/78, re-run 2026-09-09 on Windows — aowlc single-TU: 78/78 emit a self-contained translation unit, 20m22s; the corpus grew and this line once said
77/77): it reads through nifreader, whose index lets it follow an imported symbol
into its owning module and re-emit the type body.
aowlc.js text-parses one file and cannot follow a symbol anywhere, so emit
used to produce C referencing RootObj_0_sysvq0asl with nothing declaring it.
It is now given the sibling .c.nif files and pulls in, transitively and only
what the module actually references, the imported type declarations plus
extern lines and C-name/header mappings for imported globals. Reference-driven
rather than wholesale, because bin/aowlc finds siblings by taking the other
.c.nif files in the directory — right for a nimcache, wrong for examples/,
where the neighbours are unrelated programs.
It also emits a real prototype for a proc a sibling defines, instead of
letting stubExterns invent NI64 f() { return 0; } — a stub that collides at
link time and, where it does link, silently answers 0 for the real computation.
A global whose symbol names another module as its owner becomes an extern
declaration rather than a second definition, and the prelude's LENGC_ERR_ /
LENGC_OVF_ are static as they are in emitc.nim.
Three linkage rules came out of making that actually link, each matching what
emitc.nim already did:
aowlc.js did |
should | |
|---|---|---|
| a proc a sibling defines | NI64 f() { return 0; } — a stub that, where it links, silently answers 0 |
a real prototype |
| a proc or global owned by another module | defined it too (hexer copies a generic instance into every module that instantiates it, so four TUs defined raiseIndexError3_0_I…) |
the owner defines; everyone else declares |
LENGC_ERR_ / LENGC_OVF_ |
non-static, so every TU exported them |
static _Thread_local |
An {.inline.} proc is the exception to the owner rule — static inline is
internal linkage, so it must be defined in every TU that calls it — and the
reference closure follows procs and globals, not only types: nimIcheckB is
copied in as inline, and the raiseIndexError3 it calls has to come with it or
the stub reappears.
Both printers now pass aowlabi's layout gate in the same per-module mode (native 26/26, js 26/26), which means every emitted TU compiles and links on its own.
build and run link the module together with its siblings — nimony puts every
module of a program in one nimcache directory, so they are the .c.nif files
next to the one you named. --single opts back out to one translation unit.
This matters more than it sounds: a single TU cannot work for any module that
uses an imported type. An extern stub can stand in for a missing function,
but nothing can stand in for a missing type, so a lone module that merely called
echo died in gcc with unknown type name 'LongString_0_<system>'. exec --entry was unaffected, which made it look like a whole-module emission bug
rather than a missing link step.
bash test/e2e-all.sh # the sweep, with a DECLARED denominator
bash test/twoprinters.sh # BOTH printers vs nimony — see below
bash test/single-all.sh # every TU must also compile ALONE
bash test/e2e.sh examples/hello.nim # one case: emit EVERY module, gcc-link, diff vs nimony
bash test/units.sh # unit asserts, N of N declared
bash test/staticinit.sh # file-scope vs block-scope initialiser emission
npm test # exec-mode entry points + whole-program link/run
bash test/cnif-fresh.sh # the committed .c.nif still match their .nim
bash ~/aifjs/tests/cross.sh --sample 6 # this corpus through aowljs, and its through ours
bash test/driver.sh examples/hello.nim # the DRIVER (build + exec), not the raw printer
bash test/claims.sh # do the NUMBERS below still agree with the gates?test/claims.sh is the one gate that reads this file. Every number below
has a row in CLAIMS.tsv naming the command that printed it,
and the script re-runs or re-reads that command and reports every
disagreement. It exists because two numbers in this README were wrong for
commits at a time — 73/73 for twoprinters, which printed 66/67, and
77/77 for single-all.sh, which prints 78/78 — and no gate could see
either, because no gate read prose. Cheap by default: it checks the
DENOMINATORS, which is what drifted both times. --all runs the real gates.
It reports and never rewrites; a number nobody measured is not one to guess at.
twoprinters.sh is the load-bearing one, and what it costs decides how often
anyone runs it. Measured on 2026-09-09, Windows, J=8:
| wall | oracle from cache | |
|---|---|---|
cold (NOCACHE=1, or after a nimony rebuild) |
976s | 0/78 |
warm — any run where only aowlc changed |
42s | 78/78 |
Both print the same 68/68. Treat the cold number as an upper bound: nimony's
compiles take a machine-wide lock, and this one was measured with another
session compiling against the same lock. The ratio is the durable part. The cold number is nimony's own compiles, which
serialise on the machine-wide lock and so do NOT parallelise; the cache is the
whole win, and it is safe to lean on because nimony's answer is an ORACLE —
independent of aowlc — and the key covers the fixture's sources and the
toolchain (~/nimony/bin/*, everything under ~/nimony/lib). Edit aowlc, get
42s. Rebuild nimony, pay the 976s once. Both printers always re-run either way;
nothing about the comparison is cached.
ONLY=<substring> narrows the run to one fixture while iterating. It announces
itself as a PARTIAL RUN twice, and is not the gate.
bash test/single.sh examples/hello.nim # one TU alone vs all modules — separates a
# codegen bug from a whole-module-emission onee2e.sh compiles a program with nimony, emits C for every module with
aowlc, links with gcc -Wall -Wextra, runs it, and requires the stdout to match
nimony's own binary byte for byte. Three outcomes, because two would lie:
| exit | outcome | meaning |
|---|---|---|
| 0 | PASS |
output compared, and matched |
| 1 | MISMATCH / COMPILE-FAIL |
output compared and differed, or the build failed |
| 2 | VACUOUS |
the program prints nothing, so an empty-vs-empty comparison would report a pass while asserting nothing |
A nonzero gcc warning count is reported on its own line. That is not
cosmetic: return; from a non-void function and an uninitialised local
(indeterminate in C, zero in nimony) were both found that way, and both were
invisible while the gate piped gcc into head -1 — which kills gcc with SIGPIPE
on its second line of output, so a pile of warnings read as a failed build.
e2e-all.sh sweeps examples/*.nim plus every examples/*/ directory whose
entry point is main.nim (multi-module cases: a type, enum, exception or global
declared in one module and used from another, and module-initialisation order).
It declares its total, so a missing fixture is a red run rather than a quieter
green one, and a fixture that stops asserting shows up as NEWLY VACUOUS.
exec mode emits only the procs (and globals) transitively reachable from the
entry, so the nimony bootstrap (ini/main/cmdCount and its cross-module
calls into the system runtime) is excluded and the program is fully standalone.
Whole-module build/run mode emits everything and generates weak no-op stubs
for any unresolved external call so the unit still links on its own.
.c.nif is what nimony's hexer emits just before its own C backend
(lengc/aowlc) runs. Compile a .nim with nimony and look in the nimcache:
nimony c --nimcache:nc mymod.nim
node bin/aowlc exec nc/*/mymod*.c.nif --entry myproc --arg 42 nimony frontend hexer (ARC, closures, exceptions, aowlc
.nim ───────────────► .s.nif ─── monomorphisation, sized types) ──► .c.nif ──► C ──► gcc ──► native binary
(parse + sem) (this repo)
The cleanest self-owned native compiler reuses the one component that is
genuinely hard to rebuild — hexer's lowering — and owns everything else:
nifparser + nifsem → hexer → aowlc → gcc.
aowlc.js— the hand-written JavaScript one.bin/aowlc, the driver the usage examples above invoke, uses it, andnpm testis the only gate that did.src/emitc.nim→bin/aowlc-native— the nimony one, which e2e, single-TU, units and staticinit all measure.
Every gate measured exactly one of them and nothing compared them, so a defect
present in both could be fixed in one and stay in the other with every gate
green. That is exactly what happened: {.emit.} was grouped with
pragmas/comment in both and dropped silently — a program using it answered 41
where nimony says 42 — and fixing emitc.nim left aowlc.js still wrong.
test/twoprinters.sh runs the corpus through both and compares each against
nimony's output, not against each other, so it says which one is wrong.
As measured on 2026-09-09 (Windows, gcc 15.2, bash test/twoprinters.sh, full
run; re-run the same day warm, 40s, 78/78 oracle results from the cache, same
answer): 68/68 agree in both, out of 78 examples, 10 of which
are skipped for having no output to compare — nimony itself does not compile
them, so there is no oracle to score against. The KNOWN_JS_BEHIND list is
empty. It covers the multi-module fixtures (examples/<d>/main.nim) as well as
the single-module ones — the case that hid the own-module-suffix bug, since in
a single-module program every use is unsuffixed too.
That number is the one the script prints. It is quoted here because it was measured, not because it sounds finished — this README claimed 73/73 for several commits while the script's own output said 66/67, and nobody caught it because nobody re-ran the gate that says so. If you change this line, run the gate and paste what it printed.
It did not start there. The gate opened with three entries, each a fix that had landed in the nimony printer and not the JavaScript one, and a fourth turned up by emitting aowlabi's layout corpus through both:
aowlc.js did |
should | |
|---|---|---|
{.emit.} |
dropped it (41 where nimony says 42) | emit the inline C |
{.packed.} |
dropped it (24 bytes where nimony says 10) | __attribute__((packed)) |
| octal escapes | unpadded, so "\n7" → \12+7 → C reads \127 = W |
three digits |
| non-ASCII | walked CODE POINTS, é → one escape |
walk BYTES |
| distinct global | ((T)(T){…}) — a cast is not a constant initializer |
drop the cast, but ONLY around a constructor of that same type (see below) |
except T as e |
dropped the cast on EVERY conversion, so the binding initialised a T* from an Exception* |
keep the cast — nimony's own backend never unwraps one |
Every one was reported as a stale exemption the moment it started agreeing, which is the only reason a known-divergence list is safe to keep: it cannot outlive the divergence it records.
twoprinters.sh compares the two printers on program behaviour, which is
exactly the comparison a prelude divergence survives: a #pragma one printer
emits and the other does not only shows up on programs that reach it, and a
corpus is finite. Each printer carries its own copy of the C prelude
(PRELUDE in aowlc.js, CPrelude in src/emitc.nim), so test/prelude.js
— part of npm test — diffs the two texts. It came up red on its first
run: the nimony printer suppressed twelve warnings the JavaScript one did not
(-Wimplicit-function-declaration, -Wincompatible-pointer-types, -Wmain,
-Wreturn-type and nine more), a difference nobody had decided. They agree now
at 55 lines.
They agree at 55 lines on Windows too, which was not previously true and was
not previously visible. A JS template literal normalises CRLF to LF by the
language spec, while readFileSync returns what the checkout wrote, so on any
core.autocrlf=true clone every line of emitc.nim's prelude carried a
trailing \r, no line matched, and this gate was red for the same 55 lines it
called green on Linux. npm test chains with &&, so test.js never ran at
all on such a checkout — any Windows "21/24" quoted before this was measured by
hand, not by npm test. norm now strips \r.
That suppression list turned out to be the next problem. e2e.sh compiles with
-Wall -Wextra and its comment says so — but an in-file #pragma GCC diagnostic ignored beats the command line, and the prelude opened with sixteen of
them, covering precisely the categories the comment cites as the reason the
flags are there (-Wimplicit-function-declaration, -Wreturn-type,
-Wincompatible-pointer-types, -Wint-conversion). "The corpus is clean under
both" was true the way an unrun test is green.
e2e.sh now compiles a second, syntax-only pass over a stripped copy — the
pragma lines removed, nothing else — and a diagnostic there is a failure, not a
printed note. Measured before it was enforced: six categories fire across the
whole corpus and are noise for generated code (an emitter cannot know a label or
temp goes unused), and eight of the sixteen pragmas suppress nothing at all.
Those eight are gone from both preludes: they bought nothing and could only hide
a future defect — -Wreturn-type is exactly what hid a bare return; emitted
from a struct-returning function. 77/77 clean under the enforced set.
Two more are permitted and declared, because neither is aowlc's to fix today:
-Wformat, where nimony's own system prints an integer with
fprintf(stderr, "%lld", x) and x is NI64 — long on LP64, not long long
(same width, so right on every target we build for and wrong by the standard;
filed against aowlsem); and -Woverride-init, which fires exactly twice, on the
{.union.} constructor in examples/e2e_packed.nim. nimony's lowering puts a
kv for every union member in the oconstr, so United(i: 5) emits
{ .i_0 = 5, .f_0 = 0.0, .c_0 = 0 } and each designator overwrites the last —
which is why u.i reads back 0. gcc is reporting a real overwrite, not a
redundancy; both printers and nimony's own binary produce the 0, so the fixture
asserts that they agree, not that 0 is right. Also nimony's lowering, also
filed.
aowlabi states the canonical ABI for the
stack, and its tests/cbackend.sh diffs that model against sizeof/offsetof
applied by gcc to the C this repo emits — from BOTH printers (native 26/26,
js 26/26), each against the model rather than against each other — padding, object variants (an
anonymous union), {.packed.}, {.union.}, a three-deep inheritance chain, sets,
refs, proc fields, ranges, distinct and empty fields, plus the runtime string,
LongString and seq headers. Run it from an aowlabi checkout; it skips itself,
with a line, when there is no aowlc to measure.
It also found aowlc.js mangling an own-module symbol to Derived_0_ where
every cross-module reference says Derived_0_cty4i727z: on disk the symbol is
Derived.0. with an empty trailing hash slot, which nifreader expands for the
native printer and the JavaScript text parser did not. compileProgram already
called canonicalizeOwnSyms; the single-module emit path never got the hash.
No single-module gate could see it, since there every use was unsuffixed too.
That tier found {.packed.} being dropped here entirely — a packed object was 24
bytes against nimony's 10, with every field after the first at a different
offset. It compiled, it ran, and it disagreed with the compiler about layout.
MIT.