PatLang Language Reference: Grammar & Syntax

For new readers

This is a reference page, not an introduction — it assumes you already know roughly what PatLang looks like and want the precise syntax rules. "Stage 0" and "Stage 1" below refer to two separate, real parser implementations (one written in Rust, one written in PatLang itself, explained further down) that mostly — but not entirely — accept the same syntax; the table just below catalogues where they agree and differ. If you want a gentler, example-led tour first, see the Paradigms Guide or Use Cases.

PatLang exists in two real implementations, not one language with two front-ends bolted on. Stage 0 is the native Rust parser (rust-runtime/src/parser.rs) that has been there from the start. Stage 1 is the self-hosted parser (self_hosting/lib/parser.patlang) — PatLang source that parses PatLang source, itself compiled once via rustc into patc1.exe and used for all ordinary compiles thereafter. The two accept genuinely different, overlapping subsets of syntax. This page documents each precisely, and is explicit about where they diverge rather than presenting a single blended grammar that would be wrong for one stage or the other.

Compatibility at a glance

A dialect audit (mid-2026) found that self-hosting had quietly narrowed Stage 1 relative to Stage 0 — block delimiters, mutability, and member-assignment had all drifted apart. That gap has since been closed for everything except fact/query as standalone declarative statements (as opposed to the call forms fact(...)/query(...), which both stages support identically) — see Capabilities & Honest Limitations. rule/goal declarative syntax, by contrast, is real, working, backtracking-capable syntax in both stages as of the PEG-grammar work — see Rule Declarations below; this table previously described a pre-that-work state and has been corrected. The table below reflects the current, converged state.

ConstructStage 0 (native)Stage 1 (self-hosted)
let / let mut bindingYesYes
Bare re-assignment (x = 5 without let)Yes — legal only against a binding declared mutYes — same rule, same enforcement
if / while / when / function bodies / closuresBoth brace { } and Ruby-style then/do ... endBoth — same dual support
elifYes (desugars to nested else { if })Yes — same desugaring, both delimiter forms
Function definitionfn NAME(...) { }, JS-style function NAME(...) { }, and make a function called NAME ... end/{ }make a function called NAME ... end/{ } only — no bare fn/function form
member.prop = value as a statementYes (MemberAssign)Yes — same node shape, lowers to the same set host call
require / ensure / assertYes — all three parse to one Assert nodeYes — same, identical node shape
rule Head(args) :- Body. / rule Head(args). declarative syntaxYes — real RuleDecl grammar, lowers to rule_add, backtracking works via solveYes — same lowering, same behavior; verified byte-identical output against Stage 0 on the same program
goal NAME { RuleCall, ... } declarative syntaxYes — real GoalDecl grammarYes — same node shape
class NAME [inherits PARENT] { field/traits/make ... } / do ... endYes — real ClassDecl grammar, either delimiter (begin also accepted as a do synonym)Yes — same lowering, same behavior, same delimiter options
fact/query as standalone declarative statements (not call forms)Tokenised and silently swallowed as no-ops; only the call forms fact(...)/query(...) do anythingNo node shape at all — not recognised in any form
constrain / reasoning / relationship / caseTokenised, parsed, discarded as no-opsNot recognised — hard parse error
pursue(x) / activate(x)Yes — real call forms, run the named plan's bound closuresYes — same
budgeted(ms[, existing]) { ... } / do ... endYes — an expression, either delimiterYes — same, either delimiter

fact/query as standalone declarative statements remain the one genuinely open gap, deliberately scoped rather than an oversight: unlike rule/goal, which gained real dedicated grammar productions, fact/query are only ever meaningful as call forms today. See the paradigms guide's logic/goal-oriented section for what the call forms can and can't do today.

Rule declarations

Real, working, backtracking-capable declarative syntax in both stages — not sugar over a flat single-hop lookup. rule Head(args) :- Body1, Body2. declares a rule; a bare rule Head(args). declares a ground fact. Both desugar to a call to the same rule_add(head_pred, [head_args...], [[pred, [args...]], ...]) host function the older call-form syntax already used, so mixing the two styles in one program is safe. Query with solve("predicate_name", [args]) — the predicate name is always a string; an argument starting with an uppercase letter is treated as an unbound logic variable, everything else as a ground value:

rule parent(alice, bob).
rule parent(bob, carol).
rule grandparent(X, Z) :- parent(X, Y), parent(Y, Z).
let results = solve("grandparent", ["alice", "Z"])
print(results)   # [[alice, carol]]

goal NAME { RuleCall, ... } declares a named goal from one or more rule-call bodies, and is exercised via the call forms pursue(NAME)/activate(NAME) — see the goal-oriented demo for the full worked example. See Inductive Synthesis for how these same rule declarations get generated automatically from BDD scenarios, rather than hand-written.

Statements

Binding and mutability

let x = 10          # immutable
let mut y = 10       # mutable
y = y + 1            # legal: y was declared mut
x = x + 1            # error: cannot assign twice to immutable variable `x`
let x = x + 1        # legal: this is a fresh `let`, which always shadows

let introduces a binding as immutable by default; let mut marks it reassignable. Bare NAME = expr (no let) is a reassignment, not a declaration, and is only legal against a binding already declared mut — both stages enforce this identically, at lowering time, by walking each function's locals and checking the declared-mutable flag of the most recent binding for that name. Re-let-ing a name is always allowed regardless of the shadowed binding's mutability, since it introduces a fresh binding rather than reassigning the old one. Function parameters are always reassignable without needing mut — this is a deliberate scoping choice to avoid forcing mut onto every parameter that a function body happens to update in place (a loop counter passed in, for instance), not a gap.

Control flow

# Brace form, both stages
if n > 0 { print("positive") } else { print("not positive") }

# Ruby-style form, both stages
if n > 0 then
  print("positive")
elif n < 0 then
  print("negative")
else
  print("zero")
end

while i < 10 do
  i = i + 1
end

elif desugars to a nested else { if ... } in both stages and both delimiter forms — brace-form elif is written elif cond { ... }, matching the word form's elif cond then ... .

Function definitions

# Stage 0 only
fn add(a, b) { return a + b }
function add(a, b) { return a + b }   # JS-style spelling, same node

# Both stages, either delimiter
make a function called add takes a, b returns sum
  return a + b
end

make a function called add takes a, b returns sum {
  return a + b
}

The returns hint clause is a documentation hint parsed by both stages — it is not bound to a name and does not participate in type checking. The bare fn/JS-style function forms remain Stage-0-only; only the make a function called DSL form is portable.

Closures

# Both stages, either delimiter
let inc = |x| { x + 1 }
let inc = |x| do x + 1 end

Events

# Both stages, either delimiter
when "tick" { print("tick") }
when "tick" do
  print("tick")
end

emit and event dispatch are host-function behaviour, not separate grammar — see the Standard Library & Host Function Reference.

Contracts

make a function called divide takes a, b returns q
  require b != 0
  let q = a / b
  ensure q * b == a
  return q
end

require, ensure, and bare assert all parse to the same Assert statement, distinguished only by a kind tag ("require"/"ensure"/"assert") that becomes part of the failure message. All three lower to one host call, contract_check — see the Paradigms Guide for the design rationale.

Expressions

Literals

  • Numbers: a run of digits, optionally followed by a single . and more digits (42, 3.14). There is no hex, octal, binary, scientific-notation, or underscore-separated literal syntax in either stage, and no source syntax for BigInt, Rational, or Complex — those exist only as runtime promotion targets (see the numeric tower discussion in the Paradigms Guide).
  • Strings: double-quoted only, with escapes \n \t \r \" \\. No single-quoted strings, no triple-quoted strings, no interpolation.
  • Booleans: true/false are not dedicated literal tokens in either lexer — both lex them as plain identifiers; Stage 1's parser turns the identifier into a Bool AST node one step later than Stage 0 does.
  • Lists: [a, b, c], both stages.

Operators

Arithmetic: + - * / %. Comparison: == != < <= > >=. Logical: the words and, or, not — there is no symbolic &&/|| form in either dialect (Stage 0 does lex a standalone ! as logical not, in addition to the word). Precedence in both stages runs, high to low: unary not/-, * / %, + -, comparisons, and, or — Stage 0 implements this as a single Pratt-parser precedence table, Stage 1 as an equivalent hand-written recursive-descent chain; the resulting precedence is the same. Stage 0 additionally supports a pipeline operator, lhs |> rhs, sugar for rhs(lhs).

Comments

# to end of line, in both stages. Neither has block comments.

Member access, indexing, calls

obj.prop
obj.method(a, b)
xs[0]
obj.prop = value   # both stages

new/get/set_var/send are host functions, called like any other function — no dedicated grammar. class IS dedicated grammar (both stages): class NAME [inherits PARENT] { field NAME = EXPR ... traits A, B ... make a function called NAME ... end ... }, with do ... end (or begin ... end) accepted as an alternative to { } for the whole block, matching every other dual-form block in the language. It's sugar over the same host-function store, though — a class declares field defaults, methods, and composed traits once; new(class, id) still does the actual instantiation. See the Paradigms Guide.

Cooperative time-budget blocks

let mut handle = false
let r = budgeted(16) do
  while some_condition do
    process_heavy_data()
  end
end

let r2 = budgeted(16, handle) { process_more() }   # resume a paused fiber

budgeted(ms) and budgeted(ms, existing) are both genuine expressions (parsed in primary-expression position, not a dedicated statement), usable bare (result discarded, like any other expression statement) or bound via let. The optional second argument accepts a fiber id from a previous call's ["paused", id] result — pass a variable initialised to false as the "nothing to resume yet" sentinel, since PatLang has no unit/nil literal to write directly. The result is always a two-element list: ["done", value] once the body finishes, or ["paused", fiber_id] if its budget ran out first. See the concurrency section of the Paradigms Guide for the semantics and a full resumption-loop example.

Statement separators

Newline, ;, and , are all accepted as statement separators in Stage 0; a trailing . also terminates declarative-DSL-style lines where those are recognised. Stage 1 relies on newlines and the block keywords (end, else) to delimit statements.

See also