Skip to content

Working with trees & tokens

parse hands you a Tree: a rule node with a data name and a list of children. Its leaves are Tokens — a type (the terminal name), a value (the matched text), and six code-point positions. A child can also be a null/None hole (an omitted optional under maybe_placeholders), which is why walking code checks what a child is before reading it. That is the whole data model, and it is the same in every target.

(If you know Lark, this is its Tree/Token shape exactly — hyperlark implements it natively.)

Every target exposes the same operations; only the spelling changes. Python and TypeScript give you plain objects; Rust and C give you a borrowed cursor over a retained arena (no eager copy-out).

CapabilityPythonTypeScriptRustC
Parse resultTree (returned)Tree (returned)ParseResult.root()LarkParseResultlark_root
Rule nametree.datatree.datacursor.data()lark_node_data
Childrentree.childrentree.childrencursor.children()lark_node_child / _child_count
Tree vs token vs holeisinstance(x, Tree)isTree(x) / isToken(x)cursor.kind()NodeKindlark_node_kind
Token type / valuetok.type / tok.valuetok.type / tok.valuecursor.token()&Tokenlark_node_token_type / _token_value (_token_value_copy for a C string)
Positionstok.start_postok.start_postok.line() / tok.column()lark_node_token_positions
Pretty-printtree.pretty()pretty(tree)result.pretty()lark_result_pretty
Find by ruletree.find_data(n)findData(tree, n)result.find_data(n)lark_cursor_find_data
Find by tokentree.find_token(t)findToken(tree, t)result.find_token(t)lark_cursor_find_token
Ordered walksiter_subtrees()handle.subtrees()recurse children()lark_cursor_new_*

A tiny assignment list — un-inlined so the tree shape is explicit. Call it GRAMMAR below:

start: assign+
assign: NAME "=" NUMBER
NAME: /[a-z]+/
NUMBER: /[0-9]+/
%ignore " "

Parsing x = 1 y = 22 gives two assign subtrees, each holding a NAME and a NUMBER token (the "=" literal is anonymous and filtered out):

start
assign
x
1
assign
y
22

Reach the first NUMBER and read its value and position.

Tokens compare by value (num == "1") and support the usual string operations, and also carry .type and the positions — but by default (fast_tokens=True) they are not str subclasses; pass fast_tokens=False for lark-identical str-subclass tokens.

import hyperlark as lark
parser = lark.Lark(GRAMMAR, parser="lalr")
tree = parser.parse("x = 1 y = 22")
tree.data # "start"
len(tree.children) # 2 — two `assign` subtrees
first = tree.children[0] # a Tree
name, num = first.children # two Tokens
num.type # "NUMBER"
num.value # "1" (and num == "1")
num.line, num.column # (1, 5) — 1-based, code points

The same indented rendering as Lark’s Tree.pretty — handy for debugging.

print(tree.pretty())

find_data(name) yields every subtree with that rule name, and find_token(type) every token of that terminal type — both bottom-up, innermost match first (Lark’s iter_subtrees order), which matters when a rule nests inside itself. They save you a hand-written recursion when you only want some of the tree.

for a in tree.find_data("assign"): # each `assign` subtree
print(a.children[0], "=", a.children[1])
total = sum(int(t) for t in tree.find_token("NUMBER")) # 23

On a large tree, Lark(GRAMMAR, fast_scan=True) runs these queries natively over the parse arena — only matched nodes cross into Python. Results are identical; it is purely an opt-in speedup.

For a full traversal, four orders are available (the vocabulary is shared; each target names them its own way):

OrderVisitsLark name
subtreesevery subtree, leaves-first (snapshot)iter_subtrees
topdownevery subtree, root-first (live; prunable)iter_subtrees_topdown
postorderdepth-first, children before parentVisitor_Recursive order
leavesnon-tree leaves only (tokens / holes)scan_values

The Tree walk methods are the orders directly; tokens fall out via scan_values (or just index children).

for sub in tree.iter_subtrees(): # leaves-first
print(sub.data)
for sub in tree.iter_subtrees_topdown(): # root-first
print(sub.data)

Every token carries its six code-point positions everywhere, no flag needed: start_pos / end_pos (offsets), line / end_line, and column / end_column (both 1-based). Offsets and columns count Unicode code points, not bytes.

Turning on propagate_positions additionally attaches a per-node Meta — the span covering a subtree’s tokens — so you can ask where a whole rule sits, not just its leaves.

parser = lark.Lark(GRAMMAR, parser="lalr", propagate_positions=True)
tree = parser.parse("x = 1 y = 22")
m = tree.children[0].meta # Meta of the first `assign`
m.line, m.column, m.end_line, m.end_column, m.start_pos, m.end_pos

tree_class= is a Python option: give it a Tree subclass and every node of the result is an instance of it — at the root and all the way down.

class MyTree(lark.Tree):
def names(self):
return [str(t) for t in self.find_token("NAME")]
tree = lark.Lark(GRAMMAR, parser="lalr", tree_class=MyTree).parse("x = 1 y = 22")
type(tree) is MyTree # True
tree.names()

The nodes are your class, so everything you put on it behaves normally: __getattr__, copy(), __deepcopy__, pickling, and helpers of your own that rebuild through type(self).

The Rust, WASM and C surfaces have no equivalent — they return their own tree types, and tree_class is a Python-object idea.

Once you can read a tree, the next step is usually turning it into a value — see Transformers. For the full per-target picture, the feature matrix is the honest map.