oxdock-parser 0.14.0-alpha

Parser and AST definitions for the OxDock DSL.
Documentation
WHITESPACE = _{ " " | "\t" }
linebreak  = _{ "\r\n" | "\n" }
COMMENT    = _{ line_comment | block_comment }
line_comment = _{ "//" ~ (!linebreak ~ ANY)* }
block_comment = _{ "/*" ~ (block_comment | !"*/" ~ ANY)* ~ "*/" }

// Hash comments are only valid at the start of a line (possibly indented)
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 | 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 | 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 ~ expr ~ gap ~ ")" }

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 }
// Pipe handle: `pipe:NAME` evaluates to the named pipe. Must precede
// `bare_word` in `expr_atom` (which would otherwise match just `pipe` and
// strand `:NAME`). Deliberately NOT mirrored in `unquoted_arg`: bare command
// arguments keep their literal reading, so existing `pipe:...` text there is
// unaffected; the operator form lives in expression positions (LET RHS,
// CALL args, conditions) plus `WITH_IO` bindings.
pipe_read = { "pipe" ~ ":" ~ pipe_name }
func_call = ${ ident ~ gap ~ "(" ~ gap ~ (expr ~ gap ~ ("," ~ gap ~ expr ~ gap)*)? ~ gap ~ ")" }
variable = ${ "$" ~ ident }
key_path_segment = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHA | "_" | ASCII_DIGIT)* | ASCII_DIGIT+ }
key_path = ${ "$" ~ ident ~ ("." ~ key_path_segment)+ }
list_literal = ${ "[" ~ gap ~ (expr ~ gap ~ ("," ~ gap ~ expr ~ gap)*)? ~ gap ~ "]" }
map_entry = ${ (quoted_string | bare_word) ~ gap ~ ":" ~ gap ~ ws? ~ gap ~ expr }
map_literal = ${ "{" ~ gap ~ ws? ~ gap ~ (map_entry ~ gap ~ (ws? ~ "," ~ ws? ~ map_entry ~ gap)*)? ~ ws? ~ gap ~ "}" }
string_literal = { quoted_string }
expr_atom = { parenthesized_expr | func_call | key_path | variable | env_read | pipe_read | list_literal | map_literal | 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. Variant mapping lives in
// Rust via TypeKind::from_str; unknown tags become UnknownType errors.
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 }
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): UPPERCASE function names only (parity with host
// natives like GLOB/LOAD_TOML). Lowercase names fail at lex time.
func_keyword = _{ "FUNC" ~ !(ASCII_ALPHANUMERIC | "_") }
call_keyword = _{ "CALL" ~ !(ASCII_ALPHANUMERIC | "_") }
return_keyword = _{ "RETURN" ~ !(ASCII_ALPHANUMERIC | "_") }
while_keyword = _{ "WHILE" ~ !(ASCII_ALPHANUMERIC | "_") }
break_keyword = _{ "BREAK" ~ !(ASCII_ALPHANUMERIC | "_") }
continue_keyword = _{ "CONTINUE" ~ !(ASCII_ALPHANUMERIC | "_") }
func_ident = @{ ASCII_ALPHA_UPPER ~ (ASCII_ALPHA_UPPER | ASCII_DIGIT | "_")* }
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 }
call_statement = ${ call_keyword ~ sep ~ func_ident ~ gap ~ "(" ~ gap ~ (expr ~ gap ~ ("," ~ gap ~ expr ~ gap)*)? ~ gap ~ ")" }
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 ~ (call_statement | 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" }
pipe_binding = { "pipe" ~ ":" ~ pipe_name | dollar_ident }
pipe_name = @{ (ASCII_ALPHANUMERIC | "_" | "-")+ }

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

// 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 | pipe_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))* }
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. 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)* }

// 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` / `CALL(...)` 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)* }