Skip to content

Incremental Parsing

The incremental API reparses a document after a byte-range edit. Its signature is the long-term contract: today it performs a deterministic full reparse of the edited source; future versions may reuse unaffected subtrees from the previous tree, but the result is guaranteed byte-identical to a full parse either way — your code never needs to care which strategy ran.

type LexEdit = {
readonly start: number; // byte offset into the PREVIOUS source
readonly end: number; // half-open: bytes [start, end) are replaced
readonly replacement: Uint8Array;
};
  • Insertion: start === end, non-empty replacement.
  • Deletion: start < end, empty replacement (new Uint8Array(0)).
  • Replacement: both.

Offsets are byte offsets into the previous tree’s source, not character offsets. If your editor works in UTF-16 code units, convert before building the edit.

import { createParser } from '@lanexio/parser';
import { markdownGrammar } from '@lanexio/parser-grammar-markdown';
const parser = await createParser(markdownGrammar);
let tree = parser.parse('# Hello\n\nWorld.');
// Replace "Hello" (bytes 2..7) with "Goodbye"
tree = parser.reparse(tree, {
start: 2,
end: 7,
replacement: new TextEncoder().encode('Goodbye'),
});
// Chain edits — each reparse returns a fresh tree over the edited source
tree = parser.reparse(tree, { start: 0, end: 0, replacement: new TextEncoder().encode('> ') });

parser.reparse preserves Never-Throw Integrity: a malformed edit (out-of-bounds or inverted range) or any internal failure returns an error tree built over the previous source rather than throwing.

applyEdit — the pure half (throws on bad ranges)

Section titled “applyEdit — the pure half (throws on bad ranges)”

If you want loud validation instead of an error tree, apply the edit yourself and parse the result:

import { applyEdit, LexEditError, LexEditErrorCode } from '@lanexio/parser';
import { parseMarkdown } from '@lanexio/parser-grammar-markdown';
try {
const nextSource = applyEdit(tree.source, edit); // pure function, allocates the new buffer
tree = parseMarkdown(nextSource, { gfm: true });
} catch (err) {
if (err instanceof LexEditError) {
// err.code is one of:
// LexEditErrorCode.RangeOutOfBounds — start < 0 or end > source.byteLength
// LexEditErrorCode.RangeInverted — end < start
}
}

This split is deliberate: applyEdit validates at the boundary and throws typed errors (your bug), while the grammar’s parse of the edited content stays never-throwing (the document’s problem).

import { createParser } from '@lanexio/parser';
import { htmlGrammar } from '@lanexio/parser-grammar-html';
const parser = await createParser(htmlGrammar);
let tree = parser.parse(initialText);
function onEditorChange(byteStart: number, byteEnd: number, insertedText: string) {
tree = parser.reparse(tree, {
start: byteStart,
end: byteEnd,
replacement: new TextEncoder().encode(insertedText),
});
// Position-based lookups for the UI:
const nodeAtCursor = tree.root.descendantForRange([byteStart, byteStart]);
highlight(nodeAtCursor);
}

descendantForRange and the cursor’s gotoDescendantForRange give O(depth) lookups from byte position to the deepest enclosing node — the building block for hover, selection expansion, and folding. See Tree Traversal.

For a given previous source and edit, reparse is fully deterministic and equals parse(applyEdit(previousSource, edit)) byte-for-byte at the tree-buffer level. Snapshot tests can rely on buffer equality:

import { applyEdit } from '@lanexio/parser';
const viaReparse = parser.reparse(tree, edit);
const viaFull = parser.parse(applyEdit(tree.source, edit));
// new Uint8Array(viaReparse.buffer) equals new Uint8Array(viaFull.buffer)