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.
| Construct | Stage 0 (native) | Stage 1 (self-hosted) |
|---|---|---|
let / let mut binding | Yes | Yes |
Bare re-assignment (x = 5 without let) | Yes — legal only against a binding declared mut | Yes — same rule, same enforcement |
if / while / when / function bodies / closures | Both brace { } and Ruby-style then/do ... end | Both — same dual support |
elif | Yes (desugars to nested else { if }) | Yes — same desugaring, both delimiter forms |
| Function definition | fn 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 statement | Yes (MemberAssign) | Yes — same node shape, lowers to the same set host call |
require / ensure / assert | Yes — all three parse to one Assert node | Yes — same, identical node shape |
rule Head(args) :- Body. / rule Head(args). declarative syntax | Yes — real RuleDecl grammar, lowers to rule_add, backtracking works via solve | Yes — same lowering, same behavior; verified byte-identical output against Stage 0 on the same program |
goal NAME { RuleCall, ... } declarative syntax | Yes — real GoalDecl grammar | Yes — same node shape |
class NAME [inherits PARENT] { field/traits/make ... } / do ... end | Yes — 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 anything | No node shape at all — not recognised in any form |
constrain / reasoning / relationship / case | Tokenised, parsed, discarded as no-ops | Not recognised — hard parse error |
pursue(x) / activate(x) | Yes — real call forms, run the named plan's bound closures | Yes — same |
budgeted(ms[, existing]) { ... } / do ... end | Yes — an expression, either delimiter | Yes — 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/falseare not dedicated literal tokens in either lexer — both lex them as plain identifiers; Stage 1's parser turns the identifier into aBoolAST 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
- Paradigms Guide — what each of these constructs is actually for.
- Standard Library & Host Function Reference — everything reachable only through a function call, not through grammar.
- Capabilities & Honest Limitations — the full, undiluted list of gaps, including the one real remaining dialect split (logic/goal declarative syntax).
- PatLang Portfolio — working, running code for everything summarised here.