Skip to content

Grammar

The surface grammar of the .forage language. The Syntax reference explains each construct in prose; this is the formal version. The parser is recursive-descent, so precedence is the rule order shown, no separate precedence table.

Notation: := defines a production, | alternates, ? optional, * zero-or-more, + one-or-more, ( … ) groups. Quoted 'foo' and uppercase STRING/INT are terminals; lowercase names are non-terminals.

Lexical

STRING     := "..."                  // may contain {…} interpolations
INT        := -?[0-9]+
FLOAT      := -?[0-9]+\.[0-9]+
BOOL       := 'true' | 'false'
NULL       := 'null'
DATE       := YYYY-MM-DD
REGEX      := /pattern/flags          // flags ⊆ {i,m,s,u}

Ident      := lowercase-starting identifier
TypeName   := uppercase-starting identifier
Keyword    := reserved word

DollarVar  := '$' Ident               // $input and $secret are reserved roots

[*] (wildcard), ?. (optional chain), (bind), and (case arm) are single tokens. // and /* … */ comments are stripped by the lexer.

Top-level

A file is a flat sequence of forms; order is not load-bearing.

forage_file    := top_level_form*

top_level_form := recipe_header | type_decl | enum_decl | input_decl
                | emits_decl | secret_decl | fn_decl | auth_block
                | expect_block | statement | compose_block

recipe_header  := 'recipe' STRING
emits_decl     := 'emits' ( TypeName ( '|' TypeName )* )?

Types, enums, inputs, secrets

type_decl    := 'share'? 'type' TypeName ( 'aligns' alignment_uri )*
                '{' field_list '}'
field_list   := ( field ( ';' | ',' )? )*
field        := field_name ':' field_type '?'? ( 'aligns' alignment_uri )?
field_type   := 'String' | 'Int' | 'Double' | 'Bool'
              | '[' field_type ']' | 'Ref' '<' TypeName '>' | TypeName

enum_decl    := 'share'? 'enum' TypeName '{' ( variant ( ',' | ';' )? )* '}'
variant      := Ident | TypeName

input_decl   := 'input' field_name ':' field_type '?'?
secret_decl  := 'secret' Ident

alignment_uri := alignment_path? '/' alignment_path? | alignment_path
alignment_path := alignment_segment ( '.' alignment_segment )*

Type extension (extends @author/Name@v1) and the details of aligns are covered in Compose and Align.

Functions

fn_decl     := 'share'? 'fn' Ident '(' param_list? ')' '{' fn_body '}'
param_list  := DollarVar ( ',' DollarVar )*
fn_body     := let_binding* extraction
let_binding := 'let' DollarVar '=' extraction ';'?

A function body is zero or more let bindings followed by one trailing expression, its return value.

Statements

statement   := step | visit | emit | for_loop

emit        := 'emit' TypeName '{' ( emit_binding ( ';' | ',' )? )* '}' ( 'as' DollarVar )?
emit_binding := field_name '←' extraction

for_loop    := 'for' DollarVar 'in' extraction '{' statement* '}'

step        := 'step' field_name '{' step_field* '}'
step_field  := 'method' STRING
             | 'url' STRING
             | 'headers' '{' ( STRING ':' STRING ( ',' | ';' )? )* '}'
             | 'body' '.' ( 'json' | 'form' | 'raw' ) '{' body_contents '}'
             | 'paginate' pagination_block
             | 'extract' '.' 'regex' '{' regex_extract_body '}'

visit       := 'visit' field_name '{' visit_field* '}'
visit_field := 'url' STRING
             | paginate_action 'until' 'noProgressFor' '(' INT ')'
                 ( 'maxIterations' INT )? ( 'iterationDelay' NUMBER )?
paginate_action := 'scroll' | 'click' STRING

url, header values, raw bodies, and string-typed bindings are templates: any {…} segment is re-lexed as an extraction. step / paginate (author requests) and visit (drive a browser) are detailed in Engines & pagination and the Syntax reference.

Composition

A recipe body is either statements or a composition, never both.

compose_block := 'compose' recipe_ref ( '|' recipe_ref )+
recipe_ref    := STRING

Expressions

One grammar for emit bindings, step values, template interpolations, fn bodies, and case arms. Listed low-to-high precedence.

extraction  := pipe
pipe        := additive ( '|' transform_call )*
additive    := multiplicative ( ( '+' | '-' ) multiplicative )*
multiplicative := unary ( ( '*' | '/' | '%' ) unary )*
unary       := '-' unary | postfix
postfix     := primary ( '[' extraction ']' )*       // on calls / struct literals

primary     := 'case' path 'of' '{' case_arms '}'
             | struct_literal | regex_literal
             | '(' extraction ')' | call | path | literal

struct_literal := '{' ( field_name ':' extraction ( ',' | ';' )? )* '}'
call        := Ident '(' arg_list? ')'
transform_call := ( Ident | Keyword ) ( '(' arg_list? ')' )?
arg_list    := extraction ( ',' extraction )*

case_arms   := ( case_label '→' extraction ( ',' | ';' )? )*
case_label  := Ident | TypeName | Keyword | BOOL | NULL | INT | STRING

literal     := STRING | INT | FLOAT | BOOL | NULL

path        := path_head path_step*
path_head   := '$' | '$input' | '$secret' '.' Ident | DollarVar
path_step   := '.' path_field | '?.' path_field | '[' INT ']' | '[*]'
path_field  := Ident | TypeName | Keyword

| is lowest precedence, so $x * 28 | toString parses as ($x * 28) | toString.