Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions packages/liquid-html-parser/fixtures/error-corpus.ts
Original file line number Diff line number Diff line change
@@ -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 =
'<div class="card">{{ product.title }}' +
"{% if product.available %}<span>{{ product.price }}</span>{% endif %}" +
"</div>\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 %}",
},
];
15 changes: 14 additions & 1 deletion packages/liquid-html-parser/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeTypes.Document> {
Expand Down Expand Up @@ -885,6 +886,18 @@ export interface LiquidDocPromptNode extends ASTNode<NodeTypes.LiquidDocPromptNo
content: TextNode;
}

/**
* Represents a parse error recovered by the resilient parser. Its position spans
* the skipped region, from the start of the failed unit up to the boundary
* where recovery resynchronized.
*/
export interface LiquidErrorNode extends ASTNode<NodeTypes.LiquidErrorNode> {
/** 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<T> {
/**
* The type of the node, as a string.
Expand Down
103 changes: 103 additions & 0 deletions packages/liquid-html-parser/src/document/resilient-parser.ts
Original file line number Diff line number Diff line change
@@ -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<TokenType> = 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);
}
}
}
24 changes: 18 additions & 6 deletions packages/liquid-html-parser/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we have to do this now?

column: start?.col ?? 1,
},
end: {
line: end!.line,
column: end!.col,
line: end?.line ?? 1,
column: end?.col ?? 1,
},
};
}
Expand Down
1 change: 1 addition & 0 deletions packages/liquid-html-parser/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading