diff --git a/packages/liquid-html-parser/fixtures/error-corpus.ts b/packages/liquid-html-parser/fixtures/error-corpus.ts
new file mode 100644
index 000000000..8eb5429fa
--- /dev/null
+++ b/packages/liquid-html-parser/fixtures/error-corpus.ts
@@ -0,0 +1,70 @@
+/*
+ * Adversarial error corpus for the resilient-parser overhead benchmark.
+ *
+ * Every `source` here is *invalid* Liquid/HTML: the strict `toLiquidHtmlAST`
+ * throws on each one, so these sources exist only to exercise the resilient
+ * path (`toResilientLiquidHtmlAST`), which recovers instead of throwing and
+ * surfaces one `LiquidErrorNode` per region it gives up on. The bench arm
+ * that consumes this corpus measures resync/recovery cost, not throughput on
+ * clean input.
+ *
+ * Shape is identical to `THEME_FILES` in `theme-bundle.ts`
+ * (`Array<{ path; source }>`) so the bench loop stays uniform across corpora.
+ *
+ * Contract (verified in C2): each `source` passed to
+ * `toResilientLiquidHtmlAST` returns a `DocumentNode` without throwing and
+ * contains at least one `LiquidErrorNode`.
+ */
+
+/*
+ * A single orphan close tag followed by a valid variable output. The close
+ * has no matching open, so the resilient parser emits one error node, then
+ * resynchronizes on the next construct-open boundary and recovers the output.
+ * Repeating this unit forces the resync loop to fire once per unit — the
+ * "error every few tokens" density stress.
+ */
+const FREQUENT_ERROR_UNIT = "{% endfor %}{{ x }}{% endif %}{{ y }}";
+
+/*
+ * A well-formed Liquid+HTML fragment the strict parser accepts as-is. Repeated
+ * many times it builds a large clean body; a single orphan close tag appended
+ * near EOF then costs exactly one recovery, proving tail recovery does not
+ * rescan the whole document.
+ */
+const CLEAN_UNIT =
+ '
{{ product.title }}' +
+ "{% if product.available %}{{ product.price }}{% endif %}" +
+ "
\n";
+
+export const ERROR_FILES: Array<{ path: string; source: string }> = [
+ {
+ /* Seeded verbatim from resilient.test.ts:150 — one orphan close tag. */
+ path: "error-corpus/single-error.liquid",
+ source: "{% endfor %}",
+ },
+ {
+ /*
+ * Seeded verbatim from resilient.test.ts:178 — two orphan closes with a
+ * valid output recovered between them (interleaved resync).
+ */
+ path: "error-corpus/interleaved-resync.liquid",
+ source: "{% endfor %}{{ good }}{% endif %}",
+ },
+ {
+ /*
+ * Net-new: an orphan close roughly every few tokens across a moderately
+ * long source, bounding worst-case resync frequency. 60 units yields 120
+ * error nodes interleaved with 120 recovered outputs.
+ */
+ path: "error-corpus/pathological-frequent.liquid",
+ source: FREQUENT_ERROR_UNIT.repeat(60),
+ },
+ {
+ /*
+ * Net-new: a large valid body (150 clean units) with a single orphan
+ * close tag just before EOF — "parse a lot, then recover once".
+ */
+ path: "error-corpus/large-clean-error-near-eof.liquid",
+ source: CLEAN_UNIT.repeat(150) + "{% endfor %}",
+ },
+];
diff --git a/packages/liquid-html-parser/src/ast.ts b/packages/liquid-html-parser/src/ast.ts
index d95c2df13..4c8c91c97 100644
--- a/packages/liquid-html-parser/src/ast.ts
+++ b/packages/liquid-html-parser/src/ast.ts
@@ -49,7 +49,8 @@ export type LiquidHtmlNode =
| LiquidDocParamNode
| LiquidDocExampleNode
| LiquidDocPromptNode
- | LiquidDocDescriptionNode;
+ | LiquidDocDescriptionNode
+ | LiquidErrorNode;
/** The root node of all LiquidHTML ASTs. */
export interface DocumentNode extends ASTNode {
@@ -885,6 +886,18 @@ export interface LiquidDocPromptNode extends ASTNode {
+ /** The message of the caught parse error. */
+ message: string;
+ /** The token-type name at the point the parse failed, when available. */
+ found?: string;
+}
+
export interface ASTNode {
/**
* The type of the node, as a string.
diff --git a/packages/liquid-html-parser/src/document/resilient-parser.ts b/packages/liquid-html-parser/src/document/resilient-parser.ts
new file mode 100644
index 000000000..a1811f285
--- /dev/null
+++ b/packages/liquid-html-parser/src/document/resilient-parser.ts
@@ -0,0 +1,103 @@
+import { DocumentParser } from './parser';
+import { TokenType } from './tokenizer';
+import { NodeTypes } from '../types';
+import { LiquidHTMLASTParsingError } from '../errors';
+import type { LiquidErrorNode, LiquidHtmlNode } from '../ast';
+
+/*
+ * Token types that open a new top-level construct. Panic-mode recovery
+ * resynchronizes onto one of these so the next `super.parseNode()` call
+ * resumes on a real node boundary rather than mid-construct. EndOfInput is a
+ * member so recovery always has a boundary to stop on.
+ */
+export const RESYNC_TOKENS: ReadonlySet = new Set([
+ TokenType.LiquidTagOpen,
+ TokenType.LiquidVariableOutputOpen,
+ TokenType.HtmlTagOpen,
+ TokenType.HtmlCloseTagOpen,
+ TokenType.HtmlCommentOpen,
+ TokenType.HtmlDoctypeOpen,
+ TokenType.EndOfInput,
+]);
+
+/** Whether a token type opens a construct we can safely resume parsing on. */
+export function isResyncToken(type: TokenType): boolean {
+ return RESYNC_TOKENS.has(type);
+}
+
+/*
+ * The readable name of a token type. TokenType is a string enum, so its value
+ * is already the name; this indirection keeps the call sites self-documenting.
+ */
+export function tokenTypeName(type: TokenType): string {
+ return type;
+}
+
+/*
+ * Builds a LiquidErrorNode leaf covering a skipped region. Kept local to the
+ * resilient path — deliberately not in the frozen factories.ts — so the
+ * default parse has no way to construct it.
+ */
+export function makeLiquidErrorNode(
+ start: number,
+ end: number,
+ source: string,
+ message: string,
+ found?: string,
+): LiquidErrorNode {
+ return {
+ type: NodeTypes.LiquidErrorNode,
+ position: { start, end },
+ source,
+ message,
+ found,
+ };
+}
+
+/*
+ * Opt-in resilient parser. It behaves exactly like DocumentParser except that
+ * a structural parse failure — which the default parser throws on, aborting the
+ * whole parse — is caught here and turned into a LiquidErrorNode so parsing can
+ * continue. The strict/default DocumentParser is a different class reached by a
+ * different entry point and is left byte-identical.
+ */
+export class ResilientDocumentParser extends DocumentParser {
+ /*
+ * Wraps the polymorphic node parse. On a LiquidHTMLASTParsingError it emits a
+ * LiquidErrorNode covering the skipped region and resynchronizes onto the next
+ * construct-open boundary, so parsing continues and one document can surface
+ * several errors interleaved with the constructs it did recover. A forced
+ * >=1-token advance before the resync scan makes every recovery strictly
+ * advance the cursor, which guarantees the parseDocument loop terminates.
+ * Foreign (non-parse) errors are rethrown untouched.
+ */
+ parseNode(): LiquidHtmlNode {
+ const startTok = this.peek();
+ const startPos = this.getPosition();
+ try {
+ return super.parseNode();
+ } catch (e) {
+ if (!(e instanceof LiquidHTMLASTParsingError)) throw e;
+ const source = this.getSource();
+ const found = tokenTypeName(this.peek().type);
+ /*
+ * Guarantee at least one token of progress before scanning. A failed
+ * parse can throw without having advanced the cursor (consume throws
+ * before its own increment), and if the offending token is itself a
+ * resync token the scan below would match it immediately and never move,
+ * re-trapping parseDocument in an infinite loop. Forcing one advance
+ * breaks that stall.
+ */
+ if (this.getPosition() <= startPos) this.advance();
+ /*
+ * Skip to the next construct-open boundary so the following
+ * super.parseNode() resumes on a real node start rather than mid-
+ * construct. isResyncToken includes EndOfInput, so this also stops
+ * cleanly at the end of the source.
+ */
+ while (!this.isAtEnd() && !isResyncToken(this.peek().type)) this.advance();
+ const end = this.peek().start;
+ return makeLiquidErrorNode(startTok.start, end, source, e.message, found);
+ }
+ }
+}
diff --git a/packages/liquid-html-parser/src/errors.ts b/packages/liquid-html-parser/src/errors.ts
index bed0af4c8..2f97ed1a2 100644
--- a/packages/liquid-html-parser/src/errors.ts
+++ b/packages/liquid-html-parser/src/errors.ts
@@ -25,20 +25,32 @@ export class LiquidHTMLASTParsingError extends SyntaxError {
this.unclosed = unclosed ?? null;
const lc = lineColumn(source);
- const start = lc.fromIndex(startIndex);
- const end = lc.fromIndex(Math.min(endIndex, source.length - 1));
+
+ /*
+ * A parse can fail at a position that is out of the source's range - for
+ * example the end-of-input token that sits past the last character when a
+ * closing delimiter like "%}" is never found, or a -1 sentinel position.
+ * line-column returns null for an out-of-range index, and dereferencing
+ * that null while building `loc` below would throw a TypeError ("Cannot
+ * read properties of null") that masks the real syntax error. Clamp the
+ * indices into the valid range before the lookup so we always produce a
+ * real location.
+ */
+ const lastIndex = Math.max(0, source.length - 1);
+ const start = lc.fromIndex(Math.min(Math.max(startIndex, 0), lastIndex));
+ const end = lc.fromIndex(Math.min(Math.max(endIndex, 0), lastIndex));
// Plugging ourselves into @babel/code-frame since this is how
// the babel parser can print where the parsing error occured.
// https://github.com/prettier/prettier/blob/cd4a57b113177c105a7ceb94e71f3a5a53535b81/src/main/parser.js
this.loc = {
start: {
- line: start!.line,
- column: start!.col,
+ line: start?.line ?? 1,
+ column: start?.col ?? 1,
},
end: {
- line: end!.line,
- column: end!.col,
+ line: end?.line ?? 1,
+ column: end?.col ?? 1,
},
};
}
diff --git a/packages/liquid-html-parser/src/index.ts b/packages/liquid-html-parser/src/index.ts
index 5fd75e961..1cbc9f588 100644
--- a/packages/liquid-html-parser/src/index.ts
+++ b/packages/liquid-html-parser/src/index.ts
@@ -1,6 +1,7 @@
export * from './ast';
export * from './types';
export * from './errors';
+export { findErrorNodeAtOffset, toResilientLiquidAST, toResilientLiquidHtmlAST } from './resilient';
export { TAGS_WITHOUT_MARKUP, RAW_TAGS, VOID_ELEMENTS, BLOCKS } from './grammar';
export { getConditionalComment } from './conditional-comment';
export { tokenize, TokenType } from './document/tokenizer';
diff --git a/packages/liquid-html-parser/src/resilient.test.ts b/packages/liquid-html-parser/src/resilient.test.ts
new file mode 100644
index 000000000..69204ca1c
--- /dev/null
+++ b/packages/liquid-html-parser/src/resilient.test.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it } from 'vitest';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { isLiquidHtmlNode, toLiquidAST, toLiquidHtmlAST, walk } from './ast';
+import type { DocumentNode, LiquidErrorNode, LiquidHtmlNode } from './ast';
+import { LiquidHTMLASTParsingError } from './errors';
+import { findErrorNodeAtOffset, toResilientLiquidAST, toResilientLiquidHtmlAST } from './resilient';
+import { NodeTypes } from './types';
+
+/*
+ * Drop the `source`/`_source` strings before comparing two ASTs. Both parses
+ * receive the identical source argument, so those fields are equal by
+ * construction; stripping them keeps the structural comparison fast and loses
+ * nothing. Mirrors the strip step in parser-oracle.test.ts.
+ */
+function structural(ast: unknown): unknown {
+ return JSON.parse(
+ JSON.stringify(ast, (key, value) =>
+ key === 'source' || key === '_source' ? undefined : value,
+ ),
+ );
+}
+
+function errorNodesOf(ast: DocumentNode): LiquidErrorNode[] {
+ const found: LiquidErrorNode[] = [];
+ walk(ast, (node) => {
+ if (node.type === NodeTypes.LiquidErrorNode) {
+ found.push(node as LiquidErrorNode);
+ }
+ });
+ return found;
+}
+
+/*
+ * Default-path neutrality proof (Gate-S1 / golden-neutrality gate).
+ *
+ * Resilient mode is a different class reached by a different function; on
+ * well-formed input its overridden `parseNode` never catches, so it must be
+ * byte-identical to the strict default path. These are the two proofs of that:
+ * a curated set of clean sources, and the whole on-disk clean fixture corpus.
+ */
+describe('resilient mode is inert on clean input', () => {
+ const cleanSources = [
+ '',
+ 'plain text only',
+ '{{ product.title }}',
+ '{% assign x = 1 %}',
+ '{% if x %}a{% else %}b{% endif %}',
+ '{% for item in collection %}{{ item.title }}{% endfor %}',
+ '{{ x }}
',
+ '{% render \'snippet\' %}',
+ '{% liquid\n assign y = 2\n echo y\n%}',
+ '{% comment %}hi{% endcomment %}',
+ 'text {{ a }} more {% if b %}{{ c }}{% endif %} end',
+ ];
+
+ for (const source of cleanSources) {
+ it(`toResilientLiquidHtmlAST deep-equals toLiquidHtmlAST for ${JSON.stringify(source)}`, () => {
+ expect(structural(toResilientLiquidHtmlAST(source))).toEqual(
+ structural(toLiquidHtmlAST(source)),
+ );
+ });
+
+ it(`toResilientLiquidAST deep-equals toLiquidAST for ${JSON.stringify(source)}`, () => {
+ expect(structural(toResilientLiquidAST(source))).toEqual(structural(toLiquidAST(source)));
+ });
+ }
+});
+
+const FIXTURES_DIR = resolve(__dirname, '..', 'fixtures', 'theme');
+const GOLDEN_HTML_AST_DIR = resolve(__dirname, '..', 'fixtures', 'golden-html-ast');
+const GOLDEN_LIQUID_AST_DIR = resolve(__dirname, '..', 'fixtures', 'golden-liquid-ast');
+const THEMES = ['base-theme', 'dawn', 'horizon'];
+
+/** Recursively find all .liquid files under a directory. */
+function findLiquidFiles(dir: string, prefix = ''): { path: string; fullPath: string }[] {
+ const results: { path: string; fullPath: string }[] = [];
+ if (!existsSync(dir)) return results;
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ results.push(...findLiquidFiles(join(dir, entry.name), relPath));
+ } else if (entry.name.endsWith('.liquid')) {
+ results.push({ path: relPath, fullPath: join(dir, entry.name) });
+ }
+ }
+ return results;
+}
+
+function goldenFileName(relativePath: string): string {
+ return relativePath.replace(/\//g, '-') + '.json';
+}
+
+const hasFixtures = existsSync(FIXTURES_DIR) && readdirSync(FIXTURES_DIR).length > 0;
+
+/*
+ * The clean corpus is exactly the files the oracle has a golden for: a golden
+ * exists only for a file the strict parser accepts, so those files are the
+ * proven-clean set. Resilient must reproduce the strict AST for each.
+ */
+describe.skipIf(!hasFixtures)('resilient mode is inert over the clean fixture corpus', () => {
+ for (const theme of THEMES) {
+ const themeDir = join(FIXTURES_DIR, theme);
+ const files = findLiquidFiles(themeDir);
+ const themeMissing = files.length === 0;
+
+ describe.skipIf(themeMissing)(`toResilientLiquidHtmlAST === toLiquidHtmlAST - ${theme}`, () => {
+ for (const { path, fullPath } of files) {
+ const goldenPath = join(GOLDEN_HTML_AST_DIR, theme, goldenFileName(path));
+ if (!existsSync(goldenPath)) continue;
+
+ it(`matches strict for ${theme}/${path}`, () => {
+ const source = readFileSync(fullPath, 'utf-8');
+ expect(structural(toResilientLiquidHtmlAST(source))).toEqual(
+ structural(toLiquidHtmlAST(source)),
+ );
+ });
+ }
+ });
+
+ describe.skipIf(themeMissing)(`toResilientLiquidAST === toLiquidAST - ${theme}`, () => {
+ for (const { path, fullPath } of files) {
+ const goldenPath = join(GOLDEN_LIQUID_AST_DIR, theme, goldenFileName(path));
+ if (!existsSync(goldenPath)) continue;
+
+ it(`matches strict for ${theme}/${path}`, () => {
+ const source = readFileSync(fullPath, 'utf-8');
+ expect(structural(toResilientLiquidAST(source))).toEqual(structural(toLiquidAST(source)));
+ });
+ }
+ });
+ }
+});
+
+/*
+ * Positive-path resilient behavior. These consolidate the throwaway probes
+ * from B2 (error nodes emitted), B3 (panic-mode resync) and B5 (locator) into
+ * committed tests.
+ */
+describe('resilient mode surfaces parse errors as nodes', () => {
+ it('strict parse throws on a close tag with no matching open', () => {
+ expect(() => toLiquidHtmlAST('{% endfor %}')).toThrow(LiquidHTMLASTParsingError);
+ });
+
+ it('toResilientLiquidHtmlAST emits a LiquidErrorNode instead of throwing', () => {
+ const source = '{% endfor %}';
+ const ast = toResilientLiquidHtmlAST(source);
+
+ expect(ast.type).toBe(NodeTypes.Document);
+ expect(ast.children).toHaveLength(1);
+
+ const node = ast.children[0] as LiquidErrorNode;
+ expect(node.type).toBe(NodeTypes.LiquidErrorNode);
+ expect(node.position).toEqual({ start: 0, end: 12 });
+ expect(node.found).toBe('EndOfInput');
+ expect(typeof node.message).toBe('string');
+ expect(source.slice(node.position.start, node.position.end)).toBe('{% endfor %}');
+ });
+
+ it('toResilientLiquidAST also recovers instead of throwing', () => {
+ const ast = toResilientLiquidAST('{% endfor %}');
+
+ expect(ast.type).toBe(NodeTypes.Document);
+ expect(errorNodesOf(ast)).toHaveLength(1);
+ });
+});
+
+describe('resilient mode resynchronizes after an error (panic mode)', () => {
+ it('recovers valid nodes between multiple errors and terminates', () => {
+ const ast = toResilientLiquidHtmlAST('{% endfor %}{{ good }}{% endif %}');
+
+ expect(ast.children.map((child) => child.type)).toEqual([
+ NodeTypes.LiquidErrorNode,
+ NodeTypes.LiquidVariableOutput,
+ NodeTypes.LiquidErrorNode,
+ ]);
+
+ const errors = errorNodesOf(ast);
+ expect(errors.length).toBeGreaterThanOrEqual(2);
+
+ // The construct between the two errors is recovered as a real node.
+ const recovered = ast.children[1];
+ expect(recovered.type).toBe(NodeTypes.LiquidVariableOutput);
+ expect(recovered.position).toEqual({ start: 12, end: 22 });
+
+ // Errors resynced on the next construct-open boundary.
+ expect(ast.children[0].position).toEqual({ start: 0, end: 12 });
+ expect(ast.children[2].position).toEqual({ start: 22, end: 33 });
+ });
+});
+
+describe('findErrorNodeAtOffset', () => {
+ const source = '{% endfor %} {{ good }} {% endif %}';
+ const ast = toResilientLiquidHtmlAST(source);
+
+ it('produces two error nodes spanning up to the next construct boundary', () => {
+ const errors = errorNodesOf(ast);
+ expect(errors).toHaveLength(2);
+ expect(errors[0].position).toEqual({ start: 0, end: 13 });
+ expect(errors[1].position).toEqual({ start: 24, end: 35 });
+ });
+
+ it('error nodes are first-class, walkable citizens', () => {
+ const errors = errorNodesOf(ast);
+ expect(isLiquidHtmlNode(errors[0])).toBe(true);
+ });
+
+ it('returns the error node the offset sits inside, with its ancestry', () => {
+ const result = findErrorNodeAtOffset(ast, 5);
+ expect(result).not.toBeNull();
+ expect(result!.node.type).toBe(NodeTypes.LiquidErrorNode);
+ expect(result!.node.position).toEqual({ start: 0, end: 13 });
+ expect(result!.ancestors[0].type).toBe(NodeTypes.Document);
+ });
+
+ it('resolves the second error span for an offset inside it', () => {
+ const result = findErrorNodeAtOffset(ast, 30);
+ expect(result).not.toBeNull();
+ expect(result!.node.position).toEqual({ start: 24, end: 35 });
+ });
+
+ it('returns null for an offset outside every error span', () => {
+ // Offset 17 is inside the recovered {{ good }}, not an error region.
+ expect(findErrorNodeAtOffset(ast, 17)).toBeNull();
+ });
+
+ it('returns the deepest error node when spans nest', () => {
+ const src = 'x'.repeat(20);
+ const inner = {
+ type: NodeTypes.LiquidErrorNode,
+ position: { start: 5, end: 10 },
+ source: src,
+ message: 'inner',
+ } as LiquidErrorNode;
+ const container = {
+ type: NodeTypes.HtmlElement,
+ position: { start: 2, end: 15 },
+ source: src,
+ children: [inner],
+ } as unknown as LiquidHtmlNode;
+ const outer = {
+ type: NodeTypes.LiquidErrorNode,
+ position: { start: 0, end: 20 },
+ source: src,
+ message: 'outer',
+ } as LiquidErrorNode;
+ const root = {
+ type: NodeTypes.Document,
+ name: '#document',
+ position: { start: 0, end: 20 },
+ source: src,
+ _source: src,
+ children: [outer, container],
+ } as unknown as LiquidHtmlNode;
+
+ const deep = findErrorNodeAtOffset(root, 7);
+ expect(deep!.node.message).toBe('inner');
+ expect(deep!.ancestors.map((node) => node.type)).toEqual([
+ NodeTypes.Document,
+ NodeTypes.HtmlElement,
+ ]);
+
+ const shallow = findErrorNodeAtOffset(root, 1);
+ expect(shallow!.node.message).toBe('outer');
+ expect(shallow!.ancestors.map((node) => node.type)).toEqual([NodeTypes.Document]);
+ });
+});
diff --git a/packages/liquid-html-parser/src/resilient.ts b/packages/liquid-html-parser/src/resilient.ts
new file mode 100644
index 000000000..9dd5d1748
--- /dev/null
+++ b/packages/liquid-html-parser/src/resilient.ts
@@ -0,0 +1,138 @@
+/**
+ * Opt-in resilient entry points for the Liquid+HTML parser.
+ *
+ * These mirror `toLiquidAST` / `toLiquidHtmlAST` from `./ast` exactly, except
+ * they construct a `ResilientDocumentParser` instead of the base
+ * `DocumentParser`. A structural parse failure that would abort the default
+ * parse is instead caught and surfaced as a `LiquidErrorNode`, so the returned
+ * `DocumentNode` can carry several errors interleaved with the constructs the
+ * parser did recover. The default path is a different class reached by a
+ * different function and stays byte-identical.
+ */
+
+import type { ASTBuildOptions, DocumentNode, LiquidErrorNode, LiquidHtmlNode } from './ast';
+import { walk } from './ast';
+import { ResilientDocumentParser } from './document/resilient-parser';
+import { tokenize } from './document/tokenizer';
+import { Environment } from './environment';
+import { NodeTypes } from './types';
+
+/*
+ * Resilient variant of `toLiquidAST` (Liquid-only, `parseHtml: false`).
+ * Defaults `allowUnclosedDocumentNode: true` so an unterminated document is
+ * recovered rather than thrown on — the resilient contract.
+ */
+export function toResilientLiquidAST(
+ source: string,
+ options: ASTBuildOptions = {
+ allowUnclosedDocumentNode: true,
+ mode: 'tolerant',
+ },
+): DocumentNode {
+ const env = options.environment ?? Environment.default();
+ const tokens = tokenize(source);
+ const parser = new ResilientDocumentParser(
+ tokens,
+ source,
+ env,
+ false,
+ options.allowUnclosedDocumentNode,
+ );
+ return parser.parseDocument();
+}
+
+/*
+ * Resilient variant of `toLiquidHtmlAST` (Liquid+HTML, `parseHtml: true`).
+ * Defaults `allowUnclosedDocumentNode: true` so an unterminated document is
+ * recovered rather than thrown on — the resilient contract.
+ */
+export function toResilientLiquidHtmlAST(
+ source: string,
+ options: ASTBuildOptions = {
+ allowUnclosedDocumentNode: true,
+ mode: 'tolerant',
+ },
+): DocumentNode {
+ const env = options.environment ?? Environment.default();
+ const tokens = tokenize(source);
+ const parser = new ResilientDocumentParser(
+ tokens,
+ source,
+ env,
+ true,
+ options.allowUnclosedDocumentNode,
+ );
+ return parser.parseDocument();
+}
+
+/*
+ * Locate the error node the caret is sitting in.
+ *
+ * A resilient parse can leave several `LiquidErrorNode`s scattered through
+ * the tree, one per region the parser gave up on. Completion needs the one
+ * the caret is actually inside, so we return the *deepest* error node whose
+ * span contains `offset` — the most specific recovery point — paired with
+ * its ancestry.
+ *
+ * The result shape (`{ node, ancestors }`) mirrors `findCurrentNode` in the
+ * language server so a later phase can swap the completion context source
+ * with minimal churn. `null` means the caret is not inside any error region
+ * and the existing (non-error) path should handle it.
+ */
+export function findErrorNodeAtOffset(
+ ast: LiquidHtmlNode,
+ offset: number,
+): { node: LiquidErrorNode; ancestors: LiquidHtmlNode[] } | null {
+ /*
+ * `walk` only hands each visited node its immediate parent, and it visits
+ * in post-order (parents after children), so the map is only complete once
+ * the traversal ends. We therefore record every parent link first, gather
+ * the error nodes that contain the offset, and resolve depth/ancestry from
+ * the finished map afterwards.
+ */
+ const parentOf = new Map();
+ const candidates: LiquidErrorNode[] = [];
+
+ walk(ast, (node, parent) => {
+ parentOf.set(node, parent);
+ if (
+ node.type === NodeTypes.LiquidErrorNode &&
+ offset >= node.position.start &&
+ offset <= node.position.end
+ ) {
+ candidates.push(node);
+ }
+ });
+
+ if (candidates.length === 0) {
+ return null;
+ }
+
+ /* Ancestry, root→parent, by climbing the parent map from the node up. */
+ const ancestorsOf = (node: LiquidHtmlNode): LiquidHtmlNode[] => {
+ const chain: LiquidHtmlNode[] = [];
+ let cursor = parentOf.get(node);
+ while (cursor !== undefined) {
+ chain.push(cursor);
+ cursor = parentOf.get(cursor);
+ }
+ return chain.reverse();
+ };
+
+ /*
+ * Deepest wins: when error spans nest, the innermost node has the longest
+ * ancestor chain. First candidate wins ties, which is stable given walk's
+ * deterministic traversal order.
+ */
+ let best = candidates[0];
+ let bestDepth = ancestorsOf(best).length;
+ for (let i = 1; i < candidates.length; i++) {
+ const depth = ancestorsOf(candidates[i]).length;
+ if (depth > bestDepth) {
+ best = candidates[i];
+ bestDepth = depth;
+ }
+ }
+
+ return { node: best, ancestors: ancestorsOf(best) };
+}
diff --git a/packages/liquid-html-parser/src/types.ts b/packages/liquid-html-parser/src/types.ts
index 84c0a517e..9a984b538 100644
--- a/packages/liquid-html-parser/src/types.ts
+++ b/packages/liquid-html-parser/src/types.ts
@@ -52,6 +52,7 @@ export enum NodeTypes {
LiquidDocParamNode = 'LiquidDocParamNode',
LiquidDocExampleNode = 'LiquidDocExampleNode',
LiquidDocPromptNode = 'LiquidDocPromptNode',
+ LiquidErrorNode = 'LiquidErrorNode',
}
// These are officially supported with special node types