Post

Dea/L0 2.0.0: checked pointers and new editor tooling

Dea/L0 2.0.0: checked pointers and new editor tooling

I have released Dea/L0 2.0.0, a major update to the self-hosted foundation of the Dea systems programming language. This release makes many pointer failures deterministic, gives native compiler execution a stricter security boundary, and brings project-maintained editor tooling into the repository. It also fixes subtle ownership and analysis differences between the Python bootstrap compiler and the compiler written in L0 itself.

For readers new to the project, Dea is built by staged bootstrapping. L0 is the small base language: it has a C-family syntax, compiles to C99, and is intended to remain capable of compiling itself. Its original Stage 1 compiler is written in Python; Stage 1 bootstraps Stage 2, the compiler written in Dea/L0 and shipped to users. When I talk about “cross-stage” correctness below, I mean making those two independent implementations accept, reject, and compile the same L0 programs in the same way.

The release also completes two migrations announced in Dea/L0 1.1.0: case defaults now have one spelling, and the short command-line options now follow stable namespaces.

The theme of 2.0.0 is explicit boundaries. A pointer carries a runtime contract. Foreign storage has a declared lifetime. Compiler subprocesses stay inside private workspaces. Even short options say which part of the toolchain they belong to.

Checked pointers by default

Dea/L0’s language policy is that an operation must be well-defined, rejected at compile time, or stop with a named runtime failure. Pointer operations are where that promise is hardest to keep when compiling to C99.

Fully checked builds now track allocation families and validate generated pointer reads, writes, and drops. A checked access must refer to live storage, stay within its extent, satisfy alignment, use the permitted access direction, and respect read-only memory. The runtime also keeps freed user allocations in a bounded quarantine, so many stale-pointer accesses fail deterministically with allocation and release context instead of depending on what the host allocator happens to do next.

The allocation family matters too. new allocations, raw allocations, ARC-managed storage, static data, and registered foreign buffers have different release contracts. A checked build rejects cross-family operations such as using drop on memory that was not allocated by new.

In L0, pointers are non-null unless their type is explicitly marked nullable with ?. Heap objects created with new are released with drop. Strings and some container contents use automatic reference counting (ARC), so the compiler inserts the retains and releases that keep shared values alive.1 Those mechanisms explain why the runtime must know not only whether an address is valid, but also what created it and which operation is allowed to end its lifetime.

The compiler can now produce programs in three runtime modes:

  • The default fully checked mode performs all the pointer validation described above.
  • --check-basic still rejects null or misaligned pointers, stale allocations, and invalid releases, but omits checks that interior pointers stay within an allocation.
  • --unchecked removes pointer tracking, validation, and quarantine for builds where that tradeoff is explicitly acceptable.

The shipped Stage 2 compiler itself uses basic checking, but the programs it produces remain fully checked by default.

Foreign memory without pretending to own it

Checked code sometimes needs to access storage owned by a C library, an operating-system API, or another runtime. That memory cannot honestly be treated as an L0 allocation, but disabling checks for the whole program would throw away too much information.

The sys.memory module now provides rt_register_foreign and rt_unregister_foreign. Together they let checked L0 code describe the extent and mutability of externally owned storage without transferring ownership. Register the buffer before generated code dereferences it, and unregister it before the external lifetime ends.

This small public API addition closes an important gap: checked L0 code can access foreign memory without confusing permission to access it with responsibility for releasing it.

Editor tooling for L0 and L1

Dea now ships project-maintained L0 and L1 syntax support for VS Code/TextMate, Vim, Emacs, and Universal Ctags. The self-contained tree-sitter-dea package includes the generated incremental parser, parsing fixtures and corpus tests, queries for highlighting, indentation, local scopes, and tags, and bindings for C, Go, Node.js, Python, Rust, and Swift.

These integrations provide syntax-aware editing for incomplete buffers while preserving distinct L0 and L1 language identities. They are structural tools rather than semantic IDE services: the compiler remains authoritative, and 2.0.0 does not add an LSP, completion, hover, rename, or refactoring.

A smaller trust boundary around the compiler

l0c is both a language frontend and a build driver: for --build and --run, it writes generated C and invokes a host C compiler such as GCC or Clang. In 2.0.0, the self-hosted Stage 2 compiler creates a private temporary workspace under a validated parent and keeps its generated C, compiler output captures, executable, and intermediate files inside it. Cleanup never follows links outside the workspace.

Stage 1 now atomically reserves its temporary generated-C file before invoking the host compiler. Generated C also escapes every historical trigraph spelling2 without changing the strings or filenames seen by an L0 program. These are unglamorous details, but compiler correctness often lives in the gap between a valid source program and a hostile or surprising host environment.

Cross-stage correctness

Several fixes close gaps between the Python bootstrap compiler and the self-hosted compiler. They prevent generated-program ownership failures, duplicate diagnostics, compiler-internal leaks, and Stage 2 acceptance of invalid initializers or match patterns. Stage 2 also emits portable C99 constants for static ord(Variant) calls.3

These are compiler correctness fixes rather than language changes. The two stages now agree on cases involving ownership, control flow, name resolution, and top-level initialization where their behavior previously diverged.

One case default, and a reorganized CLI

The 1.1.0 migration window for case ... else has ended. In 2.0.0, _ => is the only default-arm spelling:

case (status) {
    0 => printl_s("ready");
    1 => printl_s("busy");
    _ => printl_s("unknown");
}

The old form is now rejected. This removes the temporary ambiguity between an unbraced if ... else and a case default, while making case and match use the same explicit wildcard vocabulary.

Because l0c can check source, emit C, invoke a host compiler, trace memory, and resolve project and system modules, its old one-letter options were becoming ambiguous. Short compiler options now use coordinated namespaces. The long options have not changed, so scripts can avoid most migration work by using those. The most visible short-option changes are:

  • --gen: -g becomes -Gc
  • --log: -l becomes -Vl
  • --project-root and --sys-root: -P / -S become -Rp / -Rs
  • --c-compiler and --c-options: -c / -C become -Cc / -Co
  • --runtime-include and --runtime-lib: -I / -L become -Ri / -Rl

-c and -I serve L1 separate compilation, while -g, -S, -L, and -l retain their conventional compiler meanings. L0 reserves these spellings rather than assigning them conflicting behavior, keeping the CLI consistent across Dea levels.

For mixed-language builds, repeatable -Cs / --c-source options now add C translation units without smuggling source paths through host-compiler option text.

Migration checklist

Before moving a project to 2.0.0:

  1. Replace every case ... else default with _ =>.
  2. Update old short compiler options, or switch scripts to the unchanged long forms.
  3. Check projects that shadow system modules: the configured system module directories now take precedence over project module directories.
  4. Pass additional C translation units through --c-source.
  5. Register foreign buffers before checked code accesses them, then unregister them before their external lifetime ends.
  6. Do not combine --check-basic or --unchecked with the ARC or memory trace modes.
  7. Do not index a vector between its logical length and reserved capacity. That access is now rejected.

No public L0 types were added or removed. The main public-surface changes are the completed case and CLI migrations, the checked-runtime selection options, structured C-source inputs, and the two foreign-memory lifetime functions.

Download the supported platform archives, generated API documentation, and SHA256SUMS from the Dea/L0 2.0.0 release. The full comparison and contributor guide are available in the public repository.


Notes

  1. ARC is a deterministic memory-management scheme. Each owned reference contributes to a counter; the last release destroys the value. A missing retain can cause use-after-free or double release, while a missing release leaks memory. ↩︎

  2. C99 recognizes nine three-character sequences beginning with ?? as replacements for punctuation. The replacement happens early enough that an innocent sequence inside generated text can change how the C source is parsed unless the backend breaks up or escapes it. ↩︎

  3. ord returns the numeric tag of an enum variant. At file scope, the generated C initializer must itself be a C99 constant expression, not merely an expression whose value is known to the Dea compiler. ↩︎

This post is licensed under CC BY 4.0 by the author.