Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

zuri.ast

import zuri

Everything here is re-exported by zuri, so import zuri is enough and the names are called as zuri.*. Importing zuri.ast on 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:

AttributeMeaning
startThe offset of its first character
endThe offset just past its last character
line, colWhere it starts, both counted from 1
end_line, end_colWhere 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:

KindFieldCovers
Function, Method, Classname_spanThe declared name
Property, Var, Argumentname_spanThe declared name
Get, Setname_spanThe property name after the .
Forkey_span, value_spanEach loop variable
ImportsegmentsEach 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:

KindFields
Nil, Self, ParentNone
Bool, Integer, Float, BigNumbervalue
Literalvalue: a string’s text, or a dictionary key written as a name
Interpolationparts: a string for each run of text and a node for each ${...}
Identifiername
Unaryop, operand
Binaryleft, op, right, for the arithmetic, bitwise and shift operators
Logicalleft, op, right, for the comparisons
Circuitleft, op, right, for and and or
Conditioncondition, then, otherwise: a ? b : c
Groupinginner: an expression in parentheses
Rangelower, upper
Callcallee, arguments
Getobject, name, name_span
Setobject, name, name_span, value: object.name = value
Indexobject, index
Sliceobject, lower, upper; an omitted bound is an implicit Nil
Listitems
Dictkeys, values, in matching order
Shorthandname: the value of a { name } entry, which reads name
Assigntarget, value: target = value
CompoundAssigntarget, op, value: target += value and its kin
Updatetarget, op: target++ or target--
Anonymousdeclaration: a Function named @anon and a number
TypeHinttypes, nullable
Argumentname, 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:

KindFields
Expression, Echo, Raise, Returnvalue
Varname, name_span, value, type_hint, is_constant
VarListdeclarations: the Var nodes of var a = 1, b = 2
Blockstatements
Ifcondition, then, otherwise
Whilecondition, body
DoWhilebody, condition
Forkey, key_span, value, value_span, iterable, body
Iterinitializer, condition, steps, body
Usingsubject, arms, default_body
Whenlabels, body: one arm of a Using
Catchbody, catch_body, error_var
Assertcondition, message
Importpath, segments, name, aliased, elements, imports_all, exported
Continue, BreakNone
Decldeclaration: a function declared inside a block
Comment, DocBlocktext: 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:

KindFields
Stmtstatement: a statement at the top level
Functionname, name_span, parameters, body, is_variadic
Classname, name_span, superclass, properties, methods, is_extension
Propertyname, name_span, value, type_hint, is_static, is_constant
Methodname, 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(), so echo and print() 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 counts
  • fields (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(), so echo and print() 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, as parse() returns them
  • errors (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