Skip to content

§ 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_acc
bare_ident = ALPHA (ALPHA | DIGIT | "_" | ".")*
recipe_member = bare_ident "[" member_index "]"
member_index = "in"
out_indexed = "out_" DIGIT+
out_indexed_acc = "out_" DIGIT+ "." accessor
accessor = "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.

PlaceholderValid in iteration modeMeaning
$<in>one-to-one (own, dep-driven, or data-member) or many-to-oneIn 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 onlyFor 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 outputThe unit’s single output path
$<out.ACCESSOR>any mode, single declared outputpath.ACCESSOR(out)
$<out_N> (N ∈ 1..)any mode, multi-outputThe unit’s N-th declared output, in declaration order
$<out_N.ACCESSOR>any mode, multi-outputpath.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 onlyThe 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 onlyThe 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:

  1. Declared chore parameter names (§7.1.1). A parameter name is an undotted BARE_IDENTIFIER and 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 as name/in/out was removed).
  2. $<var.NAME> form (§ 10.7).
  3. Cross-recipe outputs and gather — not available in chore bodies in any case, since chore bodies cannot contain cook / test steps (§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> for N greater than the step’s declared output count.
  • $<stem>, $<name>, $<ext>, or $<dir> as a bare sigil (i.e. with no NAME. 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 to cook.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: a gather_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 makes lib’s output list the source (§ 10.4); or a preceding cook_step in 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: requiring gather specifically contradicted § 8.4.1 and rejected a corpus case that a conforming implementation accepts.)
  • A literal PATH that names no regular file, or a glob PATH matching 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:

PlaceholderValid in modeMeaning
$<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-oneFor 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 modecook.dep_output(NAME) per § 10.5
$<TOKEN> (none of the above)any modecook.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 recipe NAME in 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. A test_step’s source is the preceding cook_step’s output list, falling back to the recipe’s resolved gather (§ 8.6.1), so a test following a cook has 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).