oxdock-parser 0.17.0-alpha

Parser and AST definitions for the OxDock DSL.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
WHITESPACE = _{ " " | "\t" }
linebreak  = _{ "\r\n" | "\n" }
COMMENT    = _{ line_comment | block_comment }
line_comment = _{ "//" ~ (!linebreak ~ ANY)* }
block_comment = _{ "/*" ~ (block_comment | !"*/" ~ ANY)* ~ "*/" }

// Hash comments occupy a whole line (possibly indented) as `element`s,
// and may also trail values inside `gap`/`gap_nl` bracket interiors.
// Inside command payloads (unquoted args) a `#` stays ordinary text.
hash_comment = _{ "#" ~ (!linebreak ~ ANY)* }

blank = _{ (WHITESPACE | linebreak)+ }

script = { SOI ~ element* ~ EOI }

// Structural statements MUST precede generic commands
element = _{ blank | hash_comment | COMMENT | semicolon | guard_line | block_start | block_end | if_statement | for_statement | while_statement | func_def | import_statement | export_statement | call_statement | return_statement | break_statement | continue_statement | let_async_statement | let_capture_statement | await_statement | cancel_statement | let_statement | mutate_statement | timeout_statement | async_statement_block | async_statement | command }

// Inside for/let blocks, block_start/block_end must NOT appear as elements
// (the block's own braces handle them).  Nested guard blocks use guard_block.
block_element = _{ blank | hash_comment | COMMENT | semicolon | guard_block | guard_line | if_statement | for_statement | while_statement | func_def | import_statement | export_statement | call_statement | return_statement | break_statement | continue_statement | let_async_statement | let_capture_statement | await_statement | cancel_statement | let_statement | mutate_statement | timeout_statement | async_statement_block | async_statement | command }
guard_block = ${ guard_line ~ gap ~ block }

block_start = { "{" }

block_end = { "}" }

semicolon = _{ ";" }

guard_line = { "[" ~ ws? ~ guard_expr ~ ws? ~ "]" }

guard_expr = { guard_seq }
guard_seq = { guard_factor ~ ( ws? ~ "," ~ ws? ~ guard_factor ~ ws? )* }
guard_factor = { guard_not }
guard_not = _{ guard_primary }
guard_primary = _{ guard_group | guard_any_call | guard_all_call | not_call | guard_term }
guard_group = { "(" ~ ws? ~ guard_expr ~ ws? ~ ")" }
not_call = { "not" ~ "(" ~ ws? ~ guard_expr ~ ws? ~ ")" }
guard_any_call = { any_kw ~ "(" ~ ws? ~ guard_expr_list ~ ws? ~ ")" }
guard_all_call = { all_kw ~ "(" ~ ws? ~ guard_expr_list ~ ws? ~ ")" }
any_kw = _{ "any" }
all_kw = _{ "all" }
guard_expr_list = { guard_expr ~ ( ws? ~ "," ~ ws? ~ guard_expr )* }
guard_term = { ws? ~ guard_predicate ~ !( "(" | ":" | "=" | "!") ~ ws? }
guard_predicate = _{ eq_guard | neq_guard | bool_guard | env_guard | bare_guard_ident }
bool_guard = { "bool" ~ ":" ~ ws? ~ bool_value }
bool_value = @{ guard_key_char+ }
eq_guard = { "eq" ~ "(" ~ env_prefix ~ env_key ~ "," ~ ws? ~ guard_value ~ ws? ~ ")" }
neq_guard = { "ne" ~ "(" ~ env_prefix ~ env_key ~ "," ~ ws? ~ guard_value ~ ws? ~ ")" }
env_guard = { env_prefix ~ ws? ~ env_key }
env_prefix = { "env" ~ ":" }
env_key = @{ guard_key_char+ }
guard_key_char = _{ !( ":" | "=" | "!" | "," | "|" | "(" | ")" | "]" | "$" | " " | "\t" | linebreak | "//" | "/*" ) ~ ANY }
guard_value = _{ quoted_string | bare_guard_value }
bare_guard_value = @{ (!( "," | "|" | ")" | "]" | "$" | linebreak | "//" | "/*" ) ~ ANY)+ }
bare_guard_ident = @{ guard_key_char+ }

// Comparison and arithmetic operators (used by expression tiers in LET/IF expressions)
eq_op = @{ "==" }
neq_op = @{ "!=" }
lt_op = @{ "<" }
le_op = @{ "<=" }
gt_op = @{ ">" }
ge_op = @{ ">=" }
plus_op = @{ "+" }
minus_op = @{ "-" }
star_op = @{ "*" }
slash_op = @{ "/" }

// Identifiers
ident_char = _{ ASCII_ALPHANUMERIC | "_" }
ident = @{ ident_char+ }
dollar_ident = @{ "$" ~ ident_char+ }

// Expressions — 7-tier precedence climbing (highest to lowest:
// atom > unary (!, -) > mul/div > add/sub > ordering > equality > logical_and > logical_or)
or_op = @{ "||" }
and_op = @{ "&&" }
// eq_op and neq_op already defined above in operator section

parenthesized_expr = ${ "(" ~ gap_nl ~ expr ~ gap_nl ~ ")" }

quoted_string = @{
    "\"" ~ ( "\\\"" | (!"\"" ~ ANY) )* ~ "\"" |
    "'" ~ ( "\\'" | (!"'" ~ ANY) )* ~ "'"
}
bare_word = @{ (ASCII_ALPHANUMERIC | "_" | "-" | "." | "/")+ }
// Numeric literal: unsigned digits with optional fraction. No leading `-`
// (negation routes through `expr_unary` + lowering-time fold, so `i64::MIN`
// stages via `UnsignedIntBoundary`). Trailing guard keeps durations (`30s`,
// `100ms`), paths (`123/456`), and versions (`1.0.0`) lexing as `bare_word`.
numeric_literal = @{ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? ~ !(ASCII_ALPHANUMERIC | "_" | "." | "/") }
// Environment read: `env:KEY` evaluates to the script environment value.
// Must precede `bare_word` in `expr_atom` (which would otherwise match just
// `env` and strand `:KEY`). In argument position the `unquoted_arg` guard
// below gives the same precedence to a leading `env:KEY` shape.
env_read_key = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "_")* }
env_read = { "env" ~ ":" ~ env_read_key }
// Static module qualification: `MODULE::NAME` in call position. At most one
// `::`; the parts are validated UPPERCASE at lowering time with span errors.
qualified_func_name = @{ ident ~ ("::" ~ ident)? }
func_call = ${ qualified_func_name ~ gap ~ "(" ~ gap_nl ~ (expr ~ gap_nl ~ ("," ~ gap_nl ~ expr ~ gap_nl)*)? ~ ")" }
variable = ${ "$" ~ ident }
key_path_segment = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHA | "_" | ASCII_DIGIT)* | ASCII_DIGIT+ }
key_path = ${ "$" ~ ident ~ ("." ~ key_path_segment)+ }
list_literal = ${ "[" ~ gap_nl ~ (expr ~ gap_nl ~ ("," ~ gap_nl ~ expr ~ gap_nl)*)? ~ "]" }
map_entry = ${ (quoted_string | bare_word) ~ gap ~ ":" ~ gap ~ ws? ~ gap ~ expr }
// Maps span lines and take comments like call args and lists: entries stay
// single-line (`key: value`), but anything between entries or braces may
// hold newlines and `//` / `/* */` comments.
map_literal = ${ "{" ~ gap_nl ~ (map_entry ~ gap_nl ~ ("," ~ gap_nl ~ map_entry ~ gap_nl)*)? ~ "}" }
string_literal = { quoted_string }
expr_atom = { parenthesized_expr | func_call | key_path | variable | env_read | list_literal | map_literal | block | string_literal | numeric_literal | bare_word }

// Ordering level (<, <=, >, >=) — operands are additive so `+`/`-` bind
// tighter than comparisons: `2 + 3 > 4` parses as `(2 + 3) > 4`.
// `gap` opens each repetition so chained ops may be spaced; ordering stays
// a single optional comparison and rejects `a < b <= c` chains.
expr_ordering = ${ expr_add_sub ~ (gap ~ (le_op | ge_op | lt_op | gt_op) ~ gap ~ expr_add_sub)? }

// Equality level (==, !=) — operands are ordering so ordering binds tighter
// than equality: `a < b == c` parses as `(a < b) == c`.
expr_comparison = ${ expr_ordering ~ (gap ~ (eq_op | neq_op) ~ gap ~ expr_ordering)? }

// Additive level (+, -) — left-associative, operands are multiplicative.
expr_add_sub = ${ expr_mul_div ~ (gap ~ (plus_op | minus_op) ~ gap ~ expr_mul_div)* }

// Multiplicative level (*, /) — left-associative, operands are unary so
// unary `-`/`!` bind tighter: `2 * -3` parses as `2 * (-3)`.
expr_mul_div = ${ expr_unary ~ (gap ~ (star_op | slash_op) ~ gap ~ expr_unary)* }

// Unary prefix — one or more `!` / `-` prefixes over an atom
// (`!true`, `!!$flag`, `-5`, `--5`, `2 * -3`, `!-x`). A `-` directly glued
// to a letter/`_`/`/` stays a bare word (`-f`, `-30s`, `/etc/passwd` keep
// their literal reading); negation applies before digits, `$`, `(`, quotes,
// calls, and whitespace-separated operands. The gap sits behind
// the prefix run (never leading): a leading gap would let `expr` match
// whitespace and glue adjacent instruction arguments into one under atomic
// parents, where only explicit gaps consume whitespace.
not_op = { "!" }
neg_op = ${ "-" ~ !(ASCII_ALPHA | "_" | "/") }
expr_unary = ${ ((not_op | neg_op) ~ gap)* ~ expr_atom }

// Logical AND (&&) — binds tighter than ||; `gap` opens each repetition
// so chained `a && b && c` may be spaced.
expr_logical_and = ${ expr_comparison ~ (gap ~ and_op ~ gap ~ expr_comparison)* }

// Logical OR (||) — binds loosest; same repetition spacing.
expr_logical_or = ${ expr_logical_and ~ (gap ~ or_op ~ gap ~ expr_logical_and)* }

// Entry point for all expressions
expr = { expr_logical_or }

// Block
block = { "{" ~ block_element* ~ "}" }

// Control flow & Assignment
let_keyword = @{ "LET" ~ !(ASCII_ALPHANUMERIC | "_") }
for_keyword = @{ "FOR" ~ !(ASCII_ALPHANUMERIC | "_") }
in_keyword = @{ "IN" ~ !(ASCII_ALPHANUMERIC | "_") }
if_keyword = @{ "IF" ~ !(ASCII_ALPHANUMERIC | "_") }
else_keyword = @{ "ELSE" ~ !(ASCII_ALPHANUMERIC | "_") }

// Open uppercase identifier for type tags. Tags stay plain names and
// resolve against the descriptor table at runtime, so host types parse
// without grammar changes; unregistered names fail at first coercion.
type_tag = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "_")* }

for_statement = ${ for_keyword ~ gap ~ dollar_ident ~ gap ~ (":" ~ gap ~ type_tag ~ gap)? ~ ("," ~ gap ~ dollar_ident ~ gap ~ (":" ~ gap ~ type_tag ~ gap)?)? ~ in_keyword ~ gap ~ expr ~ gap ~ block }
// Bare `LET $p: PIPE` (no `=`, no initializer) mints a fresh anonymous
// pipe backend lazily at first binding. All other types still require
// an initializer; the parser rejects those with a span error.
let_statement = ${ let_keyword ~ gap ~ dollar_ident ~ gap ~ ":" ~ gap ~ type_tag ~ gap ~ ("=" ~ gap ~ expr)? }
// Mutation is bare `$var = expr` (no keyword): the leading `$` distinguishes
// it from `KEY=value` command assignments, which never start with `$`.
mutate_statement = ${ dollar_ident ~ gap ~ "=" ~ gap ~ expr }

else_if_clause = ${ else_keyword ~ gap ~ if_keyword ~ gap ~ expr ~ gap ~ block }
else_clause = ${ else_keyword ~ gap ~ block }
if_statement = ${ if_keyword ~ gap ~ expr ~ gap ~ block ~ gap ~ (blank* ~ gap ~ else_if_clause ~ gap)* ~ blank* ~ gap ~ else_clause? }

// Functions + loops (#114, #146): UPPERCASE function names only (parity with
// host natives like GLOB/LOAD_TOML). Lowercase names fail at lex time.
// Bare `NAME(...)` statements invoke DSL, native, and host functions with one
// syntax; the `CALL` keyword was removed (hard break, no alias).
// Static module qualification (#146 follow-up): calls may name their module
// explicitly (`STD::GLOB(...)`, `MOCK::READ_CSV(...)`); bare `NAME(...)`
// resolves through `SCRIPT` definitions and `IMPORT`ed modules at lowering
// time (see IMPORT).
func_keyword = _{ "FUNC" ~ !(ASCII_ALPHANUMERIC | "_") }
return_keyword = _{ "RETURN" ~ !(ASCII_ALPHANUMERIC | "_") }
while_keyword = _{ "WHILE" ~ !(ASCII_ALPHANUMERIC | "_") }
break_keyword = _{ "BREAK" ~ !(ASCII_ALPHANUMERIC | "_") }
continue_keyword = _{ "CONTINUE" ~ !(ASCII_ALPHANUMERIC | "_") }
import_keyword = _{ "IMPORT" ~ !(ASCII_ALPHANUMERIC | "_") }
export_keyword = _{ "EXPORT" ~ !(ASCII_ALPHANUMERIC | "_") }
// Exactly one `::` separator: deeper paths (`A::B::F`) stay a parse error.
// The tail stays case-open so `STD::glob` lexes and fails at lowering with
// a span-accurate UPPERCASE error instead of a grammar error; the module
// part stays uppercase-only so bare lowercase heads still fall through to
// the unknown-command path with its did-you-mean hint.
func_ident = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "_")* }
func_call_head = @{ func_ident ~ ("::" ~ ident)? }
func_param = ${ dollar_ident ~ gap ~ ":" ~ gap ~ type_tag }
while_statement = ${ while_keyword ~ sep ~ expr ~ gap ~ block }
func_def = ${ func_keyword ~ sep ~ func_ident ~ gap ~ "(" ~ gap ~ (func_param ~ gap ~ ("," ~ gap ~ func_param ~ gap)*)? ~ gap ~ ")" ~ gap ~ block }
// Non-atomic on purpose: implicit whitespace applies at top level, while the
// explicit `gap` terms keep calls working inside compound-atomic parents
// (TIMEOUT, WITH_IO, ASYNC, LET-capture) where implicit skipping is off.
// The head and `(` ride one atomic token (`call_head_paren`), so no
// whitespace can intervene: `FOO (` stays an instruction, and
// `ECHO (1 + 2)` keeps its parenthesized argument instead of mis-parsing
// as a call to `ECHO`. Ordered before `instruction` so `FOO(...)` never lexes
// as a command.
call_head_paren = @{ func_call_head ~ "(" }
call_statement = { call_head_paren ~ gap_nl ~ (expr ~ gap_nl ~ ("," ~ gap_nl ~ expr ~ gap_nl)*)? ~ ")" }
return_statement = ${ return_keyword ~ (sep ~ expr)? }
break_statement = ${ break_keyword }
continue_statement = ${ continue_keyword }

// Commands
// Structural rules with special syntax stay as PEG rules.
// All other commands use the generic `instruction` rule — lowering happens in Rust.
async_keyword = _{ "ASYNC" ~ !(ASCII_ALPHANUMERIC | "_") }
async_statement = ${ async_keyword ~ sep ~ command_inner }
async_statement_block = { async_keyword ~ sep? ~ block }

// LET $var: TYPE = <sync command> — capture the command's stdout into $var
// (spilling to disk if large), or LET $var: TYPE = AWAIT $task — capture a named
// task's output. PEG alternatives are shadow-safe by construction:
// - `let_async_statement` precedes this rule and claims every ASYNC-led and
//   WITH_IO-led line, so this rule never sees them (WITH_IO sync capture is
//   handled in `parse_let_async_statement_from_pair`, which branches sync
//   bodies into capture instead of bailing).
// - Only UPPERCASE-led `instruction` lines reach the `instruction`
//   alternative (lowercase/digits/sigils fail immediately and fall through
//   to `let_statement`). Rust then branches on `is_known_command(lead)`:
//   known commands lower to capture, unknown leads re-parse as expressions.
// - `await_statement`/`timeout_statement`/`instruction` can only claim the
//   line when they run to its end (`gap ~ &(linebreak | ";" | "}" | EOI)`).
//   Without this end-guard PEG would commit to a strict-prefix match — e.g.
//   `instruction` matching just `LOAD_TOML` in `LET $d: STRING = LOAD_TOML("t.toml")`
//   and stranding `("t.toml")` — instead of falling through to
//   `let_statement`, where the RHS parses as an expression. Trailing text
//   after a complete instruction can never be a valid expression
//   continuation, so the guard claims no expression input.
let_capture_statement = ${ let_keyword ~ gap ~ dollar_ident ~ gap ~ ":" ~ gap ~ type_tag ~ gap ~ "=" ~ gap ~ (await_statement | timeout_statement | instruction) ~ gap ~ &(linebreak | ";" | "}" | EOI) }

// LET $var: TYPE = ASYNC { ... } — spawn background task, store handle in $var
// NOTE: "ASYNC" is consumed here, so we inline the block/inline forms
// instead of reusing async_statement_block/async_statement (which expect
// their own async_keyword).
// LET $var: TYPE = ASYNC { ... } — spawn background task, store handle in $var
// Uses let_keyword to ensure "LET" is not followed by alphanumeric/underscore.
// LET $var: TYPE = ASYNC { ... } — spawn background task, store handle in $var
// Uses let_keyword and dollar_ident to match exactly like let_statement,
// then requires ASYNC keyword followed by block or inline command.
// LET $var: TYPE = ASYNC { ... } — spawn background task, store handle in $var
// Uses let_keyword and dollar_ident to match exactly like let_statement,
// then requires ASYNC keyword followed by block or inline command.
// LET $var: TYPE = ASYNC { ... } — spawn background task, store handle in $var
// Implicit whitespace handles spacing between tokens in non-atomic rules.
// LET $var: TYPE = WITH_IO [flags] ASYNC <single command> binds a pipe-wired
// background task. The bindings apply inside the task thread. Block form is
// rejected during lowering: use LET $var: HANDLE = ASYNC {{ ... }} with WITH_IO inside the block for multi-step tasks.
let_async_statement = ${ let_keyword ~ gap ~ dollar_ident ~ gap ~ ":" ~ gap ~ type_tag ~ gap ~ "=" ~ gap ~ ("ASYNC" ~ gap ~ (block | command_inner) | with_io_command) }

// AWAIT $var — block until task $var completes, propagate error if failed.
// NOTE: explicit `sep?` (not implicit whitespace) so this rule also matches
// inside compound-atomic parents such as timeout_statement, where implicit
// whitespace is suppressed.
await_keyword = _{ "AWAIT" ~ !(ASCII_ALPHANUMERIC | "_") }
await_statement = { await_keyword ~ sep? ~ "$" ~ ident }

// CANCEL $var — synchronously kill a named background task spawned via
// LET $var = ASYNC .... Blocking: returns only after the task thread is
// joined. A later AWAIT $var reports cancellation.
// NOTE: explicit `sep?` like await_statement so this rule also matches
// inside compound-atomic parents (async_statement, timeout_statement).
cancel_keyword = _{ "CANCEL" ~ !(ASCII_ALPHANUMERIC | "_") }
cancel_statement = { cancel_keyword ~ sep? ~ "$" ~ ident }

// TIMEOUT <duration> <command> | TIMEOUT <duration> { <commands> } —
// abort the wrapped steps with a deadline error if they overrun.
// Duration units: ms, s, m, h (bare number means seconds).
// NOTE: compound-atomic like its siblings (async_statement,
// with_io_command): explicit `sep` separators starve when implicit
// whitespace is active, so atomicity is required here, not optional.
timeout_keyword = _{ "TIMEOUT" ~ !(ASCII_ALPHANUMERIC | "_") }
// Static literal durations plus dynamic forms (`$var`, quoted, template):
// dynamics resolve (and type-check) at runtime via the declared Duration
// arg type instead of freezing at parse.
timeout_literal = @{ ASCII_DIGIT+ ~ ("ms" | "s" | "m" | "h")? }
timeout_duration = { timeout_literal | dollar_ident | quoted_string | templated_arg }
timeout_statement = ${ timeout_keyword ~ sep ~ timeout_duration ~ sep ~ (block | await_statement | cancel_statement | call_statement | while_statement | command) }

command = _{ with_io_command | inherit_env_command | timeout_statement | async_statement_block | async_statement | cancel_statement | call_statement | while_statement | run_exec_inner | instruction_inner }
command_inner = { inherit_env_command | timeout_statement | async_statement | async_statement_block | cancel_statement | call_statement | while_statement | run_exec_statement | instruction }

with_io_command = ${ "WITH_IO" ~ sep ~ io_flags ~ (sep ~ command)? }
io_flags = { "[" ~ ws? ~ io_binding ~ (ws? ~ "," ~ ws? ~ io_binding)* ~ ws? ~ "]" }
io_binding = { io_stream ~ (ws? ~ "=" ~ ws? ~ pipe_binding)? }
io_stream = { "stdin" | "stdout" | "stderr" }
// Binding endpoints are `$var` references to PIPE-typed variables.
pipe_binding = { dollar_ident }

inherit_env_command = ${ "INHERIT_ENV" ~ sep ~ inherit_list }
inherit_list = { "[" ~ ws? ~ env_key ~ (ws? ~ "," ~ ws? ~ env_key)* ~ ws? ~ "]" }

// IMPORT [STD, MOCK] / IMPORT MOCK — bring module functions into bare-call
// scope from this point to the enclosing block exit. A pure lowering
// directive: updates LowerCtx import frames, emits zero runtime steps.
// EXPORT is reserved for future script imports and rejected at lowering.
import_module = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "_")* }
import_list = { "[" ~ ws? ~ import_module ~ (ws? ~ "," ~ ws? ~ import_module)* ~ ws? ~ "]" }
import_statement = ${ import_keyword ~ sep ~ (import_list | import_module) }
export_statement = ${ export_keyword ~ sep ~ (!linebreak ~ !";" ~ ANY)* }

// Generic instruction: uppercase command name followed by arguments.
// instruction_inner (non-atomic) — used inside with_io_command's command rule.
// instruction (atomic) — used at top-level in element.
// `assignment` is tried before plain `argument` so `KEY=...` tokens split into
// (key, value) at tokenize time on raw spans; lowering never re-stitches them.
// RUN exec form (`RUN ["exe", "arg", ...]`, direct spawn with no shell) is a
// dedicated production ordered ahead of the generic instructions: PEG tries
// it first, so a leading list routes here with full-span validation from the
// grammar engine. Bracketed shell (`RUN [ -f ... ]`) is not a valid comma
// separated list literal and falls through to the shell path unchanged.
// Exec elements are atoms only (no binary/compare operators): spaced shell
// words can never compile to `CompiledMath`/`Arithmetic` and flip shell
// dispatches to exec; compute into a variable first (`LET $n = 1 + 2`).
run_exec_arg = { parenthesized_expr | func_call | key_path | variable | env_read | list_literal | map_literal | string_literal | numeric_literal | bare_word }
run_exec_list = ${ "[" ~ gap ~ (run_exec_arg ~ gap ~ ("," ~ gap ~ run_exec_arg ~ gap)*)? ~ gap ~ "]" }
run_exec_statement = ${ "RUN" ~ sep ~ run_exec_list }
run_exec_inner = ${ "RUN" ~ sep ~ run_exec_list }
instruction_inner = ${ command_name ~ (sep ~ (assignment | argument))* }
instruction = ${ command_name ~ (sep ~ (assignment | argument))* }
// Negative lookahead keeps `NAME(...)` calls out of the instruction path:
// `FOO(` always routes to `call_statement` (ordered first), while
// `FOO bar` still lexes as a command. `FOO (` with a space stays an
// instruction and fails later as UnknownCommand.
command_name = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "_")* ~ !("(" | "$") }

sep = _{ WHITESPACE+ }

// Explicit whitespace for compound-atomic rules, replicating exactly what
// implicit matching (WHITESPACE | COMMENT) accepts at top level, plus `#`
// line comments (which are explicit elements, never implicit). Atomic
// parents (WITH_IO, TIMEOUT, ASYNC bodies) suppress implicit whitespace, so
// nestable rules carry their own gaps and behave identically in both contexts.
// `gap` intentionally excludes linebreaks, matching implicit matching.
gap = _{ (WHITESPACE | COMMENT | hash_comment)* }

// Newline-tolerant gap for bracket interiors only (call args, list items,
// parenthesized exprs, map entries): long invocations may span lines, one
// arg per line, with `//`, `/* */`, or `#` comments between entries.
// Statement structure and operators stay single-line; only text inside
// matched `()`, `[]`, `{}` may cross a linebreak.
gap_nl = _{ (WHITESPACE | linebreak | COMMENT | hash_comment)* }

// Unquoted arguments — Docker-style bare words.
// Leading !$ rejects tokens starting with $ (forces $var to expr).
// Leading `env:KEY` (uppercase key) is likewise reserved for the env_read
// expression so a lone `env:FOO` argument evaluates instead of staying
// literal; any other `env:` shape (lowercase keys, `$` tails) keeps the
// historical literal reading.
// Inner loop excludes { to prevent consuming {{ }} as a single token.
unquoted_arg = @{ !"$" ~ !("env" ~ ":" ~ ASCII_ALPHA_UPPER) ~ !("{{") ~ (!WHITESPACE ~ !linebreak ~ !";" ~ !"}" ~ !"{" ~ !"//" ~ !"/*" ~ ANY)+ }

// argument repeats to support contiguous fragments: dist/{{ $file }}.txt
// Single expr fragment -> Arg::Expr; mixed/string fragments -> Arg::String.
argument = { (string_literal | templated_arg | unquoted_arg | expr)+ }

templated_arg = @{ "{{" ~ (!"}}" ~ ANY)* ~ "}}" }

// Unified key=value assignment: the single canonical value syntax bound by
// every command (ENV values, EXPAND overrides, and any other `KEY=...` token).
// The `=` binds with explicit `gap` on both sides (so `KEY = value` parses);
// the value keeps its exact raw span, so quoted whitespace survives intact.
assignment = ${ assign_key ~ gap ~ "=" ~ gap ~ assign_value? }
assign_key = @{ (ASCII_ALPHANUMERIC | "_" | "-" | "." | "/")+ }
assign_value = { quoted_string | assign_expr | raw_fragments }
// An assignment boundary: whitespace followed by `KEY=` — the start of a
// sibling assignment (`EXPAND K1=$x K2=$y`). Values never consume across it,
// so multi-assignment lines split uniformly regardless of value shape (a bare
// `!WHITESPACE` guard would split `$x`-led values yet still glue `1`-led
// ones into a single span).
assignment_boundary = _{ WHITESPACE+ ~ assign_key ~ gap ~ "=" }
// Lone `$var` / `$a.b` / `env:KEY` / `F(...)` values stay typed `Arg::Expr`. Each shape
// carries its own continuation guard: strict PEG commits per-alternative, so a
// shared trailing guard would strand input like `$a.b` on the `variable`
// prefix. The guard requires more value content next (`{{` or a raw char past
// any assignment boundary); a boundary, `;`, `}`, linebreak, comment, or end
// lets the lone expression stand, and anything else falls through to
// `raw_fragments` as literal text.
assign_expr = { (variable ~ !assign_expr_cont | key_path ~ !assign_expr_cont | env_read ~ !assign_expr_cont | func_call ~ !assign_expr_cont) }
assign_expr_cont = _{ "{{" | (!assignment_boundary ~ raw_text_char) }
// Bounded raw span: everything else to the instruction boundary as fragments,
// preserving `{{ }}` templates and quoted regions with exact bytes. Stops at
// linebreak, `;`, `}` (single-line block bodies), comments, and lone `{`
// (block starts); `{{` always opens a template fragment instead.
raw_fragments = { raw_fragment+ }
raw_fragment = _{ quoted_string | templated_arg | raw_text }
raw_text = @{ raw_text_char+ }
raw_text_char = _{ !assignment_boundary ~ (!linebreak ~ !";" ~ !"}" ~ !"{" ~ !"//" ~ !"/*" ~ ANY) }

ws = _{ (WHITESPACE | linebreak)* }