Skip to content

§ 28 — cc — C-family build module

28. cc — C-family build module

Normative. This chapter specifies the public surface of the cc blessed 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_cc

Per § 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) -> string

Registers 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):

FieldTypeSemantics
sourceslist[string]Literal source paths, glob patterns, and !-prefixed exclusion patterns, resolved per § 28.3.1.1. REQUIRED unless dir is provided.
dirstringRoot for recursive source globbing (*.c, *.cpp, *.cc, *.cxx).
fpicbooleanCompile 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.
includeslist[string]Private include dirs.
export_includeslist[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_defineslist[string]Public macros propagated to consumers via cook.export. Bare defines is PRIVATE-by-default; see § 28.4.1.
export_system_libslist[string]Public -l<name> libraries propagated to consumers. Bare system_libs is PRIVATE-by-default.
export_frameworkslist[string]Public macOS frameworks propagated to consumers. Bare frameworks is PRIVATE-by-default. No-op on non-macOS hosts.
export_extra_ldflagsstringRaw flags propagated to consumers’ link step. Bare extra_ldflags is PRIVATE-by-default.
defineslist[string]Macro names or NAME=value. PRIVATE-by-default (target-local).
system_libslist[string]Passed to the linker as -l<name>. PRIVATE-by-default.
frameworkslist[string]macOS frameworks passed through to cc.link (§ 28.3.7). No-op on non-macOS hosts. PRIVATE-by-default.
extra_cflagsstringRaw flags appended at compile. PRIVATE (compile-only; no propagation surface).
extra_ldflagsstringRaw flags appended at link. PRIVATE-by-default.
linkslist[string]Other cc-registered targets to link against (transitive propagation per § 28.4).
needslist[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.
standardstringe.g., "c++17", "c11".
warningsstring"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):

FormMeaning
a path containing no wildcardliteral: the named source, added directly.
a glob patterninclude: every match is added to the source set.
! followed by a glob pattern or literal pathexclude: 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 ar and 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) -> string

Registers 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) -> string

Registers 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) -> string

Registers 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) -> string

Compiles 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>.o

The 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 inside build/obj/<target_name>/ and that remains injective over the source set. The reference implementation places them under a reserved build/obj/<target_name>/_external/ prefix keyed by a hash of the absolute source path. An implementation MUST NOT emit an object path that escapes build/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>.o of 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) -> string

Archives 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.

cc.link(objects: list[string], output: string, opts: LinkOpts) -> string

Links 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?) -> FindResult

Discovers 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):

FieldTypeSemantics
versionstringA 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").
cmakebooleanWhen 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:

  1. Project-registered finders (per § 28.3.12). MUST be tried first so projects can override curated behaviour.
  2. 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.
  3. pkg-config — as v0.1. MUST be skipped when opts.cmake = true.
  4. cmake-compat — described in § 28.3.8.1. When opts.cmake = true, MUST be consulted in step 3’s slot instead.
  5. 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 by cc -print-search-dirs). MUST be skipped when opts.version is 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):

FieldTypeSemantics
foundbooleantrue iff some strategy returned outcome = "hit".
cflagsstringCompile flags from the winning strategy (empty on miss).
libsstringLink flags from the winning strategy (empty on miss).
system_libslist[string]Parsed -l<name> libraries.
include_dirslist[string]Parsed -I<dir> paths.
lib_dirslist[string]Parsed -L<dir> paths.
frameworkslist[string]Parsed -framework <name> entries (macOS).
versionstring | nilVersion detected by the winning strategy (nil if the winning strategy reports none).
triedlist[Attempt]Ordered list of every strategy consulted, including the winning entry on hits.

Attempt:

FieldTypeSemantics
strategystring"project:<name>", "curated:<name>", "pkg-config", "cmake-compat", "bare-probe", or for third-party rocks "rocks.<rockname>:<finder>".
outcomestring"hit", "miss", or "skip".
reasonstringHuman-readable explanation. MAY be empty on "hit".
hintstring | nilInstall 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:

  1. If opts.version is 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 on opts.version).
  2. Locate a cmake driver on PATH. If absent, return { outcome = "skip", reason = "cmake binary not on PATH", hint = "install cmake: apt: cmake / brew: cmake / dnf: cmake" }.
  3. Probe EXIST mode for the queried package using cmake --find-package -DNAME=<name> -DCOMPILER_ID=GNU -DLANGUAGE=C -DMODE=EXIST -DQUIET=TRUE (or implementation-equivalent invocation). If EXIST reports “not found” (cmake exit ≠ 0), return { outcome = "miss", reason = "cmake found no Config or Find module for '<name>'", hint = <package-specific or generic> }.
  4. Probe COMPILE mode for cflags; probe LINK mode for libs.
  5. Parse COMPILE output per § 28.3.8.2.
  6. Parse LINK output per § 28.3.8.3.
  7. If any token in the parsed LINK output is a filesystem path whose final component matches *Config.cmake or *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" }.
  8. On a clean hit, return { outcome = "hit", strategy = "cmake-compat", payload = { … } } with payload.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.

cmake --find-package … -DMODE=LINK emits a shell-token stream whose elements MUST be classified as:

Token shapeClassificationField
-framework <name> (two tokens)macOS frameworkframeworks
-l<name>system librarysystem_libs; also appended to libs
-L<dir>library search pathlib_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 filelibs (verbatim)
*Config.cmake, *Targets.cmakeimported-target chain markertriggers outcome = "miss" per § 28.3.8.1 step 7
any other non-empty tokenrecorded in libs verbatim; not propagated to typed fieldslibs

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:

FieldTypeSemantics
compilerstringThe cxx driver to use (e.g., "clang++"); the cc driver is derived.
standardstringDefault -std= value for subsequent C++ compiles.
warningsstringDefault warnings preset or raw flags.
optimizestringOptimisation preset, or raw flags. See § 28.3.10.1.
debug_symbolsbooleanEmit debug information. See § 28.3.10.1.
gc_sectionsbooleanPlace 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):

PresetGCC / 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?) -> FindResult

Registers 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?) -> sigil
cc.checks.has_function(name: string, opts: CheckOpts?) -> sigil
cc.checks.has_define(name: string, opts: CheckOpts?) -> sigil
cc.checks.sizeof(type: string, opts: CheckOpts?) -> sigil
cc.checks.endian() -> sigil
cc.checks.has_compile_flag(flag: string) -> sigil
cc.checks.has_link_flag(flag: string) -> sigil

Each 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):

FieldTypeSemantics
standardstring-std= value for the probe compile (e.g., "c11").
extra_cflagsstringRaw flags appended at compile.
extra_ldflagsstringRaw flags appended at link (link-stage kinds only).
defineslist[string]Macro names or NAME=value.
includeslist[string]Additional headers #included before the probe’s own header.
system_libslist[string]Libraries linked when the kind requires a link stage.

Probe return types per kind:

KindReturnsOn compile failure
has-headerbooleanfalse
has-functionbooleanfalse
has-definebooleanfalse
sizeofintegernil
endian"little" | "big"nil
has-compile-flagbooleanfalse
has-link-flagbooleanfalse

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) -> string

A 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 v is a string matching ^%$<(cc:check:.+)>$, the implementation MUST add <key> to the unit’s probes list and MUST emit k = v in the command’s vars-literal argument verbatim (so the sigil pipeline expands it at execute time).
  • If v is a literal scalar (boolean, number, string not matching the sigil shape), the implementation MUST emit k = <lua-syntax-literal> (quoted for strings, true/false for 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...] → if vars[SYMBOL] is truthy: #define SYMBOL <VALUE-after-@VAR@-substitution>; if falsy: /* #undef SYMBOL */. When VALUE is absent or whitespace-only, emit #define SYMBOL with no value.
  • #cmakedefine01 SYMBOL → #define SYMBOL 1 if truthy, #define SYMBOL 0 if 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 detection
  • cc:linker-search-dirs — bare-probe linker search paths
  • cc:cmake-driver — cmake-compat driver discovery
  • cc:find:<name> — per-library find result
  • cc: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 compiling
  • info.defines — macros the consumer adds to -D<def> when compiling
  • info.system_libs — libraries the consumer links with -l<name> at link time
  • info.frameworks — macOS framework entries the consumer adds at link time as -framework <name>
  • info.extra_ldflags — raw flags to append at consumer link time
  • info.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 by cc.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 counterpartinfo field carried by cook.export
definesexport_definesinfo.defines = opts.export_defines or {}
system_libsexport_system_libsinfo.system_libs = opts.export_system_libs or {}
frameworksexport_frameworksinfo.frameworks = opts.export_frameworks or {}
extra_ldflagsexport_extra_ldflagsinfo.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:

FunctionConditionMessage
cc.bin/lib/shared#sources == 0 after globbingno sources found for target '<name>'
cc.compilesource file does not existsource file not found: <path>
cc.toolchain (during init())no compiler on PATHno C/C++ compiler on PATH (tried g++, clang++)
cc.find_or_errorresult.found == falsecould not locate '<name>'<version-suffix>:\n - <strategy>: <outcome> (<reason>)...\n<hints>
cc.register_finderfinder is not a functionregister_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_cc at the top of cook_modules/, where the search order gave it precedence over the installed rock, and stated that cook modules install had 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)
end
end
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 end
end

Both 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:

  1. The Lua escape hatch already exists and reads cleanly. Wrapping cook.platform.os checks in a declarative table buys no clarity over the four-line if block in § 28.7.1; it adds a parallel surface that must be specified, conformance-tested, and version-evolved.
  2. The matrix is open-ended. Real projects branch on compiler, bitness, libc, and feature probes (cc.checks — § 28.3.14), not just on os. Any structured surface would either be incomplete or balloon into a mini-DSL. Plain Lua composes with every existing both-phase API uniformly.
  3. 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" end
if 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.