zuri.ast
import zuri
Everything here is re-exported by
zuri, soimport zuriis enough and the names are called aszuri.*. Importingzuri.aston its own works too and reaches the same definitions.
The Node type zuri.parse() returns: a generic
representation of one AST node, tagged by kind ('Binary', 'If',
'Function', …) with a fields dict holding whatever that kind
carries. There is one Node class rather than one class per grammar
rule, since the grammar has roughly fifty
expression/statement/declaration shapes, and a dedicated class per shape
would mean fifty near-identical classes to maintain in lockstep with the
parser forever after. A fields entry that’s itself node-shaped (or a
list of node-shaped entries) is already wrapped into a Node
recursively, so you never need to call Node.from_dict() yourself.
import zuri
for node in zuri.parse('def add(a, b) { return a + b }') {
echo node.kind
}
# Function
The tree is the program as it was written. A for loop is a For node,
x += 1 is a CompoundAssign, and an interpolated string is an
Interpolation; none of them is rewritten into the simpler forms the
compiler turns them into.
Positions
Every node records the whole of the source it was read from:
| Attribute | Meaning |
|---|---|
start | The offset of its first character |
end | The offset just past its last character |
line, col | Where it starts, both counted from 1 |
end_line, end_col | Where it ends: the column just past its last character |
Offsets count characters, not bytes, and index the source the way
slicing does, so source[node.start, node.end] is exactly the node’s
own text. A node covers its delimiters: a string literal’s quotes, a
block’s braces, a call’s parentheses.
A statement or declaration starts at its keyword: var total = 1 starts
at var and def add(a, b) at def. A class member starts at its
first word, static included. In a list of declarations, var a = 1, b,
each Var spans from its name to the end of its value.
A node that names something through a name of its own also says where
that name is, in its fields:
| Kind | Field | Covers |
|---|---|---|
Function, Method, Class | name_span | The declared name |
Property, Var, Argument | name_span | The declared name |
Get, Set | name_span | The property name after the . |
For | key_span, value_span | Each loop variable |
Import | segments | Each part of the path |
A span field is a plain dict with the same six keys a node has, and a
For without a key has a key_span of nil. An import segment adds
text, the part as written: '.', '..' or a name.
A node the source leaves implicit has an empty span, start == end,
where it would have been written: the nil of a return with no value
or of a var with no initializer sits just after the keyword or name,
and so does the Any type of an untyped parameter and the List type
of a ... parameter.
Comments and doc blocks
Every #-comment and doc block comes back as its own
'Comment'/'DocBlock'-kind node, sitting exactly where it appeared:
as a sibling in the surrounding statement or declaration list, at the
top level, inside a { } block, or between two class members.
A comment written in the middle of a statement, among a call’s arguments
or between an if block’s } and its else, has no place among that
statement’s own nodes. It comes back as a sibling placed immediately
after the smallest statement, declaration or class member that contains
it, at that statement’s depth: a comment inside an if body three
blocks deep comes back three blocks deep, never hoisted to the top. Its
position still says where it was written.
import zuri
var source = "def foo() {\n echo bar(a, # x\n b)\n echo 2\n}\n"
var body = zuri.parse(source)[0].fields.body.fields.statements
echo body[0].kind # Echo, the statement the comment sat inside
echo body[1].kind # Comment, placed right after it
echo body[1].fields.text # ' x'
echo body[2].kind # Echo, the "echo 2" line, unaffected
A comment above a declaration comes back right before it:
import zuri
var source = "# a helper function\ndef add(a, b) {\n return a + b\n}\n"
var nodes = zuri.parse(source)
echo nodes[0].kind # Comment
echo nodes[0].fields.text # " a helper function"
echo nodes[1].kind # Function
A class body’s fields and methods are two separate lists, a Class
node’s properties and methods, so a comment between a field and a
method lands in methods, the list of the member right after it, and a
comment between two fields lands in properties. A comment after the
last member goes to the list of the member before it. To rebuild a class
body in its written order, merge the two lists and sort them by start.
Node kinds
Expressions:
| Kind | Fields |
|---|---|
Nil, Self, Parent | None |
Bool, Integer, Float, BigNumber | value |
Literal | value: a string’s text, or a dictionary key written as a name |
Interpolation | parts: a string for each run of text and a node for each ${...} |
Identifier | name |
Unary | op, operand |
Binary | left, op, right, for the arithmetic, bitwise and shift operators |
Logical | left, op, right, for the comparisons |
Circuit | left, op, right, for and and or |
Condition | condition, then, otherwise: a ? b : c |
Grouping | inner: an expression in parentheses |
Range | lower, upper |
Call | callee, arguments |
Get | object, name, name_span |
Set | object, name, name_span, value: object.name = value |
Index | object, index |
Slice | object, lower, upper; an omitted bound is an implicit Nil |
List | items |
Dict | keys, values, in matching order |
Shorthand | name: the value of a { name } entry, which reads name |
Assign | target, value: target = value |
CompoundAssign | target, op, value: target += value and its kin |
Update | target, op: target++ or target-- |
Anonymous | declaration: a Function named @anon and a number |
TypeHint | types, nullable |
Argument | name, name_span, type_hint |
A Literal’s value leaves out the quotes, and an Interpolation’s
text parts come with their escapes already applied. A CompoundAssign’s
op and an Update’s name the operator written, such as 'PlusEq' or
'Increment'.
Each entry of a TypeHint’s types is a node named after the type:
Any, Bool, Int, Number, BigInt, String, Bytes, List,
Dict, Range, File, Function, Type, Callable, Iterable, or
Instance for a class. Every one carries class_name, the class an
Instance names and nil on the rest.
Statements:
| Kind | Fields |
|---|---|
Expression, Echo, Raise, Return | value |
Var | name, name_span, value, type_hint, is_constant |
VarList | declarations: the Var nodes of var a = 1, b = 2 |
Block | statements |
If | condition, then, otherwise |
While | condition, body |
DoWhile | body, condition |
For | key, key_span, value, value_span, iterable, body |
Iter | initializer, condition, steps, body |
Using | subject, arms, default_body |
When | labels, body: one arm of a Using |
Catch | body, catch_body, error_var |
Assert | condition, message |
Import | path, segments, name, aliased, elements, imports_all, exported |
Continue, Break | None |
Decl | declaration: a function declared inside a block |
Comment, DocBlock | text: the comment without its #, /* and */ |
A For written as for value in iterable has a key of nil. An
Iter clause left empty is nil, and with no steps steps is an empty
list.
Declarations:
| Kind | Fields |
|---|---|
Stmt | statement: a statement at the top level |
Function | name, name_span, parameters, body, is_variadic |
Class | name, name_span, superclass, properties, methods, is_extension |
Property | name, name_span, value, type_hint, is_static, is_constant |
Method | name, name_span, parameters, body, is_variadic, is_static |
An Import’s name is the Identifier it binds: the name after as
when aliased is true, otherwise the last part of its path, placed on
that part. path joins every part with the platform’s path separator. A
decorated method’s name keeps its @, as in '@new'.
Functions
parse()
zuri.parse(source) -> list[Node]
Parses source in full and returns its top-level declarations as a list
of Node. See this module’s own doc comment for the exact
shape, and in particular for what is and isn’t preserved around
comments.
Raises (rather than returning a partial result) on a syntax error, since there is no meaningful AST to hand back for source the parser itself couldn’t make sense of.
Parameters
source(string) — The Zuri source to parse
Returns list[Node]
Raises Error if source has a syntax error
parse_partial()
zuri.parse_partial(source: string) -> ParseResult
Parses source as far as it can and returns everything it read along
with every syntax error it found, without raising.
At a statement it cannot read, the parser reports the problem, skips to
where the next statement starts and carries on, so one mistake is
reported once and the rest of the source still comes back. The statement
that failed comes back as far as it was read, or as a None node when
nothing of it could be kept. A name the source is missing, such as the
variable name in var = 2, comes back as an empty string with an empty
span where the name belongs.
import zuri
var result = zuri.parse_partial('var a = 1\nvar = 2\nvar c = 3')
echo result.errors[0] # 2:5: Variable name expected.
echo result.nodes.length() # 3
Parameters
source(string) — The Zuri source to parse
Returns ParseResult
parse_partial_file()
zuri.parse_partial_file(path: string) -> ParseResult
Reads the file at path and parses as much of it as it can; exactly
parse_partial(file(path).read()).
Parameters
path(string) — Path to the Zuri source file to parse
Returns ParseResult
Raises Error if path can’t be opened or read
parse_file()
zuri.parse_file(path) -> list[Node]
Reads the file at path and parses its contents; exactly
parse(file(path).read()), for the common case of parsing a real file
rather than a source string you’ve already got in hand.
Parameters
path(string) — Path to the Zuri source file to parse
Returns list[Node]
Raises Error if path can’t be opened or read, or has a syntax
error
dump_file()
zuri.dump_file(path) -> string
Reads, parses, and dumps the file at path in one call: the fastest way
to answer “what does this file’s AST actually look like”. Equivalent to
parsing path and joining every resulting top-level node’s own dump()
with a blank line between them.
import zuri
echo zuri.dump_file('main.zu')
Parameters
path(string) — Path to the Zuri source file to parse and dump
Returns string
Raises Error if path can’t be opened or read, or has a syntax
error
walk_nodes()
zuri.walk_nodes(nodes, visitor)
Recursively visits every node reachable from nodes (typically
zuri.parse()’s own result, or any single Node plucked out
of it), in the order they appear, calling visitor for each one. This
is the tool for “do something with every X”: most real uses of an AST
are a walk, not manual field-by-field navigation.
visitor is either a plain function, called with every node
(visitor(node)), or a dict from kind name to function, where only a
node of that kind gets called (visitor[node.kind](node)). Reach for
the dict form when you only care about one or two kinds; it reads as a
small dispatch table instead of a chain of if node.kind == ... checks
inside a single function.
Always visits the whole tree; there’s no way for visitor to skip
descending into a particular node’s children.
import zuri
var nodes = zuri.parse(source)
# every function name declared anywhere in the file, including
# nested/inner functions
var names = []
zuri.walk_nodes(nodes, {
Function: @(node) { names.append(node.fields.name) },
})
Parameters
nodes(Node|list[Node])visitor(function|dict)
find_nodes()
zuri.find_nodes(nodes, kind) -> list[Node]
Every node reachable from nodes whose kind is kind, in the order
they appear. Shorthand for the common “just give me a flat list of
these” case walk_nodes() itself is built on.
import zuri
var nodes = zuri.parse(source)
for comment in zuri.find_nodes(nodes, 'Comment') {
echo comment.fields.text
}
Parameters
nodes(Node|list[Node])kind(string)
Returns list[Node]
Classes
Node
class zuri.Node
One AST node: an expression, statement, declaration, type hint, or a standalone comment/doc block sitting between two of those.
- printable — has a
@to_string(), soechoandprint()show something useful
Constructor
zuri.Node(kind: string, position: dict, fields: dict)
Returns a new instance of a Node directly, from
already-wrapped field values. Ordinary Zuri code should use
Node.from_dict() or zuri.parse() instead, since this constructor
does no wrapping of its own.
Parameters
kind(string) — The node’s kind, e.g.'Binary','If'position(dict) — Where the node sits in its source, as{ start, end, line, col, end_line, end_col }; see this module’s own docs for what each one countsfields(dict) — This kind’s own fields, already wrapped
Node.from_dict()
zuri.Node.from_dict(data: dict) -> Node
Builds a Node from a dict holding a node’s parts ({ kind, start, end, line, col, end_line, end_col, fields }),
recursively wrapping any node-shaped entry inside fields, a single
nested node or a list of them, into its own Node. A fields
entry that isn’t node-shaped (a string, a number, a bool, nil, or a
plain dict such as a name_span) passes through unchanged.
Parameters
data(dict) —{ kind, start, end, line, col, end_line, end_col, fields }
Returns Node
Node.is_trivia()
zuri.Node.is_trivia() -> bool
Is this node a standalone comment or doc block (as opposed to a real expression/statement/declaration)? See this module’s own doc comment for exactly where these can and can’t appear.
Returns bool
Node.to_string()
zuri.Node.to_string() -> string
This node as kind@line:col, e.g. 'Binary@3:5', naming the line and
column it starts at. A one-line summary; use dump() to actually see
what’s inside a node.
Returns string
Node.dump()
zuri.Node.dump() -> string
A readable, indented, multi-line dump of this node and everything inside
it, recursively. This is the actual way to find out what a fields
entry is called for a kind you haven’t looked up yet: echo node.dump()
and read it, rather than guessing a key name and checking whether it
happens to exist.
import zuri
var nodes = zuri.parse('def add(a, b) { return a + b }')
echo nodes[0].dump()
prints
Function@1:1
name: 'add'
name_span: 1:5-1:8
parameters:
Argument@1:9
name: 'a'
name_span: 1:9-1:10
type_hint:
TypeHint@1:10
types:
Any@1:10
class_name: nil
nullable: true
Argument@1:12
name: 'b'
name_span: 1:12-1:13
type_hint:
TypeHint@1:13
types:
Any@1:13
class_name: nil
nullable: true
body:
Block@1:15
statements:
Return@1:17
value:
Binary@1:24
left:
Identifier@1:24
name: 'a'
op: 'Plus'
right:
Identifier@1:28
name: 'b'
is_variadic: false
Each node’s header is its kind and the line and column it starts at. A
span field such as name_span prints as the range it covers,
line:col-end_line:end_col. The untyped parameters carry an implicit
Any type sitting just after their names, and class_name is nil on
Any because only an Instance type names a class.
Returns string
ParseResult
class zuri.ParseResult
What parse_partial() read from a piece of source: the tree it built
and every syntax error it found on the way.
- printable — has a
@to_string(), soechoandprint()show something useful
Constructor
zuri.ParseResult(nodes: list, errors: list)
Returns a new ParseResult. Ordinary code receives
these from parse_partial() rather than building them.
Parameters
nodes(list) — The top-level nodes read, asparse()returns themerrors(list) — Every syntax error, as {zuri.Diagnostic}s, in the order they occur in the source
ParseResult.is_clean()
zuri.ParseResult.is_clean() -> bool
True when the source parsed without a single error, in which case
nodes is exactly what parse() returns.
Returns bool
ParseResult.to_string()
zuri.ParseResult.to_string() -> string
This result as ParseResult(n nodes, m errors).
Returns string
2026, Richard Ore and Zuri contributors