
## Grammar

PEG-style grammar for the `.ops` DSL. The Rust parser should implement this exactly.

```peg
File            = (TopLevel)*
TopLevel        = Comment | UseDecl | EnumDecl | EnvBlock | ScriptsBlock | DeclBlock | PipelineDecl

EnumDecl        = '@enum' IDENT '(' IDENT (',' IDENT)* ')'

Comment         = '#' REST_OF_LINE
                | '###' (ANY_CHAR)* '###'
DocComment      = '##' (ANY_CHAR)* '##'               # top-level: summary before next declaration; inline: scoped annotation

UseDecl         = '@use' Path ('as' Alias)?
Path            = IDENT ('.' IDENT)* ('/' IDENT)* ('.ops')?
Alias           = IDENT

EnvBlock        = 'env' '=' '{' EnvEntry* '}'
EnvEntry        = ('@' DslType)? ENV_KEY (':' EnvDefault)?
EnvDefault      = '[' (Atom ','?)* ']' | Literal
ENV_KEY         = [A-Z_][A-Z0-9_]*

ScriptsBlock    = 'scripts' '=' '{' ScriptEntry* '}'
ScriptEntry     = SCRIPT_PATH ':' BlockRef
BlockRef        = '@' IDENT | IDENT                    # @name references a block; plain name is group name

DeclBlock       = DeclName '=' '{' DeclBody '}'
DeclName        = IDENT ('.' IDENT)*
DeclBody        = (DeclEntry | NestedDecl)*
DeclEntry       = DECL_KEY ':' DeclValue
NestedDecl      = IDENT '=' '{' DeclBody '}'
DeclValue       = '{' (DECL_KEY ':' Atom)* '}'
                | '[' IDENT* ']'
                | Atom

PipelineDecl    = PipelineMarkers? 'main'? '@' BlockName BlockMods? ImperBlock
ImperBlock      = '{' Statement* '}'
BlockName       = IDENT ('-' IDENT)*
PipelineMarkers = ('remote' | 'dangerous' | 'deprecated')+
BlockMods       = (NeedsClause | WantsClause)*
NeedsClause     = 'needs' '[' NeedRef* ']'
WantsClause     = 'wants' '[' NeedRef* ']'
NeedRef         = '$' IDENT | '@' IDENT | IDENT

Statement       = DocComment
                | IfBlock | TryCatch | WithBlock | MatchBlock
                | EachBlock | NeedsThenBlock | HasBlock | BlockOpStmt
                | TypedAssign | ExportAssign | Step | ThrowStmt
                | ThrowErrorStmt

IfBlock         = 'if' Condition '{' Statement* '}'
                  ('elif' Condition '{' Statement* '}')*
                  ('else' '{' Statement* '}')?
Condition       = '!'? (InfixExpr | Step | VarRef)

TryCatch        = 'try' '{' Statement* '}'
                  ('catch' IDENT? '{' Statement* '}')?
                  ('finally' '{' Statement* '}')?

WithBlock       = 'with' Step '{' Statement* '}'

MatchBlock      = 'match' '$' IDENT '{' MatchArm* '}'
MatchArm        = MatchPattern '{' Statement* '}'
MatchPattern    = Literal | IDENT | '*' | '_'

EachBlock       = 'each' '$' IDENT 'in' Iterable '{' Statement* '}'

NeedsThenBlock  = 'needs' '[' NeedRef* ']' 'then' '{' Statement* '}'
                  ('catch' IDENT '{' Statement* '}')?

HasBlock        = 'has' '[' NeedRef* ']' 'then' '{' Statement* '}'
                  ('missing' IDENT '{' Statement* '}')?

BlockOpStmt     = BLOCK_OP Invocation '{' Statement* '}'

ThrowStmt       = 'throw' ErrorKeyLit (STRING_LIT | TEMPLATE_LIT)
                | 'throw' VarRef                                        # rethrow: throw $error
ThrowErrorStmt  = 'throw' '@error' '{' ErrorBody '}'
ErrorBody       = (ErrorField)*
ErrorField      = ('key' | 'code' | 'message' | 'help' | 'context') ':' Atom

TypedAssign     = '@' DslType ENV_KEY '=' Step
ExportAssign    = ENV_KEY '=' Step

Step            = OpPath Invocation StepMods?

OpPath          = IDENT ('.' IDENT)*
Invocation      = '(' ArgList? ')'
ArgList         = (NamedArg | SpreadArg | PositionalArg)*
NamedArg        = IDENT ':' ArgValue
SpreadArg       = '...' '$' IDENT
PositionalArg   = Atom
ArgValue        = '{' (ObjKey ':' Atom)* '}' | Atom
ObjKey          = IDENT | STRING_LIT

StepMods        = (NeedsClause | ExportClause | AsClause)*
ExportClause    = 'export' ENV_KEY
AsClause        = 'as' IDENT

InfixExpr       = InfixAtom (INFIX_OP InfixAtom)*
InfixAtom       = '(' InfixExpr ')' | Step | VarRef | Literal

Iterable        = STRING_LIT | TEMPLATE_LIT | '$' IDENT | Step
VarRef          = '$' IDENT ('.' IDENT)*               # $VAR, $step.key, $result.data.severity
InterpolExpr    = VarRef | Literal
Atom            = Literal | VarRef | IDENT
Literal         = STRING_LIT | TEMPLATE_LIT | NUMBER | 'true' | 'false'
ErrorKeyLit     = IDENT ('-' IDENT)*                    # kebab-case, <32 chars, no spaces

DslType         = BaseType ListMod?
BaseType        = 'string' | 'int' | 'number' | 'bool'
                | 'email' | 'url'
                | 'file-exists' | 'dir-exists' | 'file-opt' | 'dir-opt'
                | 'command' | 'glob' | 'json' | 'file-json'
                | 'duration' | 'semver' | 'version' | 'secret'
                | 'time' | 'time-iso-utc' | 'time-iso-tz'
                | 'time-unix' | 'time-unix-sec' | 'time-unix-ms' | 'time-unix-nano'
                | 'time-http' | 'time-rfc' | 'time-rfc-utc' | 'time-rfc-tz'
                | 'enum' '(' IDENT (',' IDENT)* ')'
                | EnumRef
                | InlineRegex | RegexRef
                | InlineCustom | CustomRef
ListMod         = '[' ListSep? ']'
ListSep         = 'space' | 'flag' | STRING_LIT
EnumRef         = 'enum' '.' IDENT
                | IDENT '.' 'enum' '.' IDENT
InlineRegex     = '/' REGEX_BODY '/'
RegexRef        = '\' IDENT '\'
InlineCustom    = '(' '{' CUSTOM_BODY '}' ')'
CustomRef       = '(' IDENT ('.' IDENT)* ')'

STRING_LIT      = '"' [^"]* '"' | "'" [^']* "'"   # no escape sequences; use the other quote type or template strings
TEMPLATE_LIT    = '`' (TEXT | '{' InterpolExpr '}')* '`'
NUMBER          = [0-9]+ ('.' [0-9]+)?
IDENT           = [a-zA-Z_][a-zA-Z0-9_-]*
DECL_KEY        = [a-zA-Z_][a-zA-Z0-9/_-]*      # no dot -- dots are namespace separators
BLOCK_OP        = (registry-driven: ops with --block flag)
INFIX_OP        = (registry-driven: ops with --infix flag + symbol aliases)
ERROR_CODE      = ERROR_PREFIX HEX HEX HEX
ERROR_PREFIX    = [eE]
HEX             = [0-9A-F]
REGEX_BODY      = ([^/] | '\\/')*
CUSTOM_BODY     = ([^{}] | '{' CUSTOM_BODY '}')*
```
