Skip to content

Latest commit

 

History

92 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

aowlc

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.

The cheat

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 → C printer. hexer already 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.

What works today

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/continue
  • case — 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 mangleToC name mangling and the importc/exportc extern-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.

Usage

# 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.c

The single-TU limits differ per printer

bin/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/run are whole-PROGRAM

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.

Tests

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 one

e2e.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.

Getting a .c.nif

.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

Pipeline

      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 + nifsemhexeraowlcgcc.

There are TWO printers, and they are compared

  • aowlc.js — the hand-written JavaScript one. bin/aowlc, the driver the usage examples above invoke, uses it, and npm test is the only gate that did.
  • src/emitc.nimbin/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 NI64long 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.

Layout is cross-checked against aowlabi

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.

License

MIT.

About

Native C backend for the aowl Nimony toolchain: a post-hexer .c.aif to C printer linked with gcc. hexer already injected ARC, lowered closures, erased exceptions and monomorphised generics, so the printer is mechanical and GC is free. Counterpart to aowljs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages