§ 28 — cc — C-family build module
28. cc — C-family build module
Normative. This chapter specifies the public surface of the
ccblessed module — the C-family native build module distributed through the official LuaRocks-backed registry (rocks.usecook.com). It is the first entry in the § 27.2 catalogue and a conformance-checkable contract that any reimplementation MUST honour.
28.1. Synopsis and conformance scope
The cc module specifies a portable surface for declarative C and C++ native builds. It dispatches on source-file extension (.c, .C → cc; .cpp, .cc, .cxx, .C++ → cxx) so a single target can mix C and C++ sources without per-target language opt-in.
A conforming implementation MUST expose the fifteen public functions listed in § 28.3 (the thirteen from v0.5 plus the cc.checks namespace and cc.config_header) and MUST honour their option tables, return values, and propagation semantics. A conforming implementation SHOULD provide informative error diagnostics for the negative cases described per-function.
The official cook_cc rock at rocks.usecook.com is the reference implementation. This chapter normatively specifies surface v0.10: the v0.9 surface plus the source-selection rules of CS-0168 (§ 28.3.1.1 — glob and !-exclusion entries in sources, deterministic order, non-matching includes an error), the fpic target field, and the code-generation option vocabulary of § 28.3.10.1 (optimize, debug_symbols, gc_sections). The v0.9 surface is the v0.8 surface plus the collision-free object-path derivation introduced by CS-0167 (§ 28.3.5.1) — the first breaking revision of the surface, since it changes the path every object is emitted to. The v0.8 surface (cook_cc 0.10.x) is the v0.7 surface plus the additive SharedOpts.output field introduced by CS-0084 (§ 28.3.3); it also absorbed the top-level cc.compile_commands relocation of CS-0151 (§ 28.3.11, cook_cc 0.14) without a surface-version bump. The v0.7 surface (cook_cc 0.8.x–0.9.x) is the v0.6 surface plus the PRIVATE/PUBLIC propagation distinction introduced by CS-0080 (§ 28.4.1). Conformance is independent of the rock’s internal file decomposition; only the surface returned from require("cook_cc") is normative.
28.2. Identity, resolution, init hook
The blessed rock is published as cook_cc. A Cookfile imports it by:
use cook_ccPer § 12.1 (use declaration) and § 11.1 (cross-Cookfile composition), use cook_cc MUST bind the loaded module table to the local name cook_cc in the Cookfile’s scope. The grammar of §{mods.use} does not currently provide an as-alias form; call sites refer to the module by its rock name (cook_cc.bin(...), cook_cc.find(...), etc.). A future Standard revision can introduce alias syntax; this clause will be updated in lockstep.
The implementation’s module table MUST include an init function honoured by the loader contract at § 24.2: the loader MUST call init() exactly once per Lua VM that loads the module. The init function MAY rehydrate state from cook.probes (e.g., a previously-detected compiler triple) and MUST NOT be exposed as part of the user-callable surface (callers reading § 28.3 do not invoke init() directly).
28.3. Public surface
28.3.1. cc.bin
cc.bin(name: string, opts: BinOpts) -> stringRegisters an executable target named name. A conforming implementation MUST compile every source listed in opts.sources (or globbed from opts.dir) and link the resulting objects to build/bin/<name>. The function MUST register the target in the implementation’s known-targets state so cc.compile_commands() (§ 28.3.11) can recover its inputs.
BinOpts fields (all optional unless noted):
| Field | Type | Semantics |
|---|---|---|
sources | list[string] | Literal source paths, glob patterns, and !-prefixed exclusion patterns, resolved per § 28.3.1.1. REQUIRED unless dir is provided. |
dir | string | Root for recursive source globbing (*.c, *.cpp, *.cc, *.cxx). |
fpic | boolean | Compile with -fPIC. Defaults to true for cc.shared and false otherwise. A static archive destined to be linked into a shared library MUST set it explicitly. |
includes | list[string] | Private include dirs. |
export_includes | list[string] | Public include dirs propagated to consumers via cook.export. Absent → falls back to includes (the sole backcompat carve-out; see § 28.4.1). |
export_defines | list[string] | Public macros propagated to consumers via cook.export. Bare defines is PRIVATE-by-default; see § 28.4.1. |
export_system_libs | list[string] | Public -l<name> libraries propagated to consumers. Bare system_libs is PRIVATE-by-default. |
export_frameworks | list[string] | Public macOS frameworks propagated to consumers. Bare frameworks is PRIVATE-by-default. No-op on non-macOS hosts. |
export_extra_ldflags | string | Raw flags propagated to consumers’ link step. Bare extra_ldflags is PRIVATE-by-default. |
defines | list[string] | Macro names or NAME=value. PRIVATE-by-default (target-local). |
system_libs | list[string] | Passed to the linker as -l<name>. PRIVATE-by-default. |
frameworks | list[string] | macOS frameworks passed through to cc.link (§ 28.3.7). No-op on non-macOS hosts. PRIVATE-by-default. |
extra_cflags | string | Raw flags appended at compile. PRIVATE (compile-only; no propagation surface). |
extra_ldflags | string | Raw flags appended at link. PRIVATE-by-default. |
links | list[string] | Other cc-registered targets to link against (transitive propagation per § 28.4). |
needs | list[string] | System-library names. Each name MUST be resolved via a cc:find:<name> probe registered by the implementation (§ 22.5). Compile units MUST carry probes = {…, "cc:find:<name>", …} and link units MUST weave the find probe’s libs field via the $<cc:find:<name>.libs> sigil. See § 28.3.16. |
standard | string | e.g., "c++17", "c11". |
warnings | string | "default" (-Wall), "strict" (-Wall -Wextra -Wpedantic), "none", or raw flags. |
Return: the target name (echo for chaining).
Error: if the source count is zero after globbing, the implementation MUST raise [cc.bin] no sources found for target '<name>' at the caller’s line.
28.3.1.1. Source selection
Each entry of opts.sources is one of three forms, mirroring the gather step’s item grammar (§ 8.2):
| Form | Meaning |
|---|---|
| a path containing no wildcard | literal: the named source, added directly. |
| a glob pattern | include: every match is added to the source set. |
! followed by a glob pattern or literal path | exclude: every match is subtracted from the source set. |
The resolved source set is the set difference (union of includes and literals) \ (union of excludes). Item order MUST NOT affect the result: an exclusion subtracts a matching source whether it is written before or after the include that contributed it. The glob language is the one supplied by fs.glob (§ 25.1); § 28.3.1.1 adds no wildcard syntax of its own.
Two requirements make the resolved set safe to use as a build input:
- Deterministic order. The implementation MUST present the resolved set to its compile fan-out in a stable, host-independent order (lexicographic by workspace-relative path is the reference implementation’s choice). Source order reaches the
arand link command strings, which are folded into the cache key (§ 17.1.1); an order that varies with directory-entry order would re-key the archive on machines that differ only in filesystem layout. - Duplicates collapse. A source contributed by more than one include MUST be compiled once. Without this, overlapping patterns register two units with the same
output— an ambiguous graph, and precisely the failure § 28.3.5.1 exists to prevent.
An include pattern that matches nothing MUST be an error, naming the pattern, at the caller’s line. A silently-empty pattern is the failure mode this rule exists to catch: in a target of several thousand sources, a mistyped or stale directory in one pattern drops a whole subtree without changing the build’s exit status, and the loss surfaces much later as an undefined symbol at link. An exclusion that matches nothing MUST NOT be an error — excludes are routinely written to be robust against a file’s absence.
A literal path that does not exist MUST be an error on the same terms as a non-matching include; this is the existing cc.compile behaviour (§ 28.3.5) reached one step earlier, with the target named.
Note (informative). A literal source filename beginning with
!cannot be written directly, since the leading!is the exclusion sigil. Spell it./!name.c.
28.3.2. cc.lib
cc.lib(name: string, opts: LibOpts) -> stringRegisters a static-library target. A conforming implementation MUST compile sources as for cc.bin, then archive the objects to build/lib/lib<name>.a. LibOpts is BinOpts minus link-time fields (extra_ldflags, system_libs, and frameworks apply at consumer link time, not at archive time, but MAY be recorded for propagation).
LibOpts.needs behaves as on BinOpts (§ 28.3.1): each name MUST be resolved via a cc:find:<name> probe and compile units MUST carry the corresponding probes = {…, "cc:find:<name>", …} entries. Because cc.lib produces an archive rather than a link unit, the find probe’s libs field is not woven here; it is propagated to downstream consumers through cook.export (§ 28.4) so the eventual link site receives the $<cc:find:<name>.libs> sigil. See § 28.3.16.
The PRIVATE/PUBLIC distinction introduced by CS-0080 (§ 28.4.1) applies to LibOpts as it does to BinOpts. Because cc.lib produces an archive rather than a link unit, the bare fields system_libs, frameworks, extra_ldflags on LibOpts have no in-target effect: a conforming implementation MAY accept them silently or warn; their explicit-public counterparts (export_system_libs, etc.) are the load-bearing surface on cc.lib.
Error model identical to § 28.3.1.
28.3.3. cc.shared
cc.shared(name: string, opts: SharedOpts) -> stringRegisters a shared-library target. A conforming implementation MUST compile sources with -fPIC and link the objects to build/lib/lib<name>.<ext>, where <ext> is so on Linux and macOS and dll on Windows — unless opts.output is provided (see below). The linker MUST be invoked with -shared on Linux, -dynamiclib on macOS, and /DLL on Windows.
SharedOpts fields are identical to BinOpts, including needs: compile units MUST carry probes = {…, "cc:find:<name>", …} and the shared-library link unit MUST weave $<cc:find:<name>.libs> for each entry. See § 28.3.16.
SharedOpts.output (string, optional, CS-0084): explicit override of the link output path. When present, the implementation MUST use it verbatim as the cc.link output and MUST record it on the recipe’s export so transitive linkers (§ 28.4) see the actual artifact location. The override is taken verbatim, including the filename portion — the implementation MUST NOT inject a lib prefix or replace the suffix. Authors targeting Windows MUST author .dll; authors targeting macOS MUST author .dylib or .so as their runtime loader expects. The field exists for plugin shapes where the host runtime loads the library by a fixed filename and directory (e.g., engine plugins that dlopen("base.so") from the binary’s directory); when absent, the default build/lib/lib<name>.<ext> of §28.3.3 applies.
The PRIVATE/PUBLIC distinction introduced by CS-0080 (§ 28.4.1) applies to SharedOpts as it does to BinOpts.
28.3.4. cc.headers
cc.headers(name: string, opts: HeadersOpts) -> stringRegisters a header-only library: a logical target that records exports for transitive propagation but emits no compile or link units. HeadersOpts typically carries export_includes and export_defines. cc.headers MUST register the target in known-targets but MUST NOT call cook.add_unit(...).
HeadersOpts.needs (list[string]): system-library names. The implementation MUST register each as a cc:find:<name> probe but MUST NOT add compile- or link-unit edges (headers targets export propagation only). The find probe’s libs and cflags MUST flow through cook.export (§ 28.4) to downstream consumers, which assemble the appropriate sigils at their own compile and link sites. See § 28.3.16.
The PRIVATE/PUBLIC distinction introduced by CS-0080 (§ 28.4.1) applies to HeadersOpts. Because cc.headers emits neither compile nor link units, HeadersOpts is primarily a propagation surface and authors typically declare export_includes/export_defines directly. Bare defines on a headers target are PRIVATE-by-default and have no in-target effect (no compile unit consumes them).
28.3.5. cc.compile
cc.compile(source: string, opts: CompileOpts) -> stringCompiles a single source file to an object. A conforming implementation MUST emit one work unit per call whose output is the object path derived for (target_name, source) per § 28.3.5.1, where <target_name> is opts.target_name or "default". When opts.output is supplied the implementation MUST use that value verbatim as the unit’s output instead.
The unit’s discovered_inputs MUST point at a make-format depfile so header dependencies participate in incremental rebuilds. The depfile path MUST be derived from the unit’s output and MUST NOT be derived from the source’s basename, so that the depfile is distinct whenever the object is distinct. The Standard does not otherwise constrain the depfile’s location; the reference implementation mirrors the object path under .cook/deps/.
Returns the output path.
28.3.5.1. Object path derivation
For a target named <target_name> and a source <source>, the derived object path is:
build/obj/<target_name>/<source-path-relative-to-the-project-root>.oThe source’s path relative to the workspace root (§ 20.1) is retained in full, including its directory components and its file extension; the .o suffix is appended to that path rather than replacing the extension. For example, a target duckdb_lib compiling src/common/bignum.cpp yields build/obj/duckdb_lib/src/common/bignum.cpp.o.
The derivation MUST be injective: for a fixed <target_name>, two distinct sources MUST NOT derive the same object path. Injectivity here is a property of construction — the relative source path is carried through unaltered — and not of sanitisation. Implementations MUST NOT substitute a character-folding transform (e.g. replacing path separators or non-alphanumerics with _), because such transforms are not injective: a/b.o and a.b.o collide under separator folding, as do dictionary_compression.cpp and dictionary/compression.cpp.
Retaining the extension is likewise load-bearing: a target may legally compile both foo.c and foo.cpp from one directory, and these are distinct translation units dispatched to different drivers (§ 28.1).
Two cases require an explicit rule:
- Sources outside the project root (absolute paths, or paths reached through
..). The implementation MUST map these to a path that remains insidebuild/obj/<target_name>/and that remains injective over the source set. The reference implementation places them under a reservedbuild/obj/<target_name>/_external/prefix keyed by a hash of the absolute source path. An implementation MUST NOT emit an object path that escapesbuild/obj/<target_name>/. - Generated sources already under
build/. These are ordinary project-relative sources and take the general rule; no special case applies.
Note (informative). This derivation supersedes the basename-derived
build/obj/<target_name>/<stem>.oof surface v0.8 and earlier, which was specified to collide: any tree with repeated source filenames aliased distinct translation units onto one object path. The aliasing was silent, because the engine accepted the resulting ambiguous graph without diagnostic — a separate defect, addressed independently. Because object paths change, the first build after an implementation adopts this derivation is a full rebuild; stale objects under the old layout are swept by § 17.7 stale-output reconciliation wherever the owning recipe is reached.
28.3.6. cc.archive
cc.archive(objects: list[string], output: string) -> stringArchives objects into a static library at output via ar rcs. The implementation MUST emit one work unit whose inputs = objects, output = output, command = "ar rcs <output> <objects...>".
cc.archive accepts an optional third argument opts. opts.dep_recipes (list[string]): recipe names the emitted unit must be ordered after. For each entry, the implementation MUST call cook.dep_order(name) (§ 22.11) immediately before registering the archive unit — after the compile step group has closed, so the reference attaches to the archive unit and nothing earlier. The entries MUST NOT enter the unit’s inputs: an archive consumes only its own objects, and the reference exists to fine-cover the recipe’s requires edges (§ 10.6, CS-0161) so the recipe’s compile units run in one flat wave with the upstream’s.
28.3.7. cc.link
cc.link(objects: list[string], output: string, opts: LinkOpts) -> stringLinks objects to output. LinkOpts.system_libs is appended as -l<name>; LinkOpts.extra_ldflags is appended verbatim; LinkOpts.shared = true adds the platform’s shared-library flag (§ 28.3.3).
LinkOpts.frameworks (list[string]): each entry MUST emit -framework <name> at link time on macOS hosts. On non-macOS hosts the implementation MUST treat the field as a no-op, allowing cross-platform call sites where a curated finder returns system_libs={"openal"} on Linux and frameworks={"OpenAL"} on macOS to use the same cc.link invocation.
LinkOpts.dep_recipes (list[string]): as for cc.archive — the implementation MUST call cook.dep_order(name) for each entry immediately before registering the link unit. The link unit’s data dependency on the upstream archives is carried separately by LinkOpts.link_inputs (the archive paths folded into the unit’s inputs for cache invalidation); dep_recipes supplies the execution edges that make those reads safe once the recipe-level requires edges are fine-covered.
28.3.8. cc.find
cc.find(name: string, opts: FindOpts?) -> FindResultDiscovers a package by name. Returns a sigil record per § 28.3.16: a Lua table whose fields are $<cc:find:<name>.<field>> sigils for each documented FindResult field below. The implementation MUST register the underlying cc:find:<name> probe (idempotently) and MUST NOT execute discovery strategies at register phase; strategies run inside the probe’s produce callback at execute phase per § 22.5.8.
FindOpts (all optional):
| Field | Type | Semantics |
|---|---|---|
version | string | A semver-style constraint (e.g., ">=4.0", "=4.0.1", ">=2.0,<3.0"). Honoured by every strategy that can determine the discovered package’s version. Pre-release tags are excluded ("4.0.0-rc1" does NOT satisfy ">=4.0.0"). |
cmake | boolean | When true, the cmake-compat strategy MUST be consulted immediately after curated finders, before pkg-config, and pkg-config MUST be skipped for this call. The default false places cmake-compat after pkg-config in the standard order. |
A conforming v0.3 implementation MUST consult strategies in this order, first-match-wins:
- Project-registered finders (per § 28.3.12). MUST be tried first so projects can override curated behaviour.
- Curated finders — implementation-provided finders for a documented short list. The reference implementation ships finders for
raylib,sdl2,openal,gl/opengl,threads,zlib,libcurl. Implementations MAY ship a different set; each curated finder MUST conform to the FindResult contract. - pkg-config — as v0.1. MUST be skipped when
opts.cmake = true. - cmake-compat — described in § 28.3.8.1. When
opts.cmake = true, MUST be consulted in step 3’s slot instead. - Bare-lib probe — file-existence check for
lib<name>.{so,dylib,a}on the host’s default linker search paths (/usr/lib,/usr/local/lib, and paths reported bycc -print-search-dirs). MUST be skipped whenopts.versionis set; bare probe cannot verify version.
A strategy MAY report outcome = "skip" (the strategy structurally could not be consulted) or outcome = "miss" (the strategy was consulted and rejected the package). Only outcome = "hit" populates found = true and the result fields. Implementations MUST NOT stop the chain on skip or miss; they MUST proceed to the next strategy. The skip/miss distinction is informational.
FindResult (returned by every cc.find call):
| Field | Type | Semantics |
|---|---|---|
found | boolean | true iff some strategy returned outcome = "hit". |
cflags | string | Compile flags from the winning strategy (empty on miss). |
libs | string | Link flags from the winning strategy (empty on miss). |
system_libs | list[string] | Parsed -l<name> libraries. |
include_dirs | list[string] | Parsed -I<dir> paths. |
lib_dirs | list[string] | Parsed -L<dir> paths. |
frameworks | list[string] | Parsed -framework <name> entries (macOS). |
version | string | nil | Version detected by the winning strategy (nil if the winning strategy reports none). |
tried | list[Attempt] | Ordered list of every strategy consulted, including the winning entry on hits. |
Attempt:
| Field | Type | Semantics |
|---|---|---|
strategy | string | "project:<name>", "curated:<name>", "pkg-config", "cmake-compat", "bare-probe", or for third-party rocks "rocks.<rockname>:<finder>". |
outcome | string | "hit", "miss", or "skip". |
reason | string | Human-readable explanation. MAY be empty on "hit". |
hint | string | nil | Install hint (e.g., "apt: libraylib-dev / brew: raylib"). Curated finders SHOULD emit on miss; other strategies SHOULD NOT. |
Caching: results MUST be cached in cook.probes keyed by "cc.find:<name>:<canonical-opts>", where canonical-opts is the deterministic serialisation of opts (keys sorted, values stringified, list-valued opts joined with ,). Repeat calls within one invocation MUST NOT re-consult the chain.
28.3.8.1. cmake-compat strategy
A conforming cmake-compat strategy MUST:
- If
opts.versionis set, return{ outcome = "skip", reason = "version detection unsupported by legacy cmake --find-package" }without invoking any subprocess. cmake-compat does not surface version metadata in v0.3; the chain falls through to bare-probe (itself a no-op onopts.version). - Locate a
cmakedriver on PATH. If absent, return{ outcome = "skip", reason = "cmake binary not on PATH", hint = "install cmake: apt: cmake / brew: cmake / dnf: cmake" }. - Probe
EXISTmode for the queried package usingcmake --find-package -DNAME=<name> -DCOMPILER_ID=GNU -DLANGUAGE=C -DMODE=EXIST -DQUIET=TRUE(or implementation-equivalent invocation). IfEXISTreports “not found” (cmake exit ≠ 0), return{ outcome = "miss", reason = "cmake found no Config or Find module for '<name>'", hint = <package-specific or generic> }. - Probe
COMPILEmode for cflags; probeLINKmode for libs. - Parse
COMPILEoutput per § 28.3.8.2. - Parse
LINKoutput per § 28.3.8.3. - If any token in the parsed LINK output is a filesystem path whose final component matches
*Config.cmakeor*Targets.cmake, return{ outcome = "miss", reason = "imported-target chain too complex for legacy --find-package", hint = "register a project finder for '<name>' via cc.register_finder" }. - On a clean hit, return
{ outcome = "hit", strategy = "cmake-compat", payload = { … } }withpayload.version = nil.
Names passed to cmake-compat are case-sensitive and forwarded to cmake unchanged. cc.find("SDL3") probes for SDL3Config.cmake; cc.find("sdl3") probes for sdl3-config.cmake. The case convention follows cmake’s own resolver behaviour.
28.3.8.2. cmake-compat COMPILE-mode grammar
cmake --find-package … -DMODE=COMPILE emits a shell-token stream containing -I<dir>, -D<macro>, and (rarely) -isystem <dir> tokens, whitespace-separated, with a trailing newline. The implementation MUST reuse the v0.2 token grammar from the pkg-config strategy: -I ⇒ include_dirs; -D ⇒ collected in cflags; other tokens preserved verbatim in cflags.
28.3.8.3. cmake-compat LINK-mode grammar
cmake --find-package … -DMODE=LINK emits a shell-token stream whose elements MUST be classified as:
| Token shape | Classification | Field |
|---|---|---|
-framework <name> (two tokens) | macOS framework | frameworks |
-l<name> | system library | system_libs; also appended to libs |
-L<dir> | library search path | lib_dirs; also appended to libs |
*.so, *.so.<version>, *.dylib, *.a, *.lib (any token containing / or \ and ending in one of these suffixes) | absolute path to a library file | libs (verbatim) |
*Config.cmake, *Targets.cmake | imported-target chain marker | triggers outcome = "miss" per § 28.3.8.1 step 7 |
| any other non-empty token | recorded in libs verbatim; not propagated to typed fields | libs |
Absolute paths emitted to libs MUST be passable to the linker as-is; linkers accept absolute paths and resolve them without -L search.
28.3.9. cc.defaults
cc.defaults(opts: DefaultsOpts)Records module-wide defaults that subsequent cc.bin, cc.lib, cc.shared, and cc.headers calls MUST honour by additive merge (lists prepend; scalars replace). cc.defaults is the canonical mechanism for setting build-wide overrides from config blocks; implementations MUST NOT consult CPP_* environment variables.
28.3.10. cc.toolchain
cc.toolchain(opts: ToolchainOpts)Overrides the active compiler, standard, warnings, and code-generation defaults. ToolchainOpts:
| Field | Type | Semantics |
|---|---|---|
compiler | string | The cxx driver to use (e.g., "clang++"); the cc driver is derived. |
standard | string | Default -std= value for subsequent C++ compiles. |
warnings | string | Default warnings preset or raw flags. |
optimize | string | Optimisation preset, or raw flags. See § 28.3.10.1. |
debug_symbols | boolean | Emit debug information. See § 28.3.10.1. |
gc_sections | boolean | Place functions and data in their own sections and drop the unreferenced ones at link. See § 28.3.10.1. |
optimize, debug_symbols, and gc_sections are also accepted per-target on BinOpts, LibOpts, and SharedOpts, where they override the toolchain-wide value — the same two-level arrangement standard and warnings already use.
If never called, the implementation MUST auto-detect a compiler via PATH probing during init().
28.3.10.1. Code-generation options
These options exist so that call sites need not hand-author driver-specific flag strings. cc places the compiler behind a probe precisely so a Cookfile is portable across drivers; a raw extra_cflags = "-O3 -DNDEBUG -g -ffunction-sections -fdata-sections" defeats that, and is not portable to a driver with a different flag dialect.
optimize takes a preset, or any other string, which is passed through as raw flags (the same escape hatch warnings provides):
| Preset | GCC / Clang dialect |
|---|---|
"none" | -O0 |
"debug" | -Og |
"size" | -Os -DNDEBUG |
"speed" | -O2 -DNDEBUG |
"max" | -O3 -DNDEBUG |
debug_symbols = true emits -g. It is independent of optimize: optimize = "max" with debug_symbols = true is the ordinary shippable-with-symbols build, and an implementation MUST NOT let either option suppress the other.
gc_sections = true MUST emit both halves of the transformation: the per-section compile flags (-ffunction-sections -fdata-sections) and the linker’s collection flag (-Wl,--gc-sections on Linux, -Wl,-dead_strip on macOS). Emitting only the compile half is the failure this option exists to prevent — it is silent, it costs object size and link time, and it delivers none of the intended reduction. An implementation MUST NOT provide the compile half alone.
On a cc.lib target only the compile half applies, because the target emits an archive and never a link. The link half is supplied by whichever cc.bin or cc.shared links that archive, from its own gc_sections setting — the flag does not propagate along links, since a bare link-time flag on a library is PRIVATE by default (§ 28.4.1). In the ordinary arrangement the option is set once on cc.toolchain, so every target in the build — the archives and the binary that consumes them — sees it, and both halves are emitted without any propagation.
The toolchain probe’s value MUST record both the chosen driver names (cxx, cc) and their canonical identities (cxx_id, cc_id) — the lowercase-hex content hash of each resolved driver binary, obtainable via cook.tools.id (§ 24.9) — so that units sealing the probe (§28.3.14’s seal adoption) key on the toolchain’s identity rather than its name: a compiler upgrade MUST re-key every sealing unit. The value MUST NOT contain a resolved path or any machine-specific location (§ 12.7.5, CS-0158).
28.3.11. cc.compile_commands
cc.compile_commands()cc.compile_commands() is a top-level register-phase call, invoked at column 0 like cc.uses, cc.toolchain, and cc.config_header. It MUST queue — via cook.on_register_complete (§ 22.10) — a run-once finalizer that renders compile_commands.json in the current working directory from the complete known-target set, after every recipe body of the registration pass has been evaluated. The call itself mints no recipe.
The rendered database MUST include one entry per source per registered target, for every registered target regardless of link closure or dispatch reachability, and MUST NOT require any ordering dependency of the author. The output MUST be valid JSON parseable by clangd and equivalent tools.
Each entry’s compile command MUST name the object path that the build actually produces for that (target, source) pair — that is, the path derived by § 28.3.5.1. A conforming implementation MUST NOT re-derive this path independently of its cc.compile implementation: the two MUST be computed by a single shared derivation, so that they cannot drift. An implementation that renders a compile database naming object paths the build never produces is non-conforming, even if every other field is correct.
Repeat top-level cc.compile_commands() calls MUST be idempotent: at most one finalizer is queued per registration pass, producing one write of compile_commands.json per pass.
A cc.compile_commands() call made from inside a recipe body MUST be rejected with a register-phase error naming the top-level form. (This is a hard break from the pre-0.14 recipe-body invocation.)
The finalizer runs on the register-phase VM after the registration pass has closed the target set (§ 22.10), so its inputs MUST be sourced from the implementation’s module-held known-target state — live per § 22.10 when the callback fires — and from cook.import’s per-target compile_info. The completeness guarantee is by construction: the finalizer snapshots the module-held target state only once every recipe body has registered its targets, so no target can be missing from the database.
28.3.12. cc.register_finder
cc.register_finder(name: string, finder: function(opts) -> FindResult)Registers a project-scoped finder for name. Subsequent cc.find(name, ...) calls MUST consult finder before the curated/pkg-config/bare-probe stages, recording its Attempt with strategy = "project:<name>". The function MUST return a FindResult; the implementation MAY discard the function’s own tried field (the resolver synthesises the chain-level tried list externally). Re-registration replaces the prior finder without warning; this enables config blocks to override defaults per build profile.
Raising in the finder is permitted; the error surfaces at the cc.find call site at level 2 (per § 28.5).
28.3.13. cc.find_or_error
cc.find_or_error(name: string, opts: FindOpts?) -> FindResultRegisters a cc:find:<name> probe whose produce callback consults the discovery chain and raises a Lua error when no strategy reports outcome = "hit". Returns a sigil record per § 28.3.16, structurally identical to cc.find. The raised error MUST be prefixed [cc.find_or_error], MUST list every Attempt in the result’s tried field (strategy / outcome / reason), and MUST include any hint fields.
Failure is demand-driven: per § 22.5.8, the probe error fails the consuming unit at execute phase and propagates via DAG edge. Register-phase callers of cc.find_or_error MUST NOT observe a register-time error for missing libraries; a Cookfile that references cc.find_or_error("missing") solely from an unreached branch MUST register and analyse successfully. This is the only function in § 28 that raises on a missing package.
28.3.14. cc.checks namespace
cc.checks.has_header(name: string, opts: CheckOpts?) -> sigilcc.checks.has_function(name: string, opts: CheckOpts?) -> sigilcc.checks.has_define(name: string, opts: CheckOpts?) -> sigilcc.checks.sizeof(type: string, opts: CheckOpts?) -> sigilcc.checks.endian() -> sigilcc.checks.has_compile_flag(flag: string) -> sigilcc.checks.has_link_flag(flag: string) -> sigilEach function MUST register (or reuse) a cc:check:<kind>:<name>:<short-fp> probe and return the sigil string "$<cc:check:<kind>:<name>:<short-fp>>". <kind> is one of has-header, has-function, has-define, sizeof, endian, has-compile-flag, has-link-flag. <short-fp> is an 8-hex-character hash of the canonicalised opts (standard, extra_cflags, extra_ldflags, defines, includes, system_libs). <name> is the probed identifier; for endian it MUST be the literal _; for has-compile-flag / has-link-flag it MUST be a sanitised form of the flag (characters outside [A-Za-z0-9._+-] replaced with _).
CheckOpts (all optional):
| Field | Type | Semantics |
|---|---|---|
standard | string | -std= value for the probe compile (e.g., "c11"). |
extra_cflags | string | Raw flags appended at compile. |
extra_ldflags | string | Raw flags appended at link (link-stage kinds only). |
defines | list[string] | Macro names or NAME=value. |
includes | list[string] | Additional headers #included before the probe’s own header. |
system_libs | list[string] | Libraries linked when the kind requires a link stage. |
Probe return types per kind:
| Kind | Returns | On compile failure |
|---|---|---|
has-header | boolean | false |
has-function | boolean | false |
has-define | boolean | false |
sizeof | integer | nil |
endian | "little" | "big" | nil |
has-compile-flag | boolean | false |
has-link-flag | boolean | false |
Each check function MUST be idempotent: a repeat call with the same name and equivalent opts MUST NOT re-register the probe and MUST return the same sigil. Different opts hash differently and therefore derive different probe keys; conflict-on-same-key is by construction impossible.
The implementation MUST register each probe with requires = { "cc:compiler:<override-or-auto>" } so the toolchain probe is a transitive dependency of every check.
28.3.15. cc.config_header
cc.config_header(template: string, output: string, vars: table) -> stringA conforming implementation MUST register a cook.add_unit whose inputs = { template }, output = output, and command invokes a CMake-compatible substitution renderer. For each (k, v) pair in vars:
- If
vis a string matching^%$<(cc:check:.+)>$, the implementation MUST add<key>to the unit’sprobeslist and MUST emitk = vin the command’s vars-literal argument verbatim (so the sigil pipeline expands it at execute time). - If
vis a literal scalar (boolean, number, string not matching the sigil shape), the implementation MUST emitk = <lua-syntax-literal>(quoted for strings,true/falsefor booleans, decimal for numbers).
The renderer MUST process the template’s content per CMake’s configure_file rules:
@VAR@→tostring(vars[VAR]); missing key → empty string.${VAR}→ identical to@VAR@.#cmakedefine SYMBOL [VALUE...]→ ifvars[SYMBOL]is truthy:#define SYMBOL <VALUE-after-@VAR@-substitution>; if falsy:/* #undef SYMBOL */. When VALUE is absent or whitespace-only, emit#define SYMBOLwith no value.#cmakedefine01 SYMBOL→#define SYMBOL 1if truthy,#define SYMBOL 0if falsy.
Truthiness MUST follow Lua semantics: only false and nil are falsy; 0, "", and {} are truthy. This matches CMake’s quirks for #cmakedefine (where 0 is truthy) and is documented explicitly.
Returns output (echo for chaining).
Error: a non-string-non-scalar value type in vars MUST raise [cc.config_header] unsupported var type: <type> at the caller’s line.
28.3.16. Find semantics under probes
A conforming implementation MUST treat each name passed via opts.needs
as a probe-key suffix: "cc:find:" .. name. The implementation MUST
register the probe idempotently: subsequent cc.find or needs
references to the same name MUST NOT re-register the probe. A probe
registered with conflicting opts (e.g., two calls for "raylib" with
different version constraints) MUST raise a register-phase diagnostic
naming both call sites.
The implementation’s cc.find(name, opts) and cc.find_or_error(name, opts)
public functions MUST register the probe (if not already) and MUST
return a sigil record: a Lua table whose fields are strings of the
form "$<cc:find:" .. name .. "." .. field .. ">", one per documented
field of the find result (§ 28.3.8). The sigil record’s fields
MUST NOT support mutation; assigning to a sigil-record field MUST raise.
cc.find_or_error MUST register a probe whose produce raises a Lua
error when no strategy reports outcome == "hit". Per § 22.5.8
the raised error fails the consuming unit and propagates via DAG edge.
Register-phase callers of cc.find_or_error MUST NOT observe a
register-time error for missing libraries; failure is demand-driven.
The probe-key namespace is normative for this chapter. Implementations MUST use the keys:
cc:compiler:<override-or-auto>— toolchain detectioncc:linker-search-dirs— bare-probe linker search pathscc:cmake-driver— cmake-compat driver discoverycc:find:<name>— per-library find resultcc:check:<kind>:<name>:<short-fp>— per-check feature-test result (per § 28.3.14)
28.4. Transitive propagation
Every cc target maker (cc.bin/cc.lib/cc.shared/cc.headers) MUST call cook.export(name, info) recording the target’s outward-facing fields:
info.includes— directories the consumer adds to-I<dir>when compilinginfo.defines— macros the consumer adds to-D<def>when compilinginfo.system_libs— libraries the consumer links with-l<name>at link timeinfo.frameworks— macOS framework entries the consumer adds at link time as-framework <name>info.extra_ldflags— raw flags to append at consumer link timeinfo.links— further transitive targets (forms the propagation graph)info.compile_info— compile-time view of the target (post-merge: includes and defines as the compiler sees them, including transitively merged values), consumed bycc.compile_commands(§ 28.3.11)
When opts.links = { "a", "b" } is provided to a target maker, the implementation MUST resolve the closure by walking cook.import("a").links, cook.import("b").links, recursively, deduplicating both nodes (cycle-safe) and field values. The closure walk MUST additionally record, in first-seen walk order, the name of every walked target whose export carries a non-empty lib_path (the link_dep_recipes set); target makers pass this set as dep_recipes to cc.archive / cc.link so the emitted unit is ordered after every artifact-producing target in its link closure (§ 22.11, CS-0161). The closure’s includes / defines / system_libs / extra_ldflags MUST be merged into the new target’s compile and link commands. The closure’s frameworks MUST be merged into the new target’s link command analogously to system_libs (dedup-preserving first-seen order); a non-macOS host implementation MAY merge but MUST NOT emit (§ 28.3.7).
28.4.1. PRIVATE/PUBLIC distinction
Surface v0.7 (CS-0080) distinguishes PRIVATE from PUBLIC propagation. The bare fields defines, system_libs, frameworks, extra_ldflags on each target maker’s options are PRIVATE: a conforming implementation MUST use them only for the target’s own compile (defines) or link (system_libs, frameworks, extra_ldflags) and MUST NOT include them in the cook.export(name, info) record. The PUBLIC counterparts are explicit:
| Bare field (PRIVATE) | Public counterpart | info field carried by cook.export |
|---|---|---|
defines | export_defines | info.defines = opts.export_defines or {} |
system_libs | export_system_libs | info.system_libs = opts.export_system_libs or {} |
frameworks | export_frameworks | info.frameworks = opts.export_frameworks or {} |
extra_ldflags | export_extra_ldflags | info.extra_ldflags = opts.export_extra_ldflags or "" |
export_includes is the exception: it retains a backcompat fall-back. A conforming implementation MUST set info.includes = opts.export_includes or opts.includes — that is, when export_includes is absent, bare includes is exported. This carve-out exists because (a) bare includes had a coherent meaning at v0.1 (header layout is typically shared between target and consumers), (b) the v0.1 fixture corpus and the existing in-tree examples relied on it, and (c) the cost-benefit of forcing every consumer of v0.7 to add export_includes everywhere outweighs the clarity gained.
A conforming implementation of surface v0.7 MUST NOT export bare defines/system_libs/frameworks/extra_ldflags even if opts.export_* is absent (i.e., absence means “nothing exported” for these four). Implementations targeting surface v0.6 MUST continue to export bare values for backcompat with v0.6 fixtures; the surface version is declared by the conforming implementation per § 28.1.
extra_cflags has no propagation channel by design: compile flags are target-local.
28.4.2. Module-wide defaults stay target-local
Per § 28.3.9, cc.defaults({ ... }) records module-wide defaults that subsequent target makers MUST additively merge into each target’s bare fields. Under PRIVATE-by-default (CS-0080), these merged defaults are target-local: they do NOT leak through cook.export to consumers. A project that wants a global default propagated to every target’s consumers MUST declare it on each target’s export_* field (or via a headers-only target that consumers link against).
cc.toolchain({ ... }) (§ 28.3.10) sets compiler/standard/warnings only and does not interact with the PRIVATE/PUBLIC matrix.
28.5. Error model
Every error MUST be prefixed with [cc.<fn>] and named at level 2 (error(msg, 2)) so the blame surfaces at the Cookfile call site, not inside the module. Negative cases that MUST be diagnosed:
| Function | Condition | Message |
|---|---|---|
cc.bin/lib/shared | #sources == 0 after globbing | no sources found for target '<name>' |
cc.compile | source file does not exist | source file not found: <path> |
cc.toolchain (during init()) | no compiler on PATH | no C/C++ compiler on PATH (tried g++, clang++) |
cc.find_or_error | result.found == false | could not locate '<name>'<version-suffix>:\n - <strategy>: <outcome> (<reason>)...\n<hints> |
cc.register_finder | finder is not a function | register_finder for '<name>' requires a function, got <type> |
cc.find (cmake-compat) | cmake binary not on PATH | (returned as Attempt, not raised) cmake binary not on PATH |
cc.find (cmake-compat) | EXIST mode reports not found | (returned as Attempt) cmake found no Config or Find module for '<name>' |
cc.find (cmake-compat) | LINK output contains *Config.cmake / *Targets.cmake | (returned as Attempt) imported-target chain too complex for legacy --find-package |
cc.find does NOT raise on a miss; it returns { found = false }. Callers that require a hit MUST test result.found and raise their own error.
28.6. Patching cook_cc
Rewritten by CS-0207. This section described hand-vendoring a copy of
cook_ccat the top ofcook_modules/, where the search order gave it precedence over the installed rock, and stated thatcook modules installhad to leave it alone. § 27.1.2 withdrew that workflow together with the precedence it rested on.
A project that needs a patched cook_cc copies the rock’s source into its own tree and reaches it with a path-form use (§ 5.1), for example use cook_cc ./lua/cook_cc/init.lua. The declaration names the file, so nothing about which cook_cc ran depends on a search order, and the copy is ordinary tracked source that review and git diff can see.
There is correspondingly no coexistence question left to answer. An installed rock under .cook/modules/share/lua/5.4/cook_cc/ and a patched copy in the project are reached by two different declarations and cannot shadow one another.
28.7. Per-platform source selection
Cookfiles that target multiple host operating systems often need to compile a different set of source files per platform: a POSIX backend on Linux and macOS, a Win32 backend on Windows, an SDL or framework-specific shim split by platform, and so on. The cc.bin/cc.lib/cc.shared target makers take sources as a plain Lua array of string; Cook deliberately does NOT provide a structured per-platform option (e.g. sources_per_platform = { linux = ..., darwin = ... }), nor a cc.platform_split helper, nor any other declarative shape that branches on the host. Per-platform selection MUST be expressed in user Lua using cook.platform (§ 24.6) and fs.glob (§ 25.8).
28.7.1. Canonical pattern
The canonical idiom is a plain Lua block that builds the sources array by globbing the common patterns first, then conditionally extending the array with per-platform patterns gated on cook.platform.os:
local sys_sources = fs.glob({ "neo/sys/*.cpp", "neo/sys/sdl/*.cpp" })if cook.platform.os == "linux" then for _, s in ipairs(fs.glob({ "neo/sys/posix/*.cpp", "neo/sys/linux/*.cpp" })) do table.insert(sys_sources, s) endend
cook_cc.bin("dhewm3", { sources = sys_sources, links = { "idLib", "framework", "renderer", "sound", "game" },})A conforming implementation MUST evaluate this pattern at register time on the register-phase Lua VM (§ 22); cook.platform.os is a both-phase API (§ 24.6), so the read at register time observes the host that ran cook, and the resulting cook.add_unit calls record only the platform-appropriate sources. The execute phase sees no platform branching — the work-unit DAG is already specialised.
fs.glob (§ 25.8) accepts an array of patterns (CS-0079), so the per-platform extension MAY be expressed in a single fs.glob call when ordering does not matter:
local sys_sources = fs.glob({ "neo/sys/*.cpp", "neo/sys/sdl/*.cpp",})if cook.platform.os == "linux" then local posix = fs.glob({ "neo/sys/posix/*.cpp", "neo/sys/linux/*.cpp" }) for _, s in ipairs(posix) do sys_sources[#sys_sources + 1] = s endendBoth forms are conforming. The table.insert form and the t[#t+1] form are interchangeable plain-Lua idioms.
28.7.2. Non-pattern: structured per-platform tables
Cook does NOT define and a conforming implementation MUST NOT recognise a structured per-platform option on cc target makers. The following Cookfile is non-conforming and a conforming implementation MUST raise an error (the surface is “unknown option key” per § 28.5) if the key reaches the target maker:
-- NON-CONFORMING. Cook does not specify this surface.cook_cc.bin("dhewm3", { sources_per_platform = { linux = { "neo/sys/posix/*.cpp", "neo/sys/linux/*.cpp" }, darwin = { "neo/sys/posix/*.cpp", "neo/sys/osx/*.cpp" }, },})The reasons this surface is deliberately excluded:
- The Lua escape hatch already exists and reads cleanly. Wrapping
cook.platform.oschecks in a declarative table buys no clarity over the four-lineifblock in § 28.7.1; it adds a parallel surface that must be specified, conformance-tested, and version-evolved. - The matrix is open-ended. Real projects branch on compiler, bitness, libc, and feature probes (
cc.checks— § 28.3.14), not just onos. Any structured surface would either be incomplete or balloon into a mini-DSL. Plain Lua composes with every existing both-phase API uniformly. - Platform branching is not the only conditional source-selection use case. Toolchain detection, feature probes, optional dependencies — all branch sources the same way. Privileging the OS axis with structured syntax would distort the design.
cc.find results (§ 28.3.8) and cc.checks results (§ 28.3.14) MAY participate in the same source-selection Lua block when a feature probe gates whole files (e.g. a vendored fallback only compiled when a system library is absent). The same cook.platform/fs.glob/table.insert shape applies.
28.7.3. Interaction with PRIVATE/PUBLIC propagation
Per-platform source selection is orthogonal to the PRIVATE/PUBLIC distinction introduced by CS-0080 (§ 28.4.1). When per-platform sources rely on a per-platform define (e.g. ID_PC_LINUX only on Linux), the define goes on defines (or export_defines) per the propagation rules of § 28.4; the per-platform set of defines is constructed in the same if cook.platform.os == ... block that builds the per-platform sources:
local defs = { "_GNU_SOURCE" }if cook.platform.os == "linux" then defs[#defs + 1] = "ID_PC_LINUX" endif cook.platform.os == "darwin" then defs[#defs + 1] = "ID_PC_OSX" end
cook_cc.bin("dhewm3", { sources = sys_sources, defines = defs, -- PRIVATE: target-local})A conforming implementation MUST treat the resulting bare defines (or export_defines) array as defined by § 28.4.1 — the per-platform construction is invisible to the propagation rules, which see only the final array.