Skip to content

Working with trees and tokens

Hyperlark builds a tree based on the structure of the grammar: each rule that is matched becomes a branch (node) in the tree, and its children are its matches, in the order of matching. A node has the name of the rule or alias in data and its matches in children. Terminals are always values in the tree, never branches: Tokens, with the terminal name in type (Rust: a numeric type_id) and the text in value.

In the grammar, [item] is an optional item. [item] returns the item if it matched, or a placeholder if it didn’t, so the other children keep their indexes: None in Python, null in TypeScript, NodeKind::None in Rust and LARK_NODE_NULL in C. This is maybe_placeholders, on by default. With it off, [] behaves like ()?: an unmatched item leaves no child. To turn it off: maybe_placeholders=False in Python, maybe_placeholders: false in TypeScript, maybe_placeholders: Some(false) in Rust, LARK_OPT_NO_PLACEHOLDERS in C.

In Rust and C, every node, token and string slice borrows the parse result and is valid only while it lives. Rust checks this at compile time. In C, copy out anything you keep, and free each cursor (lark_cursor_free) before the result (lark_result_free).

OperationPythonTypeScriptRustC
Parse resultTree (returned)Tree (returned)ParseResult → .root()LarkParseResult → lark_root
Rule nametree.datatree.datacursor.data()lark_node_data
Childrentree.childrentree.childrencursor.children()lark_child / lark_node_child_count
Tree vs token vs placeholderisinstance(x, Tree)isTree(x) / isToken(x)cursor.kind() → NodeKindlark_node_kind
Token type / valuetok.type / tok.valuetok.type / tok.valuecursor.token() → Option<&Token>, then tok.type_id / tok.valuelark_node_token_type / lark_node_token_value
Positionstok.start_pos …tok.start_pos …tok.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() …iterSubtrees(tree) …; handle.subtrees() …recurse children(), or SubtreesCursor …lark_cursor_new_*

The samples below call this GRAMMAR:

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

x = 1 y = 22 gives two assign subtrees. The unnamed "=" literal is dropped as punctuation (keep_all_tokens keeps it). pretty prints:

start
assign
x
1
assign
y
22
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, in code points

A token equals its text and has the str methods, but by default is not a str instance, so "".join(tokens) raises TypeError. Use tok.value there, or build the parser with fast_tokens=False for str-subclass tokens as in Lark (slower).

pretty returns an indented string representation of the tree; great for debugging. Its size grows with node count times depth, so bound untrusted input before printing it. In Rust and C, an allocation failure aborts the process.

print(tree.pretty())

find_data(data) returns all nodes of the tree whose data equals the given value, nested matches first. find_token(type) returns all tokens whose type equals the given terminal name. Python and TypeScript also take a predicate: find_pred(pred) (findPred) returns all nodes for which pred(node) is true, and scan_values(pred) (scanValues) all values for which pred(value) is true.

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

fast_scan=True on Lark (off by default) runs these four queries and both iter_subtrees walks in compiled code, with the same results.

A walk visits every subtree or leaf in one of four orders.

OrderVisits
subtreesEvery subtree, bottom-up, one level at a time, from a sequence fixed before the walk starts, so changing the tree inside the loop does not alter it.
topdownEvery subtree, root first, in the order pretty prints them.
postorderEach child’s whole subtree, then the parent: the order Transformer uses.
leavesTokens and placeholders only, left to right.

A topdown cursor can skip the children of the node it just returned: skip_children in Rust, skipChildren on a TypeScript parse handle cursor, lark_cursor_skip_children in C. Plain iter_subtrees_topdown and iterSubtreesTopdown cannot.

iter_subtrees, iter_subtrees_topdown and scan_values(pred) are the subtrees, topdown and leaves orders. Tree has no postorder iterator; Transformer and Visitor_Recursive visit in that order.

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

To read a small part of a large parse, parseHandle builds no JavaScript tree: the nodes stay in WebAssembly memory, and the handle names each one by an integer pos. handle.kind(pos), data, token and meta read a node, childCount and childPos(pos, i) step to its children, and toJs(pos) builds the plain tree for one subtree.

using handle = parser.parseHandle("x = 1 y = 22");
for (const pos of handle.subtrees()) // bottom-up
console.log(handle.data(pos)); // the rule name
for (const pos of handle.leaves()) // tokens / placeholders
console.log(handle.token(pos)?.value);
// handle.topdown() / handle.postorder() / handle.findData("assign") too

The walk iterators free themselves when the loop ends, break included. The handle does not: call handle.free(), or declare it with using (Node 24+, or compiled by TypeScript 5.2+) to free it at the end of the block.

parseHandle throws for a parser with a custom lexer; use parse there.

A token records its place in the text: start_pos and end_pos, the indexes where it starts and ends; line and column, from 1; end_line and end_column, the line of its end and the next column after it. Indexes and columns count code points, not bytes or UTF-16 units. So convert an index before slicing with it: in Rust and C for non-ASCII input, in TypeScript for input with characters outside the Basic Multilingual Plane, such as emoji.

A token from the built-in lexer has all six. One from a custom lexer has only the positions you set. One fed by hand to the interactive parser has none in Python, TypeScript and C, and in Rust whatever the Token carries.

propagate_positions (off by default) propagates token positions into the meta attribute of all tree branches: a Meta with the same six fields, spanning the node’s tokens.

meta.empty is True for a node with no positioned tokens under it; its position attributes are then missing (meta.line raises AttributeError).

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

With tree_class=, Hyperlark will produce trees comprised of instances of this class instead of the default Tree, so your overrides (__getattr__, copy(), __deepcopy__, pickling) apply throughout.

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()

To turn a tree into a value, see Transformers. The feature matrix lists per-target support.