19. Compilation and runtime
19.1 Pipeline#
Source is tokenised, laid out, parsed, resolved, and typed into the same typed intermediate representation as full Luce, then lowered to the canonical machine representation every backend consumes. A Base module is checked by the same passes with the Base profile: the grammar additions of Chapter 21 are admitted, runtime-dependent constructs are rejected with a diagnostic naming the tier they belong to, and the freestanding property is checked by reachability.
19.2 What Base adds to the shared representation#
All target-neutral:
- a pointer-width integer type, and symbolic layout constants for
memory.size_of,memory.align_of, andmemory.offset_ofthat the backend folds; - a nullable pointer type, the one recorded exception to full Luce's uniform tagged optional, justified by the C layout of struct fields, and shared with native Luce's raw pointers;
- a two-word unmanaged interface-view type with a witness-table-address instruction;
- a union type;
- a memory-zeroing instruction;
- pointer difference, pointer-integer conversion, and pointer ordering;
- a
volatileflag on loads and stores, and anoaliasfact on parameters; - atomic load, store, read-modify-write, compare-and-swap, wait, wake, and fence, each with an ordering;
- an
asmregion carrying every architecture variant, and anakedfunction form; - a variadic call form;
- the attribute facts of §9.8.
The verifier checks each. new and free lower to calls through the memory module's thread-local view and add nothing to the representation.
19.3 Backends and bridges#
The Base compiler uses its native backend by default. It lowers checked Base through its own IR, optimization passes and register allocation to target assembly, then assembles and links the result. Native executable, test-runner and static-library builds target arm64 macOS, x86-64 Linux and x86-64 Windows, from any host: --emit=asm writes another target's assembly anywhere, and the build links it where the target's toolchain is. --native is an explicit alias for the default; --opt 0 through --opt 3 select native optimization levels.
The bootstrap starts with a C snapshot compiled by the host C compiler, or with a compiler built by luce-seed. That initial compiler builds the next compiler using the native backend, and the resulting compiler self-hosts natively again. The native assembly must reach a fixpoint.
The existing C emitter is retained as an explicitly selected comparison and snapshot facility: --backend=c compiles its output through the host C compiler, and --backend=c --release selects the optimized C comparison. --release alone retains the native default. --emit=c, --emit=asm, and --emit=ir select their corresponding backend and text output independently of backend-flag order.
C interoperability is part of native compilation: C ABI calls, linked C libraries, and manifest C sources do not require translating Base code to C. Unsupported native targets report an error; they do not silently fall back to C emission.
19.4 Artifacts and build profiles#
A Base executable links a startup shim and a trap reporter and no Luce runtime (§1.3). By default the shim uses the host C library for process start, output, and exit; --freestanding drops it and the program supplies _start through a naked func or a module-level asm block. There are two build profiles: default, and diagnostic, selected with luce build --profile diagnostic or luce test --profile diagnostic; a diagnostic build's cache key differs from a default build's (§19.7). No profile changes overflow, bounds, evaluation order, or error behaviour. The diagnostic profile additionally fills unwritten storage with the byte 0xAA where it is made: a --- local (§6.2), the elements of new T[count] --- (§12.2), the bytes of memory.allocate and those memory.grow adds, and the elements of memory.frame (§12.7), so a read before the first write sees the pattern rather than whatever the memory held, whichever allocator provided it; a default build writes nothing there and pays nothing. Bytes a program asks of an allocator directly, allocator.allocate(size, alignment), are the allocator's own and are left as it hands them out. It records allocation sites (the most recent 256, oldest first), and quarantines released blocks: CAllocator and PageAllocator fill a released block with a pattern and hold it back, the last 256 blocks and the last 8 mappings, so a use after release reads the pattern; releasing a block it still holds traps as a double free, and a held block whose pattern was written over traps as a use after free when it is finally returned. Arena and FixedBuffer fill a released block in place, since their memory returns all at once.
19.5 Targets#
luce build --target NAME compiles for a target; without --target the host is the target. The compiler writes the platform standard module for the build, whose constants os re-exports, so if os.linux and os.x86_64: is decided at compile time and the other arms are pruned (§19.6): one source covers every target, and each target links only what it uses. The standard library is written that way: a constant whose value differs by target is a conditional of constants, 6 if platform.macos else 1, and a call whose shape differs is an if over the target's arms. The native backend emits arm64-macos, x86_64-linux and x86_64-windows from any host (§19.3); the C backend writes C for any of them with --emit=c. These three are the only targets.
The same module names the program: platform.program is the name of the package the entry belongs to as its package.prisma spells it (luced-2d), app for a program outside any package, and platform.program_version is that manifest's version, empty when it has none. They are how a crash report (crash in luce-std) names the program without being told.
--target | asm name | Pointer width | c.long | c.char | Calling convention |
|---|---|---|---|---|---|
x86_64-linux | x86_64 | 64 | 64 | signed | SysV |
x86_64-windows | x86_64 | 64 | 32 | signed | Windows x64 |
arm64-macos | arm64 | 64 | 64 | signed | Apple arm64 |
A target has an instruction-set level beyond its family's baseline: on x86-64, v1 (SSE2), v2 (SSE4.2, POPCNT), v3 (AVX2, FMA, BMI2), and v4 (AVX-512); on arm64, neon and sve. Without --cpu, a build for the host is for the level of the processor it is built on, and a build for another target is for the baseline; --cpu NAME chooses the level, --cpu v1 for a program that must run anywhere. A program compiled for a level runs only on processors that have it; the compiler emits the level's instructions where it has forms for them, and the baseline's elsewhere, so the level never changes what a program means. os.cpu_level is the level the program was compiled for; os.cpu_level_running() is the level of the processor it runs on, for a program that dispatches at run time.
19.6 Tooling#
Every diagnostic is file:line:column: message, one per line behind the tool's name (luce-base: ), and a rejection exits with status 1. A declaration that does not check is reported and the next declaration of its module is checked, so one run names every failing declaration; a diagnostic with a second site, the earlier binding a name would shadow or the earlier import a repeated one, follows it with an indented note: file:line:column: ... line. An error in a module's imports or signatures ends the run, since nothing after it can be checked.
luce fmt, luce check, luce build, luce test, luce describe (§17.7), and luce bind apply to Base modules. luce fmt FILE prints the module in the canonical layout, --write puts it back in the file, and --check prints nothing and exits with 1 when the file is not in that layout. The layout: four spaces per block, one statement per line, one space around a binary operator and after a comma, none inside brackets or around a range operator, one blank line between declarations with bodies, at most one blank line elsewhere where the source had one, a one-statement suite on its header's line where the source wrote it there, a list broken across lines kept broken at the same items with each continuation under the first item, and comments kept: one on a line of its own stays before what follows it, one after code stays after that line. The parser reads the result back into the same tree, and formatting the result again changes nothing. -W on check, build, or test prints the checker's warnings: an unused local (a name beginning with _ is exempt), an unused import, a private function nothing references, a statement no path reaches, and a branch or loop whose literal condition rules it out. Each is also pruned from the program by the checker, so nothing after the checker sees it. A condition that is a constant expression without being a literal, if os.arm64: or if os.pointer_bits == 64:, is decided the same way and its ruled-out branch pruned, silently, since the program meant it, and so is what follows a branch it makes always leave: this is how a program covers several targets in one source; luce check FILE --target NAME decides such conditions for NAME, check --package checks every other module of FILE's package too, imported or not, as test --package loads them (§16.5; what luc check asks for, once per target), and check -W exits with 1 when it printed a warning; an unused binding whose initialiser may have an effect stays as that expression. luce build --lib produces a library and header. luce build --freestanding drops the shim. luce build --costs is to print the adapter and allocation report of §18 (planned). luce build --target NAME selects a target (§19.5); --target with no argument lists the targets above and the asm architectures a package covers.
19.7 The build cache#
A build's object is a function of what went into it: every source the compiler read (the standard modules, the program's modules, its manifest), the target and its instruction-set level, the backend and the flags (and, for a C build, the C compiler CC names and LUCE_CFLAGS), and the compiler itself. luce build and luce test keep the object under a key that hashes all of these, in --cache-dir DIR, else the directory LUCE_CACHE names, else the project's own build/.cache (beside the build output, so removing build clears it and no two projects share one); none builds without a cache. A source is named in the key as the object names it in its trap positions, so a kept object says what a fresh build would: a standard module as std/..., a dependency's as <package>/..., and the program's own relative to the project root, so the same project built from another directory, by a relative or an absolute entry, has the same key. A debug or symbol build records the directory it was built in and names the program's sources as they were read, and a C object is built by path; those key by the root and by the paths as read. A build whose key is in the cache checks the program and links the kept object, translating and assembling nothing; any other build translates, assembles, and keeps its object. A native build without debug information keeps its object in pieces, cut where the program's functions choose, and assembles only the pieces whose text no earlier build assembled. A native build also keeps the text it generated for each function, and the next build of the program takes it again for every function that stands as it did, with the same callees and settings, optimising and generating only the rest; the program is the same, byte for byte, as a build without the cache makes. --cache-report says which happened. An object is written beside its place and renamed, so a concurrent build never reads a partial one. --emit, --lib, and the manifest's own C sources are not cached. The cache only grows; removing its directory is always safe, and a tool that shares one cache across projects (luce keeps ~/.luce/cache) keeps it within a limit itself.