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.

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
fact/query/goal/rule as declarative syntaxTokenised and silently swallowed as no-ops; only the call forms fact(...)/query(...)/goal(...) do anythingNo node shape at all — not recognised in any form
constrain / pursue / reasoning / relationship / activate / caseTokenised, parsed, discarded as no-opsNot recognised — hard parse error
budgeted(ms[, existing]) { ... } / do ... endYes — an expression, either delimiterYes — 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/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

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