Plugins
This page documents the plugin/visitor API, a first-party runtime that
ships with Lanexio™ Parser: walk, transform, and createPipeline, all in
@lanexio/parser-core and re-exported by @lanexio/parser. Where an earlier
run shipped only the registration seam and documented a plugin ecosystem as
something to build, this run ships the runtime that walks a flat AST, rewrites
its source safely, and composes grammars with plugin hooks. ADR 0051 records the
decision and supersedes ADR 0047’s deferral for the plugin deliverable.
The plugin API is grammar-agnostic and lives in parser-core (Tier 1). It
accepts grammars and callbacks as arguments and never imports a grammar pack,
so parser-core keeps its “depends on nothing” property. walk and
transform are pure functions over LexTree and LexNode; createPipeline
composes the existing shared grammarRegistry and the plugins you pass in.
Public API surface
Section titled “Public API surface”| API | Package | What it does |
|---|---|---|
walk(tree, visitors, opts?) | @lanexio/parser-core | Preorder enter / postorder leave over every node, with byte-range access. Returning false from enter prunes the subtree. |
transform(tree, visitor) | @lanexio/parser-core | Rewrite a document by applying replacement text in one ascending, non-overlapping pass over a copy of the source. Never mutates the tree. |
createPipeline(inputs) | @lanexio/parser-core | Compose grammar registrations and plugins ({ name, onParse?, transform? }) into a single parse/transform pipeline. |
register(reg) | @lanexio/parser | Register a GrammarRegistration for unified dispatch (the same registry createPipeline uses). |
Supporting types (Visitor, WalkOptions, Edit, SkippedEdit,
TransformResult, TransformVisitor, Plugin, Pipeline, PipelineContext,
PipelineParseOptions) are exported alongside the functions.
Walk a tree
Section titled “Walk a tree”walk visits every node in preorder (enter runs before the node’s children)
and postorder (leave runs after every node in the node’s subtree). The node
gives you range (half-open byte span into the tree source) and text
(decoded source text), so a linter or a formatter can work over the flat AST
directly. Returning false from enter prunes the subtree: the walk neither
enters nor leaves anything below that node, and it does not call leave for
the pruned node itself.
import { walk } from '@lanexio/parser';import { parse } from '@lanexio/parser/all';
const tree = parse('{"name": "Ada", "tags": ["ast", "parser"]}', { language: 'json'});
let count = 0;walk(tree, { enter(node) { count += 1; // every node, including the root }});console.log(count);The walk is iterative and allocation-light (one LexNode per visited node, a
frame per open ancestor), so deeply nested documents cannot overflow the call
stack. It never materializes the parent side table and never mutates the tree,
the buffer, or the source bytes. Passing { cursor: true } selects a
cursor-driven engine with identical observable behavior; the flag only changes
the navigation mechanism.
Rewrite a document with transform
Section titled “Rewrite a document with transform”transform is the mutation-safe rewrite hook. The visitor is called once per
node in preorder; return the replacement text for a node’s byte range (a
string is UTF-8 encoded, a Uint8Array is used as-is, and an empty string
deletes the node’s source text), or null/undefined to leave it alone.
import { register, parse, transform } from '@lanexio/parser';import { htmlRegistration, HtmlKind } from '@lanexio/parser-grammar-html';
register(htmlRegistration);
const tree = parse('<p>Hello world</p>', { language: 'html' });const { source, applied, skipped } = transform(tree, (node) => { if (node.kind === HtmlKind.Text) { return node.text.toUpperCase(); } return undefined;});
console.log(new TextDecoder().decode(source)); // <p>HELLO WORLD</p>console.log(applied.length); // 1console.log(skipped); // []Mutation-consistency contract
Section titled “Mutation-consistency contract”transform returns { source, applied, skipped } where source is a fresh
Uint8Array with every applied replacement spliced in:
- The original tree is never mutated.
tree.source,tree.buffer, and every node range stay byte-for-byte identical after the call. - One ascending, non-overlapping pass. Edits are applied in document
order from byte 0 upward. A candidate that overlaps the already-applied
region (for example an edit on a node inside a container whose edit was
applied first) or that starts before the previously applied edit is skipped
and reported in
skipped, never applied and never thrown. Each skipped entry carries a stable reason:overlaps-applied-edit,descending-edit, orinvalid-range. - Re-parse to get a new tree. The transform produces a new source
deterministically and reports what it skipped. The flat buffer stays frozen:
nothing rewrites
subtree_sizeor patches node records. When you need a tree for the new source, re-parse it. A pipeline does this for you between transform hooks.
The visitor callback is caller code, so its errors propagate to the transform
caller rather than being swallowed.
Compose grammars and plugins with createPipeline
Section titled “Compose grammars and plugins with createPipeline”createPipeline(inputs) takes grammar registrations and plugins, in the order
their hooks run. Registrations go into the shared grammarRegistry, so a
grammar registered through a pipeline is dispatchable anywhere; plugins are
kept in input order.
import { createPipeline, transform } from '@lanexio/parser';import { htmlRegistration, HtmlKind } from '@lanexio/parser-grammar-html';import { jsonRegistration } from '@lanexio/parser-grammar-json';
const pipeline = createPipeline([ htmlRegistration, jsonRegistration, { name: 'shout', transform: (tree) => transform(tree, (node) => node.kind === HtmlKind.Text ? node.text.toUpperCase() : undefined ) }]);
const html = pipeline.parse('<p>Hello</p>', { filename: 'sample.html' });new TextDecoder().decode(html.source); // <p>HELLO</p>
// The same pipeline dispatches by language name for any registered grammar.const json = pipeline.parse('{"a": 1}', { language: 'json' });Plugin lifecycle
Section titled “Plugin lifecycle”- Dispatch.
parse(source, { language | filename })resolves the grammar through the shared registry by language name or by filename extension, then runs the plugin hooks in order. onParsehooks run first. Each receives the current tree and a context with the resolved language. Returning a tree replaces the current tree; returningnullorundefinedpasses the previous tree through unchanged.transformhooks run next. Each runs over the current source. When a hook returns a source that differs from the current tree’s source, the pipeline re-parses that source before the next hook, so later hooks see the previous edits. A hook that returnsnull/undefinedleaves the source unchanged.- The pipeline never throws on the parse path. An unresolvable dispatch returns an error tree with diagnostics. A throwing plugin is caught, reported as a structured pipeline diagnostic on the returned tree, and the last good tree is returned. Composition stays never-throw even when plugin code is not.
The pipeline also exposes transform(tree) to run the plugin transform hooks
over an existing tree, walk(tree, visitors), register(reg), and
grammars() for the current registrations. Calling transform directly is
caller code: plugin errors there propagate, and chaining over a tree whose
grammar is not registered reports that it cannot re-parse.
Why the plugin API lives in parser-core
Section titled “Why the plugin API lives in parser-core”walk and transform are grammar-agnostic tree operations (primitives-first):
parser-query, the DOM adapter, validators, and future linters all build on
them. createPipeline composes the existing grammarRegistry with no new
dependencies. Placing the runtime in parser-core means a plugin ecosystem
works for every grammar pack without a separate install step. ADR 0051 records
the condition under which a future plugin market (discovery, versioning,
sandboxing, remote registries) splits out to its own Tier 2 package.
Related
Section titled “Related”- parser-core reference: the full export surface, including the plugin API
- DOM Adapter: a first-party facade built on the same walk primitives
- GraphQL validation: a validator over the flat AST
- Adapters and extension points: every extension seam
- Flat AST: the 16-byte node layout
walkreads