- Motivation
- IMPORTANT
- Dependency Rule (Strict)
- Design Philosophy
- Getting Started
- Scope
- Examples
- Usage
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.
Feedback requested on:
- Does this categorization make sense?
- Are things in the right layers?
- 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.
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.
- 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.
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.
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.
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.
Higher-level abstractions for safer programming:
Option<T>(option/) — presence/absence semanticsResult<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.
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.
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).
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:
- Generic —
void*+ function pointer interface, works on any type - Typed macro — compile-time type safety, wraps the generic level
- Typed instantiation —
DEFINE_ALGO_X(type)stamps out fully typed slice variants with novoid*, 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.
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.
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'slog.hcovers 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 intoResult<T, Error>andowned()/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 withowned(),borrowed(), andDEFINE_OWNEDto make ownership explicit at the integration point. Wrap external handles likesqlite3*,uv_loop_t*, orMDB_env*withCANON_DROPto ensure cleanup is never missed.
semantics/result/result.h— convert integer return codes from SQLite, libsodium, or libuv intoResult<T, Error>at the adapter boundary. UseTRYandTRY_REMAPto propagate failures up without boilerplate. For libraries with many domain-specific codes (SQLite has 30+), define a local error enum and instantiateCANON_RESULTwith it.
core/slice.h/semantics/borrow.h— pass Canon-Cbytes_t,cbytes_t, andborrowed_bytesdirectly into buffer-based APIs like mpack, miniz, and LMDB'sMDB_val. The{ptr, len}layout is compatible with most C buffer conventions without copying.
core/scope.h— useDEFER(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 C99goto cleanup;idiom instead.DEFERdoes not fire onreturn,break, or outwardgoto, and adapter functions almost always need cleanup on error exits.DEFERis 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, usingDIAG_PUSHandDIAG_PROPAGATE.
core/arena.h— some libraries accept custom allocator hooks (SQLite'ssqlite3_config(SQLITE_CONFIG_MALLOC), lwIP'smem_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.husesfprintf— replace it once at startup withcontract_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 useFILE*.diag_print()andlog.husefprintf— avoid these on bare-metal. Usediag_render()instead: it writes the same output into a caller-suppliedchar[]buffer with noFILE*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 forsnprintfin optional formatting functions — skip those call sites and the dependency is inert on toolchains with stub stdio support (newlib, picolibc).malloc / free
core/memory.hincludes<stdlib.h>and providesmem_alloc()/mem_free()as explicit wrappers overmalloc/free.core/arena.handcore/pool.hincludememory.hbut never callmalloc— they operate entirely on caller-supplied buffers. The heap dependency is only active if your code callsmem_alloc(),mem_alloc_type(), ormem_alloc_array()directly. Everything indata/convenience/(dynvec, smallvec, dynstring) uses heap allocation internally.On bare-metal, if you avoid
mem_alloc()anddata/convenience/, nomallocis ever reached. If you do need heap allocation,#define malloc,realloc, andfreeto your RTOS allocator (e.g. FreeRTOS'spvPortMalloc/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, andcore/primitives/compare.hpull in only freestanding headers (<stdint.h>,<stddef.h>,<limits.h>,<stdbool.h>) and are safe on any target without modification.core/scope.his 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 othercore/headers includecontract.h— they require the handler replacement described above.
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.
/* 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.
/* 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.
/* 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.
/* 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.
/* 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.
/* 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.
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.
