Skip to content

Fikoko/Canon-C

Repository files navigation

Canon-C

Canon-C

Language

C99

Platforms

Windows Linux macOS

License

License: MPL-2.0 License: MIT

Development & Testing

GCC Clang MSVC ASan+UBSan Valgrind Fuzzing

Static Analysis

clang-tidy Cppcheck Polyspace/LDRA

Formal Verification & Timing Analysis

Frama-C MC/DC aiT WCET

Production & Certification

CompCert DO-178C Level C/D ISO26262 ASIL C/B IEC 62304 EN 50128 IEC 61508 SIL2 ECSS-E-ST-40C


Table of Contents

  1. Motivation
  2. IMPORTANT
  3. Dependency Rule (Strict)
  4. Design Philosophy
  5. Getting Started
  6. Scope
  7. Examples
  8. Usage

Motivation

Over time, I found myself repeatedly re-implementing the same low-level patterns in C: arenas, error handling, vectors, parsing, file I/O, and more. Existing libraries either hide allocation, impose heavy frameworks, rely on implicit conventions, or lack consistency across modules. None matched my preference for explicit ownership, predictable allocation, header-only usage, and minimal hidden behavior.

In practice, this meant rewriting the same infrastructure across projects and re-learning the same informal rules every time.

Canon-C is an attempt to unify these patterns into a small, disciplined, and composable semantic standard library for C, governed by strict design principles. The goal is not to add new functionality, but to make program intent visible directly in APIs, so that ownership, lifetime, failure, and data flow are immediately clear at call sites.

C is fast, portable, and predictable, but its native semantics are low-level and mechanical. Writing non-trivial programs in C requires memorizing conventions and boilerplate around memory management, ownership rules, error handling, and iteration. These details obscure intent and significantly increase cognitive load.

Modern languages embed these abstractions directly into the language. While powerful, this often hides behavior, increases semantic complexity, and reduces transparency. Canon-C takes a different approach: C itself remains unchanged. Meaning is added explicitly through libraries, not syntax.

The result is a set of semantic building blocks that improve readability, preserve explicit control, maintain performance, and remain fully transparent.

The long-term goal is for this taxonomy of C abstractions to become a shared standard vocabulary — a common foundation that allows C programs to communicate intent clearly, consistently, and safely.

Canon-C is licensed under MPL 2.0. ( See Licence-MIT related issues at Licence-MIT file )

Using Canon-C headers unmodified in your project (commercial or non-commercial) does not trigger MPL requirements — only modifications to Canon-C files themselves do.


IMPORTANT

Feedback requested on:

  1. Does this categorization make sense?
  2. Are things in the right layers?
  3. What’s missing or wrong?

Note:

This project is an architectural proposal and design foundation built by a team. The taxonomy, dependency rules, and API designs are intentional and carefully considered. The implementations are fully tested* and compiled end-to-end. AI assistance was used for scaffolding, but architecture and philosophy are entirely human-designed.


Dependency Rule (Strict)

Canonical layer order:

core/ → semantics/ → data/ → algo/ → util/

Rules:

  • Modules are organized by semantic depth, not feature count. Lower layers define unavoidable mechanics; higher layers build meaning on top.
  • Lower ( The lowest is Core ) layers may be used by higher ( The highest is Util ) layers, but upward or circular dependencies are strictly forbidden.
  • Each module must be independently usable.

This ensures explicitness and prevents hidden behaviors or fragile dependencies.


Design Philosophy

  • Everything is optional
  • Everything is explicit
  • No forced hidden allocation
  • No forced implicit ownership
  • No forced global state
  • No forced framework coupling
  • No forced require runtime
  • Works in plain C99
  • Convenience macros requiring GNU C or C23 are optional (disable with #define CANON_NO_GNU_EXTENSIONS)
  • No clever tricks

If behavior cannot be understood by reading the header, it does not belong. Abstractions must clarify behavior, not conceal it.


Getting Started

Canon-C is organized into strict layers, from low-level mechanics to high-level convenience modules.
To get the most out of the library, explore layers in order.


1. core/primitives/ — Foundations

This is the bedrock of Canon-C:

  • Fixed-width types (u8, usize, isize, etc.)
  • Compile-time numeric limits & alignment constants
  • Pointer arithmetic and alignment utilities
  • Overflow-checked arithmetic
  • Min/max/clamp helpers
  • Explicit contracts and assertions (require_msg, ensure_msg)

Goal: Understand primitive building blocks of memory, arithmetic, and pointer safety.


2. core/ — Core memory & ownership

Builds on primitives for fundamental memory management:

  • Linear bump allocator (arena.h)
  • Fixed-size object pools (pool.h)
  • Slice views (slice.h) and region-based lifetimes (region.h)
  • Scope-bound defer for cleanup pairing (scope.h)
  • Ownership and borrowing annotations (ownership.h)
  • Low-level memory utilities (memory.h)

Goal: Learn to safely allocate, manage, and access memory with explicit ownership.


3. semantics/ — Explicit semantic types

Higher-level abstractions for safer programming:

  • Option<T> (option/) — presence/absence semantics
  • Result<T,E> (result/) — success/failure handling
  • Borrowed views (borrow.h) — non-owning references
  • Structured diagnostics (diag.h) — contextual error reporting

Goal: Introduce explicit semantic meaning to data and operations.


4. data/ — Fixed-capacity collections

Bounded, caller-owned containers:

  • Typed vectors (vec/), double-ended queues (deque/)
  • Fixed-size arrays, stacks, queues, priority queues
  • Hashmaps, bitsets, ranges, string builders

Goal: Work with structured data safely and predictably without hidden allocations.


5. data/convenience/ — Ergonomic collections

Optional modules for ease-of-use:

  • Auto-growing vectors (dynvec), small-buffer vectors (smallvec)
  • Auto-growing string builders (dynstring)

Goal: Simplify common tasks while understanding trade-offs (hidden allocations, growth logic).


6. algo/ — Algorithms on collections

Generic and typed reusable algorithms, each implemented as a modular 5-file architecture (consistent with data/ and semantics/ layers):

  • Transformation (map/), filtering (filter/), folding (fold/), searching (search/)
  • Sorting (sort/), reversing (reverse/), uniqueness (unique/)
  • Predicate checks (any_all/), element location (find/)

Eight of the nine modules provide three levels of use:

  • Genericvoid* + function pointer interface, works on any type
  • Typed macro — compile-time type safety, wraps the generic level
  • Typed instantiationDEFINE_ALGO_X(type) stamps out fully typed slice variants with no void*, directly optimizable by the compiler

fold/ is the exception: the accumulator type and element type are both caller-determined and may differ, so a generic void*-based function would require unsafe function pointer casts. Instead, fold provides macro-based operations at Levels 1 and 2 (ALGO_FOLD, ALGO_FOLD_RESULT) and typed slice functions at Level 3 (DEFINE_ALGO_FOLD). The deviation is documented in fold.h.

map/ supports cross-type transformation (different input and output types). When multiple cross-type mappings share the same input type, use DEFINE_ALGO_MAP for the first and DEFINE_ALGO_MAP_CROSS for subsequent calls to avoid redefinition of the in-place variant.

Goal: Apply operations to collections predictably and generically, with opt-in typed instantiation for cases where full type visibility matters.


7. util/ — Utility modules

Convenience utilities for Canon-C codebases — not standalone replacements for specialized libraries. These modules exist to prevent convention mismatch at the boundary where algo/ ends and application code begins. Every module returns Result, accepts explicit arenas, and follows the same ownership and lifetime conventions as the rest of Canon-C.

  • Strings: str.h, str_split.h, str_join.h, str_view.h, intern.h
  • Logging: log.h, log_macros.h
  • File I/O, parsing, random number generation, timing

Goal: Extend Canon-C's conventions — explicit ownership, Result-based errors, arena-backed allocation — into application-level utility code. For production logging, use zlog. For everything else, util/ removes the seam between Canon-C's lower layers and the rest of your codebase.

Scope

Canon-C covers exactly what is needed to write explicit, ownership-aware C programs without reaching for a larger framework. Every module exists because the pattern it encodes recurs across real programs and has no good idiomatic C equivalent.

Nothing was added speculatively. The cutoff rule is simple: if a module can be built cleanly from existing layers using Canon-C's own primitives, it doesn't need to live here — the user writes it. If the absence of a module forces every downstream user to re-invent the same unsafe boilerplate, it belongs.

Canon-C stops at the boundary where general-purpose utility ends and application-specific concerns begin. Concretely, all things that are not covered are listed below . These domains are large, platform-specific, and opinionated in ways that don't belong in a vocabulary library — and they're already covered by libraries that specialize in exactly that.

For what Canon-C intentionally omits, established C libraries exist:


Networking / async I/O

  • libuv — cross-platform async I/O and event loop, the industry standard for non-blocking network programming in C. Battle-tested across Windows, Linux, and macOS. Used as the runtime underneath Node.js.
  • lwIP — lightweight TCP/IP stack for embedded systems with constrained memory. Use when libuv's hosted runtime assumptions are too heavy for your target.

GUI / graphics

  • raylib — immediate mode graphics, input, audio, and windowing in a single library. Zero external dependencies, builds on Windows, Linux, macOS, and compiles through Emscripten for WebAssembly. The closest in philosophy to Canon-C among GUI libraries — explicit, minimal, no hidden framework. Use for games, simulations, tools, and visualizers.
  • nuklear — single-header immediate mode GUI in pure C99. No dependencies, no state machine, no heap allocation by default. Renders through a backend you provide (raylib, SDL2, OpenGL). Use when you need a lightweight debug or tool UI without pulling in a full GUI framework.
  • SDL2 — cross-platform window creation, input, audio, and 2D rendering. Lower level than raylib — you bring your own rendering pipeline. Use when you need fine-grained control over the graphics stack or are integrating with an existing renderer.
  • lvgl — embedded GUI library designed for microcontrollers and displays with constrained memory. Runs on bare-metal, FreeRTOS, and Zephyr. No operating system required. Use for industrial HMIs, IoT devices, and any embedded target with a display.

Threading / concurrency

  • pthreads — POSIX standard threading API. Available natively on Linux and macOS. No external dependency needed on Unix-like systems.
  • TinyCThread — portable implementation of the C11 threads API in two files. No external dependencies. Use when you need cross-platform threading including Windows without pulling in a larger framework.
  • C11 <threads.h> — if your compiler fully supports C11, the standard threading API is available directly with no library needed.

Serialization

  • cJSON — ultralightweight JSON parser and emitter in ANSI C. Single file, MIT license. Widely used in embedded and systems projects.
  • yyjson — fastest JSON library in C. Use when performance matters and cJSON is the bottleneck.
  • mpack — MessagePack for C. Use when binary serialization is needed over JSON: smaller payloads, faster parsing, schema-driven.
  • miniz — single-file deflate/inflate and ZIP compression. Use for compressing serialized data or reading and writing ZIP archives.

Math / numerical computing

  • cglm — optimized graphics and general-purpose math (vectors, matrices, quaternions, transforms) in pure C99. Header-only, SIMD-accelerated where available, no dependencies. Fully cross-platform. Use for 3D transforms, physics, coordinate geometry, and any linear algebra that fits the graphics math model.
  • CMSIS-DSP — ARM's official digital signal processing library for Cortex-M targets. Fixed-point and floating-point FFT, filters, matrix operations, statistics. Use on ARM embedded targets doing signal processing, sensor fusion, or control loops.
  • libfixmath — portable fixed-point arithmetic (Q16.16) in pure C. No floating-point unit required, no dependencies. Fully cross-platform. Use on embedded targets without an FPU where <math.h> operations are prohibitively slow or unavailable.

Hashing (non-cryptographic)

  • xxHash — extremely fast general-purpose hash. Use for checksums, data fingerprinting, or hash tables where security is not a concern.
  • SipHash — hash-flooding resistant hash function. Recommended for string keys in Canon-C's hashmap — prevents algorithmic complexity attacks when keys are user-controlled input.

Cryptography

  • monocypher — minimal cryptographic library, single C file, zero allocation, no global state. Covers ChaCha20, Poly1305, Blake2, Argon2, Ed25519. The closest in philosophy to Canon-C among crypto libraries.
  • libsodium — higher-level cryptographic API, widely known and battle-tested. Heavier than monocypher and manages its own initialization, but more familiar to most developers.

Testing

  • Unity — the most widely used unit testing framework in embedded and C99 projects. Simple assertion macros, no dynamic allocation, minimal setup.
  • Criterion — modern testing framework with automatic test discovery and no boilerplate. Better suited for desktop targets where a richer test runner is acceptable.
  • greatest — single header, public domain. Use when you want zero friction and no framework overhead at all.

Logging

  • zlog — for production systems requiring async, multi-target, or runtime-configurable logging. Canon-C's log.h covers the common case — reach for zlog only when you need to write to multiple sinks simultaneously, change log levels at runtime without recompiling, or need buffered async writes in high-throughput systems.

Database / storage

  • SQLite — the universal embedded relational database. Zero configuration, single file, battle-tested. Use when your data has relational structure or you need SQL query capability.
  • LMDB — memory-mapped key-value store. Extremely clean C API, no hidden allocation, ACID transactions. Use when you need fast persistent storage without the overhead of a full relational database.

Embedded / bare-metal

  • FreeRTOS — the most widely adopted RTOS for constrained microcontrollers. Minimal footprint, simple task scheduler, direct hardware control. Use when your device has well-defined behavior and you want full architectural control with minimal overhead.
  • Zephyr — full-featured, scalable RTOS managed by the Linux Foundation. Built-in drivers, networking, Bluetooth, and security. Use when your project needs to scale across hardware revisions, run multiple subsystems, or be maintained long-term. Higher learning curve than FreeRTOS but significantly more structure.

WebAssembly

  • Emscripten — compiles C and C++ to WebAssembly using LLVM as a drop-in replacement for gcc/clang. Output runs in browsers, Node.js, and standalone Wasm runtimes. Canon-C's C99 code compiles cleanly through Emscripten without modification.

Integrating external libraries with Canon-C

These libraries use traditional C API conventions — raw pointers, integer error codes, implicit ownership, and occasional global state. They cannot be made fully Canon-C-idiomatic, but a thin adapter layer can keep the rest of your codebase consistent. How cleanly they integrate depends on the library:

Wrap cleanly — monocypher, mpack, miniz, xxHash, SipHash, cglm, libfixmath, CMSIS-DSP. Flat API, no global state, buffer-based. Maps directly to Canon-C conventions with a thin adapter.

Wrap partially — SQLite, LMDB, cJSON, yyjson, libsodium, TinyCThread, pthreads, C11 <threads.h>, raylib, nuklear, SDL2, zlog. Core operations wrap well into Result<T, Error> and owned()/borrowed(). Callbacks, domain-specific error codes, global initialization, stateful handle lifecycles, or thread entry points create contained mismatches that cannot be eliminated, only documented.

Isolate only — libuv, FreeRTOS, Zephyr, lwIP, lvgl. Callback-driven, tick-driven, or RTOS task models are architecturally incompatible with Canon-C's explicit control flow. Contain the mismatch behind a single adapter file. Canon-C's lower layers (arena, slice, result) remain usable inside these environments — just not as wrappers around them.

Not applicable — Unity, Criterion, greatest, Emscripten. Testing frameworks run at build time, not as runtime dependencies in your application. Emscripten is a compiler toolchain, not a linked library. These do not require integration adapters.

Canon-C provides the following tools for integration boundaries:

  • core/ownership.h — annotate adapter functions with owned(), borrowed(), and DEFINE_OWNED to make ownership explicit at the integration point. Wrap external handles like sqlite3*, uv_loop_t*, or MDB_env* with CANON_DROP to ensure cleanup is never missed.

  • semantics/result/result.h — convert integer return codes from SQLite, libsodium, or libuv into Result<T, Error> at the adapter boundary. Use TRY and TRY_REMAP to propagate failures up without boilerplate. For libraries with many domain-specific codes (SQLite has 30+), define a local error enum and instantiate CANON_RESULT with it.

  • core/slice.h / semantics/borrow.h — pass Canon-C bytes_t, cbytes_t, and borrowed_bytes directly into buffer-based APIs like mpack, miniz, and LMDB's MDB_val. The {ptr, len} layout is compatible with most C buffer conventions without copying.

  • core/scope.h — use DEFER(cleanup_expr) { body } to pair acquisition with release for external handles when the work runs to completion. For adapter functions with error-return paths — the common case when wrapping libraries like SQLite or libuv, where almost every API call can fail — use the standard C99 goto cleanup; idiom instead. DEFER does not fire on return, break, or outward goto, and adapter functions almost always need cleanup on error exits. DEFER is Canon-C's contribution (a C99-portable macro for run-to-completion cleanup); goto cleanup; is plain C and needs no library support. Canon-C recommends the combination because each tool fits a different shape of function.

  • semantics/diag.h — attach context to external failures as they propagate. "SQLite failed → while writing record → during sync" with no allocation, using DIAG_PUSH and DIAG_PROPAGATE.

  • core/arena.h — some libraries accept custom allocator hooks (SQLite's sqlite3_config(SQLITE_CONFIG_MALLOC), lwIP's mem_malloc). Canon-C's arena can back these if you write the adapter, giving you explicit lifetime control over the library's internal allocations.

The pattern is always the same: one thin adapter file per external library, using the tools above at the boundary. Everything above the adapter stays pure Canon-C. Callback-driven APIs (libuv, FreeRTOS tasks) and libraries requiring global initialization (libsodium's sodium_init()) cannot be fully wrapped — isolate them behind the adapter and contain the convention mismatch there.


Bare-metal and embedded use

Canon-C has two platform dependencies that require attention on bare-metal or RTOS targets: stdio and malloc.

stdio stdio enters Canon-C in exactly two ways. The default contract panic handler in core/primitives/contract.h uses fprintf — replace it once at startup with contract_set_handler() pointing to your UART or fault handler, and stdio is never reached again from the entire core layer. The second entry point is rendering functions that use FILE*. diag_print() and log.h use fprintf — avoid these on bare-metal. Use diag_render() instead: it writes the same output into a caller-supplied char[] buffer with no FILE* dependency, and the caller sends that buffer to UART, CAN, flash, or whatever the platform provides. Everything else in Canon-C that includes <stdio.h> does so only for snprintf in optional formatting functions — skip those call sites and the dependency is inert on toolchains with stub stdio support (newlib, picolibc).

malloc / free core/memory.h includes <stdlib.h> and provides mem_alloc() / mem_free() as explicit wrappers over malloc / free. core/arena.h and core/pool.h include memory.h but never call malloc — they operate entirely on caller-supplied buffers. The heap dependency is only active if your code calls mem_alloc(), mem_alloc_type(), or mem_alloc_array() directly. Everything in data/convenience/ (dynvec, smallvec, dynstring) uses heap allocation internally.

On bare-metal, if you avoid mem_alloc() and data/convenience/, no malloc is ever reached. If you do need heap allocation, #define malloc, realloc, and free to your RTOS allocator (e.g. FreeRTOS's pvPortMalloc / pvPortReAlloc / vPortFree) before including any Canon-C header. This single redefinition propagates through the entire dependency chain automatically.

What requires no mitigation at all core/primitives/types.h, core/primitives/limits.h, core/primitives/bits.h, core/primitives/checked.h, and core/primitives/compare.h pull in only freestanding headers (<stdint.h>, <stddef.h>, <limits.h>, <stdbool.h>) and are safe on any target without modification. core/scope.h is the most portable header in Canon-C — it has zero header dependencies at all, not even freestanding ones, and is safe on any target including bare-metal and freestanding environments with no libc. All other core/ headers include contract.h — they require the handler replacement described above.


Examples

Canon-C is not about writing less code. It is about writing code that stays readable, explicit, and safe as the codebase grows. The ... below represents real programs — the conventions you see in each snippet are the same ones you will find everywhere in a Canon-C codebase, whether it is 300 lines or 30,000.


Contracts — preconditions are visible at every function boundary

/* WITHOUT Canon-C — preconditions are invisible or scattered */
int process(Arena* arena, Config* cfg, size_t count) {
    if (!arena) return -1;  /* what does -1 mean? */
    if (!cfg)   return -1;  /* same code, different cause — indistinguishable */
    if (!count) return -1;  /* caller has no idea which check failed */
    /* ... */
}
/* WITH Canon-C — preconditions are visible, grep-able, self-documenting */
#define CANON_CONTRACT_IMPL
#include "core/primitives/contract.h"

result_int_Error process(
    borrowed(Arena*)  arena,
    borrowed(Config*) cfg,
    usize             count)
{
    require_msg(arena != NULL, "process: arena is NULL");
    require_msg(cfg   != NULL, "process: cfg is NULL");
    require_msg(count  > 0,    "process: count must be > 0");

    /* ... */
}

No silent failures. No NULL dereferences discovered at 3am. Every precondition is visible, grep-able, and self-documenting.


Ownership — intent is visible at every call site

/* WITHOUT Canon-C — ownership is implicit, conventions drift */
Config* config_create(uint8_t* buffer, size_t size); /* owned? borrowed? */
char*   config_get_name(const Config* cfg);           /* heap copy? pointer into cfg? */
void    config_destroy(Config* cfg);                  /* can I use cfg after this? */

/* ... 2000 lines later ... */

Config* cfg  = config_create(buf, sizeof(buf));
char*   name = config_get_name(cfg);
config_destroy(cfg);
printf("%s\n", name); /* is name still valid? nobody knows */
/* WITH Canon-C — intent is visible at every call site */
#include "core/ownership.h"

/* caller receives ownership — caller must free */
owned(Config*)  config_create(owned(u8*) buffer, usize size);

/* callee borrows — caller retains ownership */
str_t           config_get_name(borrowed(const Config*) cfg);

/* callee consumes — caller must not use after */
void            config_destroy(dropped(Config*) cfg);

/* ... 2000 lines later, still the same conventions ... */

owned(Config*) cfg  = config_create(buf, sizeof(buf));
str_t          name = config_get_name(cfg);
config_destroy(cfg); /* cfg is now invalid — dropped() makes this obvious */

No convention drift. A new contributor reading any function signature immediately knows who owns what.


Cleanup — pairing acquisition with release

/* WITHOUT Canon-C — allocations are scattered, lifetimes implicit */
void process(void) {
    void* a = malloc(256);
    void* b = malloc(512);
    if (!validate(a)) {
        free(a);
        return; /* b leaked */
    }
    free(a);
    free(b);
    /* ... 500 lines of this ... */
}
/* WITH Canon-C — cleanup is paired with acquisition, visible at the site */
#include "core/arena.h"
#include "core/scope.h"

u8    backing[4096];
Arena arena;
arena_init(&arena, backing, sizeof(backing));

ArenaMark mark = arena_mark(&arena);
DEFER(arena_reset_to(&arena, mark)) {
    /* allocations made in this block live until the block ends */
    void* tmp = arena_alloc(&arena, 256);

    /* ... work that runs to completion ... */
}
/* arena reset to `mark` here — no manual free */

/* ... 500 lines later, same arena, still predictable ... */

No hidden allocations. No malloc scattered across the codebase. Lifetime boundaries are visible at the site where they are declared.

DEFER (from core/scope.h) handles run-to-completion blocks like this cleanly — the body runs straight through, the cleanup expression fires at the closing brace. But most real functions have error-return paths after a resource has been acquired, and DEFER does not fire on return, break, or outward goto. For those functions, the standard C99 goto cleanup; idiom is what Canon-C recommends — not as a library feature, but because it is already the right tool for that shape of function. Here is what it looks like paired with require_msg for the precondition check:

/* WITHOUT Canon-C — cleanup scattered across every exit path */
int load_calibration(const char* path) {
    FILE* f = fopen(path, "r");
    if (!f) return -1;

    void* buf = malloc(4096);
    if (!buf) {
        fclose(f);              /* must remember f */
        return -1;
    }

    if (fread(buf, 1, 4096, f) != 4096) {
        free(buf);              /* must remember buf */
        fclose(f);              /* must remember f */
        return -1;
    }

    if (!verify_crc(buf)) {
        free(buf);              /* must remember buf */
        fclose(f);              /* must remember f */
        return -1;
    }

    apply(buf);

    free(buf);
    fclose(f);
    return 0;
}
/* WITH Canon-C — one cleanup block, every exit routed through it */
#define CANON_CONTRACT_IMPL
#include "core/ownership.h"
#include "core/primitives/contract.h"

int load_calibration(borrowed(const char*) path) {
    require_msg(path != NULL, "load_calibration: path is NULL");

    int   rc  = 0;
    FILE* f   = NULL;
    void* buf = NULL;

    f = fopen(path, "r");
    if (!f) { rc = ERR_OPEN; goto done; }

    buf = malloc(4096);
    if (!buf) { rc = ERR_ALLOC; goto done; }

    if (fread(buf, 1, 4096, f) != 4096) { rc = ERR_IO;  goto done; }
    if (!verify_crc(buf))                { rc = ERR_CRC; goto done; }

    apply(buf);

done:
    if (buf) free(buf);
    if (f)   fclose(f);
    return rc;
}

One cleanup block, listed in reverse acquisition order, reached by every exit path through a single goto done. Adding a new error path later means adding one line — the goto done automatically routes through the existing cleanup. Nothing to remember, nothing to forget.

DEFER and goto cleanup; are complementary, not alternatives. DEFER is Canon-C's contribution, supplied by core/scope.h — a C99-portable macro that pairs cleanup with acquisition for run-to-completion blocks where the body runs straight through without error-return paths. goto cleanup; is plain C99 and needs no header, no macro, no library support. Canon-C recommends it for error-handled functions because it is already the right tool for that shape, proven at scale in the Linux kernel, glibc, and every major C codebase that handles errors carefully. Canon-C did not invent it and does not reinvent it. A single function may use both — goto cleanup; for the outer error-handled structure, DEFER for a straight-line inner block such as a scratch arena checkpoint.

Contracts versus error propagation — two tools, two categories of failure. Notice that the example above uses require_msg(path != NULL, ...) at the top and if (!f) { rc = ERR_OPEN; goto done; } further down. These are not redundant — they check different categories of failure. require_msg (from core/primitives/contract.h) is for programmer errors: NULL pointers where NULL is forbidden, preconditions the caller promised to honor, invariants that must hold if the code is correct. If a contract fires, the program has a bug and require_msg panics to stop before damage spreads. Runtime failures are different: fopen returning NULL, allocation failing, a hardware register not responding, a checksum not matching. These are not bugs — they are real conditions a correct program must handle. For those, propagate the failure to the caller as a value — most Canon-C functions do this with Result<T, Error> in the return type (shown in the next example), pairing it with goto cleanup; inside the function body when resources need to be released before the Result leaves. Result is how the failure leaves the function; goto cleanup; is how the function tears down its state before the Result leaves. The two roles are orthogonal — one is about call signatures, the other is about internal control flow — and a well-written function with both error paths and resources to release will usually use both together.

Using require_msg for a recoverable runtime failure converts an error into a crash; using Result for a contract violation hides a bug behind an error value. The rule for picking between contracts and error propagation is simple: if the failure means your code has a bug, use a contract. If the failure means reality didn't cooperate, propagate the error through Result.


Error propagation — failures are values, not surprises

/* WITHOUT Canon-C — errors are easy to ignore, origin is lost */
int parse_and_validate(Arena* arena, const char** inputs, size_t count) {
    int vec[MAX];
    if (collect(arena, inputs, count, vec) < 0) return -1; /* which error? */
    if (validate(vec)                      < 0) return -1; /* same -1, different cause */
    return sum(vec);
}

/* ... caller, 1000 lines away ... */

int result = parse_and_validate(&arena, inputs, count);
if (result < 0) {
    printf("something failed\n"); /* no idea what or where */
}
/* WITH Canon-C — failures are values, propagation is explicit */
#include "semantics/result/result.h"
#include "semantics/error.h"

result_int_Error parse_and_validate(
    borrowed(Arena*)       arena,
    borrowed(const char**) inputs,
    usize                  count)
{
    require_msg(arena  != NULL, "arena is NULL");
    require_msg(inputs != NULL, "inputs is NULL");

    canon_vec_int vec;
    TRY(int, Error, collect(arena, inputs, count, &vec));

    /* ... */

    TRY(int, Error, validate(&vec));

    return sum(&vec);
}

/* ... caller, 1000 lines away ... */

result_int_Error r = parse_and_validate(&arena, inputs, count);
if (!result_int_Error_is_ok(r)) {
    Error e = result_int_Error_unwrap_err(r);
    printf("Failed: %s\n", error_message(e));
}

No errno. No sentinel returns. No forgotten error checks. Failures propagate explicitly through the entire call chain.


Error context — the full chain, no allocation, no framework

/* WITHOUT Canon-C — one error code reaches the surface, context is gone */
int load_config(const char* path, int* out_timeout) {
    const char* raw = read_field(path, "timeout");
    if (parse_int(raw, out_timeout) < 0)
        return -1; /* caller sees -1. was it a missing file? bad field? overflow? */
    return 0;
}

/* ... surface ... */

if (load_config("config.txt", &timeout) < 0) {
    printf("config failed\n"); /* the chain is gone — root cause is invisible */
}
/* WITH Canon-C — full chain survives from root cause to surface */
#include "semantics/diag.h"
#include "semantics/result/result.h"
#include "semantics/error.h"

/* ... deep in the call chain ... */

static bool parse_timeout(
    borrowed(const char*) str,
    int*                  out,
    Diag*                 diag)
{
    require_msg(str != NULL, "parse_timeout: str is NULL");
    require_msg(out != NULL, "parse_timeout: out is NULL");

    result_i64_Error r = parse_i64(str, NULL);
    if (!result_i64_Error_is_ok(r)) {
        DIAG_PUSH(diag, ERR_PARSE_FAILED, "timeout value is not a valid integer");
        return false;
    }

    *out = (int)result_i64_Error_unwrap(r);
    return true;
}

/* ... one level up ... */

static bool load_config(
    borrowed(const char*) path,
    int*                  out_timeout,
    Diag*                 diag)
{
    require_msg(path        != NULL, "load_config: path is NULL");
    require_msg(out_timeout != NULL, "load_config: out_timeout is NULL");

    const char* raw_value = /* ... read from file ... */ "bad_value";

    DIAG_PROPAGATE(
        parse_timeout(raw_value, out_timeout, diag),
        diag,
        ERR_INVALID_FORMAT,
        "failed to parse timeout field in config",
        false
    );

    return true;
}

/* ... surface, 1000 lines away ... */

Diag diag = diag_init();
int  timeout;

if (!load_config("config.txt", &timeout, &diag)) {
    diag_print(&diag, stderr);
}

/*
 * Output:
 * [0] parse.c:24 in parse_timeout() — error 3: "timeout value is not a valid integer"
 * [1] config.c:51 in load_config()  — error 4: "failed to parse timeout field in config"
 *
 * No heap allocation. No logging framework. No lost context.
 * The full chain survives from root cause to surface — stack allocated.
 */

Without diag.h, the caller sees one error code and nothing else. With diag.h, the full context chain propagates from root cause to surface — allocation-free, framework-free, visible at every level.


Borrow lifetime — know when a borrowed value is still valid

/* WITHOUT Canon-C — stale borrows are silent undefined behavior */
const char* get_name(Arena* scratch) {
    char* name = arena_alloc(scratch, 64);
    strcpy(name, "Alice");
    arena_reset(scratch); /* name is now dangling — nothing warns you */
    return name;
}

/* ... 500 lines later ... */

const char* name = get_name(&scratch);
/* scratch was reset somewhere in between */
printf("%s\n", name); /* undefined behavior — crash or silent corruption */
/* WITH Canon-C — borrow validity is assertable at any point */
#include "core/region.h"
#include "core/arena.h"
#include "semantics/borrow.h"

u8    backing[2048];
Arena scratch;
arena_init(&scratch, backing, sizeof(backing));

Region r;
region_begin(&r);
region_attach_arena(&r, &scratch); /* arena resets automatically on region_end */

/* stamp the borrow with this region's lifetime */
region_id_t  rid  = region_id(&r);
borrowed_str name = borrowed_str_from_cstr("Alice", &r);

/* ... pass name through several layers of the codebase ... */

/* assert the region is still alive before using name */
region_assert_borrow_valid(&r, rid);

/* ... work with name ... */

region_end(&r);
/* arena reset, cleanup hooks fired in reverse registration order */
/* name is now invalid — region is closed                         */

/* ... 500 lines later ... */

/* if someone tries to use name here: */
region_assert_borrow_valid(&r, rid); /* fires — region is closed */

/*
 * Plain C: stale borrows are silent — undefined behavior, data corruption,
 *          crashes that only appear in production under specific conditions.
 *
 * Canon-C: borrow validity is assertable at any point in the codebase.
 *          The region carries the lifetime. The assertion catches misuse early.
 */

Three enforcement levels — no code changes needed between them:

  • Default — assertions are debug-only, compiled away with NDEBUG
  • CANON_STRICT — assertions are always-on in every build including production
  • Frama-C + CANON_NO_REQUIRE — borrows proved statically, zero runtime cost

The ... represents real programs where borrowed values travel far from their source — exactly where stale borrows become invisible bugs.


Usage

Canon-C is header-only. To use:

#include "Canon-c/core/arena.h"
#include "Canon-c/semantics/option/option.h"

Then you’re ready to go. No runtime or build system integration required.

Sponsor this project

Packages

 
 
 

Contributors