Skip to content

§ 5 — Declarations

5. Declarations

Normative. This chapter defines the four top-level declaration forms — use, import, config, and register — along with their bodies and composition rules. Each declaration’s effect on Cook Lua state, on cross-Cookfile composition, or on the register-phase program is specified in the chapter named in the cross-reference.

5.1. use declarations

A use_declaration is the top-level form "use" ( use_name | use_alias? use_path ) NEWLINE (App. A.2). It requests that a module be made available to the Cookfile’s recipes, chores, and config blocks. Module resolution is governed by § 12.1. The declaration has two forms, distinguished by App. A.2’s rule: the last argument is a use_path if and only if it ends in .lua, and a use_name otherwise.

  • The name form names a module the implementation installs: a built-in (§ 12.4) or a module in the project’s package tree (§ 12.5). Its use_name is a LUA_IDENT (App. A.5), written bare or as a double-quoted STRING whose contents lex as a LUA_IDENT.
  • The path form names a Lua file in the declaring Cookfile’s own directory subtree, addressed as a tree_path (App. A.5). It MAY be preceded by an explicit use_alias, itself a LUA_IDENT under the same rules as a use_name.

The keyword use MUST be followed by whitespace, a tab, or a " character before the argument; otherwise the line is not a use_declaration. A bare word such as useful MUST be classified as a Content token rather than a use_declaration.

A conforming implementation MUST accept both the quoted form (use "cpp", use "./build/helpers.lua") and the bare form (use cpp, use ./build/helpers.lua) and treat them as equivalent.

A conforming implementation MUST reject a use_declaration whose use_name or use_alias does not match the LUA_IDENT production (App. A.5) — that is, any name containing a character outside [A-Za-z0-9_], any name not starting with [A-Za-z_], or the empty string. In particular, names containing whitespace, hyphens, or dots are rejected. The diagnostic MUST identify the offending name and source line. The restriction reflects the load-time binding (§ 12.1): the name is bound as a Lua local under the same spelling, and a non-identifier name would yield malformed Lua at codegen time. Hyphens are rejected to avoid silent collisions when codegen rewriters fold - to _ (e.g. foo-bar and foo_bar would otherwise resolve to the same Lua local).

A conforming implementation MUST reject a use_declaration carrying anything after its arguments, including a trailing # comment. App. A.2’s production ends at NEWLINE and admits no trailing text, and the diagnostic MUST name the comment rather than counting it as a further argument.

The rule is stated because the reference implementation had been breaking it. Its lexer read the first token of a use line and discarded the remainder, so use cpp # the C module parsed as use cpp while tree-sitter-cook rejected the same line — a Cookfile two conforming readers disagreed about. CS-0206 gives the declaration a second argument position, which makes the discarded remainder meaningful, so the divergence had to be settled; it is settled toward what the grammar always said. Put the comment on its own line above. [amended CS-0206]

A conforming implementation MUST reject a use_declaration whose use_path is absolute, begins with the // workspace-root sigil, contains a .. segment, or contains whitespace (App. A.5’s path_segment admits none, and quoting does not admit it either). Each diagnostic MUST identify the offending path and source line. The confinement is specified at § 12.2.1 and argued at § C.11.0.

Example 5.1.1

use cpp
use "proto"
use ./build/helpers.lua
use fmt ./tools/lua/formatting.lua

The first two are the name form and are equivalent to each other in shape; the module names are cpp and proto. The third is the path form with the alias derived from the file’s basename, binding helpers. The fourth is the path form with an explicit alias, binding fmt.

Example 5.1.2

use foo-bar

Rejected: foo-bar is not a LUA_IDENT (hyphen is not permitted). The same rule rejects use cpp.bad, use "has spaces", and use "9lives". None of these ends in .lua, so each is a use_name and each is judged as one.

Example 5.1.3

use ../shared/helpers.lua
use //build/helpers.lua
use /opt/cook/helpers.lua
use ./build/9lives.lua

All four are rejected. The first escapes the declaring Cookfile’s directory; the second is the workspace-root sigil, which use does not admit; the third is absolute. The fourth is a well-formed use_path whose derived alias 9lives is not a LUA_IDENT — the diagnostic MUST name the explicit-alias form (use nine_lives ./build/9lives.lua) as the remedy (§ 12.1).

Note 5.1.1

The cook-lang parser tests test_use_prefix_is_content and test_use_bare_name pin the distinction between use as a keyword and use… as the beginning of a Content token. The tests test_use_name_with_dash_rejected, test_use_name_with_dots_rejected, test_use_name_with_space_rejected, and test_use_name_starting_with_digit_rejected pin the LUA_IDENT constraint introduced by CS-0035.

5.2. import declarations

An import_declaration is the top-level form "import" BARE_IDENTIFIER path NEWLINE (App. A.2), where path is a non-empty run of non-whitespace characters (App. A.5). The BARE_IDENTIFIER is the local alias under which the imported Cookfile’s recipes become addressable; the path is the filesystem location of the imported Cookfile. Import resolution is governed by § 11.

Unlike use_declaration, the alias in import_declaration MUST be a BARE_IDENTIFIER; the quoted form is not accepted for the alias.

The BARE_IDENTIFIER of each import_declaration MUST be unique within a single Cookfile. A conforming implementation MUST reject a Cookfile that contains two import_declarations with the same alias. The diagnostic MUST identify the second occurrence’s source line.

The path MUST be present. A line of the form import <name> with no following path MUST be rejected.

Example 5.2.1

import backend ./services/backend
import proto ../../libs/proto

Two imports with distinct aliases backend and proto, and relative paths. Recipes from either imported Cookfile are subsequently addressable as backend.<name> and proto.<name>; see § 11.

Note 5.2.1

The cook-lang parser test test_parse_duplicate_import_names_fails pins the uniqueness rule.

5.3. config blocks

A config_block is the top-level form "config" name? NEWLINE config_body (App. A.2). The optional name is either a BARE_IDENTIFIER or a STRING. The config_body is a sequence of raw source lines (LUA_SOURCE, App. A.5) running from the line after the config header up to the next top-level keyword or end-of-file. The contents of config_body are not parsed by the Cookfile grammar; the Lua runtime interprets them at load time (§ 10, § 21).

At most one unnamed config_block MUST appear in a Cookfile. Named config_block names MUST be unique within a Cookfile. A conforming implementation MUST reject a Cookfile that violates either constraint.

A config_body extends from the line following the header up to (but not including) the next terminating line of the surrounding Cookfile, or end-of-file (whichever comes first). See § 4.3 for the full implicit-termination rule (which includes register and the top-level module_call shape as of CS-0072), which applies symmetrically to recipe and config bodies.

A config_body MAY be empty — that is, the next top-level construct or EOF MAY immediately follow the header — in which case the block contributes the empty Lua source.

A config_body declares named values by ordinary assignment onto a sink table named var (var.NAME = <expr>). Each such assignment exports NAME. A local binding in a config_body is scratch and is NOT exported.

The declared-variable namespace is exactly the set of names the config blocks export (CS-0172). It has no other source: not the ambient process environment, not a dotenv-style file, not a CLI flag. A conforming implementation MUST NOT seed the namespace from the environment of the cook process, and MUST reject a $<NAME> (§ 10.2) or a Lua var.NAME read naming an unexported name, with a diagnostic that suggests the closest exported names (§ 19).

An exported value MUST be a string, a number, or a boolean. A conforming implementation MUST reject any other type — table, function, nil — with a load-time error naming the offending variable. Lists and structured values are composed in a register block (§ 5.4), which has the full Lua library, not in the sandbox.

A declared variable is read two ways, which differ in type:

  • $<NAME> (§ 9) interpolates its string form, because a placeholder expands into command text: a boolean renders true / false, and an integer-valued number renders without a decimal point.
  • var.NAME in register-phase Lua (§ 24.3) yields the value with its declared Lua type, so a boolean-valued variable reaches a module option as a boolean rather than as a truthy string.

Outside a config_body, var is read-only: a conforming implementation MUST reject an assignment through it. A declared value is a cache determinant of every unit that consulted it (§ 17.1), so a later mutation would invalidate a key already computed against it.

A conforming implementation MUST NOT place a declared value into the process environment of any step it spawns. Reading one as a shell variable is therefore not a supported channel. A config_body observes ambient environment through host.env (§ 5.3.2); a build step inherits ambient variables normally, and an ordinary named shell probe can observe and group values intended as shareable keyed determinants (§ 8.4.3).

The sink is named var and not env: a config block’s values are named build variables, not entries in any process environment. A conforming implementation MUST report a load-time error, pointing the author at the var sink, when a config_body reads a global named env (the pre-CS-0164 sink name).

Composition of multiple config_blocks — base + at most one selected overlay — is specified in § 5.3.1.

Example 5.3.1

config
    var.CC = "gcc"

config release
    var.CXXFLAGS = "-O3"

config dev
    var.CXXFLAGS = "-O0 -g"

Three config blocks: one unnamed, two named. Each body is raw Lua source that the runtime interprets; the Cookfile grammar treats it as opaque text. var.CC / var.CXXFLAGS export values consumed as $<CC> / $<CXXFLAGS>.

Note 5.3.1

The config_body is collected by source-line range rather than by re-tokenising the enclosed lines. Lines that would otherwise tokenise as Cookfile constructs (for example, a line that looks like BARE_IDENTIFIER "value") are passed through verbatim to the Lua runtime.

5.3.1. Config-block composition

A Cookfile MAY contain at most one unnamed config_block (the base config) and zero or more named config_blocks (the overlay configs). At load time, a conforming implementation MUST select at most one overlay config by name. The selection mechanism is implementation-defined.

A conforming implementation MAY offer an invocation-time override of a declared variable (the reference implementation spells it --set NAME=VALUE). When it does, the override MUST be applied after both config bodies have run, so that it wins over a config-block default however the block was authored — this is what makes the override mechanism trustworthy without an or-idiom at every declaration site. An override naming a variable no config block exported MUST be rejected with a diagnostic naming it (CS-0172): the namespace has exactly one source, so an override with nothing to override is a typo, not a declaration.

A conforming implementation MUST report a load-time error when the selected name does not match any named config_block declared in the Cookfile. When a workspace loads more than one Cookfile (§ 11), the selection is validated against the union of all loaded Cookfiles rather than one Cookfile in isolation, and a config block’s scope is its own Cookfile; both rules are specified in § 11.6. The diagnostic MUST identify the requested name.

When a base config is present, its body MUST execute first against the config sandbox environment (§ 5.3.2): the var output sink and the host read surface. When an overlay is selected, its body MUST execute second against the same sandbox environment. The overlay’s writes therefore observe values established by the base; an overlay write to a key already set by the base replaces the base’s value (last-write-wins). When no overlay is selected, only the base (if present) executes. When neither is present, no config_block Lua executes during the load phase.

Both bodies execute during the load phase, after use resolution (§ 12.1) and before recipe registration (§ 13).

Example 5.3.1.1

use cpp

config
    var.CC = "gcc"
    var.CXXFLAGS = "-O0 -g"

config release
    var.CXXFLAGS = "-O3"

config dev
    var.CXXFLAGS = "-O0 -g -DDEBUG"

recipe build
    cook "build/main" { $<CC> $<CXXFLAGS> -o $<out> main.c }

When no overlay is selected, the base alone executes and $<CXXFLAGS> resolves to -O0 -g. When release is selected, the base runs first (var.CXXFLAGS = "-O0 -g"), then release runs (var.CXXFLAGS = "-O3"); $<CXXFLAGS> resolves to -O3. Selecting a name that does not appear as a named config_block in the Cookfile is a load-time error.

Note 5.3.1.1

Multi-overlay selection is deliberately not specified in this revision: per the rule above, a conforming implementation permits at most one overlay. The single-overlay restriction avoids ordering questions across multiple selected overlays and is non-breaking to relax in a future revision; the design rationale appears in App. C.

5.3.2. Config-block sandbox and the host read surface

A config_body executes in a sandboxed Lua environment. The sandbox makes a config block a pure function from a small set of declared host reads to its output namespace: the only side effect a body MAY produce is writing its output namespace (via var.*, § 5.3), and the only external input a body MAY read is the host surface defined below.

A conforming implementation MUST NOT make the following reachable from a config_body: the os and io libraries, any wall-clock or monotonic-clock source, any pseudo-random source (including math.random and math.randomseed), process spawning, filesystem writes, dynamic code loading (require, load, loadstring, dofile, loadfile, package), the debug library, and coroutines. A conforming implementation MUST report a load-time error, identifying the offending name, when a config_body attempts to read one of these globals. Any other undefined global MUST resolve to nil, as in ordinary Lua, so that a presence guard such as if flag then does not spuriously error.

A conforming implementation MUST keep pure Lua control flow available to a config_body: conditionals, loops, local bindings, string concatenation, table construction and access, arithmetic, the boolean operators, and the string library’s methods. A use-imported module handle in scope at the block MAY be called from the body; module surfaces are not part of the removed set.

The host table is the sole channel for external input into a config_body. A conforming implementation MUST provide:

  • host.os: the host operating-system identifier (the same value as cook.platform.os in a full-Lua context).
  • host.arch: the host architecture identifier (the same value as cook.platform.arch).
  • host.env(name, default): the value of the ambient environment variable name, or default (which MAY be omitted, yielding nil) when name is unset.
  • host.read(path): the contents of the file at path as a string, resolved relative to the Cookfile’s directory when path is relative; a read that fails MUST raise a load-time error.

Each host.* read MUST be recorded as a determinant of the config block, for provenance. These records are explanatory only: a config value that varies with a host.* read re-keys every consuming step through the value it resolves in $<NAME> (§ 17.1), so host.* reads are not a second cache-key channel on top of value hashing.

The output namespace is written via var.* (see § 5.3 and Example 5.3.1); a config_body MUST NOT read the ambient process environment except through host.env, and MUST NOT read files except through host.read.

Example 5.3.2.1

config
    var.MODE = host.env("MODE", "debug")

config profiling
    local export = host.os == "macos" and "-Wl,-export_dynamic" or "-rdynamic"
    var.LDFLAGS = export

The base block reads the ambient MODE with a debug fallback; the profiling overlay branches on host.os to compute a platform-specific linker flag (using a local for the shared intermediate, which stays scratch and is not exported). Neither block can observe a clock, a random source, or the process environment outside host.env, so the resolved var.* values depend only on the recorded host.* reads.

Note 5.3.2.1

The sandbox is applied to config bodies only. register blocks (§ 5.4) and recipe bodies retain the full Lua standard library, because those contexts run commands rather than compute cache-determining values; os.execute in a recipe body is a supported escape hatch. Renaming cook.platform.* to host.* in those full-Lua contexts is not specified in this revision.

5.4. register blocks

A register_block is the top-level form "register" NEWLINE register_body. The register_body is a sequence of raw source lines (LUA_SOURCE, App. A.5) running from the line after the register header up to the next terminating line of the surrounding Cookfile (§ 4.3) or end-of-file. The contents of register_body are not parsed by the Cookfile grammar; the register-phase Lua VM interprets them at recipe-registration time (§ 13.1).

A Cookfile MAY contain zero or more register_blocks. The block takes no name. A conforming implementation MUST reject a register line whose content after the keyword is non-empty (e.g. register foo or register "release"); the diagnostic MUST identify the offending source line.

A register_body MAY be empty: the next top-level construct or EOF MAY immediately follow the register header, in which case the block contributes the empty Lua source.

5.4.1. Splicing semantics

A conforming implementation MUST execute each register_block body on the register-phase Lua VM in source order, interleaved with recipe registrations. The effect MUST be as if each register_block body were spliced verbatim into the compiled register-phase Lua program at its source position. local variables and other Lua state declared in one register_block body MUST remain in scope for subsequent register_block bodies, for recipe-body module_call lines (§ 8.1) in recipes that follow, and for the bodies of cook.recipe(…) invocations triggered by either, subject to the visibility rules of the underlying Lua language. An implementation MAY represent the parsed register_blocks as an ordered collection within its abstract syntax tree; the reference implementation in cli/crates/cook-lang uses a Vec<RegisterBlock> field on the top-level Cookfile AST node (informative).

A register_block body MAY call any register-phase Cook Lua API, including cook.recipe, cook.add_unit, cook.step_group, cook.exec, cook.interactive, and the both-phase APIs.

Example 5.4.1

use cook_cc

register
    local raylib = cook_cc.find_or_error("raylib")
    cook_cc.bin("game", {
        sources     = { "src/main.c" },
        includes    = raylib.include_dirs,
        system_libs = raylib.system_libs,
    })

The register block calls cook_cc.bin, a § 28 target-maker that internally invokes cook.recipe("game", …) to create the recipe header and cook.add_unit(…) to record its work units. The block runs once during recipe registration; the resulting recipe "game" is indistinguishable from one declared as a recipe game header carrying a bare cook_cc.bin(…) module-call line in its body (§ 8.1).

Example 5.4.2

register
    zlib = cook_cc.find_or_error("zlib")
    cook_cc.lib("common", { sources = { "src/common.c" } })

chore run
    ./packer test.dat

register
    cook_cc.bin("packer", { sources = { "src/packer.c" }, links = { "common" }, system_libs = zlib.system_libs })

The interactive shell step lives in a chore body (§ 7), not a recipe body — a recipe body is purely declarative under CS-0134, and a chore shell command is interactive by default (§ 7.3), so no @ marker is written (CS-0134 removed it from the language). The two-block-with-chore-between form demonstrates the source-order splicing rule plus shared scope: the zlib table written in the first block is visible in the second block. Both blocks splice into the same register-phase Lua chunk; the only restriction is Lua’s normal lexical-scope rules (local in a do … end is not visible outside it, but assignments and local at the chunk’s top level — which is where register-block bodies splice — persist through the rest of the chunk).

Note 5.4.1

As of CS-0134 the recipe-body step-dispatch cascade in § 8.1 reinstates the module_call rule that CS-0072 had removed. A bare <id>.<id>(…) line inside a recipe body is a module_call, classified and evaluated as register-phase Lua alongside the recipe’s other declarative steps. The register-phase doubled-> prefixes >> (inline_lua_line) and >>{ (inline_lua_block) were removed from the language entirely by CS-0134 (§ 3.7); a bare module call is now the way to write register-phase Lua inside a recipe body, and a top-level register block (§ 5.4) hosts register-phase Lua that is not itself a module call (e.g. a local assignment reading var).