Examples
Complete programs, not fragments. Each one is self-contained — grammar and input inline — so you can copy a tab and run it. Where a task exists in more than one target the programs share a grammar and a shape, so you can read the same program across languages and compare the APIs side by side.
Every filename below is a real file in the repository, rendered here from source.
| Task | Python | TypeScript | Rust | C |
|---|---|---|---|---|
| Calculator — parse and evaluate | ✅ | ✅ | ✅ | ✅ |
| JSON — transform, query, stream | ✅ | ✅ | ✅ | — |
| Interpreter — top-down, evaluate on demand | ✅ | — | ✅ | — |
| Indentation — a postlexer | ✅ | — | — | — |
| Ambiguity — Earley, kept explicit | ✅ | ✅ | ✅ | — |
| Interactive — ask what comes next | ✅ | ✅ | ✅ | ✅ |
| Error reporting — friendly diagnostics | ✅ | — | — | — |
A — almost always means nobody has written that program yet, not that the
target can’t do it. The real capability gaps are C’s — no Earley, so no explicit
ambiguity; no postlex; and no peer of JS’s subtree streaming. One more spans two
targets: the match_examples error recipe needs the LALR state carried on the
error, which the TypeScript and C surfaces do not expose (they give
accepts/expected only), so that recipe is not portable to either. See the
feature matrix for the authoritative answer.
Calculator
Section titled “Calculator”Parse arithmetic and evaluate it to a number. The classic Lark calculator, running unchanged.
The Python and TypeScript tabs embed the transformer in the parser, so the value
comes back during the parse and no tree is ever built. The Rust and C tabs
parse first and fold the result with a reusable algebra, which is the shape you
want when one parse feeds several folds; each also has a tree-free entry point
for the other case — Rust’s parse_fold, C’s lark_parse_fold.
"""Calculator quickstart: parse arithmetic and evaluate it DURING the parsewith an embedded Transformer. hyperlark is API-compatible with lark — this isthe classic lark calculator, running unchanged.
Run: python calc.py (any environment with the hyperlark wheel installed)"""
import hyperlarkfrom hyperlark import Transformer, v_args
GRAMMAR = r"""?start: sum?sum: product | sum "+" product -> add | sum "-" product -> sub?product: atom | product "*" atom -> mul | product "/" atom -> div?atom: NUMBER -> number | "-" atom -> neg | "(" sum ")"
%import common.NUMBER%import common.WS_INLINE%ignore WS_INLINE"""
@v_args(inline=True) # children arrive as positional argumentsclass Calc(Transformer): # The rules map straight onto the operator module's builtins — C-level # callables with no Python frame per reduction, so this is also the # fastest way to write it (it matters on big inputs, where the # transformer runs once per rule application). from operator import add, mul, neg, sub from operator import truediv as div
number = float
# A plain parser first, to look at the tree: aliases (`-> add`) label the nodes.parser = hyperlark.Lark(GRAMMAR, parser="lalr")print(parser.parse("2 + 3 * 4").pretty())
# transformer= embeds Calc INTO the parse: the native engine folds every rule# as it reduces, calling straight into those C-level builtins, and no Python# tree is ever materialized — parse() returns the finished number. (See# json_to_dict.py for the same transformer run both ways.)calc = hyperlark.Lark(GRAMMAR, parser="lalr", transformer=Calc())for expr in ["2 + 3 * 4", "(2 + 3) * 4", "-(10 / 4) + 0.5"]: print(f"{expr} = {calc.parse(expr)}")
# Parse errors carry position and context, like lark's.try: calc.parse("1 + * 2")except hyperlark.exceptions.UnexpectedInput as e: print(f"as expected, '1 + * 2' fails at line {e.line}, column {e.column}:") print(e.get_context("1 + * 2"))// The typed calculator: same program as calc.mjs, with the TypeScript surface// working for you — typed options, a transformer whose fold RESULT TYPE is// inferred, pretty() over any node, and instanceof on imported error classes.//// Run from docs/examples/js: npm install// typecheck: npm run typecheck (tsc --noEmit)// run: npm run calc:ts (node strip-types)
import { Lark, pretty, UnexpectedInput, type ReduceTransformer, type Token,} from "hyperlark";
const GRAMMAR = String.raw`?start: sum?sum: product | sum "+" product -> add | sum "-" product -> sub?product: atom | product "*" atom -> mul | product "/" atom -> div?atom: NUMBER -> number | "-" atom -> neg | "(" sum ")"
%import common.NUMBER%import common.WS_INLINE%ignore WS_INLINE`;
const parser = new Lark(GRAMMAR, { parser: "lalr" });
// pretty() takes any Node (Tree | Token | null) — no isTree guard needed.console.log(pretty(parser.parse("2 + 3 * 4")));
// A transformer's children arrive as unknown[] — each method declares the// shape its rule actually produces. `satisfies` keeps the literal's concrete// method types (an annotation `: ReduceTransformer` would widen them away).const calc = { number: (c: Token[]) => Number(c[0].value), neg: (c: number[]) => -c[0], add: (c: number[]) => c[0] + c[1], sub: (c: number[]) => c[0] - c[1], mul: (c: number[]) => c[0] * c[1], div: (c: number[]) => c[0] / c[1],} satisfies ReduceTransformer;
for (const expr of ["2 + 3 * 4", "(2 + 3) * 4", "-(10 / 4) + 0.5"]) { // The fold's result type is INFERRED from the transformer's method return // types — `value` is a number here, no cast needed. const value = parser.parse(expr, { transformer: calc }); console.log(`${expr} = ${value}`);}
// Error classes are named exports; instanceof narrows to the typed shape// (line/column/getContext are on UnexpectedInput and everything below it).try { parser.parse("1 + * 2");} catch (e) { if (e instanceof UnexpectedInput) { console.log(`as expected, "1 + * 2" fails at line ${e.line}, column ${e.column}:`); console.log(e.getContext("1 + * 2")); } else { throw e; }}//! Calculator quickstart: compile a `.lark` grammar, look at the tree, and//! evaluate expressions with a reusable bottom-up fold.//!//! Run from `docs/examples/rust`://!//! cargo run --bin calc
use hyperlark::{fold_fn, Lark};
/// Lark grammar syntax: `?rule` inlines single-child nodes, `-> name` gives an/// alternative its own tree label, `%import common.X` pulls a stdlib terminal.const GRAMMAR: &str = r#"?start: sum?sum: product | sum "+" product -> add | sum "-" product -> sub?product: atom | product "*" atom -> mul | product "/" atom -> div?atom: NUMBER -> number | "-" atom -> neg | "(" sum ")"
%import common.NUMBER%import common.WS_INLINE%ignore WS_INLINE"#;
fn main() -> Result<(), Box<dyn std::error::Error>> { // The engine named in one argument: Lark::lalr(src) / Lark::earley(src) // (Lark::new(src) builds under all-defaults — LALR, start rule "start"; note // the lark-compatible bindings, Python and JS, default to Earley instead). // Any other option: field-init LarkOptions over Default, pass to from_lark_source. let parser = Lark::lalr(GRAMMAR)?;
// The tree first: aliases (`-> add`) label the nodes. println!("{}", parser.parse("2 + 3 * 4")?.pretty());
// A fold algebra is the Rust sibling of lark's Transformer: two matches // with one shape — what each token TYPE is worth, and how each rule // (aliases included) combines its children's already-folded values. // Built once, it evaluates any number of parses — and the walk is // iterative, so tree depth is never a stack concern. let mut calc = fold_fn( |name, leaf| match name { "NUMBER" => leaf.text().parse().unwrap(), // NUMBER is the only terminal kept in this grammar; nothing else // reaches here, so an unhandled name is a bug, not a value to invent. other => unreachable!("unexpected terminal: {other}"), }, |name, kids: Vec<f64>| match name { "add" => kids[0] + kids[1], "sub" => kids[0] - kids[1], "mul" => kids[0] * kids[1], "div" => kids[0] / kids[1], "neg" => -kids[0], "number" => kids[0], // passes its lexed child through // Named, not a catch-all: `_ => kids[0]` would silently return the // LEFT operand if a two-child rule were ever added and its arm // forgotten. Same reasoning as the leaf match above. other => unreachable!("unexpected rule: {other}"), }, );
for expr in ["2 + 3 * 4", "(2 + 3) * 4", "-(10 / 4) + 0.5"] { println!("{expr} = {}", parser.parse(expr)?.fold(&mut calc)); } // (A fold visits every node. When arms should CHOOSE what to visit — // an if/else evaluating only the taken branch — see `ParseResult::eval` // and the `interpreter` example next door.)
// Parse errors are ordinary values. Position accessors and get_context() // (Lark's caret view) read straight off the error — no variant destructure — // so this closes on the same beat as calc.py. let Err(err) = parser.parse("1 + * 2") else { unreachable!("\"1 + * 2\" must fail to parse"); }; println!( "as expected, \"1 + * 2\" fails at line {}, column {}:", err.line().unwrap(), err.column().unwrap() ); print!("{}", err.get_context("1 + * 2"));
Ok(())}/* * Calculator quickstart for the C binding: build a parser from a `.lark` * grammar, register fold callbacks by RULE name and TOKEN-TYPE name — the * library resolves each name to its id once and dispatches an iterative * bottom-up walk, so there is no lookup-table plumbing and no recursion in * this file. Also shows error handling via the last-error buffer, and the * free discipline. * * Build the library once, then compile against it (from docs/examples/c): * * cargo build -p hyperlark-c --release # any workspace dir * cc calc.c -I ../../../crates/hyperlark-c/include \ * ../../../target/release/libhyperlark_c.a \ * -lpthread -ldl -lm -o calc && ./calc */
#include <stdbool.h>#include <stdio.h>#include <stdlib.h> /* strtod */#include <string.h>
#include "hyperlark.h"
static const char GRAMMAR[] = "?start: sum\n" "?sum: product\n" " | sum \"+\" product -> add\n" " | sum \"-\" product -> sub\n" "?product: atom\n" " | product \"*\" atom -> mul\n" " | product \"/\" atom -> div\n" "?atom: NUMBER -> number\n" " | \"-\" atom -> neg\n" " | \"(\" sum \")\"\n" "\n" "%import common.NUMBER\n" "%import common.WS_INLINE\n" "%ignore WS_INLINE\n";
/* One callback per name, one uniform signature per kind: leaves get the token * node, rules get their children's already-folded values. (The void casts are * the cost of that uniform signature.) *//* Token values are length-prefixed, NOT NUL-terminated, so they can't be handed * to strtod directly. lark_node_token_value_copy makes a NUL-terminated copy; from * there it's ordinary C — strtod, strtol, sscanf, whatever. (A parser taking a * pointer RANGE — C++17 from_chars, fast_float — can read * lark_node_token_value's (ptr,len) with no copy at all; the copy is what libc * needs, not what the tree needs.) * * A value too long for the buffer is REJECTED, not truncated. A fold callback * has no error channel — it must return a LarkValue — so the failure is recorded * through the userdata pointer and checked after the fold. Returning 0 and * saying nothing would make `<very long number> + 1` quietly evaluate to 1. */static LarkValue number(void *u, LarkNode tok) { bool *ok = (bool *)u; char buf[64]; if (lark_node_token_value_copy(tok, buf, sizeof buf, NULL) != LARK_OK) { fprintf(stderr, "number: %s\n", lark_last_error()); *ok = false; return (LarkValue){ .d = 0 }; } return (LarkValue){ .d = strtod(buf, NULL) };}static LarkValue add(void *u, LarkNode n, const LarkValue *k, size_t c){ (void)u; (void)n; (void)c; return (LarkValue){ .d = k[0].d + k[1].d }; }static LarkValue sub(void *u, LarkNode n, const LarkValue *k, size_t c){ (void)u; (void)n; (void)c; return (LarkValue){ .d = k[0].d - k[1].d }; }static LarkValue mul(void *u, LarkNode n, const LarkValue *k, size_t c){ (void)u; (void)n; (void)c; return (LarkValue){ .d = k[0].d * k[1].d }; }static LarkValue divide(void *u, LarkNode n, const LarkValue *k, size_t c){ (void)u; (void)n; (void)c; return (LarkValue){ .d = k[0].d / k[1].d }; }static LarkValue neg(void *u, LarkNode n, const LarkValue *k, size_t c){ (void)u; (void)n; (void)c; return (LarkValue){ .d = -k[0].d }; }
int main(void) { /* LARK_OPT_NONE = the defaults (LALR, start rule "start"). */ Lark *parser = NULL; if (lark_from_source(GRAMMAR, sizeof(GRAMMAR) - 1, LARK_OPT_NONE, &parser) != LARK_OK) { fprintf(stderr, "grammar error: %s\n", lark_last_error()); return 1; }
/* The tree first, like the other examples: parse one expression and print * its shape. lark_result_pretty is owned by the result — nothing to free but * the result itself. */ LarkParseResult *shape = NULL; if (lark_parse(parser, "2 + 3 * 4", 9, NULL, &shape) == LARK_OK) { printf("%s\n", lark_result_pretty(shape)); lark_result_free(shape); }
/* Register once — names resolve to ids here; the walk dispatches O(1). * The `number` rule needs no callback: an unregistered rule passes its * first (only) child's value through. */ LarkFold *calc = NULL; bool ok = lark_fold_new(parser, &calc) == LARK_OK; /* userdata: the flag `number` trips if a literal doesn't fit its buffer */ bool numbers_ok = true; ok &= lark_fold_token(calc, "NUMBER", number, &numbers_ok) == LARK_OK; ok &= lark_fold_rule(calc, "add", add, NULL) == LARK_OK; ok &= lark_fold_rule(calc, "sub", sub, NULL) == LARK_OK; ok &= lark_fold_rule(calc, "mul", mul, NULL) == LARK_OK; ok &= lark_fold_rule(calc, "div", divide, NULL) == LARK_OK; ok &= lark_fold_rule(calc, "neg", neg, NULL) == LARK_OK; if (!ok) { fprintf(stderr, "fold setup: %s\n", lark_last_error()); return 1; }
const char *exprs[] = {"2 + 3 * 4", "(2 + 3) * 4", "-(10 / 4) + 0.5"}; for (size_t i = 0; i < sizeof(exprs) / sizeof(exprs[0]); i++) { LarkParseResult *result = NULL; if (lark_parse(parser, exprs[i], strlen(exprs[i]), NULL, &result) != LARK_OK) { fprintf(stderr, "parse error: %s\n", lark_last_error()); continue; } LarkValue v = { .d = 0 }; numbers_ok = true; /* Two channels to check: the status covers the fold itself, numbers_ok * covers a callback that could not convert. Ignoring either reports a * value nobody computed — 0 for a failed fold (`v` is pre-zeroed), or * the expression with that literal read as 0, which is the `+ 1` -> 1 * case the `number` comment above describes. */ if (lark_fold_apply(calc, result, &v) != LARK_OK) fprintf(stderr, "fold error: %s\n", lark_last_error()); else if (numbers_ok) printf("%s = %g\n", exprs[i], v.d); else printf("%s = <unevaluated: a literal did not fit>\n", exprs[i]); lark_result_free(result); /* frees the arena — and every node view into it */ }
/* Errors: a failing parse returns a status; the message, the caret context, * the structured line/column, and the expected-terminals list all live on * the thread-local last-error buffer. This closes on the same caret beat as * the other examples — lark_last_error_context() renders it, and (unlike the * other bindings) needs no source text: the buffer captured it at parse time. */ LarkParseResult *result = NULL; if (lark_parse(parser, "1 + * 2", 7, NULL, &result) != LARK_OK) { uint32_t line = 0, column = 0; lark_last_error_position(&line, &column); printf("as expected, \"1 + * 2\" fails at line %u, column %u:\n", line, column); const char *caret = lark_last_error_context(); if (caret) printf("%s", caret); /* ends in a newline */ /* The expected-terminals set is structured data too — an editor would * offer these as completions rather than print them. */ printf("expected:"); for (size_t i = 0; i < lark_last_error_expected_count(); i++) printf(" %s", lark_last_error_expected(i)); printf("\n"); } else { lark_result_free(result); /* not reached — kept so no branch can leak */ }
lark_fold_free(calc); lark_free(parser); return 0;}Three things you do with a parsed document, one per target: turn it into native values, query it, and stream it. The Python program runs the transformer both ways — after the parse, and embedded in it — so you can see what the embedded form costs you and what it saves.
"""Parse JSON into plain Python objects with a Transformer — the classic larktutorial, unchanged on hyperlark.
Shows both ways to run a transformer: 1. post-parse: T().transform(parser.parse(text)) — simple, tree in memory 2. embedded: Lark(..., transformer=T()) — folds DURING the parse; substantially faster on large inputs because the tree is never built
Run: python json_to_dict.py"""
import json
import hyperlarkfrom hyperlark import Transformer, v_args
GRAMMAR = r"""?start: value?value: object | array | string | SIGNED_NUMBER -> number | "true" -> true | "false" -> false | "null" -> nullarray : "[" [value ("," value)*] "]"object : "{" [pair ("," pair)*] "}"pair : string ":" valuestring : ESCAPED_STRING
%import common.ESCAPED_STRING%import common.SIGNED_NUMBER%import common.WS%ignore WS"""
DOC = """{ "name": "hyperlark", "engines": ["lalr", "earley"], "bindings": {"python": true, "js": true, "c": true}, "empty": [], "holes": [null, 1], "version": 0.1}"""
class ToDict(Transformer): """Each method replaces the rule of the same name with a Python value; rules whose children are already final map straight onto builtins."""
@v_args(inline=True) def string(self, s): # str() first: tokens are FastToken by default on EVERY path (tree and # embedded alike) — fast and sliceable, but not a str subclass, so # json.loads would reject one. Pass fast_tokens=False for str-subclass # tokens. json.loads then unquotes + unescapes. return json.loads(str(s))
@v_args(inline=True) def number(self, n): return float(n)
array = list pair = tuple object = dict
def true(self, _): return True
def false(self, _): return False
def null(self, _): return None
# maybe_placeholders=False, as in lark's own JSON tutorial: without it, an# empty `[...]` optional inserts a None child — indistinguishable from a real# JSON null once the transformer has run.OPTS = dict(parser="lalr", maybe_placeholders=False)
# 1. Post-parse: parse to a tree, then transform it.tree_parser = hyperlark.Lark(GRAMMAR, **OPTS)via_tree = ToDict().transform(tree_parser.parse(DOC))
# 2. Embedded: the transformer runs at the reduce seam, during the parse.folding_parser = hyperlark.Lark(GRAMMAR, transformer=ToDict(), **OPTS)via_fold = folding_parser.parse(DOC)
assert via_tree == via_fold == json.loads(DOC)print(json.dumps(via_fold, indent=2))print("matches json.loads:", via_fold == json.loads(DOC))// Working with big documents without materializing the whole tree:// 1. parseStream — a callback receives each completed subtree as it reduces,// and can stop the parse early.// 2. parseHandle — the tree stays in wasm memory; cursors pull out only the// nodes you ask for.//// Run from docs/examples/js: npm install && npm run stream
import { Lark } from "hyperlark";
const GRAMMAR = String.raw`?start: value?value: object | array | ESCAPED_STRING -> string | SIGNED_NUMBER -> number | "true" -> true | "false" -> false | "null" -> nullarray : "[" [value ("," value)*] "]"object : "{" [pair ("," pair)*] "}"pair : ESCAPED_STRING ":" value
%import common.ESCAPED_STRING%import common.SIGNED_NUMBER%import common.WS%ignore WS`;
// A few thousand records — enough that "don't build the whole tree" matters.const DOC = JSON.stringify( Array.from({ length: 5000 }, (_, i) => ({ id: i, name: `user_${i}`, active: i % 3 === 0, })),);
// Streaming taps the LALR reduce seam, so the engine must be opted in// explicitly (the binding's default is Earley, same as Python hyperlark and Lark).const parser = new Lark(GRAMMAR, { parser: "lalr" });
// An ESCAPED_STRING token's value is the raw quoted slice (`'"name"'`);// JSON.parse unquotes it. One helper owns that subtlety — it takes the TOKEN.const keyOf = (t) => JSON.parse(t.value);
// --- parseStream: every completed `pair` flows through the callback ---------let names = 0;const emitted = parser.parseStream( DOC, (pair) => { if (keyOf(pair.children[0]) === "name") names++; }, { on: "pair" },);console.log(`streamed ${emitted} pairs, ${names} of them "name" keys`);
// Returning false stops the parse — useful for "find the first match and get// out" over a large input.let firstName;parser.parseStream( DOC, (pair) => { if (keyOf(pair.children[0]) === "name") { // the value side is a `string` tree (the `-> string` alias), so the // raw token is one level down firstName = keyOf(pair.children[1].children[0]); return false; } }, { on: "pair" },);console.log(`first name in the document: ${firstName}`);
// --- parseHandle: query the retained tree, copy out only what you need ------// The handle holds wasm memory — free() it when done. (On Node >= 24,// `using handle = parser.parseHandle(DOC)` frees it automatically instead.)const handle = parser.parseHandle(DOC);let active = 0;// findData yields matching node positions; the cursor frees itself when the// loop ends.for (const pos of handle.findData("pair")) { const key = handle.token(handle.childPos(pos, 0)); if (keyOf(key) === "active" && handle.data(handle.childPos(pos, 1)) === "true") { active++; }}handle.free();console.log(`active users: ${active}`);//! Querying a parse tree: parse a JSON document, then pull out nodes by rule//! or terminal name — without walking everything by hand.//!//! Run from `docs/examples/rust`://!//! cargo run --bin json_queries
use hyperlark::Lark;
const GRAMMAR: &str = r#"?start: value?value: object | array | ESCAPED_STRING -> string | SIGNED_NUMBER -> number | "true" -> true | "false" -> false | "null" -> nullarray : "[" [value ("," value)*] "]"object : "{" [pair ("," pair)*] "}"pair : ESCAPED_STRING ":" value
%import common.ESCAPED_STRING%import common.SIGNED_NUMBER%import common.WS%ignore WS"#;
const DOC: &str = r#"{ "name": "hyperlark", "engines": ["lalr", "earley"], "bindings": { "python": true, "js": true, "c": true }, "version": 0.1}"#;
fn main() -> Result<(), Box<dyn std::error::Error>> { let parser = Lark::lalr(GRAMMAR)?; let result = parser.parse(DOC)?;
// find_data("pair"): every object entry in the document, as walkable // cursors — lark's `tree.find_data`, in the same bottom-up order (inner // pairs before the pairs that contain them). println!("all object keys:"); for pair in result.find_data("pair") { // pair: ESCAPED_STRING ":" value — children[0] is the key token. let key = pair.children()[0].token().expect("pair key is a token"); println!( " {} (line {}, column {})", key.value, key.line(), key.column() ); }
// find_token("ESCAPED_STRING"): every token of a terminal type, in // document order — keys and string values alike. let strings = result.find_token("ESCAPED_STRING").count(); println!("string tokens in the document: {strings}");
// (Lower level, when you need raw arena positions: ParseTable::display_id // resolves a rule name to the id the filtered cursors take.) Ok(())}Interpreter
Section titled “Interpreter”A transformer folds bottom-up, so every node is visited exactly once — which is
the wrong shape for repeat 3 { … }, where a block must run several times, or
for a conditional, where it must run zero. An interpreter walks top-down and
lets each method decide what to visit and how often.
"""A tiny language, run with an Interpreter — the top-down sibling ofTransformer. Adapted from lark's turtle DSL, but headless: instead of driving agraphics window it computes the pen's path, so it runs anywhere.
Why an Interpreter and not a Transformer here? A Transformer folds bottom-up:every node is visited exactly once, children before parents. So it cannotdirectly produce a VALUE for `repeat 3 { ... }`, where the block must runseveral times, or for a conditional, where it must run zero times — by the time`repeat` is called its block has already been folded, once. (You can still do itbottom-up by folding each block to a thunk and calling it N times; that is whata compiler does. This is about which is direct, not which is possible.) AnInterpreter visits top-down and lets each method decide what to visit and howoften, so `repeat` just loops. (Compare rust/interpreter.rs.)
Run: python dsl_interpreter.py"""
import math
import hyperlarkfrom hyperlark.visitors import Interpreter
# f/b = forward/back, l/r = turn left/right (degrees). `repeat N { ... }` takes# a nested block — that nesting is why we need top-down eval.GRAMMAR = r"""start: instruction+
?instruction: MOVE NUMBER -> move | TURN NUMBER -> turn | "repeat" NUMBER block -> repeat
block: "{" instruction+ "}"
MOVE: "f" | "b"TURN: "l" | "r"
%import common.NUMBER%import common.WS%ignore WS"""
PROGRAM = """repeat 4 { f 100 r 90}f 50"""
class Turtle(Interpreter): """Walks the tree top-down, keeping pen state as it goes. Each method is named after the node it handles — a rule, or the `-> alias` that renamed it. `repeat` re-visits its block, which is the whole point of an Interpreter. (`start` and `block` here just recurse, which is exactly what the inherited __default__ does; they are spelled out to keep the walk readable.)"""
def __init__(self): self.x = self.y = 0.0 self.heading = 0.0 # degrees; 0 = +x axis self.segments = [] # (x0, y0, x1, y1) for each 'f'/'b' move
def start(self, tree): self.visit_children(tree) # run each top-level instruction in order
def move(self, tree): direction, dist = tree.children # MOVE token, NUMBER token step = float(dist) * (1 if direction == "f" else -1) rad = math.radians(self.heading) x1 = self.x + step * math.cos(rad) y1 = self.y + step * math.sin(rad) self.segments.append((self.x, self.y, x1, y1)) self.x, self.y = x1, y1
def turn(self, tree): direction, degrees = tree.children self.heading += float(degrees) * (1 if direction == "l" else -1)
def repeat(self, tree): count, block = tree.children # NUMBER token, block subtree for _ in range(int(count)): self.visit(block) # re-visit the SAME subtree N times
def block(self, tree): self.visit_children(tree)
parser = hyperlark.Lark(GRAMMAR, parser="lalr")turtle = Turtle()turtle.visit(parser.parse(PROGRAM))
# `+ 0.0` normalises the -0.0 that trig at right angles produces, so the closing# corner reads (0.0, 0.0) rather than (-0.0, -0.0).def _z(v, places=1): return round(v, places) + 0.0
print(f"{len(turtle.segments)} pen strokes:")for x0, y0, x1, y1 in turtle.segments: print(f" ({_z(x0):6.1f}, {_z(y0):6.1f}) -> ({_z(x1):6.1f}, {_z(y1):6.1f})")
xs = [c for seg in turtle.segments for c in (seg[0], seg[2])]ys = [c for seg in turtle.segments for c in (seg[1], seg[3])]print(f"bounding box: x [{_z(min(xs), 0):.0f}, {_z(max(xs), 0):.0f}] " f"y [{_z(min(ys), 0):.0f}, {_z(max(ys), 0):.0f}]")//! An interpreter-style evaluator: `ParseResult::eval` walks TOP-DOWN, and//! each match arm decides which children to fold — so `if` evaluates only//! the branch it takes. A bottom-up fold (see `calc.rs`) can't do that: by//! the time the `if` rule runs, both branches have already been folded.//!//! Run from `docs/examples/rust`://!//! cargo run --bin interpreter
use std::cell::Cell;
use hyperlark::Lark;
const GRAMMAR: &str = r#"?start: expr?expr: "if" expr "then" expr "else" expr -> if_expr | sum?sum: product | sum "+" product -> add | sum "-" product -> sub?product: atom | product "*" atom -> mul | product "/" atom -> div?atom: NUMBER -> number | "(" expr ")"
%import common.NUMBER%import common.WS%ignore WS"#;
fn main() -> Result<(), Box<dyn std::error::Error>> { let parser = Lark::lalr(GRAMMAR)?;
// Same expression twice — only the condition differs. The visit counter // shows the evaluator never enters the branch it didn't take. for expr in [ "if 1 then 2 + 3 else 100 * (4 - 5) / 6", "if 0 then 2 + 3 else 100 * (4 - 5) / 6", ] { let result = parser.parse(expr)?;
// One match dispatches rule aliases (lowercase) and token TYPES // (uppercase) side by side; `n.eval(i)` folds exactly the child an // arm asks for, and unasked children are never visited. let visited = Cell::new(0u32); let value: f64 = result.eval(|n| { visited.set(visited.get() + 1); match n.name() { "if_expr" => { if n.eval(0) != 0.0 { n.eval(1) } else { n.eval(2) } } "add" => n.eval(0) + n.eval(1), "sub" => n.eval(0) - n.eval(1), "mul" => n.eval(0) * n.eval(1), "div" => n.eval(0) / n.eval(1), "number" => n.eval(0), "NUMBER" => n.text().parse().unwrap(), other => unreachable!("unexpected node: {other}"), } }); println!("{expr} => {value} ({} nodes visited)", visited.get()); }
// The skipped branch isn't just uncounted work: side effects, errors, // and non-termination in dead code all stay dead — interpreter rules. // When every node SHOULD be visited (a transformer, a serializer), the // bottom-up `Fold` in calc.rs is the right tool, and its iterative walk // also holds up on trees too deep for recursion. Ok(())}Indentation
Section titled “Indentation”Languages like Python and YAML carry structure in their leading whitespace. The
grammar declares _INDENT / _DEDENT; the bundled Indenter postlexer turns
indentation into those tokens as the stream goes past.
"""Parsing an indentation-based language (like Python or YAML) with theIndenter postlexer: the grammar declares _INDENT/_DEDENT, the Indenter turnsleading whitespace into those tokens.
Run: python indented_language.py"""
import hyperlarkfrom hyperlark.indenter import Indenter
GRAMMAR = r"""?start: _NL* treetree: NAME _NL [_INDENT tree+ _DEDENT]
NAME: /\w+/_NL: /(\r?\n[\t ]*)+/
%declare _INDENT _DEDENT
// _NL owns the newline + leading indent; WS_INLINE covers spaces WITHIN a line%import common.WS_INLINE%ignore WS_INLINE"""
DOC = """\root branch leaf leaf2 branch2"""
class TreeIndenter(Indenter): NL_type = "_NL" OPEN_PAREN_types = [] # brackets that suspend indentation, e.g. ["LPAR"] CLOSE_PAREN_types = [] INDENT_type = "_INDENT" DEDENT_type = "_DEDENT" tab_len = 8
parser = hyperlark.Lark(GRAMMAR, parser="lalr", postlex=TreeIndenter())print(parser.parse(DOC).pretty())Ambiguity
Section titled “Ambiguity”Some grammars let one input parse more than one way. Earley can keep every
reading instead of silently picking one: ambiguity="explicit" leaves an
_ambig node wherever the grammar admitted more than one derivation, and
CollapseAmbiguities expands that packed tree into the flat list of
unambiguous trees it stands for.
"""Ambiguity, made explicit: some grammars let one input parse more than oneway. The Earley engine can keep every reading instead of silently picking one.Adapted from lark's classic "fruit flies like bananas" example.
ambiguity="explicit" — the parse tree keeps an `_ambig` node wherever the grammar admitted more than one derivation, with each reading as a child. CollapseAmbiguities — expands that packed tree into the flat list of unambiguous trees it stands for.
Only the Earley engine can report ambiguity. LALR still parses this grammar --it just commits to one reading silently, with no way to ask for the others.
Run: python earley_ambiguity.py"""
import hyperlarkfrom hyperlark.visitors import CollapseAmbiguities
# "fruit flies like bananas" reads two ways: fruit-flies (a kind of fly) that# like bananas, or fruit that flies the way bananas do. Both terminals overlap# on purpose — NOUN, VERB and ADJ all admit some of the same words.GRAMMAR = r"""sentence: noun verb noun -> simple | noun verb "like" noun -> comparative
noun: adj? NOUNverb: VERBadj: ADJ
NOUN: "flies" | "bananas" | "fruit"VERB: "like" | "flies"ADJ: "fruit"
%import common.WS%ignore WS"""
# ambiguity="explicit" only means something under Earley (the default engine),# so there's no parser= to set here — it's already Earley.parser = hyperlark.Lark(GRAMMAR, start="sentence", ambiguity="explicit")
tree = parser.parse("fruit flies like bananas")
# The `_ambig` node at the root holds both readings side by side.print("packed tree (both readings under one _ambig node):")print(tree.pretty())
# CollapseAmbiguities flattens the packed tree into the list of plain,# unambiguous trees it represents — one per reading, ready for a Transformer# or any ordinary tree code that doesn't expect `_ambig`.readings = CollapseAmbiguities().transform(tree)print(f"{len(readings)} distinct readings:")for i, reading in enumerate(readings, 1): # reading.data is the alias of the top alternative — 'simple' vs 'comparative' print(f" {i}. {reading.data}")
# Without ambiguity="explicit", Earley returns a single tree (the default,# "resolve"). This grammar declares no priorities, so the tie is broken by the# ORDER the alternatives are written — swap the two `sentence` lines and the# answer below flips to `comparative`.resolving = hyperlark.Lark(GRAMMAR, start="sentence") # ambiguity="resolve"chosen = resolving.parse("fruit flies like bananas")assert chosen.data != "_ambig"print(f"with the default resolver, the engine commits to: {chosen.data}")// Ambiguity, made explicit. Some grammars let one input parse more than one// way; the Earley engine can keep every reading instead of silently choosing.// The classic "fruit flies like bananas".//// ambiguity: "explicit" — the tree keeps an `_ambig` node wherever the// grammar admitted more than one derivation, with// each reading as a child.//// Earley is the binding's default engine (same as Python hyperlark and Lark),// and only Earley can REPORT ambiguity, so there's no parser: option to set// here. LALR parses this grammar too -- it just picks one reading silently.//// Run from docs/examples/js: npm install && npm run ambiguity
import { Lark, pretty, findData } from "hyperlark";
const GRAMMAR = String.raw`sentence: noun verb noun -> simple | noun verb "like" noun -> comparative
noun: adj? NOUNverb: VERBadj: ADJ
NOUN: "flies" | "bananas" | "fruit"VERB: "like" | "flies"ADJ: "fruit"
%import common.WS%ignore WS`;
const parser = new Lark(GRAMMAR, { start: "sentence", ambiguity: "explicit" });
const tree = parser.parse("fruit flies like bananas");
// The `_ambig` node holds both readings side by side.console.log("packed tree (both readings under one _ambig node):");console.log(pretty(tree));
// findData walks the tree for nodes with a given `.data`. Note it yields// bottom-up (root LAST, like lark's iter_subtrees), so take the LAST match to// get the outermost `_ambig` — `[0]` would be the most deeply nested one. This// grammar admits exactly one, but nested ambiguity would differ.const ambigs = [...findData(tree, "_ambig")];const ambig = ambigs.at(-1);const readings = ambig ? ambig.children : [tree];console.log(`${readings.length} distinct readings:`);for (const [i, reading] of readings.entries()) { console.log(` ${i + 1}. ${reading.data}`);}
// Without ambiguity:"explicit", Earley returns a single tree (the default).// This grammar declares no priorities, so the tie is broken by the ORDER the// alternatives are written — swap the two `sentence` lines and this flips.const resolving = new Lark(GRAMMAR, { start: "sentence" });const chosen = resolving.parse("fruit flies like bananas");console.log(`with the default resolver, the engine commits to: ${chosen.data}`);//! Ambiguity, made explicit. Some grammars let one input parse more than one//! way; the Earley engine can keep every reading instead of silently choosing.//! The classic "fruit flies like bananas".//!//! `Ambiguity::Explicit` makes the tree keep an `_ambig` node wherever the//! grammar admitted more than one derivation, with each reading as a child.//! Only the Earley engine can REPORT ambiguity, so this builds with//! `ParserKind::Earley`. LALR parses this grammar too -- it just commits to one//! reading silently, with no way to ask for the others.//!//! Run from `docs/examples/rust`://!//! cargo run --bin earley_ambiguity
use hyperlark::{Ambiguity, Lark, LarkOptions, NodeKind, ParserKind};
const GRAMMAR: &str = r#"sentence: noun verb noun -> simple | noun verb "like" noun -> comparative
noun: adj? NOUNverb: VERBadj: ADJ
NOUN: "flies" | "bananas" | "fruit"VERB: "like" | "flies"ADJ: "fruit"
%import common.WS%ignore WS"#;
fn main() -> Result<(), Box<dyn std::error::Error>> { // Options beyond the one-word constructors go through LarkOptions over // Default: Earley engine, explicit ambiguity, and "sentence" as the start // rule. let parser = Lark::from_lark_source( GRAMMAR, LarkOptions { parser: ParserKind::Earley, ambiguity: Ambiguity::Explicit, start: vec!["sentence".to_string()], ..Default::default() }, )?;
let result = parser.parse("fruit flies like bananas")?;
// The `_ambig` node holds both readings side by side. println!("packed tree (both readings under one _ambig node):"); print!("{}", result.pretty());
// The alternatives are the children of the `_ambig` node — walk the root // cursor and read each child's rule name. (find_data deliberately does not // resolve the internal `_ambig` sentinel, so we match on it directly.) let root = result.root(); let readings: Vec<_> = match root.kind() { NodeKind::Tree { data: "_ambig" } => root.children(), _ => vec![root], // unambiguous: the whole tree is the one reading }; println!("{} distinct readings:", readings.len()); for (i, reading) in readings.iter().enumerate() { // each reading's data is its rule alias — "simple" vs "comparative" if let NodeKind::Tree { data } = reading.kind() { println!(" {}. {data}", i + 1); } }
// With the default `Ambiguity::Resolve`, Earley returns a single tree. // This grammar declares no priorities, so the tie is broken by the ORDER // the alternatives are written — swap the two `sentence` lines and this // flips to `comparative`. let resolving = Lark::from_lark_source( GRAMMAR, LarkOptions { parser: ParserKind::Earley, start: vec!["sentence".to_string()], ..Default::default() }, )?; let chosen = resolving.parse("fruit flies like bananas")?; if let NodeKind::Tree { data } = chosen.root().kind() { println!("with the default resolver, the engine commits to: {data}"); }
Ok(())}Interactive parsing
Section titled “Interactive parsing”Drive an LALR parse one token at a time and ask, at any point, what could
legally come next? That accept-set is exactly what an autocomplete, a linter or
a structured editor needs. Fork the parser with copy to try a token
speculatively without committing to it.
"""The interactive parser: drive an LALR parse one token at a time and ask, atany point, "what could legally come next?" That accept-set is exactly what anautocomplete / linter / structured editor needs. This is lark's`parse_interactive` API, method for method — with the divergences noted at thebottom of this file.
parse_interactive(text) -> InteractiveParser .iter_parse() — pull tokens from the built-in lexer, YIELDING each one before feeding it (lark's ordering); it's a generator, so you can inspect state — or retype the yielded token — and `break` to stop .accepts() — the terminal names valid in the current state .copy() — fork, to try a token speculatively without committing .exhaust_lexer() — feed every remaining token in one go .feed_eof() — finish with a synthesized $END -> parse tree .resume_parse() — drive whatever is left + $END -> parse tree
Interactive parsing is an LALR feature, so parser="lalr".
Run: python interactive_autocomplete.py"""
import hyperlarkfrom hyperlark import Token
# A toy query language. The keywords become terminals named after themselves# (SELECT, FROM, WHERE), so the accept-set reads like a list of suggestions.GRAMMAR = r"""start: "SELECT" columns "FROM" NAME where?columns: "*" | NAME ("," NAME)*where: "WHERE" NAME COMP valuevalue: NUMBER | ESCAPED_STRINGCOMP: ">" | "<" | "="NAME: /[a-z_]+/
%import common.NUMBER%import common.ESCAPED_STRING%import common.WS%ignore WS"""
parser = hyperlark.Lark(GRAMMAR, parser="lalr")
QUERY = "SELECT id, name FROM users WHERE id = 42"
# Friendly labels for the terminal names the parser reports: keywords stand for# themselves, and the open-ended terminals get a placeholder an editor could# show. Anything absent falls through to its raw terminal name.LABELS = { "SELECT": "SELECT", "FROM": "FROM", "WHERE": "WHERE", "STAR": "*", "COMMA": ",", "COMP": "> < =", "NAME": "<column/table>", "NUMBER": "<number>", "ESCAPED_STRING": '"<text>"', "$END": "(end of query)",}
def labelled(names): return ", ".join(LABELS.get(n, n) for n in sorted(names))
# --- 1. Walk the parse with iter_parse --------------------------------------# iter_parse() yields each token BEFORE feeding it (lark's ordering — that's# what lets you retype a yielded token to steer the parse). So the loop body# runs with the parser still in the state the token was lexed in: accepts()# here answers "what could legally stand at this token's spot" — the yielded# token's own type is always among the suggestions.print(f"walking {QUERY!r} token by token:\n")ip = parser.parse_interactive(QUERY)for tok in ip.iter_parse(): print(f" {tok.type:15} {str(tok.value):8} ← could be: {labelled(ip.accepts())}")
# iter_parse stops at end of input without feeding EOF — finish explicitly.tree = ip.feed_eof()print(f"\nfeed_eof() completed the parse: {tree.data}")
# --- 2. Stop early, then take over ------------------------------------------# Because iter_parse is a generator, `break` leaves the parser suspended# mid-input, fully usable: inspect it, fork it, or drive it to the end. The# yielded-but-unfed token (FROM here) stays QUEUED, so nothing is lost — the# next driving call feeds it first.ip = parser.parse_interactive(QUERY)for tok in ip.iter_parse(): if tok.type == "FROM": break # pause the parse at the FROM keyword (yielded, not yet fed)print(f"\npaused at FROM — the parser expects: {labelled(ip.accepts())}")
# copy() forks an independent parser: try a token on the fork to see whether it# would be legal here, leaving the real parse untouched.fork = ip.copy()try: fork.feed_token(Token("COMMA", ",")) comma_ok = Trueexcept hyperlark.UnexpectedToken: comma_ok = Falseprint(f"would ',' be legal at this point? {comma_ok}")
# The suspended parser still has the rest of the input — the queued FROM# included; resume_parse drives it plus $END to a finished tree. (In lark the# abandoned token is stranded and this resume fails; keeping it queued is a# hyperlark divergence.)print("resume_parse() finished the rest:")print(ip.resume_parse().pretty())
# --- 3. Completion for a half-typed query -----------------------------------# Same idea, packaged: feed everything typed so far, then read the accept-set.# exhaust_lexer() is iter_parse()'s drive-it-all sibling, for when you don't# need each token on the way through.def suggest(prefix): ip = parser.parse_interactive(prefix) ip.exhaust_lexer() return labelled(ip.accepts())
print("completion after each prefix:")for prefix in ["", "SELECT", "SELECT name", "SELECT name, email FROM users", "SELECT * FROM users WHERE age"]: print(f" {prefix or '(empty)':32} → {suggest(prefix)}")// The interactive parser: drive an LALR parse one token at a time and ask, at// any point, "what could legally come next?" That accept-set is exactly what// an autocomplete / linter / structured editor needs. (Mirrors python/// interactive_autocomplete.py.)//// parser.parseInteractive(text) -> InteractiveParser// .iterParse() — pull tokens from the built-in lexer, feeding and// YIELDING each one; it's a generator, so you can// inspect state between tokens and `break` to stop// .accepts() — terminal names valid in the current state// .copy() — fork, to try a token speculatively without committing// .exhaustLexer() — feed every remaining token in one go// .feedEof() — finish with a synthesized $END -> parse tree// .resumeParse() — drive whatever is left + $END -> parse tree//// Interactive parsing taps the LALR machine, so parser: "lalr" is required// (the binding's default is Earley). These handles hold wasm memory — free()// them. `feedEof`/`resumeParse` hand back a ParseHandle (the retained-arena// view, same as parseHandle in json_stream.mjs).//// The straight-line sections below free on the happy path, which is all a script// needs. Where a throw is EXPECTED — the speculative feed, and suggest() — the// free is in a `finally`; that is the shape to copy in a long-running program// (or `using handle = ...` on Node >= 24, which frees on scope exit).//// Run from docs/examples/js: npm install && npm run interactive
import { Lark, pretty, UnexpectedToken } from "hyperlark";
// A toy query language. The keywords become terminals named after themselves// (SELECT, FROM, WHERE), so the accept-set reads like a list of suggestions.const GRAMMAR = String.raw`start: "SELECT" columns "FROM" NAME where?columns: "*" | NAME ("," NAME)*where: "WHERE" NAME COMP valuevalue: NUMBER | ESCAPED_STRINGCOMP: ">" | "<" | "="NAME: /[a-z_]+/
%import common.NUMBER%import common.ESCAPED_STRING%import common.WS%ignore WS`;
const parser = new Lark(GRAMMAR, { parser: "lalr" });
const QUERY = "SELECT id, name FROM users WHERE id = 42";
// Friendly labels for the terminal names the parser reports.const LABELS = { SELECT: "SELECT", FROM: "FROM", WHERE: "WHERE", STAR: "*", COMMA: ",", COMP: "> < =", NAME: "<column/table>", NUMBER: "<number>", ESCAPED_STRING: '"<text>"', $END: "(end of query)",};
const labelled = (names) => names.sort().map((n) => LABELS[n] ?? n).join(", ");
// --- 1. Walk the parse with iterParse ---------------------------------------// iterParse() feeds each lexed token and yields it back, so the loop body runs// BETWEEN tokens — with the parser sitting in the state that token produced.// That is the moment to ask accepts(): it's what could follow, right here.console.log(`walking ${JSON.stringify(QUERY)} token by token:\n`);let ip = parser.parseInteractive(QUERY);for (const tok of ip.iterParse()) { console.log(` ${tok.type.padEnd(15)} ${String(tok.value).padEnd(8)} → next: ${labelled(ip.accepts())}`);}
// iterParse stops at end of input without feeding EOF — finish explicitly.let handle = ip.feedEof();console.log(`\nfeedEof() completed the parse: ${handle.data(handle.rootPos())}`);handle.free();ip.free();
// --- 2. Stop early, then take over ------------------------------------------// Because iterParse is a generator, `break` leaves the parser suspended// mid-input, fully usable: inspect it, fork it, or drive it to the end.ip = parser.parseInteractive(QUERY);for (const tok of ip.iterParse()) { if (tok.type === "FROM") break; // pause the parse right after the FROM keyword}console.log(`\npaused after FROM — the parser now expects: ${labelled(ip.accepts())}`);
// copy() forks an independent parser: try a token on the fork to see whether it// would be legal here, leaving the real parse untouched.const fork = ip.copy();let commaOk = true;try { fork.feedToken({ type: "COMMA", value: "," });} catch (e) { if (!(e instanceof UnexpectedToken)) throw e; commaOk = false;} finally { fork.free(); // released on the rethrow path too}console.log(`would ',' be legal at this point? ${commaOk}`);
// The suspended parser still has the rest of the input queued; resumeParse// drives it plus $END to a finished tree.handle = ip.resumeParse();console.log("resumeParse() finished the rest:");console.log(pretty(handle.toJs(handle.rootPos())));handle.free();ip.free();
// --- 3. Completion for a half-typed query -----------------------------------// Same idea, packaged: feed everything typed so far, then read the accept-set.// exhaustLexer() is iterParse()'s drive-it-all sibling, for when you don't need// each token on the way through.function suggest(prefix) { const p = parser.parseInteractive(prefix); try { p.exhaustLexer(); return labelled(p.accepts()); } finally { p.free(); // wasm memory: release it even if the lex throws }}
console.log("completion after each prefix:");for (const prefix of ["", "SELECT", "SELECT name", "SELECT name, email FROM users", "SELECT * FROM users WHERE age"]) { console.log(` ${(prefix || "(empty)").padEnd(32)} → ${suggest(prefix)}`);}//! The interactive parser: drive an LALR parse one token at a time and ask, at//! any point, "what could legally come next?" That accept-set is exactly what//! an autocomplete / linter / structured editor needs. (Mirrors python///! interactive_autocomplete.py and js/interactive.mjs.)//!//! `OwnedInteractiveParser` is the self-contained, lifetime-free handle: it owns//! the grammar (`Arc<Lark>`) and the input, so you can hold it, move it between//! threads, and `copy()` it.//!//! feed_next() — pull ONE token from the built-in lexer, feed it, return//! it. Looping on it is Rust's `iter_parse`: the loop body//! runs between tokens, and `break` just stops.//! peek_next() — borrow the next token as `&mut` WITHOUT feeding it, to//! inspect or edit in place before a driving call sends it//! accepts() — the terminal names valid in the current state//! feed_token(tok) — feed a token you built yourself//! copy() — fork, to try a token speculatively without committing//! exhaust_lexer() — feed every remaining token in one go//! feed_eof() / resume_parse() — finish into a tree//!//! Run from `docs/examples/rust`://!//! cargo run --bin interactive
use std::sync::Arc;
use hyperlark::{Lark, OwnedInteractiveParser, ParseError, ParseResult, Token};
// A toy query language. The keywords become terminals named after themselves// (SELECT, FROM, WHERE), so the accept-set reads like a list of suggestions.const GRAMMAR: &str = r#"start: "SELECT" columns "FROM" NAME where?columns: "*" | NAME ("," NAME)*where: "WHERE" NAME COMP valuevalue: NUMBER | ESCAPED_STRINGCOMP: ">" | "<" | "="NAME: /[a-z_]+/
%import common.NUMBER%import common.ESCAPED_STRING%import common.WS%ignore WS"#;
const QUERY: &str = "SELECT id, name FROM users WHERE id = 42";
/// Friendly labels for the terminal names the parser reports.fn label(name: &str) -> &str { match name { "STAR" => "*", "COMMA" => ",", "COMP" => "> < =", "NAME" => "<column/table>", "NUMBER" => "<number>", "ESCAPED_STRING" => "\"<text>\"", "$END" => "(end of query)", other => other, // keywords are named after themselves }}
fn labelled(mut names: Vec<String>) -> String { names.sort(); names .iter() .map(|n| label(n)) .collect::<Vec<_>>() .join(", ")}
fn main() -> Result<(), Box<dyn std::error::Error>> { // The interactive parser holds the grammar by Arc, so build one and share. let lark = Arc::new(Lark::lalr(GRAMMAR)?); // The parse table resolves terminal NAMES to the ids Token carries — used // below both to match a token by type and to build one by hand. let table = lark.parse_table(); let from_id = table.token_id("FROM").expect("declared terminal");
// --- 1. Walk the parse, one token at a time ----------------------------- // feed_next() feeds the next lexed token and hands it back, so the loop // body runs BETWEEN tokens — with the parser in the state that token // produced. That is the moment to ask accepts(): what could follow, here. println!("walking {QUERY:?} token by token:\n"); let mut ip = OwnedInteractiveParser::new(lark.clone(), QUERY.to_string(), None)?; while let Some(tok) = ip.feed_next()? { // Token carries a type id; the table maps it back to the name. let ty = &table.id_to_token[tok.type_id]; println!( " {:15} {:8} -> next: {}", ty, tok.value, labelled(ip.accepts()) ); }
// The loop ends at end of input without feeding EOF — finish explicitly. // feed_eof hands back a bare ParsedTree; pair it with the table to get the // same ParseResult (and so the same cursor/pretty API) a plain parse returns. let done = ParseResult { tree: Arc::new(ip.feed_eof()?), table: ip.parse_table(), }; println!( "\nfeed_eof() completed the parse: {}", done.root().data().unwrap_or("?") );
// --- 2. Stop early, then take over -------------------------------------- // `break` leaves the parser suspended mid-input, fully usable: inspect it, // fork it, or drive it to the end. let mut ip = OwnedInteractiveParser::new(lark.clone(), QUERY.to_string(), None)?; while let Some(tok) = ip.feed_next()? { // Match on the token's TYPE, not its text — the value happens to be // unique here, but the type id is what actually identifies a terminal. if tok.type_id == from_id { break; // pause the parse right after the FROM keyword } } println!( "\npaused after FROM - the parser now expects: {}", labelled(ip.accepts()) );
// copy() forks an independent parser: try a token on the fork to see // whether it would be legal here, leaving the real parse untouched. let comma = Token::synthetic(table.token_id("COMMA").expect("declared terminal"), ","); let mut fork = ip.copy()?; // Distinguish "the state rejected a real terminal" (the answer we want) // from any other error — a bad terminal name is a bug, not a parse answer. let comma_ok = match fork.feed_token(comma) { Ok(()) => true, Err(ParseError::UnexpectedToken { .. }) => false, Err(e) => return Err(e.into()), }; println!("would ',' be legal at this point? {comma_ok}");
// The suspended parser still has the rest of the input queued; resume_parse // drives it plus $END to a finished tree. let rest = ParseResult { tree: Arc::new(ip.resume_parse()?), table: ip.parse_table(), }; println!("resume_parse() finished the rest:"); print!("{}", rest.pretty());
// --- 3. Completion for a half-typed query ------------------------------- // Same idea, packaged: feed everything typed so far, then read the // accept-set. exhaust_lexer() is the drive-it-all sibling of feed_next(), // for when you don't need each token on the way through. println!("\ncompletion after each prefix:"); for prefix in [ "", "SELECT", "SELECT name", "SELECT name, email FROM users", "SELECT * FROM users WHERE age", ] { let mut ip = OwnedInteractiveParser::new(lark.clone(), prefix.to_string(), None)?; ip.exhaust_lexer()?; let shown = if prefix.is_empty() { "(empty)" } else { prefix }; println!(" {shown:32} -> {}", labelled(ip.accepts())); }
Ok(())}/* * Interactive parsing in C: drive an LALR parse one token at a time and ask, at * any point, "what could legally come next?" That accept-set is exactly what an * autocomplete / linter / structured editor needs. (Mirrors python/ * interactive_autocomplete.py, js/interactive.mjs, rust/interactive.rs.) * * The same walk as the other bindings, spelled in C: * * lark_parse_interactive() seat a parser over the text * lark_interactive_feed_next() pull ONE token from the built-in lexer, * feed it, report it — looping on it is * iter_parse, and stopping the loop is * `break` * lark_interactive_accepts_count/get the accept-set, as a count plus indexed * reads (no joined string to split) * lark_interactive_feed_token() feed a token you built yourself, BY * TERMINAL NAME — no Token struct to * build, so you can drive the parser * straight from whatever your editor * knows about the text * lark_interactive_copy() fork, to try a token speculatively * lark_interactive_feed_eof() / finish into a tree * lark_interactive_resume_parse() * * Build the library once, then compile against it (from docs/examples/c): * * cargo build -p hyperlark-c --release # any workspace dir * cc interactive.c -I ../../../crates/hyperlark-c/include \ * ../../../target/release/libhyperlark_c.a \ * -lpthread -ldl -lm -o interactive && ./interactive */
#include <stdbool.h>#include <stdio.h>#include <stdlib.h> /* exit */#include <string.h>
#include "hyperlark.h"
/* A toy query language. The keywords become terminals named after themselves * (SELECT, FROM, WHERE), so the accept-set reads like a list of suggestions. */static const char GRAMMAR[] = "start: \"SELECT\" columns \"FROM\" NAME where?\n" "columns: \"*\" | NAME (\",\" NAME)*\n" "where: \"WHERE\" NAME COMP value\n" "value: NUMBER | ESCAPED_STRING\n" "COMP: \">\" | \"<\" | \"=\"\n" "NAME: /[a-z_]+/\n" "\n" "%import common.NUMBER\n" "%import common.ESCAPED_STRING\n" "%import common.WS\n" "%ignore WS\n";
static const char QUERY[] = "SELECT id, name FROM users WHERE id = 42";
/* Every feed below is one the grammar accepts, so a failure is a bug in this * file, not user input — abort loudly. (Ignoring a status would silently parse * something else: feed_next CONSUMES the offending token even on failure.) */static void die(const char *what) { fprintf(stderr, "%s: %s\n", what, lark_last_error()); exit(1);}
/* Print the accept-set inline. accepts_count snapshots the current state's * terminals (sorted); accepts_get reads them back — each pointer is valid until * the next accepts_count on this handle, so print as you go. */static void show_accepts(LarkInteractive *ip) { size_t n = lark_interactive_accepts_count(ip); for (size_t i = 0; i < n; i++) printf(" %s", lark_interactive_accepts_get(ip, i)); putchar('\n');}
int main(void) { Lark *parser = NULL; if (lark_from_source(GRAMMAR, sizeof(GRAMMAR) - 1, LARK_OPT_NONE, &parser) != LARK_OK) die("grammar error");
/* --- 1. Walk the parse, one token at a time --------------------------- */ /* feed_next() lexes the next token, feeds it, and fills a LarkTokenEvent — * so the loop body runs BETWEEN tokens, with the parser in the state that * token produced. That is the moment to ask for the accept-set: what could * follow, right here. The event's value is a borrowed (ptr,len) span, and * lark_token_name maps its type id back to the terminal name. */ printf("walking \"%s\" token by token:\n\n", QUERY); LarkInteractive *ip = NULL; if (lark_parse_interactive(parser, QUERY, sizeof(QUERY) - 1, NULL, &ip) != LARK_OK) die("interactive setup"); for (;;) { LarkTokenEvent ev; bool has_token; if (lark_interactive_feed_next(ip, &ev, &has_token) != LARK_OK) die("feed_next"); if (!has_token) break; const char *name = NULL; size_t name_len = 0; if (lark_token_name(parser, ev.type_id, &name, &name_len) != LARK_OK) die("token_name"); printf(" %-15.*s %-8.*s -> next:", (int)name_len, name, (int)ev.value_len, ev.value); show_accepts(ip); }
/* The walk ends at end of input without feeding EOF — finish explicitly. * feed_eof synthesizes "$END" and hands back the completed tree. */ LarkParseResult *result = NULL; if (lark_interactive_feed_eof(ip, &result) != LARK_OK) die("feed_eof"); printf("\nfeed_eof() completed the parse:\n%s", lark_result_pretty(result)); lark_result_free(result); /* frees the arena and every node view into it */ lark_interactive_free(ip);
/* --- 2. Stop early, speculate, then take over ------------------------- */ /* `break` mid-walk leaves the parser suspended mid-input, fully usable: * inspect it, fork it, or drive it to the end. Match on the token's TYPE * id, not its text — lark_token_id resolves the name once, up front. */ uint32_t from_id = 0; if (lark_token_id(parser, "FROM", 4, &from_id) != LARK_OK) die("token_id"); if (lark_parse_interactive(parser, QUERY, sizeof(QUERY) - 1, NULL, &ip) != LARK_OK) die("interactive setup"); for (;;) { LarkTokenEvent ev; bool has_token; if (lark_interactive_feed_next(ip, &ev, &has_token) != LARK_OK) die("feed_next"); if (!has_token || ev.type_id == from_id) break; /* pause the parse right after the FROM keyword */ } printf("\npaused after FROM - the parser now expects:"); show_accepts(ip);
/* copy() forks an independent parser. Test whether a stray comma would be * legal right after FROM — on the fork, so `ip` is left untouched. The * fork is fed BY NAME: feed_token builds the token from a terminal name * and a value span. Note the two failure modes are distinct: * LARK_UNEXPECTED_TOKEN means the state rejected a real terminal (the * answer we want), while LARK_INVALID_ARGUMENT would mean "COMMA" isn't a * declared terminal at all — a typo, not a parse answer. */ LarkInteractive *fork = NULL; if (lark_interactive_copy(ip, &fork) != LARK_OK) die("copy"); LarkStatus comma = lark_interactive_feed_token(fork, "COMMA", ",", 1); if (comma != LARK_OK && comma != LARK_UNEXPECTED_TOKEN) die("speculative feed"); printf("would ',' be legal at this point? %s\n", comma == LARK_OK ? "yes" : "no"); lark_interactive_free(fork);
/* The suspended parser still has the rest of the input queued; * resume_parse drives it plus "$END" to a finished tree. */ if (lark_interactive_resume_parse(ip, &result) != LARK_OK) die("resume_parse"); printf("resume_parse() finished the rest:\n%s", lark_result_pretty(result)); lark_result_free(result); lark_interactive_free(ip);
/* --- 3. Completion for a half-typed query ----------------------------- */ /* Same idea, packaged: seat a parser over everything typed so far, drain * the lexer, then read the accept-set. The drain loop is C's * exhaust_lexer — feed_next until has_token goes false. */ printf("\ncompletion after each prefix:\n"); static const char *PREFIXES[] = { "", "SELECT", "SELECT name", "SELECT name, email FROM users", "SELECT * FROM users WHERE age", }; for (size_t i = 0; i < sizeof(PREFIXES) / sizeof(PREFIXES[0]); i++) { const char *prefix = PREFIXES[i]; if (lark_parse_interactive(parser, prefix, strlen(prefix), NULL, &ip) != LARK_OK) die("interactive setup"); for (;;) { LarkTokenEvent ev; bool has_token; if (lark_interactive_feed_next(ip, &ev, &has_token) != LARK_OK) die("feed_next"); if (!has_token) break; } printf(" %-32s ->", *prefix ? prefix : "(empty)"); show_accepts(ip); lark_interactive_free(ip); }
lark_free(parser); return 0;}Error reporting
Section titled “Error reporting”Turn a raw parse failure into a specific, friendly message by matching the
parser’s state against a handful of labelled broken inputs. You describe the
mistakes by example; match_examples() re-parses each one and compares parser
state with the real failure — no hand-written state tables.
"""Example-driven error reporting: turn a raw parse failure into a friendly,specific message by matching the parser's state against a handful of labelledbroken inputs — lark's example-driven error-reporting recipe (with the examplesets retuned to this grammar's states; see the note by the examples below).
The trick is UnexpectedInput.match_examples(): you hand it your own malformedsnippets grouped by the message you'd like to show, and it re-parses each oneand compares parser state with the real failure. No hand-written state tables —you describe the mistakes by example.
Run: python error_reporting.py"""
import hyperlarkfrom hyperlark import UnexpectedInput
JSON_GRAMMAR = r"""?start: value?value: object | array | ESCAPED_STRING -> string | SIGNED_NUMBER -> number | "true" -> true | "false" -> false | "null" -> nullarray : "[" [value ("," value)*] "]"object : "{" [pair ("," pair)*] "}"pair : ESCAPED_STRING ":" value
%import common.ESCAPED_STRING%import common.SIGNED_NUMBER%import common.WS%ignore WS"""
json_parser = hyperlark.Lark(JSON_GRAMMAR, parser="lalr")
# Each subclass is a diagnosis. match_examples() returns one of these classes# (or None); we raise it with the caret context filled in.class JsonSyntaxError(SyntaxError): def __str__(self): context, line, column = self.args return f"{self.label} at line {line}, column {column}.\n\n{context}"
class JsonMissingOpening(JsonSyntaxError): label = "Missing opening bracket"
class JsonMissingClosing(JsonSyntaxError): label = "Missing closing bracket"
class JsonMissingComma(JsonSyntaxError): label = "Missing comma"
class JsonTrailingComma(JsonSyntaxError): label = "Trailing comma"
def parse(json_text): try: return json_parser.parse(json_text) except UnexpectedInput as u: # The keys are the diagnosis classes; the values are broken inputs that # fail the same way. match_examples re-parses each and picks the label # whose parser state matches the real failure. exc_class = u.match_examples( json_parser.parse, { JsonMissingOpening: ['{"foo": ]}', '{"foo": }'], JsonMissingClosing: ["{", '{"a": 1', "[1", '{"foo": [}'], # Several variants per label, because the parser state depends # on more than the mistake. Here it is the terminal preceding # the junk token: '[1 2]' and '["a" 1]' fail in DIFFERENT states, # since the value reduction is deferred and the error lands in # the state after SIGNED_NUMBER vs after ESCAPED_STRING. JsonMissingComma: ['[1 2]', '["a" 1]', '{"a":1 "b":2}'], # And here it is the element count: '[1,]' and '[1,2,]' are # distinct states, so the second is what classifies '[1, 2, ]'. JsonTrailingComma: ["[,]", "[1,]", "[1,2,]", '{"foo":1,}'], }, use_accepts=True, ) if not exc_class: raise # unrecognized shape — re-raise the raw UnexpectedInput raise exc_class(u.get_context(json_text), u.line, u.column) from None
CASES = [ '{"example1": "value"', # never closed '{"example2": ] ', # value expected, got a bracket '[1 2]', # comma missing between elements '[1, 2, ]', # trailing comma]
for case in CASES: try: parse(case) except JsonSyntaxError as e: print(f"input: {case!r}") print(e) print()Where to go next
Section titled “Where to go next”- Transformers & visitors — the four ways to turn a tree into a value, side by side in every target
- Interactive parsing — the full accept-set / copy / feed API
- Errors — the exception hierarchy behind the example above
- Lexers & terminals — postlexers, contextual lexing, and writing your own token stream