§ 9 — Placeholders
9. Placeholders
Normative. This chapter defines the placeholder surface — the
$<IDENT>token, the resolution rules that route it to a builtin / recipe / env var, and the per-step-kind tables that say which placeholder shapes are valid in which iteration mode. Cross-recipe name reference shapes that share the same$<...>syntax are specified in § 10.
9.1. Placeholder token
Normative.
A placeholder is the byte sequence $<IDENT> appearing in shell text (defined in § 9.3). The lexer recognizes a placeholder by the production:
placeholder = "$<" placeholder_ident ">"placeholder_ident = bare_ident | recipe_member | out_indexed | out_indexed_accbare_ident = ALPHA (ALPHA | DIGIT | "_" | ".")*recipe_member = bare_ident "[" member_index "]"member_index = "in"out_indexed = "out_" DIGIT+out_indexed_acc = "out_" DIGIT+ "." accessoraccessor = "stem" | "name" | "ext" | "dir"ALPHA = "a"…"z" | "A"…"Z" | "_"DIGIT = "0"…"9"A byte sequence beginning with $< that does not match this production in full — by malformed IDENT, missing >, or IDENT exceeding the production’s character set — is not a placeholder. Its bytes are literal shell text. A conforming implementation MUST NOT search forward beyond the first non-IDENT byte for a closing >.
The strict-bail rule eliminates ambiguity between Cook placeholders and shell idioms that use {/} ({a,b,c} brace expansion, ${VAR} parameter expansion, awk '{print $1}', JSON literals, etc.). None of those forms begin with $< followed by an IDENT and a >, so they are unambiguously literal under this rule.
The recipe_member alternative (CS-0098, respelled by CS-0137) is the per-member cross-recipe accessor of § 8.9. The scanner’s IDENT-continue set admits [ and ], so a sigil with a trailing bracket group lexes as a single span; resolution then admits only the literal index in — the empty index (the pre-v1.0 $<NAME[]> spelling) MUST be rejected with a did-you-mean naming the [in] respelling, and any other index content MUST be rejected with a diagnostic noting member-field joins are not part of v1.0 (§ 9.3). The strict-bail rule is unchanged: bracket content outside the IDENT-continue character set (e.g. a space) makes the whole sequence literal shell text.
Probe-value reference sigils (informative; CS-0131, amended by CS-0240). A sigil whose IDENT names a declared probe key is a probe-value reference, resolved at step 3 of § 10.2 and read via the substitution path of § 22.5.7. Its IDENT is NOT a bare_ident: it admits the probe-key character set (: as the module-prefix separator and the widened PROBE_SEG class, internal -) followed by an optional access path, so $<demo:cc-version.ver> tokenises as a single probe-ref span. The strict-bail rule of § 9.1 is unaffected. This chapter’s bare_ident production governs only the non-probe forms (built-ins, recipe names, declared variables).
CS-0131 read the colon as the discriminator: a colon-carrying IDENT went to the probe path and every colon-free one went to the table in this chapter, whether or not it named a probe. CS-0240 withdrew that reading — the sigil was the only bare-name position in the language resolving lexically rather than by lookup, and the native probe declaration mints colon-free keys, so probe keyed_obs declared a probe $<keyed_obs> could not reach. What survives is narrower: a colon can appear in no builtin or variable name. A colon-carrying IDENT first names an exact in-scope recipe; otherwise it is a probe reference, which lets an implementation resolve a module-prefixed key where no probe keyset is at hand.
Resolution of a successfully-lexed placeholder is governed by § 10.2. The closed set of valid IDENT resolutions and per-iteration-mode validity rules are stated normatively in § 9.3.
Implementation note (informative). Implementations of the sigil placeholder substitution typically share a single helper for the four shell-text contexts (cook output patterns,
{ ... }bodies, test bodies, bare shell commands), with per-context configuration of iteration mode and declared output count. The accessor set (stem,name,ext,dir) is constant across every position that admits a placeholder; sharing a single definition across the resolver and the migration tooling is a sound implementation practice.
9.2. Resolution
Placeholder resolution is the closed function defined in § 10.2. Every well-lexed $<IDENT> either resolves to a known thing (a builtin shape from § 9.3 or § 9.4, a recipe name from § 10.1, a declared probe key per § 22.5.7, or a variable declared via var.NAME in a config block per § 10.7 / § 5.3) or fails loudly. There is no silent fallthrough. A variable resolved this way is a determinant of the consuming step (its resolved string enters consulted_env_keys); it is never delivered to the step through the process environment (§ 5.3, CS-0164, CS-0172).
9.3. Cook-step placeholders
Phase. Substitution is performed by the code generator at register time; the resulting Lua expression is evaluated at register time when the unit is recorded.
A cook step’s { ... } shell-block body, a test shell-block body (§ 8.6), and every bare shell_command is a shell text for the purposes of this section. Shell text MAY contain sigil placeholders of the form $<IDENT> where IDENT is a dot-free or single-dotted identifier (e.g. $<in>, $<in.stem>, $<out_2.dir>). Before a conforming implementation records the resulting unit, it MUST substitute every $<IDENT> according to the closed resolution table in this section. A $<IDENT> whose shape does not match any row of the placeholder table resolves through the remaining steps of § 10.2 — a recipe name, a probe key, a declared variable, or that section’s hard error.
The sigil syntax $<IDENT> is the sole placeholder surface for shell text as of v0.7. Shell brace constructs (${HOME:-x}, ${}, {a,b,c}) are not sigil placeholders and MUST be passed through verbatim.
| Placeholder | Valid in iteration mode | Meaning |
|---|---|---|
$<in> | one-to-one (own, dep-driven, or data-member) or many-to-one | In one-to-one mode: the current iteration item — a path for gather-driven recipes (glob form), or the whole data member rendered as canonical key-sorted JSON (record) / string form (scalar) for gather <probe>-driven recipes (§ 8.2). In many-to-one mode: the unit’s full input list, joined by single space characters — byte-identical to the join the removed batched-input placeholder (App. E CS-0130) used to produce. |
$<in.ACCESSOR> | one-to-one only | For path members (gather glob): path.ACCESSOR(in) per § 26.1; for record members (gather <probe>): the value of record field ACCESSOR. An accessor requires the input be a single item, so this form is rejected in many-to-one mode (CS-0130) — see the invalid shapes below. |
$<out> | any mode, single declared output | The unit’s single output path |
$<out.ACCESSOR> | any mode, single declared output | path.ACCESSOR(out) |
$<out_N> (N ∈ 1..) | any mode, multi-output | The unit’s N-th declared output, in declaration order |
$<out_N.ACCESSOR> | any mode, multi-output | path.ACCESSOR(out_N) |
$<NAME[in]> (NAME is a recipe in scope; CS-0098, respelled by CS-0137) | one-to-one inside gather <probe> fan-out body only | The named recipe’s terminal output for the current iteration member (§ 8.9). In the Lua lowering, $<NAME[in]> emits cook.dep_output_member("NAME", cook.member_to_string(item)) where item is the loop-local fan-out variable. The compiler extracts $<NAME[in]> as a recipe-level DAG ordering edge (NAME MUST build before the referencing recipe, identical to the edge produced by bare $<NAME>) and folds the referenced output into the current member’s cache fingerprint (per-member isolation — § 8.9). Consumer and producer MUST iterate the same probe (same-probe precondition, v1); cross-probe joins via declared alignment keys are a tracked follow-up and are not part of v1.0. The bracket index MUST be the literal in; $<NAME[in]> with an unknown NAME is a load-time error naming the unknown recipe (no env fallthrough). The pre-v1.0 $<NAME[]> spelling MUST be rejected with a did-you-mean (“$<NAME[]> was respelled $<NAME[in]> in v1.0”); any other bracket content MUST be rejected with a diagnostic noting member-field joins are not part of v1.0. $<NAME> (no brackets) continues to mean all terminal outputs, space-joined, in every position — including inside fan-out bodies (§ C.6.27). |
$<NAME> (NAME is a declared chore parameter, §7.1.1) | chore bodies only | The bound parameter value, POSIX single-quote-escaped to produce a single shell word; for variadic parameters, the space-separated, individually shell-quoted joined form. Scope-limited to chore bodies that declare the parameter. Resolved at register phase, before the shell step is dispatched. |
ACCESSOR is one of stem, name, ext, dir, with semantics defined by § 10.3, when $<in> refers to a path member. When $<in> refers to a record member (gather <probe>, § 8.2), ACCESSOR in $<in.ACCESSOR> is instead a record key: it selects the member’s field, rendered as a string (canonical key-sorted JSON for a nested table value).
The selected field’s rendering is the same rendering $<in> gives the whole member (§ 8.2): a table value (array or object) renders as compact, key-sorted JSON; a string renders as its bare text with no surrounding quotes; a number, boolean, or absent field renders as its JSON scalar text (42, true, null). A conforming implementation MUST NOT render a table field through a host-runtime value-to-string conversion whose result varies between processes (CS-0162): the rendered text lands in the unit’s command string, which is a cache-key determinant (§ 17.1 observable 1), so a per-process rendering makes every such unit permanently uncacheable.
Chore-body resolution order. Within a chore body, $<NAME> resolution proceeds in this order:
- Declared chore parameter names (§7.1.1). A parameter name is an undotted
BARE_IDENTIFIERand is resolved ahead of every other form; the remaining chore-body forms are either unavailable in chore bodies (the cross-recipe / input$<in>and$<out>families, item 3) or take a dotted shape an undotted parameter name cannot ($<var.KEY>). A parameter name therefore collides with none of them, without needing a reserved-name ban (CS-0132; the ban that formerly forbade parameter names such asname/in/outwas removed). $<var.NAME>form (§ 10.7).- Cross-recipe outputs and gather — not available in chore bodies in any case, since chore bodies cannot contain
cook/teststeps (§7.2).
An unknown name within a chore body raises a runtime error when the shell step is dispatched.
A conforming implementation MUST reject the following shapes with a load-time diagnostic:
$<in.ACCESSOR>in a many-to-one step — an accessor requires the input set be singular; the collected set has no single member.$<in>or$<in.ACCESSOR>in a one-shot step (no input set — the step has no input source).$<out>in a multi-output step (ambiguous — use$<out_N>).$<out_N>in a single-output step (use plain$<out>).$<out_N>forNgreater than the step’s declared output count.$<stem>,$<name>,$<ext>, or$<dir>as a bare sigil (i.e. with noNAME.prefix). These bare accessors have no special meaning at the placeholder layer; the correct spelling is$<in.stem>etc. The diagnostic MUST name the new form. (A bare$<stem>would otherwise resolve through § 10.2 tocook.require_var("stem"), which is a confusing failure mode.)$<in>used where the recipe establishes no input source. A source is established by any of: agather_step, in any of its quoted-glob, bare-source-name, or$<recipe>forms (§ 8.2); a dep-driven output pattern, where a$<lib.ACCESSOR>in the step’s own output list makeslib’s output list the source (§ 10.4); or a precedingcook_stepin the same recipe, whose collected outputs are the source under the chained-step rule (§ 8.4.1). With none of them present there is nothing for$<in>to name, and the reference MUST be rejected. The diagnostic MUST state that nothing gathers what the command references — the wording § 8.2 already mandates for this rejection, which is the same rule stated from the placeholder’s side. The predicate is static and MUST NOT consult the filesystem. Having a source is a separate question from how the source is consumed: § 8.4.1 decides, from the output patterns alone, whether the step fans out over the source or collapses it into one many-to-one unit whose$<in>is the whole set. Both consume a source, so both are admitted here. (CS-0224, restated by CS-0232: requiringgatherspecifically contradicted § 8.4.1 and rejected a corpus case that a conforming implementation accepts.)- A literal
PATHthat names no regular file, or a globPATHmatching no files, MUST produce a load-time diagnostic naming the placeholder (mirrors § 10.2 step 5 — no silent empty expansion).
Example 9.3.1
cook "build/$<in.stem>.o" { gcc -c $<in> -o $<out> }with input main.c records one shell unit per input. For input main.c, the unit’s command field is set -e\ngcc -c main.c -o build/main.o. The output pattern’s $<in.stem> resolves to main before the body’s $<out> is substituted; the resulting $<out> is build/main.o.
Example 9.3.2
cook "out.js" "out.wasm" { wasm-pack build cp pkg/main.js $<out_1> cp pkg/main.wasm $<out_2>}with any input list records one shell unit (many-to-one mode — outputs are literal). The body’s $<out_1> and $<out_2> resolve to out.js and out.wasm respectively.
Note 9.3.1
The block forms ({ ... } and >{ ... }) and the placeholder layer compose differently. A shell_block is shell text and gets the substitution defined in this section. An exec_lua_block is Lua text and does not get textual substitution; the same iteration data is exposed as Lua bindings per § 23.1. The asymmetry is principled — see App. C — and reflects that shell has no binding mechanism for “the current file” while Lua does.
Note 9.3.2
Cross-recipe references inside a shell block follow § 10.5: a bare $<NAME> substitutes to the named recipe’s full output list, joined by space. The $<lib.ACCESSOR> form is rejected inside any cook-step body, including a shell block, because it would smuggle a second iteration source past the output pattern’s declaration. Authors who want a recipe’s per-element accessors inside a step driven by something else use Lua (path.stem(cook.dep_output_list("lib")[1])).
9.4. Test placeholders
A test_step body that is a shell_block cannot textually reference a collected input set — a shell body has no all-inputs placeholder (CS-0130). It therefore either references the per-item accessor $<in> / $<in.ACCESSOR> (one-to-one) or references no iteration item (a single unit, whose caching follows the recipe’s source per § 8.6.1). A Lua-block test body MAY expose the collected set as the Lua inputs table (§ 23.1). A shell-block test body admits the following placeholders:
| Placeholder | Valid in mode | Meaning |
|---|---|---|
$<in> | one-to-one (own, dep-driven, or data-member) | The current iteration item: a path for gather-driven recipes (glob form), or the whole data member (record as canonical key-sorted JSON, scalar as string) for gather <probe>-driven recipes (§ 8.2) |
$<in.ACCESSOR> | one-to-one | For path members (gather glob): path.ACCESSOR(in) per § 26.1; for record members (gather <probe>): the value of record field ACCESSOR |
$<NAME> (NAME is a recipe in scope) | any mode | cook.dep_output(NAME) per § 10.5 |
$<TOKEN> (none of the above) | any mode | cook.require_var(TOKEN) per § 10.2 |
A conforming implementation MUST reject:
$<in>or$<in.ACCESSOR>in a one-shot body (no current iteration);- Any
$<out>/$<out.ACCESSOR>/$<out_N>/$<out_N.ACCESSOR>token in a test body — the step declares no outputs. The diagnostic MUST state that test produces no declared outputs; for a body in one-to-one mode the diagnostic SHOULD additionally direct the author to$<in>as the iteration item. $<NAME.ACCESSOR>for any recipeNAMEin scope (preserves the § 10.4 firewall).$<in>used where the recipe establishes no input source — the same predicate and the same diagnostic as § 9.3‘s. Atest_step’s source is the precedingcook_step’s output list, falling back to the recipe’s resolvedgather(§ 8.6.1), so atestfollowing acookhas a source whether or not the recipe gathers, and only a recipe establishing none of the sources listed in § 9.3 rejects (CS-0232).
Bare path-accessors ($<stem>, $<name>, $<ext>, $<dir>) in a test body resolve through § 10.2 step 5. A conforming implementation MUST emit a specific diagnostic for these four names that points to $<in.ACCESSOR> (same diagnostic CS-0022 introduced for cook bodies).