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:

  • Eq includes bare "=" as well as "==" — a bare = means assignment at the start of a statement (see Assign, near the top) but equality mid-expression; Assign is tried as its own Stmt alternative before the generic ExprStmt fallback gets a chance to consume it as a comparison.
  • Two parallel expression towers, Expr/Pipeline/.../Postfix and CondExpr/CondPipeline/.../CondPostfix, structurally identical except that only the plain Postfix tier includes TrailingClosure. That's the whole mechanism behind f(x) { ... } attaching as a closure argument everywhere except inside an if/while condition, where the same trailing block must not be swallowed — If/While call into the Cond* tower, everything else uses the general one.
  • RuleDecl requires no keyword reservation for ruleStmt'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 and ExprStmt picks 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.