The canonical PEG grammar
The actual grammar file, published in full: docs/grammar/patlang-full.peg, the single canonical Parsing Expression Grammar behind the PEG grammar validator dev tool. It's interpreted identically by two independent engines — self_hosting/lib/peg.patlang and rust-runtime/src/ir/peg.rs — that share nothing but this file and a thin per-side token adapter, giving PatLang a third, structurally independent answer to "is this valid PatLang," distinct from the two hand-written recursive-descent parsers (native Rust, self-hosted PatLang) that actually compile programs.
Standard PEG notation: Name <- Expression defines a rule; space-separated items are a sequence; / is ordered choice (try the left alternative first, only fall through to the right on failure — this is what distinguishes PEG from plain BNF/EBNF, and is exactly what lets rule Head(...) :- Body. and rule(...)-as-an-ordinary-call share the rule keyword with no reservation needed, see below); */+/? are the usual repetition/optional operators; quoted text is a literal terminal; a bare capitalized name is a reference to another rule.
Three things worth noticing while reading it, each a place where PatLang's grammar depends on something a plain PEG grammar has no native way to express, because the hand-written parsers handle it with parser-level state rather than grammar shape — see the dev-tooling write-up for the full reasoning behind each:
Eqincludes bare"="as well as"=="— a bare=means assignment at the start of a statement (seeAssign, near the top) but equality mid-expression;Assignis tried as its ownStmtalternative before the genericExprStmtfallback gets a chance to consume it as a comparison.- Two parallel expression towers,
Expr/Pipeline/.../PostfixandCondExpr/CondPipeline/.../CondPostfix, structurally identical except that only the plainPostfixtier includesTrailingClosure. That's the whole mechanism behindf(x) { ... }attaching as a closure argument everywhere except inside anif/whilecondition, where the same trailing block must not be swallowed —If/Whilecall into theCond*tower, everything else uses the general one. RuleDeclrequires no keyword reservation forrule—Stmt's ordered choice just tries the declarative form first; if a `rule` token turns out to be followed by `(` and shaped like an ordinary call instead, that alternative fails cleanly andExprStmtpicks it up, exactly PEG's own backtracking doing the disambiguation for free.
Deliberately scoped: this is Slice 2 of a staged effort, covering the full actively-used grammar — including goal/pursue/activate, added the same day the real syntax itself landed, and when/budgeted/contracts, present since Slice 2's original pass. activate needed its own small design decision, not just a mechanical addition: a single shared production using the general expression tower for its own argument would have let if activate(p) { ... }'s trailing { } get swallowed as a closure argument to activate(p) instead of being recognised as the if's own body — exactly the ambiguity the Expr/CondExpr split above exists to prevent. Fixed with a dedicated CondActivateExpr using CondExpr for its argument, mirroring the same split every other condition-position construct already uses. Deliberately excluded: the legacy logic-DSL forms (fact, bare goal with no dependencies, reasoning, constrain, relationship, case, no-paren query) that the real parsers tokenise and silently discard as no-ops — there's nothing there to validate against yet, since they don't currently do anything when compiled. Also excluded, and structurally impossible for one static grammar file to express at all: syntax NAME { ... }, PatLang's mechanism for a single source file to define its own dynamic, per-file grammar extensions.
A genuine, pre-existing gap worth naming plainly rather than burying: this grammar never enforces a statement separator (newline or ;) between consecutive statements — let x = 5 print(x), with no separator at all, is wrongly accepted, because Stmt* just tries the next statement immediately regardless of what actually sits between the two. Confirmed not specific to the goal/pursue/activate work above (the same ordinary let/print pair triggers it) — a real limitation of the grammar as published, not a new one.
Grammar source
Program <- Stmt*
Stmt <- Let / Contract / When / RuleDecl / GoalDecl / FuncDef / If / While
/ MemberAssign / Assign / Return / PrintStmt / ExprStmt
Let <- "let" "mut"? Identifier "=" Expr
MemberAssign <- Identifier ("." Identifier)+ "=" Expr
Assign <- Identifier "=" Expr
Contract <- ("require" / "ensure" / "assert") Expr
When <- "when" Identifier Block
Return <- "return" Expr?
PrintStmt <- "print" Expr
ExprStmt <- Expr
RuleDecl <- "rule" Identifier "(" ArgList ")" (":-" RuleBody)? "."
ArgList <- (Identifier ("," Identifier)*)?
RuleBody <- RuleCall ("," RuleCall)*
RuleCall <- Identifier "(" ArgList ")"
# goal NAME { dep1(args), dep2(args) } -- names a target state as a list
# of fact-terms, reusing RuleCall's shape (same convention a rule body's
# goals already use). "goal" deliberately stays non-reserved, exactly
# like "rule": ordered choice tries this production first and falls
# through to ExprStmt (an ordinary call) on failure, e.g. goal(x).
GoalDecl <- "goal" Identifier "{" (RuleCall ("," RuleCall)*)? "}"
FuncDef <- MakeForm / FnForm / FunctionForm
FnForm <- "fn" Identifier "(" ParamList ")" BraceBlock
FunctionForm <- "function" Identifier "(" ParamList ")" BraceBlock
MakeForm <- "make" "a" "function" "called" Identifier
("takes" ParamList)? ("returns" Identifier)?
FuncBlock
ParamList <- (Identifier ("," Identifier)*)?
FuncBlock <- BraceBlock / FuncWordBlock
FuncWordBlock <- Stmt* "end"
Block <- BraceBlock / WordBlock
BraceBlock <- "{" Stmt* "}"
WordBlock <- ("then" / "do") Stmt* "end"
If <- IfBrace / IfWord
IfBrace <- "if" CondExpr BraceBlock ElifBraceTail? ElseBraceTail?
ElifBraceTail <- "elif" CondExpr BraceBlock ElifBraceTail?
ElseBraceTail <- "else" BraceBlock
IfWord <- "if" CondExpr ("then" / "do") Stmt* ElifWordTail* ElseWordTail? "end"
ElifWordTail <- "elif" CondExpr ("then" / "do") Stmt*
ElseWordTail <- "else" Stmt*
While <- WhileBrace / WhileWord
WhileBrace <- "while" CondExpr BraceBlock
WhileWord <- "while" CondExpr ("do" / "then") Stmt* "end"
# General expression tower (allows a trailing '{ }' to attach to a call
# as sugar for a closure argument, e.g. f(x) { ... }).
Expr <- Pipeline
Pipeline <- Or (PipeOp Or)*
PipeOp <- "|>" / ("|" ">")
Or <- And ("or" And)*
And <- Eq ("and" Eq)*
Eq <- Cmp (("==" / "!=" / "=") Cmp)*
Cmp <- Bitwise (("<=" / ">=" / "<" / ">") Bitwise)*
Bitwise <- Shift (("band" / "bxor" / "bor") Shift)*
Shift <- Add (("shl" / "shr") Add)*
Add <- Mul (("+" / "-") Mul)*
Mul <- Unary (("*" / "/" / "%") Unary)*
Unary <- ("not" / "bnot" / "-") Unary / Postfix
Postfix <- Primary PostfixOp*
PostfixOp <- Call / Index / Member / TrailingClosure
Call <- "(" (Expr ("," Expr)*)? ")"
Index <- "[" Expr "]"
Member <- "." Identifier
TrailingClosure <- BraceBlock
# PursueExpr/ActivateExpr must be tried before the bare Identifier
# alternative below -- "pursue"/"activate" aren't reserved keywords
# (same non-reservation as "rule"/"goal": pursue(x)/activate(x) fall
# through to an ordinary call via Identifier+Call on failure here), so
# without this ordering a bare Identifier match on "pursue" alone would
# silently swallow just the keyword and leave the goal name/plan
# expression as a dangling, separately-parsed statement -- accepting
# some malformed input (e.g. `pursue 123`) that the real parser rejects.
Primary <- Number / String / PursueExpr / ActivateExpr / Identifier
/ "(" Expr ")" / ListLit / ClosureLit / BudgetedExpr
PursueExpr <- "pursue" Identifier
ActivateExpr <- "activate" Expr
# Condition expression tower -- structurally identical, but its Postfix
# tier deliberately excludes TrailingClosure so `if cond { ... }` doesn't
# swallow the block as a closure argument. Nested parens can freely use
# the general Expr tower again since that ambiguity only applies to the
# bare, unparenthesized condition itself.
CondExpr <- CondPipeline
CondPipeline <- CondOr ("|>" CondOr)*
CondOr <- CondAnd ("or" CondAnd)*
CondAnd <- CondEq ("and" CondEq)*
CondEq <- CondCmp (("==" / "!=" / "=") CondCmp)*
CondCmp <- CondBitwise (("<=" / ">=" / "<" / ">") CondBitwise)*
CondBitwise <- CondShift (("band" / "bxor" / "bor") CondShift)*
CondShift <- CondAdd (("shl" / "shr") CondAdd)*
CondAdd <- CondMul (("+" / "-") CondMul)*
CondMul <- CondUnary (("*" / "/" / "%") CondUnary)*
CondUnary <- ("not" / "bnot" / "-") CondUnary / CondPostfix
CondPostfix <- CondPrimary CondPostfixOp*
CondPostfixOp <- Call / Index / Member
CondPrimary <- Number / String / PursueExpr / CondActivateExpr / Identifier
/ "(" Expr ")" / ListLit / ClosureLit / BudgetedExpr
# A separate condition-tower activate production, using CondExpr (not
# Expr) for its own inner argument -- reusing the general ActivateExpr
# here would reintroduce exactly the trailing-block ambiguity the whole
# Expr/CondExpr split exists to prevent: `if activate(p) { ... }`'s
# general Expr tower would let TrailingClosure swallow the if's own
# body block as a closure argument to activate(p), leaving nothing for
# IfBrace's required BraceBlock afterward.
CondActivateExpr <- "activate" CondExpr
ListLit <- "[" (Expr ("," Expr)*)? "]"
ClosureLit <- "|" ParamList "|" Block
BudgetedExpr <- "budgeted" "(" Expr ("," Expr)? ")" Block
Colour key: rule name (definition site) · PEG operator (<-, /, *, +, ?) · "quoted literal" · # comment. Bare unstyled words are references to other rules.
See also
The dev-tooling write-up for the design decisions behind this grammar, the engine that interprets it, and the benchmark result (~1.8× the native parser's wall-clock time). The Grammar & Syntax reference for the prose, per-stage compatibility documentation this file complements rather than replaces. Goal-Oriented Programming for the newer goal/pursue/activate syntax not yet reflected in this particular grammar snapshot.