Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/hand-written-liquid-html-parser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@shopify/liquid-html-parser': minor
'@shopify/theme-language-server-common': minor
'@shopify/theme-check-common': minor
'@shopify/prettier-plugin-liquid': minor
'@shopify/theme-graph': minor
---

Adopt the hand-written `liquid-html-parser` for performance

Replace the parser-combinator-based Liquid/HTML parser with a hand-written
recursive-descent parser. The new parser is significantly faster, adds
resilient parsing (it recovers from malformed input instead of bailing), and is
adapted to the theme-tools source model. `theme-language-server-common`,
`theme-check-common`, `prettier-plugin-liquid`, and `theme-graph` are updated to
consume the new parser.
26 changes: 26 additions & 0 deletions .changeset/port-theme-check-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@shopify/theme-check-common': minor
---

Port additional theme-check checks

Add the following checks:
- `LiquidComplexity` -- Reports Liquid files with high cyclomatic complexity (default threshold 120).
- `LiquidNestingDepth` -- Reports Liquid files with deeply nested control-flow structures (default max depth 10).
- `LiquidSyntaxError` -- Reports Liquid syntax errors.
- `MaxFileSize` -- Reports theme files that exceed Shopify's maximum per-file size.
- `ExcessiveSettingsCount` -- Reports section/block schemas declaring more top-level settings than the max (default 40).
- `BlockArgumentSettingCollision` -- Reports a plain block-tag argument whose name matches a setting id in the block's schema.
- `DuplicateBlockArguments` -- Reports duplicate argument names in a block tag.
- `MissingBlockArguments` -- Reports required `{% doc %}` arguments not provided on a block tag.
- `UnknownBlockSetting` -- Reports a `block.settings.<name>` argument where `<name>` is not in the block's schema.
- `UnrecognizedBlockArguments` -- Reports block-tag arguments not declared in the block's `{% doc %}` tag.
- `ValidBlockArgumentTypes` -- Reports type mismatches between block-tag arguments and their `{% doc %}` declarations.
- `SchemaOncePerFile` -- Reports when `{% schema %}` appears more than once in a file.
- `SchemaSectionOrBlockOnly` -- Reports `{% schema %}` used outside section or block files.
- `StylesheetOncePerFile` -- Reports when `{% stylesheet %}` appears more than once in a file.
- `StylesheetSectionOrBlockOnly` -- Reports `{% stylesheet %}` used outside section or block files.
- `JavascriptOncePerFile` -- Reports when `{% javascript %}` appears more than once in a file.
- `JavascriptSectionOrBlockOnly` -- Reports `{% javascript %}` used outside section or block files.

`RequiredLayoutThemeObject` now flags any `layout/*.liquid` file missing the required theme objects, not just `layout/theme.liquid`.
9 changes: 6 additions & 3 deletions packages/liquid-html-parser/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ pnpm-debug.log*
.DS_Store
dawn
TODO
grammar/liquid-html.ohm.js
standalone.js
standalone.js.LICENSE.txt
**/actual.liquid
coverage
.nyc_output

# Generated parser fixtures (goldens + downloaded themes) — regenerated by scripts/setup-fixtures.ts
fixtures/golden-html-ast/
fixtures/golden-liquid-ast/
fixtures/theme/
fixtures/theme-bundle.ts
75 changes: 75 additions & 0 deletions packages/liquid-html-parser/fixtures/error-corpus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Adversarial error corpus for the tolerant-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 tolerant
* path (`toTolerantLiquidHtmlAST`), 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
* `toTolerantLiquidHtmlAST` 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 tolerant 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 tolerant.test.ts:150 — one orphan close tag. */
path: "error-corpus/single-error.liquid",
source: "{% endfor %}",
},
{
/*
* Seeded verbatim from tolerant.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 %}",
},
];
7 changes: 3 additions & 4 deletions packages/liquid-html-parser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,18 @@
"@shopify:registry": "https://registry.npmjs.org"
},
"files": [
"grammar/*",
"dist/**/*.js",
"dist/**/*.ts"
],
"scripts": {
"build": "pnpm build:ts",
"build:ci": "pnpm build",
"build:ts": "tsc -p tsconfig.build.json",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest --root ../.. --run src/**/*.test.ts"
},
"dependencies": {
"line-column": "^1.0.2",
"ohm-js": "^17.0.0"
"line-column": "^1.0.2"
},
"devDependencies": {
"@types/line-column": "^1.0.0",
Expand Down
20 changes: 20 additions & 0 deletions packages/liquid-html-parser/src/ast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,26 @@ describe('Unit: Stage 2 (AST)', () => {
expect(useChild.type).to.eql('TextNode');
});

it('should depth-balance nested same-name raw tags and close at the outer tag (issue 156)', () => {
const expectPath = makeExpectPath('toLiquidHtmlAST - nested raw close balancing');

// Nested <svg> elements: the outer <svg> must close at the LAST
// </svg>, keeping the inner <svg>…</svg> as raw body text rather than
// closing early at the first </svg> (which previously threw / mis-parsed).
ast = toLiquidHtmlAST('<svg>a<svg>b</svg>c</svg>');
expectPath(ast, 'children').to.have.lengthOf(1);
expectPath(ast, 'children.0.type').to.eql('HtmlRawNode');
expectPath(ast, 'children.0.name').to.eql('svg');
expectPath(ast, 'children.0.body.value').to.eql('a<svg>b</svg>c');

// Same balancing for a non-svg raw tag (<script>).
ast = toLiquidHtmlAST('<script>a<script>b</script>c</script>');
expectPath(ast, 'children').to.have.lengthOf(1);
expectPath(ast, 'children.0.type').to.eql('HtmlRawNode');
expectPath(ast, 'children.0.name').to.eql('script');
expectPath(ast, 'children.0.body.value').to.eql('a<script>b</script>c');
});

it(`should parse a basic text node into a TextNode`, () => {
for (const { toAST, expectPath, expectPosition } of testCases) {
ast = toAST('Hello world!');
Expand Down
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 tolerant 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
11 changes: 11 additions & 0 deletions packages/liquid-html-parser/src/document/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ export class ParserBase {
return this.source;
}

/**
* Whether this document parse is tolerant. The strict/default parser returns
* false; `TolerantDocumentParser` overrides it to true. Markup-construction
* sites consult this to enable the markup parser's tolerant recovery axis
* (`enableTolerant()`), leaving strict (and theme-check) parses untouched.
* Disjoint from the render-tree lax axis.
*/
isTolerant(): boolean {
return false;
}

tokenAt(index: number): Token {
return this.tokens[index];
}
Expand Down
5 changes: 4 additions & 1 deletion packages/liquid-html-parser/src/document/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,15 @@ export function makeLiquidTagBaseCase(
blockEndPosition?: Position,
delimiterWhitespace?: { start: LiquidOpenWhitespace; end: LiquidCloseWhitespace },
reason?: string,
// `#`-comment lines in `{% liquid %}` keep their inner indentation verbatim;
// every other base-case tag trims (the default).
preserveMarkup: boolean = false,
): LiquidTagBaseCase {
const posEnd = blockEndPosition ? blockEndPosition.end : envelope.blockStartPosition.end;
return {
type: NodeTypes.LiquidTag,
name: envelope.tagName,
markup: envelope.markupString.trim(),
markup: preserveMarkup ? envelope.markupString : envelope.markupString.trim(),
children,
whitespaceStart: envelope.whitespaceStart,
whitespaceEnd: envelope.whitespaceEnd,
Expand Down
59 changes: 48 additions & 11 deletions packages/liquid-html-parser/src/document/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,11 +217,15 @@ export function parseHtmlComment(parser: HtmlParserDelegate): HtmlComment {
const openToken = parser.consume(TokenType.HtmlCommentOpen);
const bodyStart = openToken.end;

while (!parser.isAtEnd() && !parser.check(TokenType.HtmlCommentClose)) {
parser.advance();
}

if (parser.isAtEnd()) {
// Find the closing `-->` by scanning the source rather than walking tokens.
// A conditional comment body (`<!--[if IE]>…<![endif]-->`) contains
// `<![endif]`, which the tokenizer treats as a doctype open — it enters
// HtmlTag mode and consumes the trailing `-->` as an HtmlTagClose, so no
// HtmlCommentClose token is ever emitted and the token walk would run to EOF.
// A source scan pins us to the real comment close, so the (unchanged)
// HtmlComment node feeds getConditionalComment correctly in the printer.
const closeIdx = source.indexOf('-->', bodyStart);
if (closeIdx === -1) {
throw new LiquidHTMLASTParsingError(
`Attempting to end parsing before HtmlComment '<!--' was closed`,
source,
Expand All @@ -230,11 +234,13 @@ export function parseHtmlComment(parser: HtmlParserDelegate): HtmlComment {
);
}

const bodyEnd = parser.peek().start;
const closeToken = parser.consume(TokenType.HtmlCommentClose);
const bodyEnd = closeIdx;
const commentEnd = closeIdx + 3; // past `-->`
const body = source.slice(bodyStart, bodyEnd).trim();

return makeHtmlComment(body, openToken.start, closeToken.end, source);
parser.seekToSourceOffset(commentEnd);

return makeHtmlComment(body, openToken.start, commentEnd, source);
}

// htmlDoctype := "<!" text ">"
Expand Down Expand Up @@ -499,7 +505,11 @@ function parseAttributeList(parser: HtmlParserDelegate): AttributeNode[] {
const attrEnd = closeQuote.end;
const attributePosition: Position = { start: valueStart, end: valueEnd };

if (quoteChar === '"') {
// Double straight quote and double curly quotes (“ ”) map to a
// double-quoted attr; single straight quote and single curly quotes
// (‘ ’) map to a single-quoted attr. The printer normalizes the curly
// variants to straight quotes.
if (quoteChar === '"' || quoteChar === '“' || quoteChar === '”') {
attrs.push(
makeAttrDoubleQuoted(name, value, attributePosition, attrStart, attrEnd, source),
);
Expand Down Expand Up @@ -738,13 +748,39 @@ export function scanForHtmlCloseTag(parser: ParserBase, tagName: string): number
const tokenCount = parser.tokenCount();
const pos = parser.getPosition();

// Depth-balance nested same-name elements so the OUTER close tag is
// returned, not the first inner one (e.g. `<svg>…<svg>…</svg>…</svg>`). A
// nested open tag is an `HtmlTagOpen` followed by a `Text` token whose first
// word matches the tag name — the tokenizer folds the tag name and any
// trailing attributes into a single text token, so we take the first word.
// The scan begins after the outer open tag has been consumed, so the outer
// open is never counted. Mirrors `scanForEndTagNested`.
let depth = 0;
for (let i = pos; i < tokenCount; i++) {
if (parser.tokenAt(i).type !== TokenType.HtmlCloseTagOpen) continue;
const token = parser.tokenAt(i);

if (token.type === TokenType.HtmlTagOpen) {
const textIdx = i + 1;
if (textIdx >= tokenCount) continue;
if (parser.tokenAt(textIdx).type !== TokenType.Text) continue;
const trimmed = source
.slice(parser.tokenAt(textIdx).start, parser.tokenAt(textIdx).end)
.trimStart();
const firstWs = trimmed.search(/\s/);
const name = firstWs === -1 ? trimmed.trim() : trimmed.slice(0, firstWs);
if (name.toLowerCase() === lowerName) depth++;
continue;
}

if (token.type !== TokenType.HtmlCloseTagOpen) continue;
const textIdx = i + 1;
if (textIdx >= tokenCount) continue;
if (parser.tokenAt(textIdx).type !== TokenType.Text) continue;
const text = source.slice(parser.tokenAt(textIdx).start, parser.tokenAt(textIdx).end);
if (text.trim().toLowerCase() === lowerName) return i;
if (text.trim().toLowerCase() === lowerName) {
if (depth === 0) return i;
depth--;
}
}
return -1;
}
Expand All @@ -768,6 +804,7 @@ function scriptKindFromAttributes(attributes: AttributeNode[]): RawMarkupKinds {
const typeValue = extractPlainAttributeValue(attributes, 'type');
if (typeValue === null) return RawMarkupKinds.javascript;
if (typeValue === 'text/html') return RawMarkupKinds.html;
if (typeValue === 'text/markdown') return RawMarkupKinds.markdown;
if (
typeValue.endsWith('json') ||
typeValue.endsWith('importmap') ||
Expand Down
3 changes: 3 additions & 0 deletions packages/liquid-html-parser/src/document/liquid-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export function parseBlockTag(
const markupStringStart = closeToken.start - envelope.markupString.length;
const tokens = tokenizeMarkup(envelope.markupString, markupStringStart);
const markupParser = new MarkupParser(tokens, parser.getSource());
if (parser.isTolerant()) markupParser.enableTolerant();
markup = def.parse(envelope.tagName, markupParser, parser);
if (!markupParser.isAtEnd()) {
markup = undefined;
Expand Down Expand Up @@ -341,6 +342,7 @@ export function parseBranchMarkup(
try {
const tokens = tokenizeMarkup(envelope.markupString, markupStringStart);
const markupParser = new MarkupParser(tokens, parser.getSource());
if (parser.isTolerant()) markupParser.enableTolerant();
const result = elsifBranchParse(branchName, markupParser);
if (!markupParser.isAtEnd()) return envelope.markupString.trim();
return result;
Expand All @@ -352,6 +354,7 @@ export function parseBranchMarkup(
try {
const tokens = tokenizeMarkup(envelope.markupString, markupStringStart);
const markupParser = new MarkupParser(tokens, parser.getSource());
if (parser.isTolerant()) markupParser.enableTolerant();
const result = whenBranchParse(branchName, markupParser);
if (!markupParser.isAtEnd()) return envelope.markupString.trim();
return result;
Expand Down
1 change: 1 addition & 0 deletions packages/liquid-html-parser/src/document/liquid-hybrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function parseHybridTag(
const markupStringStart = closeToken.start - envelope.markupString.length;
const tokens = tokenizeMarkup(envelope.markupString, markupStringStart);
const markupParser = new MarkupParser(tokens, parser.getSource());
if (parser.isTolerant()) markupParser.enableTolerant();
markup = def.parse(envelope.tagName, markupParser, parser);
if (!markupParser.isAtEnd()) {
markup = undefined;
Expand Down
Loading
Loading