PatLang Language Reference: Grammar & Syntax
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 the logic/goal-oriented declarative syntax, which remains genuinely Stage-0-only and is now understood to need real new engine work (rule expansion, backtracking), not just re-parsing, before it's worth reviving in Stage 1 — see Capabilities & Honest Limitations. 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 |
fact/query/goal/rule as declarative syntax | Tokenised and silently swallowed as no-ops; only the call forms fact(...)/query(...)/goal(...) do anything | No node shape at all — not recognised in any form |
constrain / pursue / reasoning / relationship / activate / case | Tokenised, parsed, discarded as no-ops | Not recognised — hard parse error |
budgeted(ms[, existing]) { ... } / do ... end | Yes — an expression, either delimiter | Yes — same, either delimiter |
The remaining gap is deliberately scoped, not an oversight: reviving the declarative logic/goal syntax as genuinely working (not just re-parsed) in both stages needs real rule-expansion-and-backtracking engine work first, since the flat single-hop fact lookup behind today's call forms is too weak to back it either way. That's tracked as its own future phase — see the paradigms guide's logic/goal-oriented section for what the call forms can and can't do today.
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
Neither stage has dedicated class/new/send grammar — object orientation is entirely host-function based (new, get, set_var, send), called like any other function. 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.