Grammar and parser
The authoritative grammar for what the parser accepts, plus a quick tour of the AST node table and the parser’s structure.
Grammar - EBNF
This grammar describes the token stream after preprocessing - file
splices (include STRING ;) are expanded before the parser runs, so
they don’t appear here. Library imports (use IDENT ;) and module
imports (import STRING [ "as" IDENT ] ;) do reach the parser: use
becomes an ImportStmt, import a ModuleImportStmt.
Terminals in CAPITALS are token classes from the lexer (see Lexer > Token types); quoted strings are keywords or punctuation that match the corresponding token’s lexeme.
program = { useStmt | moduleImport | exported | methodDef | structDef | statement } EOF ;
exported = "export" ( methodDef | structDef | constDefine ) ;
(* `export` publishes a name from a
module; it may only precede a
`func`, `def struct`, or `def const`.
Whether a program may contain
`export` at all (module vs script)
is decided at load time, not by the
grammar. *)
useStmt = "use" IDENT [ "as" IDENT ] ";" ; (* library import; the
optional "as ALIAS"
renames the namespace
at the use site *)
moduleImport = "import" STRING [ "as" IDENT ] ";" ; (* module import; the
STRING path must end
in ".j". Top-level
only - a module is a
declaration, so an
import inside a block
is a parse error *)
methodDef = "func" IDENT "(" [ paramList ] ")" block ;
paramList = param { "," param } ;
param = IDENT "as" type ;
block = "{" { statement } "}" ;
structDef = "def" "struct" IDENT "{" structField { "," structField } [ "," ] "}" ";" ;
(* top-level only;
IDENT names the struct type;
field names follow the IDENT
rule too. Hoisted before the
first top-level statement runs. *)
structField = IDENT "as" type ;
statement = defineStmt
| assignStmt
| indexAssign
| fieldAssign
| appendStmt
| returnStmt
| ifStmt
| whileStmt
| forStmt
| forEachStmt
| tryStmt
| throwStmt
| exprStmt ;
tryStmt = "try" block "catch" "(" IDENT ")" block ;
(* IDENT is the catch
binding, follows the
iteration-variable name rule
(letters only). No `finally`
in v1. *)
throwStmt = "throw" expr ";" ;
(* expr may produce any
value; convention is an
`Error` struct. *)
returnStmt = "return" [ expr ] ";" ;
constDefine = "def" "const" IDENT "as" type "init" expr ";" ; (* the const
form of defineStmt - the only `def`
an `export` may mark *)
defineStmt = "def" [ "const" ] IDENT "as" type [ "init" expr ] ";" ;
(* constants require "init" and an
uppercase name matching
[A-Z]+(_[A-Z]+)* (uppercase
chunks joined by single `_`;
no leading, trailing or
consecutive `_`); variables may
omit "init" and get zero-value,
and use the letters-only IDENT
form *)
assignStmt = VARREF "=" expr ";" ;
indexAssign = VARREF lvalueTail { lvalueTail } "[" expr "]" "=" expr ";" ;
(* l-value chain ending in `[index]`;
root is a VARREF. Tail
steps may freely mix `[index]`
and `.field`. *)
fieldAssign = VARREF lvalueTail { lvalueTail } "." IDENT "=" expr ";" ;
(* l-value chain ending in `.field`.
Root is a VARREF; tail
may mix `[index]` and `.field`. *)
lvalueTail = "[" expr "]" | "." IDENT ;
appendStmt = VARREF "[" "]" "=" expr ";" ;
(* append sugar: write-only
target meaning "the position
just past the end of the
list"; read use `e[]` is a
parse error. Only one bare
VARREF root - chained forms
like `$xs[0][]` are not supported
(yet). *)
ifStmt = "if" "(" expr ")" block
{ "elseif" "(" expr ")" block }
[ "else" block ] ;
whileStmt = "while" "(" expr ")" block ;
forStmt = "for" "(" [ defineStmt | assignStmt | ";" ]
[ expr ] ";"
[ assignNoSemi ]
")" block ;
assignNoSemi = VARREF "=" expr ; (* same shape as assignStmt without trailing ";" *)
forEachStmt = "for" "(" "def" IDENT "in" expr ")" block ;
(* iterates list elements (in order)
or map keys (insertion order);
the iteration variable is a fresh
binding in the body's scope *)
exprStmt = expr ";" ;
type = primType | listType | mapType | taskType | structType ;
primType = "int" | "float" | "string" | "bool" | "null" | "bytes" ;
listType = "list" "of" type ;
mapType = "map" "of" type "to" type ;
taskType = "task" "of" type ; (* `task of T` - handle to
a `spawn`ed computation. Same
shape as `list of T`; recurses
the same way (`task of list of
int` is legal). *)
(* recursive; nesting like
`list of list of int` and
`map of string to list of int`
falls out naturally *)
structType = IDENT [ "." IDENT ] ; (* User-defined struct type (bare
IDENT) or library-provided
namespaced struct type
(`IDENT.IDENT`). Resolved
at runtime against the
user-struct table or the
NSStructs table respectively;
unknown names are positioned
errors. *)
expr = orExpr ;
orExpr = andExpr { "or" andExpr } ;
andExpr = notExpr { "and" notExpr } ;
notExpr = "not" notExpr | compExpr ;
compExpr = addExpr { ("<" | ">" | "<=" | ">=" | "==") addExpr } ;
addExpr = mulExpr { ("+" | "-") mulExpr } ;
mulExpr = unaryExpr { ("*" | "/" | "//" | "%") unaryExpr } ;
unaryExpr = "-" unaryExpr | primary ;
primary = ( INT | FLOAT | STRING | "true" | "false" | "null"
| VARREF | qualifiedCall | qualifiedConstRef
| call | typeCall | structLit | constRef | "(" expr ")"
| listLit | mapLit | lenExpr | spawnExpr )
{ "[" expr "]" | "." IDENT } ;
(* any primary can be index- or
field-chained, including the
`.field` form *)
spawnExpr = "spawn" block ; (* launches the block as a
goroutine and evaluates
immediately to a `task of T`
where T is the body's return
type at the use site. Bare
`return;` produces `task of
null`. Value-semantics
capture: every binding visible
at the spawn site is
deep-copied into a fresh frame
at launch. *)
lenExpr = "len" "(" expr ")" ; (* polymorphic
structural-length built-in
(string / list / map /
bytes). Reserved keyword,
not a library function; the
`core` library that once
hosted it no longer exists. *)
structLit = IDENT [ "." IDENT ] "{" structLitField { "," structLitField } [ "," ] "}" ;
(* struct literal.
Bare IDENT names a user-defined
struct; `IDENT.IDENT` names a
library-provided namespaced
struct. The recogniser must
decide before the constant-name
check because struct names are
PascalCase / camelCase, not
uppercase.
The `{` after IDENT in
expression position is the
tie-breaker against `constRef`;
empty struct literals are
rejected (every field required). *)
structLitField = IDENT ":" expr ;
call = IDENT "(" [ expr { "," expr } ] ")" ;
qualifiedCall = IDENT "." IDENT "(" [ expr { "," expr } ] ")" ;
qualifiedConstRef = IDENT "." IDENT ;
(* qualifiedCall / qualifiedConstRef:
IDENT "." IDENT, then `(` decides which.
Resolved against the namespaced-builtin
/ constant registry, gated by `use lib;`
(or alias-aware equivalent). *)
typeCall = ("int" | "float" | "string" | "bool") "(" [ expr { "," expr } ] ")" ;
(* type keywords usable as calls only when
immediately followed by `(`; resolved as
convert-library builtins at runtime *)
constRef = IDENT ; (* bare-IDENT: constant reference; the
parser disambiguates `call` vs
`qualifiedCall` vs `constRef` by
peeking for "." / "(". *)
listLit = "[" [ expr { "," expr } [ "," ] ] "]" ;
mapLit = "{" [ expr ":" expr { "," expr ":" expr } [ "," ] ] "}" ;
(* `{` is also a block opener; only
legal as a map literal in
expression position, where the
parser is unambiguous *)
Semantic notes that aren’t expressed in the grammar:
- Two separate keywords:
defintroduces a binding (variable or constant);funcintroduces a method. There’s no longer any lookahead disambiguation in this area - the parser dispatches purely on the keyword. - The name in
defineStmtis a bareIDENT. Writingdef $x as intproduces a parse error with a hint to drop the$(it’s reserved for use-site references). - Operator precedence (lowest to highest):
or,and, unarynot, comparison< > <= >= ==, additive+ -, multiplicative* / %, unary-. Binary operators are left-associative;notand unary-are right-associative (not not xand--xare both valid). andandorshort-circuit: the right operand is not evaluated when the left already decides the result. Both operands must bebool.- Unary
notrequiresbool; unary-requiresintorfloat. - Comparison operators produce
bool;if/while/forconditions must bebool(no implicit truthiness). - Mixed
int/floatarithmetic promotesinttofloat; the result isfloat.%requires int operands.+on twostringvalues concatenates. /(true division) always returnsfloat(Python 3 semantics). For integer-result division use//:5 / 2 = 2.5,5 // 2 = 2.//on float operands returns the floor as a float (5.7 // 2.0 = 2.0). Line comments are#(not//), which leaves//free for the operator and lets a Jennifer file start with a shebang (#!/usr/bin/env -S jennifer run).- Floats always display with a
.so the type stays visible:5.0prints as"5.0", not"5". Seeinterpreter.DisplayFloat. - Methods may only be defined at the top level. Variable definitions, assignments, control flow, and expression statements may appear at the top level or inside a block.
- Each
block({...}) introduces a new lexical scope. A binding is visible from itsdefto the end of the enclosing block, and is inherited by inner blocks; inner scopes cannot redeclare a name already visible. foropens a private scope for itsinit,cond,step, and body so the init variable does not leak out of the loop.- There is no required entry point. Top-level statements execute in source order. Methods are hoisted (collected before any top-level statement runs) so they can be called regardless of textual order.
- Method bodies inherit the global scope as their outer scope, so top-level variables are visible inside methods (subject to the no-shadowing rule).
- Method parameters use bare
IDENT(no$), same as variable definitions. Writingfunc f($x as int)errors with “parameter name has no$”. - Call sites type-check arguments against the declared parameter types at runtime; both arity and per-argument kind are checked.
- Method return values are dynamically typed - methods don’t declare a
return type, and callers receive whatever value the body returns (or
nullfor a barereturn;or a body that falls off the end). - A bare
IDENTin expression position is parsed as aCallExprif immediately followed by(, otherwise as aConstRefExpr. At runtime the latter must resolve to a constant in scope; a name that resolves to a variable produces a helpful error (“use$name”). - Lists are array-backed sequences, not Lisp-style linked lists:
def xs as list of int init [1, 2, 3]. Element access is$xs[i], 0-indexed, in-bounds-checked. Out-of-bounds reads and writes are positioned runtime errors. - Maps preserve insertion order: iteration via
for (def k in $m)visits keys in the order they were first inserted; updating an existing key does not move it; appending a new key extends. Reads of missing keys are runtime errors - usehas($m, key)to test. - Lists and maps are value-typed:
$ys = $xs;copies, function parameters bind by copy, andconstis deep (constness extends to every nested element). Aliasing is impossible; mutations through$xs[i] = ...only affect that binding. - Index assignment (
$xs[i][j] = ...) walks the chain on a copy of the root binding’s value, applies the write, and stores the result back viaenv.Assign. The const-target check fires once against the root binding; deep constness falls out of the value-semantics invariant. - Iteration (
for (def x in $coll)) opens a fresh scope per iteration. The loop variable is bound to each element (list) or key (map). The collection is evaluated once at loop entry; concurrent mutation of the original binding during iteration doesn’t affect the walk because the iterator works against a snapshot. {is overloaded: it opens a block in statement position and a map literal in expression position. The parser disambiguates by context; the formatter (which doesn’t run the parser) tracks the classification through a small stack so both forms get the right indentation and spacing.
Parser (internal/parser)
Recursive descent with precedence climbing for binary operators. The grammar the parser implements is the EBNF above.
The exported entry points (Parse, ParseTokens) return a raw
*Program without running the scope-analysis pass. Callers
that intend to execute the program must invoke parser.Resolve(prog)
themselves (Interpreter.Run does this automatically). Splitting the
two lets grammar tests focus on parse trees without wiring up scope
context for every fragment; see scope analysis
below.
AST nodes
| Node | Kind | Fields |
|---|---|---|
Program | root | Imports []*ImportStmt, Methods []*MethodDef, Structs []*StructDef, TopLevel []Stmt, NumGlobals int |
ImportStmt | stmt | Name, AsName (empty unless use NAME as ALIAS;) |
MethodDef | stmt | Name, Params []Param, Body *Block |
Param | - | Name, Type |
StructDef | stmt | Name, Fields []StructField (top-level only, hoisted before execution) |
StructField | - | Name, Type (each field of a struct definition) |
Block | stmt | Stmts []Stmt, NumSlots int (hint used by NewEnvironmentSized) |
DefineStmt | stmt | IsConst, VarName, VarType Type, InitExpr Expr (nil = uninit), Slot int (-1 = unresolved) |
AssignStmt | stmt | VarName, Value Expr, Depth, Slot (both -1 = unresolved) |
IndexAssignStmt | stmt | Target *IndexExpr, Value Expr - $xs[i][j] = ... (chain may include FieldAccessExpr nodes) |
FieldAssignStmt | stmt | Target *FieldAccessExpr, Value Expr - $p.field = ... |
TryStmt | stmt | Body *Block, CatchName, CatchBody *Block, CatchSlot (slot for CatchName in the handler frame) - try { ... } catch (NAME) { ... } |
ThrowStmt | stmt | Value Expr - throw EXPR; |
AppendStmt | stmt | Target *VarExpr, Value Expr - $xs[] = item; |
ReturnStmt | stmt | Value Expr (nil for bare return;) |
IfStmt | stmt | Cond, Then *Block, ElseIfs []Expr, ElseIfBodies []*Block, Else *Block |
WhileStmt | stmt | Cond, Body *Block |
ForStmt | stmt | Init Stmt, Cond Expr, Step Stmt, Body *Block (any may be nil) |
ForEachStmt | stmt | VarName, Coll Expr, Body *Block, IterSlot (slot for the iterator in each iteration frame) |
ExprStmt | stmt | Expr |
IntLit | expr | Value int64 |
FloatLit | expr | Value float64 |
StringLit | expr | Value string |
BoolLit | expr | Value bool |
NullLit | expr | - |
VarExpr | expr | Name (no $), Depth, Slot (both -1 = unresolved, use name lookup) - mutable-variable reference |
ConstRefExpr | expr | Name, Depth, Slot (-1 = unresolved) - bare-IDENT reference; interpreter expects it to resolve to a constant |
CallExpr | expr | Callee, Args []Expr, Method *MethodDef (pre-resolved pointer for hoisted user methods; nil for builtins and resolver-less paths) |
LenExpr | expr | Operand Expr - len(EXPR) language built-in |
QualifiedCallExpr | expr | Prefix, Callee, Args []Expr, Fn any (pre-resolved Builtin; nil for resolver-less paths) |
QualifiedConstRefExpr | expr | Prefix, Name, Const any (pre-resolved Value; nil for resolver-less paths) |
BinaryExpr | expr | Op BinaryOp, Left, Right, Folded Expr (pre-computed fold result; nil for runtime-only exprs) |
UnaryExpr | expr | Op UnaryOp (OpNeg/OpNot/OpBitNot), Operand, Folded Expr |
ListLit | expr | Elements []Expr - [1, 2, 3] |
MapLit | expr | Keys []Expr, Values []Expr (parallel) - {"a": 1} |
IndexExpr | expr | Target Expr, Index Expr - $xs[i], chained |
StructLit | expr | NS, Name, Fields []StructLitField - Point{...} bare or lib.Point{...} namespaced |
StructLitField | - | Name, Expr (one named field in a struct literal) |
FieldAccessExpr | expr | Target Expr, Field - $p.field, chainable with IndexExpr |
Every node embeds a pos{File, Line, Col} for error reporting and exposes
it via Node.Pos() (line/col) and Node.Filename() (file path). The file
is populated from the originating token so cross-file diagnostics work.
Sprint(node) produces a stable textual representation used by tests.
Scope analysis
internal/parser/resolver.go is a post-parse pass that walks the AST
and fills in the slot fields (Depth, Slot, NumSlots,
Program.NumGlobals, Block.NumSlots, etc.). It also promotes two
classes of error from first-execution runtime errors to positioned
parse-time diagnostics:
- Undefined variables -
Resolvewalks its own scope stack in parallel with the AST and reports anyVarExpr/AssignStmtwhose name isn’t in scope. - Shadowing - a
def(variable or constant) whose name is already visible in an enclosing scope. Same rule the runtime’s name-basedDefineused to enforce; now caught earlier.
The resolver is idempotent: running twice on the same AST produces
the same annotations. Interpreter.Run calls it before any structural
check; EvalInteractive (REPL) does not (each REPL turn lacks the
accumulated global context that would let resolution succeed). The
runtime handles the resolver-less path by leaving all slot fields at
the -1 sentinel and using name-based Environment methods.
Scope-frame model. The resolver tracks scopes as a stack. Each
frame carries a name -> slot map and a count allocator. A frame is
isRoot=true at the boundaries where the runtime chain jumps
directly to globals (the globals frame itself, and a method’s
callFrame). Reference lookup walks innermost-out, respects those
root boundaries, and terminates at globals.
Three scope-shape carve-outs where the resolver deliberately deviates from “one AST scope = one runtime frame” to stay aligned with the interpreter:
trybody runs in the enclosing env at runtime; the resolver walks its stmts inline in the current scope rather than pushing a fresh frame.- For-header init lands in
forEnv(a frame the resolver pushes for the header), body lands in a nested body-frame (pushed byresolveBlock). - Spawn body is skipped entirely. The runtime’s two-frame spawn
snapshot doesn’t line up with a static single-frame view of the
enclosing scope, so references inside a spawn body stay at
(Depth=-1, Slot=-1)and the interpreter falls back to name-based lookup at runtime.
Method-call pre-resolution. The resolver also pre-fills
CallExpr.Method whenever the callee names a hoisted top-level user
method. The interpreter’s evalCall consults the pointer first and
skips the i.methods hash lookup on every recursive call. Builtins
keep Method = nil because the namespaced / global registries need
the runtime use-activation check.
Namespaced-call pre-resolution. For
QualifiedCallExpr.Fn and QualifiedConstRefExpr.Const the pre-fill
happens on the interpreter side, not in the parser resolver, because
the namespace / import tables don’t exist until processImports has
run. Interpreter.resolveQualifiedRefs is a second pass invoked from
Interpreter.Run after processImports that walks the same AST and
stamps the exact Builtin / Value a call would otherwise look up.
Constant folding. internal/parser/fold.go runs from
inside Resolve as a post-step on BinaryExpr / UnaryExpr. When
both operands are literal (checked transitively through their own
Folded fields via asLit), the operator is applied at parse time
and the result stamped on Folded as a fresh literal node. Chains
collapse in a single pass - ((1+2)*3)+4 folds to IntLit(11).
Operations that would error at runtime (division by zero, negative
shift count, unknown op) leave Folded nil so the runtime hits the
error at its actual source position.
See interpreter.md > Environment for the runtime side.