Skip to content

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.

APIPackageWhat it does
walk(tree, visitors, opts?)@lanexio/parser-corePreorder enter / postorder leave over every node, with byte-range access. Returning false from enter prunes the subtree.
transform(tree, visitor)@lanexio/parser-coreRewrite 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-coreCompose grammar registrations and plugins ({ name, onParse?, transform? }) into a single parse/transform pipeline.
register(reg)@lanexio/parserRegister 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 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.

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); // 1
console.log(skipped); // []

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, or invalid-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_size or 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' });
  • 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.
  • onParse hooks run first. Each receives the current tree and a context with the resolved language. Returning a tree replaces the current tree; returning null or undefined passes the previous tree through unchanged.
  • transform hooks 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 returns null/undefined leaves 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.

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.