diff --git a/.changeset/hand-written-liquid-html-parser.md b/.changeset/hand-written-liquid-html-parser.md new file mode 100644 index 000000000..0ee06def3 --- /dev/null +++ b/.changeset/hand-written-liquid-html-parser.md @@ -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. diff --git a/.changeset/port-theme-check-checks.md b/.changeset/port-theme-check-checks.md new file mode 100644 index 000000000..105d752b5 --- /dev/null +++ b/.changeset/port-theme-check-checks.md @@ -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.` argument where `` 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`. diff --git a/packages/liquid-html-parser/.gitignore b/packages/liquid-html-parser/.gitignore index 02a8b6d42..3a0374528 100644 --- a/packages/liquid-html-parser/.gitignore +++ b/packages/liquid-html-parser/.gitignore @@ -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 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..db465c168 --- /dev/null +++ b/packages/liquid-html-parser/fixtures/error-corpus.ts @@ -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 = + '
{{ product.title }}' + + "{% if product.available %}{{ product.price }}{% endif %}" + + "
\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 %}", + }, +]; diff --git a/packages/liquid-html-parser/package.json b/packages/liquid-html-parser/package.json index e052b1b57..aba6395a9 100644 --- a/packages/liquid-html-parser/package.json +++ b/packages/liquid-html-parser/package.json @@ -20,7 +20,6 @@ "@shopify:registry": "https://registry.npmjs.org" }, "files": [ - "grammar/*", "dist/**/*.js", "dist/**/*.ts" ], @@ -28,11 +27,11 @@ "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", diff --git a/packages/liquid-html-parser/src/ast.test.ts b/packages/liquid-html-parser/src/ast.test.ts index 3fc517c4b..996d97a6d 100644 --- a/packages/liquid-html-parser/src/ast.test.ts +++ b/packages/liquid-html-parser/src/ast.test.ts @@ -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 elements: the outer must close at the LAST + // , keeping the inner as raw body text rather than + // closing early at the first (which previously threw / mis-parsed). + ast = toLiquidHtmlAST('abc'); + 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('abc'); + + // Same balancing for a non-svg raw tag (c'); + 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('ac'); + }); + it(`should parse a basic text node into a TextNode`, () => { for (const { toAST, expectPath, expectPosition } of testCases) { ast = toAST('Hello world!'); diff --git a/packages/liquid-html-parser/src/ast.ts b/packages/liquid-html-parser/src/ast.ts index d95c2df13..cc1051e66 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/base.ts b/packages/liquid-html-parser/src/document/base.ts index e362275a7..6d9125017 100644 --- a/packages/liquid-html-parser/src/document/base.ts +++ b/packages/liquid-html-parser/src/document/base.ts @@ -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]; } diff --git a/packages/liquid-html-parser/src/document/factories.ts b/packages/liquid-html-parser/src/document/factories.ts index 38aba5ff7..1640e6f5b 100644 --- a/packages/liquid-html-parser/src/document/factories.ts +++ b/packages/liquid-html-parser/src/document/factories.ts @@ -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, diff --git a/packages/liquid-html-parser/src/document/html.ts b/packages/liquid-html-parser/src/document/html.ts index 18ad20c67..a296519f4 100644 --- a/packages/liquid-html-parser/src/document/html.ts +++ b/packages/liquid-html-parser/src/document/html.ts @@ -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 (``) contains + // `` 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 '` 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 := "" @@ -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), ); @@ -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. ``). 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; } @@ -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') || diff --git a/packages/liquid-html-parser/src/document/liquid-blocks.ts b/packages/liquid-html-parser/src/document/liquid-blocks.ts index 470c4ac37..ba08d651e 100644 --- a/packages/liquid-html-parser/src/document/liquid-blocks.ts +++ b/packages/liquid-html-parser/src/document/liquid-blocks.ts @@ -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; @@ -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; @@ -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; diff --git a/packages/liquid-html-parser/src/document/liquid-hybrid.ts b/packages/liquid-html-parser/src/document/liquid-hybrid.ts index 4262f2dc3..f855efa19 100644 --- a/packages/liquid-html-parser/src/document/liquid-hybrid.ts +++ b/packages/liquid-html-parser/src/document/liquid-hybrid.ts @@ -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; diff --git a/packages/liquid-html-parser/src/document/liquid-lines.test.ts b/packages/liquid-html-parser/src/document/liquid-lines.test.ts index 0741ef38a..e5c1729cb 100644 --- a/packages/liquid-html-parser/src/document/liquid-lines.test.ts +++ b/packages/liquid-html-parser/src/document/liquid-lines.test.ts @@ -112,6 +112,14 @@ describe('Unit: liquid-lines', () => { expectPath(ast, 'children.0.markup.1.name').to.eql('echo'); }); + it('should preserve inner indentation after `#`, stripping only one separator space (Category J)', () => { + // `# fancy` must keep four spaces of the ASCII-art indent — only a + // single separator space after `#` is stripped (ohm `"#" space?`). + const ast = toLiquidHtmlAST('{% liquid\n# fancy\necho "hi"\n%}'); + expectPath(ast, 'children.0.markup.0.name').to.eql('#'); + expectPath(ast, 'children.0.markup.0.markup').to.eql(' fancy'); + }); + it('should skip empty lines', () => { const ast = toLiquidHtmlAST('{% liquid\n\necho "hi"\n\n%}'); expectPath(ast, 'children.0.markup').to.have.lengthOf(1); @@ -226,4 +234,33 @@ describe('Unit: liquid-lines', () => { expectPath(ast, `${branch}.blockEndPosition.end`).to.eql(source.indexOf('endif')); }); }); + + describe('nested comment/doc balancing (Category B)', () => { + it('should balance nested comments so the outer endcomment closes the block', () => { + // The inner `comment`/`endcomment` pair must not close the outer + // block early; the outer comment ends at the final `endcomment`. + const source = '{% liquid\ncomment\nouter\ncomment\ninner\nendcomment\nendcomment\n%}'; + const ast = toLiquidHtmlAST(source); + expectPath(ast, 'children.0.markup').to.have.lengthOf(1); + expectPath(ast, 'children.0.markup.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.markup.0.name').to.eql('comment'); + expectPath(ast, 'children.0.markup.0.body.value').to.eql( + 'outer\ncomment\ninner\nendcomment\n', + ); + }); + + it('should carve out a nested raw so a literal endcomment inside it does not close early', () => { + // The `endcomment` line sits inside a nested `raw`…`endraw` block, so it + // must be ignored by the depth scan; the outer comment closes at the + // real trailing `endcomment`. + const source = '{% liquid\ncomment\nbefore\nraw\nendcomment\nendraw\nafter\nendcomment\n%}'; + const ast = toLiquidHtmlAST(source); + expectPath(ast, 'children.0.markup').to.have.lengthOf(1); + expectPath(ast, 'children.0.markup.0.type').to.eql('LiquidRawTag'); + expectPath(ast, 'children.0.markup.0.name').to.eql('comment'); + expectPath(ast, 'children.0.markup.0.body.value').to.eql( + 'before\nraw\nendcomment\nendraw\nafter\n', + ); + }); + }); }); diff --git a/packages/liquid-html-parser/src/document/liquid-lines.ts b/packages/liquid-html-parser/src/document/liquid-lines.ts index 92499f105..f00cf46a1 100644 --- a/packages/liquid-html-parser/src/document/liquid-lines.ts +++ b/packages/liquid-html-parser/src/document/liquid-lines.ts @@ -64,7 +64,9 @@ export function parseLiquidStatement( const envelope = envelopeFromLine(line, parser.getSource()); if (tagName === '#') { - return makeLiquidTagBaseCase(envelope); + // Preserve the inline comment's inner indentation (`preserveMarkup`); only + // the single separator space after `#` was stripped upstream. + return makeLiquidTagBaseCase(envelope, undefined, undefined, undefined, undefined, true); } if (tagName.startsWith('end')) { @@ -93,6 +95,7 @@ export function parseLiquidStatement( markupOffset, envelope.markupEnd, ); + if (parser.isTolerant()) markupParser.enableTolerant(); const markup = def.parse(tagName, markupParser, parser); if (!markupParser.isAtEnd()) { @@ -140,6 +143,7 @@ export function parseLineBlockTag( try { const tokens = tokenizeMarkup(markupString, markupOffset); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { @@ -196,11 +200,41 @@ export function parseLineRawTag( ? ctx.lines[ctx.index - 1].lineEnd + 1 : envelope.blockStartPosition.end; + // `comment`/`doc` bodies balance nested opens of the same tag before + // matching their end line, mirroring the document-path scan in + // liquid-raw.ts (Ruby comment.rb v5.13.0 `comment_tag_depth`). A nested + // `raw` block is carved out: `raw` is first-match and does not nest, so an + // `endcomment`/`comment` word sitting on a line inside a raw body must not + // affect the depth. Every other raw tag (`raw`, `javascript`, `schema`, + // `style`) keeps the original first-match scan and stays byte-identical. + const balanced = tagName === 'comment' || tagName === 'doc'; let endLineIndex = -1; - for (let i = ctx.index; i < ctx.lines.length; i++) { - if (ctx.lines[i].tagName === endTagName) { - endLineIndex = i; - break; + if (balanced) { + let depth = 0; + for (let i = ctx.index; i < ctx.lines.length; i++) { + const name = ctx.lines[i].tagName; + if (name === 'raw') { + // Skip past the nested raw block; its body lines never affect depth. + i++; + while (i < ctx.lines.length && ctx.lines[i].tagName !== 'endraw') i++; + continue; + } + if (name === tagName) { + depth++; + } else if (name === endTagName) { + if (depth === 0) { + endLineIndex = i; + break; + } + depth--; + } + } + } else { + for (let i = ctx.index; i < ctx.lines.length; i++) { + if (ctx.lines[i].tagName === endTagName) { + endLineIndex = i; + break; + } } } @@ -262,6 +296,7 @@ export function parseLineHybridTag( try { const tokens = tokenizeMarkup(markupString, markupOffset); const markupParser = new MarkupParser(tokens, parser.getSource()); + if (parser.isTolerant()) markupParser.enableTolerant(); markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { @@ -385,6 +420,7 @@ export function parseLineBranchedBody( line.tagName as BranchName, branchEnvelope, parser.getSource(), + parser.isTolerant(), ); currentBranch = makeLiquidBranchNamed(branchEnvelope, branchMarkup); currentChildren = []; @@ -423,12 +459,14 @@ export function parseLineBranchMarkup( branchName: BranchName, envelope: LiquidTagEnvelope, source: string, + tolerant: boolean = false, ): unknown { switch (branchName) { case 'elsif': { try { const tokens = tokenizeMarkup(envelope.markupString, envelope.markupOffset); const markupParser = new MarkupParser(tokens, source); + if (tolerant) markupParser.enableTolerant(); const result = elsifBranchParse(branchName, markupParser); if (!markupParser.isAtEnd()) return envelope.markupString.trim(); @@ -441,6 +479,7 @@ export function parseLineBranchMarkup( try { const tokens = tokenizeMarkup(envelope.markupString, envelope.markupOffset); const markupParser = new MarkupParser(tokens, source); + if (tolerant) markupParser.enableTolerant(); const result = whenBranchParse(branchName, markupParser); if (!markupParser.isAtEnd()) return envelope.markupString.trim(); diff --git a/packages/liquid-html-parser/src/document/liquid-raw.ts b/packages/liquid-html-parser/src/document/liquid-raw.ts index dd136716a..56690aad5 100644 --- a/packages/liquid-html-parser/src/document/liquid-raw.ts +++ b/packages/liquid-html-parser/src/document/liquid-raw.ts @@ -188,6 +188,14 @@ export function scanForEndTag(parser: ParserBase, endTagName: string): EndTagSca const source = parser.getSource(); const searchStart = parser.tokenAt(parser.getPosition()).start; + // `comment` and `doc` bodies balance nested opens of the same tag before + // matching their end tag, mirroring Ruby comment.rb v5.13.0's + // `comment_tag_depth`. `raw` (and every other raw tag) keeps first-match + // semantics — Ruby's raw does not balance. + if (endTagName === 'endcomment' || endTagName === 'enddoc') { + return scanForBalancedEndTag(source, searchStart, endTagName); + } + const pattern = new RegExp(`\\{%(-?)\\s*${endTagName}\\s*(-?)%\\}`); const match = pattern.exec(source.slice(searchStart)); if (!match) return null; @@ -200,6 +208,56 @@ export function scanForEndTag(parser: ParserBase, endTagName: string): EndTagSca return { tagStart, tagEnd, wsStart, wsEnd }; } +/** + * Depth-balancing end-tag scan for `comment`/`doc`. Counts nested opens of the + * same tag so a `{% comment %}` inside the body is paired with its own + * `{% endcomment %}` rather than closing the outer block early. + * + * `searchStart` is already positioned past the outer open tag, so depth starts + * at 0 and every `{% comment %}` encountered is a nested open. A nested + * `{% raw %}`…`{% endraw %}` is carved out — its literal contents (which may + * contain `{% comment %}`/`{% endcomment %}` text) do not affect the depth — + * mirroring Ruby's `parse_raw_tag_body`. + */ +function scanForBalancedEndTag( + source: string, + searchStart: number, + endTagName: string, +): EndTagScanResult | null { + const openTagName = endTagName.slice(3); // "endcomment" -> "comment" + const scanner = new RegExp( + `\\{%(-?)\\s*(${openTagName}|${endTagName}|raw|endraw)\\b\\s*(-?)%\\}`, + 'g', + ); + scanner.lastIndex = searchStart; + + let depth = 0; + let inRaw = false; + let match: RegExpExecArray | null; + while ((match = scanner.exec(source)) !== null) { + const name = match[2]; + if (inRaw) { + if (name === 'endraw') inRaw = false; + continue; + } + if (name === 'raw') { + inRaw = true; + } else if (name === openTagName) { + depth++; + } else if (name === endTagName) { + if (depth === 0) { + const tagStart = match.index; + const tagEnd = tagStart + match[0].length; + const wsStart: LiquidOpenWhitespace = match[1] === '-' ? '-' : ''; + const wsEnd: LiquidCloseWhitespace = match[3] === '-' ? '-' : ''; + return { tagStart, tagEnd, wsStart, wsEnd }; + } + depth--; + } + } + return null; +} + export function rawMarkupKindForTag(tagName: string, bodySource: string = ''): RawMarkupKinds { switch (tagName) { case 'javascript': diff --git a/packages/liquid-html-parser/src/document/liquid-tags.ts b/packages/liquid-html-parser/src/document/liquid-tags.ts index e23cfff21..604761a90 100644 --- a/packages/liquid-html-parser/src/document/liquid-tags.ts +++ b/packages/liquid-html-parser/src/document/liquid-tags.ts @@ -98,6 +98,7 @@ function parseStandaloneTag( markupStringStart, markupStringEnd, ); + if (parser.isTolerant()) markupParser.enableTolerant(); const markup = def.parse(envelope.tagName, markupParser, parser); if (!markupParser.isAtEnd()) { return makeLiquidTagBaseCase( diff --git a/packages/liquid-html-parser/src/document/liquid-variable-output.ts b/packages/liquid-html-parser/src/document/liquid-variable-output.ts index d7af5452a..e3ad2f7f2 100644 --- a/packages/liquid-html-parser/src/document/liquid-variable-output.ts +++ b/packages/liquid-html-parser/src/document/liquid-variable-output.ts @@ -17,6 +17,7 @@ export function parseLiquidVariableOutput(parser: ParserBase): LiquidVariableOut try { const tokens = tokenizeMarkup(rawMarkup, openToken.end); const markupParser = new MarkupParser(tokens, source); + if (parser.isTolerant()) markupParser.enableTolerant(); const liquidVariable = markupParser.liquidVariable(); if (!markupParser.isAtEnd()) { diff --git a/packages/liquid-html-parser/src/document/tokenizer.ts b/packages/liquid-html-parser/src/document/tokenizer.ts index f32b2ac1d..dcef56ec4 100644 --- a/packages/liquid-html-parser/src/document/tokenizer.ts +++ b/packages/liquid-html-parser/src/document/tokenizer.ts @@ -250,7 +250,18 @@ export function tokenize(source: string, options: TokenizeOptions = {}): Token[] continue; } - if (ch(0) === '"' || ch(0) === "'") { + // Accept straight quotes and curly (smart) quotes as attribute-value + // openers. Curly quotes get normalized to straight quotes downstream; + // recognizing them here (HTML scope only — Liquid tokenization is + // untouched) stops the value from splitting at interior spaces. + if ( + ch(0) === '"' || + ch(0) === "'" || + ch(0) === '“' || + ch(0) === '”' || + ch(0) === '‘' || + ch(0) === '’' + ) { quoteChar = ch(0); emit(TokenType.HtmlQuoteOpen, 1); pushMode(Mode.QuotedValue); @@ -265,7 +276,10 @@ export function tokenize(source: string, options: TokenizeOptions = {}): Token[] case Mode.QuotedValue: { if (scanLiquidOpen()) continue; - if (ch(0) === quoteChar) { + // Curly quotes are directional, so a value opened with a left curly + // quote closes on its right partner (and vice-versa); straight quotes + // close on themselves. + if (ch(0) === closingQuoteFor(quoteChar) || ch(0) === quoteChar) { emit(TokenType.HtmlQuoteClose, 1); popMode(); continue; @@ -293,3 +307,21 @@ enum Mode { LiquidTag = 'LiquidTag', LiquidVariableOutput = 'LiquidVariableOutput', } + +// Curly (smart) quotes come in directional pairs: a value opened with a left +// curly quote closes on its right partner and vice-versa. Straight quotes are +// their own partner, so they close on an identical character. +function closingQuoteFor(open: string): string { + switch (open) { + case '“': + return '”'; + case '”': + return '“'; + case '‘': + return '’'; + case '’': + return '‘'; + default: + return open; + } +} diff --git a/packages/liquid-html-parser/src/document/tolerant-parser.ts b/packages/liquid-html-parser/src/document/tolerant-parser.ts new file mode 100644 index 000000000..f942cd61f --- /dev/null +++ b/packages/liquid-html-parser/src/document/tolerant-parser.ts @@ -0,0 +1,111 @@ +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 + * tolerant 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 tolerant 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 TolerantDocumentParser extends DocumentParser { + /** + * Marks this parse as tolerant so the markup-construction sites enable the + * markup parser's tolerant recovery axis. Disjoint from lax. + */ + isTolerant(): boolean { + return true; + } + + /* + * 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..21086fe84 100644 --- a/packages/liquid-html-parser/src/errors.ts +++ b/packages/liquid-html-parser/src/errors.ts @@ -25,20 +25,44 @@ 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: the + * end-of-input token sits one past the last character when a closing + * delimiter like "%}" is never found, and some failures report a -1 + * sentinel position. line-column returns null for any out-of-range + * index, so dereferencing it while building `loc` below would throw a + * TypeError ("Cannot read properties of null") that masks the real + * syntax error. + * + * We have to guard this now because the tolerant parser is the first + * caller to drive malformed input through this throw site. The `loc` + * is built here at error-construction time, before the tolerant + * parser's recovery catch runs, so a null dereference would crash + * before the throw can become a recovered LiquidErrorNode, defeating + * the tolerant parser entirely. + * + * Clamping the indices into the valid range covers the out-of-range + * and -1 cases. The `?? 1` fallback on each line/column below stays + * load-bearing for empty or degenerate source, where fromIndex(0) on + * "" is still null even after clamping. Same root cause, both needed, + * 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..749d2c404 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, toTolerantLiquidAST, toTolerantLiquidHtmlAST } from './tolerant'; 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/liquid-doc/parser.ts b/packages/liquid-html-parser/src/liquid-doc/parser.ts index 364a418a9..b0bb4bec0 100644 --- a/packages/liquid-html-parser/src/liquid-doc/parser.ts +++ b/packages/liquid-html-parser/src/liquid-doc/parser.ts @@ -119,6 +119,18 @@ export class LiquidDocParser { const annotationToken = this.consume(LiquidDocTokenType.Annotation); const name = annotationToken.value; + // `@descriptionText` — no space between `@description` and its content. The + // level-1 tokenizer greedily folds the leading text into the annotation + // name (`descriptionText`), so recognize the `description` prefix here and + // treat the glued remainder as the start of the inline description content. + // This matches the pre-swap parser, which printed `@description Text`. + if ( + name !== LiquidDocAnnotation.Description && + name.startsWith(LiquidDocAnnotation.Description) + ) { + return this.parseGluedDescription(annotationToken); + } + if (!isKnownAnnotation(name)) { return this.parseUnsupportedAnnotation(annotationToken); } @@ -282,6 +294,36 @@ export class LiquidDocParser { ); } + // gluedDescription := "@description" gluedContent (no separating space) + // + // The glued suffix and any same-line text that follows are one inline + // description. Content is read straight from source (not by concatenating + // token values) so the original spacing between the suffix and the trailing + // text is preserved — the suffix's own token boundary is mid-word. + private parseGluedDescription(annotationToken: LiquidDocToken): LiquidDocDescriptionNode { + const start = annotationToken.start; + // Source offset immediately past the literal `@description`; the glued + // remainder (e.g. `This` in `@descriptionThis`) begins here. + const contentStart = start + 1 + LiquidDocAnnotation.Description.length; + + const inlineToken = this.accept(LiquidDocTokenType.TextLine); + const { value: rest, endPos } = this.collectContentLines(); + const hasRest = rest.trim().length > 0; + + let end: number; + if (hasRest) end = endPos; + else if (inlineToken) end = inlineToken.end; + else end = annotationToken.end; + + const content = makeTextNode( + this.source.slice(contentStart, end), + contentStart, + end, + this.source, + ); + return makeLiquidDocDescriptionNode(content, false, true, start, end, this.source); + } + // example := "@example" content private parseExample(annotationToken: LiquidDocToken): LiquidDocExampleNode { const start = annotationToken.start; diff --git a/packages/liquid-html-parser/src/markup/parser.ts b/packages/liquid-html-parser/src/markup/parser.ts index a5a1c5961..de78f751b 100644 --- a/packages/liquid-html-parser/src/markup/parser.ts +++ b/packages/liquid-html-parser/src/markup/parser.ts @@ -52,6 +52,14 @@ export class MarkupParser { * `liquid-render-tree/src/lax-recover.ts`. */ private lax: boolean; + /** + * Tolerant recovery axis, DISJOINT from `lax`. Enabled exclusively by the + * tolerant document parser (`TolerantDocumentParser`) so the formatter can + * build a best-effort tag/markup node instead of discarding markup. It never + * affects strict parsing (`toLiquidHtmlAST` / `theme-check`) and it does NOT + * change lax (Ruby-render-parity) behavior. + */ + private tolerant: boolean; /** * True while parsing a condition (`if`/`unless`/`elsif`). In lax mode this * permits stripping meaningless grouping parens (`(false || true)`), which is @@ -69,6 +77,7 @@ export class MarkupParser { this.markupStart = markupStart ?? tokens[0]?.start ?? 0; this.markupEnd = markupEnd ?? this.computeLastTokenEnd(); this.lax = false; + this.tolerant = false; this.inCondition = false; } @@ -86,6 +95,21 @@ export class MarkupParser { return this.lax; } + /** Enables tolerant recovery for subsequent parse calls. Returns `this` for + * chaining. Only the tolerant document parser calls this. Disjoint from + * lax — enabling one does not enable the other. */ + enableTolerant(): this { + this.tolerant = true; + return this; + } + + /** True when tolerant recovery is enabled. Tag/markup parse callbacks consult + * this to build a best-effort node instead of discarding markup, without + * affecting strict or lax parsing. */ + isTolerant(): boolean { + return this.tolerant; + } + /** Returns the raw source text from the current token up to (but excluding) * the token whose type is `stop` (or end of markup), trimmed. Advances the * cursor past everything consumed. Used by lax recovery to capture an @@ -1173,7 +1197,7 @@ export class MarkupParser { const result: LiquidFilter[] = []; while (this.consumeOptional(MarkupTokenType.Pipe)) { // Lax: a `|` not followed by a filter name (e.g. trailing `|`) is dropped. - if (this.lax && !this.look(MarkupTokenType.Id)) { + if ((this.lax || this.tolerant) && !this.look(MarkupTokenType.Id)) { this.skipToNextPipe(); continue; } @@ -1199,7 +1223,7 @@ export class MarkupParser { if (this.consumeOptional(MarkupTokenType.Colon)) { // Lax: a colon with no following argument (`upcase:`) is tolerated; only // parse arguments when something argument-like actually follows. - if (!this.lax || this.atArgumentStart()) { + if (!(this.lax || this.tolerant) || this.atArgumentStart()) { args = this.arguments(); } if (args.length > 0) { @@ -1259,7 +1283,7 @@ export class MarkupParser { while (this.consumeOptional(MarkupTokenType.Comma)) { // Lax: a trailing or empty comma (`append: "x",`) ends the argument list // rather than forcing another argument parse. - if (this.lax && !this.atArgumentStart()) { + if ((this.lax || this.tolerant) && !this.atArgumentStart()) { break; } args.push(this.argument()); diff --git a/packages/liquid-html-parser/src/parser-oracle.test.ts b/packages/liquid-html-parser/src/parser-oracle.test.ts index b9d7c981b..7e8d36d4a 100644 --- a/packages/liquid-html-parser/src/parser-oracle.test.ts +++ b/packages/liquid-html-parser/src/parser-oracle.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it } from 'vitest'; import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { resolve, join } from 'node:path'; import { toLiquidHtmlAST, toLiquidAST } from './ast'; import { nonTraversableProperties } from './types'; -const __dirname = dirname(fileURLToPath(import.meta.url)); 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'); diff --git a/packages/liquid-html-parser/src/tags/assign.ts b/packages/liquid-html-parser/src/tags/assign.ts index 10ca63e8a..7c6f68502 100644 --- a/packages/liquid-html-parser/src/tags/assign.ts +++ b/packages/liquid-html-parser/src/tags/assign.ts @@ -42,7 +42,7 @@ export const assignTag: TagDefinitionTag = { name, value, position: { start, end: markup.peek().start }, - source: '', + source: value.source, }; }, }; diff --git a/packages/liquid-html-parser/src/tags/block.ts b/packages/liquid-html-parser/src/tags/block.ts index 471bf7897..d57ed52e9 100644 --- a/packages/liquid-html-parser/src/tags/block.ts +++ b/packages/liquid-html-parser/src/tags/block.ts @@ -37,7 +37,7 @@ export const blockTag: TagDefinitionBlock = { name, args, position: { start: name.position.start, end: markup.peek().start }, - source: '', + source: name.source, }; }, }; diff --git a/packages/liquid-html-parser/src/tags/capture.ts b/packages/liquid-html-parser/src/tags/capture.ts index ea9b69b79..1c877aef4 100644 --- a/packages/liquid-html-parser/src/tags/capture.ts +++ b/packages/liquid-html-parser/src/tags/capture.ts @@ -48,7 +48,7 @@ export const captureTag: TagDefinitionBlock = { name: match[1], lookups: [], position: { start: bodyStart, end: bodyEnd }, - source: '', + source, }; } // No VariableSignature run (`''`, empty, spaces only) → unrecoverable in diff --git a/packages/liquid-html-parser/src/tags/content-for.ts b/packages/liquid-html-parser/src/tags/content-for.ts index 5c9c06727..b9832d2d7 100644 --- a/packages/liquid-html-parser/src/tags/content-for.ts +++ b/packages/liquid-html-parser/src/tags/content-for.ts @@ -13,8 +13,14 @@ export const contentForTag: TagDefinitionTag = { } const args: LiquidNamedArgument[] = []; - if (markup.consumeOptional(MarkupTokenType.Comma)) { - args.push(...markup.namedArguments()); + markup.consumeOptional(MarkupTokenType.Comma); + while (markup.look(MarkupTokenType.Id)) { + try { + args.push(markup.namedArgument()); + } catch { + break; + } + markup.consumeOptional(MarkupTokenType.Comma); } return { @@ -22,7 +28,7 @@ export const contentForTag: TagDefinitionTag = { contentForType, args, position: { start: contentForType.position.start, end: markup.peek().start }, - source: '', + source: contentForType.source, }; }, }; diff --git a/packages/liquid-html-parser/src/tags/cycle.ts b/packages/liquid-html-parser/src/tags/cycle.ts index 994ff7a8f..6dc8e5ebc 100644 --- a/packages/liquid-html-parser/src/tags/cycle.ts +++ b/packages/liquid-html-parser/src/tags/cycle.ts @@ -34,7 +34,7 @@ export const cycleTag: TagDefinitionTag = { groupName, args, position: { start: first.position.start, end: markup.peek().start }, - source: '', + source: first.source, }; }, }; diff --git a/packages/liquid-html-parser/src/tags/for.ts b/packages/liquid-html-parser/src/tags/for.ts index b59dd8f24..32d8cf65f 100644 --- a/packages/liquid-html-parser/src/tags/for.ts +++ b/packages/liquid-html-parser/src/tags/for.ts @@ -42,7 +42,7 @@ function parseForMarkup(_name: string, markup: MarkupParser, _parser: Parser): F reversed, args, position: { start: nameToken.start, end: markup.peek().start }, - source: '', + source: collection.source, }; } diff --git a/packages/liquid-html-parser/src/tags/liquid.ts b/packages/liquid-html-parser/src/tags/liquid.ts index c2b1e8554..a0d2c4592 100644 --- a/packages/liquid-html-parser/src/tags/liquid.ts +++ b/packages/liquid-html-parser/src/tags/liquid.ts @@ -78,7 +78,11 @@ function parseLines(body: string, bodyStart: number): LiquidLine[] { if (firstLineTrimmed.startsWith('#')) { tagName = '#'; - markup = trimmed.slice(1).trimStart(); + // Strip at most one separator whitespace after `#`, mirroring the + // pre-swap ohm grammar `"#" space?`. Trimming all leading whitespace + // would collapse intentional indentation in `#`-comment art such as + // `# fancy`, which the printer re-pads with a single space. + markup = trimmed.slice(1).replace(/^[ \t]/, ''); nameOffset = startIndex + leadingWs; markupOffset = startIndex + leadingWs + 1 + (trimmed.length - 1 - markup.length); } else { diff --git a/packages/liquid-html-parser/src/tags/paginate.ts b/packages/liquid-html-parser/src/tags/paginate.ts index 06f910ff1..9d8422bcb 100644 --- a/packages/liquid-html-parser/src/tags/paginate.ts +++ b/packages/liquid-html-parser/src/tags/paginate.ts @@ -19,6 +19,12 @@ export const paginateTag: TagDefinitionBlock = { const args: LiquidNamedArgument[] = []; if (markup.consumeOptional(MarkupTokenType.Comma)) { args.push(...markup.namedArguments()); + } else if (markup.isTolerant() && markup.peek().type === MarkupTokenType.Id) { + // Tolerant: paginate accepts whitespace-separated named attrs with no + // leading comma (`... by N window_size: 50`). Consume them so the tag + // builds as PaginateMarkup instead of leaving leftover tokens. Strict is + // unchanged. + args.push(...markup.namedArguments()); } return { @@ -27,7 +33,7 @@ export const paginateTag: TagDefinitionBlock = { pageSize, args, position: { start: collection.position.start, end: markup.peek().start }, - source: '', + source: collection.source, }; }, }; diff --git a/packages/liquid-html-parser/src/tags/render.ts b/packages/liquid-html-parser/src/tags/render.ts index ca4b83be3..f53ee2178 100644 --- a/packages/liquid-html-parser/src/tags/render.ts +++ b/packages/liquid-html-parser/src/tags/render.ts @@ -58,7 +58,7 @@ function parseRenderMarkup( kind, name, position: { start: kwStart, end: name.position.end }, - source: '', + source: name.source, }; } @@ -70,7 +70,7 @@ function parseRenderMarkup( type: NodeTypes.RenderAliasExpression, value: aliasToken.value, position: { start: asStart, end: aliasToken.end }, - source: '', + source: snippet.source, }; } @@ -96,7 +96,7 @@ function parseRenderMarkup( alias, args, position: { start: snippet.position.start, end }, - source: '', + source: snippet.source, }; } diff --git a/packages/liquid-html-parser/src/tags/section.ts b/packages/liquid-html-parser/src/tags/section.ts index 3f5828c0f..3df088ca2 100644 --- a/packages/liquid-html-parser/src/tags/section.ts +++ b/packages/liquid-html-parser/src/tags/section.ts @@ -22,7 +22,7 @@ export const sectionTag: TagDefinitionHybrid = { name, args, position: { start: name.position.start, end: markup.peek().start }, - source: '', + source: name.source, }; }, }; diff --git a/packages/liquid-html-parser/src/tolerant.test.ts b/packages/liquid-html-parser/src/tolerant.test.ts new file mode 100644 index 000000000..b4da977b3 --- /dev/null +++ b/packages/liquid-html-parser/src/tolerant.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, toTolerantLiquidAST, toTolerantLiquidHtmlAST } from './tolerant'; +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). + * + * Tolerant 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('tolerant 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(`toTolerantLiquidHtmlAST deep-equals toLiquidHtmlAST for ${JSON.stringify(source)}`, () => { + expect(structural(toTolerantLiquidHtmlAST(source))).toEqual( + structural(toLiquidHtmlAST(source)), + ); + }); + + it(`toTolerantLiquidAST deep-equals toLiquidAST for ${JSON.stringify(source)}`, () => { + expect(structural(toTolerantLiquidAST(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. Tolerant must reproduce the strict AST for each. + */ +describe.skipIf(!hasFixtures)('tolerant 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)(`toTolerantLiquidHtmlAST === 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(toTolerantLiquidHtmlAST(source))).toEqual( + structural(toLiquidHtmlAST(source)), + ); + }); + } + }); + + describe.skipIf(themeMissing)(`toTolerantLiquidAST === 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(toTolerantLiquidAST(source))).toEqual(structural(toLiquidAST(source))); + }); + } + }); + } +}); + +/* + * Positive-path tolerant behavior. These consolidate the throwaway probes + * from B2 (error nodes emitted), B3 (panic-mode resync) and B5 (locator) into + * committed tests. + */ +describe('tolerant mode surfaces parse errors as nodes', () => { + it('strict parse throws on a close tag with no matching open', () => { + expect(() => toLiquidHtmlAST('{% endfor %}')).toThrow(LiquidHTMLASTParsingError); + }); + + it('toTolerantLiquidHtmlAST emits a LiquidErrorNode instead of throwing', () => { + const source = '{% endfor %}'; + const ast = toTolerantLiquidHtmlAST(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('toTolerantLiquidAST also recovers instead of throwing', () => { + const ast = toTolerantLiquidAST('{% endfor %}'); + + expect(ast.type).toBe(NodeTypes.Document); + expect(errorNodesOf(ast)).toHaveLength(1); + }); +}); + +describe('tolerant mode resynchronizes after an error (panic mode)', () => { + it('recovers valid nodes between multiple errors and terminates', () => { + const ast = toTolerantLiquidHtmlAST('{% 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 = toTolerantLiquidHtmlAST(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/tolerant.ts b/packages/liquid-html-parser/src/tolerant.ts new file mode 100644 index 000000000..27de69e64 --- /dev/null +++ b/packages/liquid-html-parser/src/tolerant.ts @@ -0,0 +1,138 @@ +/** + * Opt-in tolerant entry points for the Liquid+HTML parser. + * + * These mirror `toLiquidAST` / `toLiquidHtmlAST` from `./ast` exactly, except + * they construct a `TolerantDocumentParser` 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 { TolerantDocumentParser } from './document/tolerant-parser'; +import { tokenize } from './document/tokenizer'; +import { Environment } from './environment'; +import { NodeTypes } from './types'; + +/* + * Tolerant variant of `toLiquidAST` (Liquid-only, `parseHtml: false`). + * Defaults `allowUnclosedDocumentNode: true` so an unterminated document is + * recovered rather than thrown on — the tolerant contract. + */ +export function toTolerantLiquidAST( + source: string, + options: ASTBuildOptions = { + allowUnclosedDocumentNode: true, + mode: 'tolerant', + }, +): DocumentNode { + const env = options.environment ?? Environment.default(); + const tokens = tokenize(source); + const parser = new TolerantDocumentParser( + tokens, + source, + env, + false, + options.allowUnclosedDocumentNode, + ); + return parser.parseDocument(); +} + +/* + * Tolerant variant of `toLiquidHtmlAST` (Liquid+HTML, `parseHtml: true`). + * Defaults `allowUnclosedDocumentNode: true` so an unterminated document is + * recovered rather than thrown on — the tolerant contract. + */ +export function toTolerantLiquidHtmlAST( + source: string, + options: ASTBuildOptions = { + allowUnclosedDocumentNode: true, + mode: 'tolerant', + }, +): DocumentNode { + const env = options.environment ?? Environment.default(); + const tokens = tokenize(source); + const parser = new TolerantDocumentParser( + tokens, + source, + env, + true, + options.allowUnclosedDocumentNode, + ); + return parser.parseDocument(); +} + +/* + * Locate the error node the caret is sitting in. + * + * A tolerant 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 diff --git a/packages/liquid-html-parser/tsconfig.json b/packages/liquid-html-parser/tsconfig.json index f1f831c96..601fa06fc 100644 --- a/packages/liquid-html-parser/tsconfig.json +++ b/packages/liquid-html-parser/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", "include": ["./src/**/*.ts", "./src/**/*.json"], - "exclude": ["./dist"], + "exclude": ["./dist", "src/parser.bench.ts"], "compilerOptions": { "outDir": "dist", "rootDir": "src", diff --git a/packages/prettier-plugin-liquid/src/parser.ts b/packages/prettier-plugin-liquid/src/parser.ts index 143889a7a..51bcb66d8 100644 --- a/packages/prettier-plugin-liquid/src/parser.ts +++ b/packages/prettier-plugin-liquid/src/parser.ts @@ -1,9 +1,9 @@ -import { toLiquidHtmlAST, LiquidHtmlNode } from '@shopify/liquid-html-parser'; +import { toTolerantLiquidHtmlAST, LiquidHtmlNode } from '@shopify/liquid-html-parser'; import { locEnd, locStart } from './utils'; export function parse(text: string): LiquidHtmlNode { - return toLiquidHtmlAST(text); + return toTolerantLiquidHtmlAST(text); } export const liquidHtmlAstFormat = 'liquid-html-ast'; diff --git a/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts b/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts index f5d204b09..9aac64c54 100644 --- a/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts +++ b/packages/prettier-plugin-liquid/src/printer/preprocess/augment-with-css-properties.ts @@ -121,6 +121,8 @@ function getCssDisplay(node: AugmentedNode, options: LiquidParserO case NodeTypes.Range: case NodeTypes.VariableLookup: case NodeTypes.AssignMarkup: + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: case NodeTypes.CycleMarkup: case NodeTypes.ContentForMarkup: case NodeTypes.ForMarkup: @@ -134,6 +136,7 @@ function getCssDisplay(node: AugmentedNode, options: LiquidParserO case NodeTypes.LiquidDocExampleNode: case NodeTypes.LiquidDocDescriptionNode: case NodeTypes.LiquidDocPromptNode: + case NodeTypes.LiquidErrorNode: return 'should not be relevant'; default: @@ -232,6 +235,8 @@ function getNodeCssStyleWhiteSpace( case NodeTypes.Range: case NodeTypes.VariableLookup: case NodeTypes.AssignMarkup: + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: case NodeTypes.CycleMarkup: case NodeTypes.ContentForMarkup: case NodeTypes.ForMarkup: @@ -245,6 +250,7 @@ function getNodeCssStyleWhiteSpace( case NodeTypes.LiquidDocExampleNode: case NodeTypes.LiquidDocDescriptionNode: case NodeTypes.LiquidDocPromptNode: + case NodeTypes.LiquidErrorNode: return 'should not be relevant'; default: diff --git a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts index 7e8628311..3e750ea66 100644 --- a/packages/prettier-plugin-liquid/src/printer/print/liquid.ts +++ b/packages/prettier-plugin-liquid/src/printer/print/liquid.ts @@ -7,6 +7,7 @@ import { LiquidDocExampleNode, LiquidDocDescriptionNode, LiquidDocPromptNode, + TAGS_WITHOUT_MARKUP, } from '@shopify/liquid-html-parser'; import { Doc, doc } from 'prettier'; @@ -192,13 +193,28 @@ function printNamedLiquidBlockStart( case NamedTags.increment: case NamedTags.decrement: case NamedTags.layout: - case NamedTags.section: { + case NamedTags.section: + /* + * `block` prints like `section` (both carry a name plus optional + * named arguments) and `partial` prints like `sections` (a bare + * string markup). Both delegate markup rendering to `printNode`. + */ + case NamedTags.block: + case NamedTags.partial: { return tag(' '); } case NamedTags.sections: { return tag(' '); } + /* + * `ifchanged` has no markup (`markup: null`), so we print just the + * tag name — there is nothing to render between the name and `%}`. + */ + case NamedTags.ifchanged: { + return wrapper([...prefix, node.name, ...suffix(' ')]); + } + case NamedTags.form: { const trailingWhitespace = node.markup.length > 1 ? line : ' '; return tagWithArrayMarkup(trailingWhitespace); @@ -342,16 +358,16 @@ export function printLiquidBlockStart( } const markup = node.markup; - return group([ - '{%', - whitespaceStart, - ' ', - node.name, - markup ? ` ${markup}` : '', - ' ', - whitespaceEnd, - '%}', - ]); + /* + * A few tags — `break`, `continue`, `else`, and friends listed in + * TAGS_WITHOUT_MARKUP — accept no markup at all. When one of them is + * written with stray arguments, e.g. `{% break huh?? %}`, we drop the + * markup rather than echo the invalid text back out. This restores the + * pre-port behaviour, where the parser blanked the markup before the + * printer ever saw it. + */ + const printedMarkup = markup && !TAGS_WITHOUT_MARKUP.includes(node.name) ? ` ${markup}` : ''; + return group(['{%', whitespaceStart, ' ', node.name, printedMarkup, ' ', whitespaceEnd, '%}']); } export function printLiquidBlockEnd( @@ -426,7 +442,15 @@ export function printLiquidTag( let body: Doc = []; - if (isBranchedTag(node)) { + /* + * `tablerow` is not a branched tag, but like `for` it wraps its body in a + * single default `LiquidBranch`. Routing it through `printChildren` (the + * non-branched path below) would indent that branch twice and prepend a + * blank line; the branched-path `path.map` prints the default branch with + * `for`'s single-indent, no-leading-blank shape. This is a printer-only + * special-case — `tablerow` is deliberately kept out of `isBranchedTag`. + */ + if (isBranchedTag(node) || node.name === 'tablerow') { body = cleanDoc( path.map( (p) => @@ -480,7 +504,13 @@ export function printLiquidRawTag( ' ', node.name, ' ', - node.markup ? `${node.markup} ` : '', + /* + * Argument-less raw tags such as `style` keep no markup. When one + * carries stray arguments, e.g. `{% style what %}`, we strip them + * instead of printing the invalid text; other raw tags keep their + * markup as before. + */ + node.markup && !TAGS_WITHOUT_MARKUP.includes(node.name) ? `${node.markup} ` : '', node.whitespaceEnd, '%}', ]); @@ -530,16 +560,30 @@ export function printLiquidDocParam( _args: LiquidPrinterArgs, ): Doc { const node = path.getValue(); + + /* + * A malformed `@param` line (e.g. an unclosed `[missingTail`) parses to a + * degenerate node with an empty `paramName`. Synthesizing the parts below + * would emit a spurious `@param - ...`, so emit the raw source span + * verbatim instead to preserve a faithful round-trip. `@param` is kept as + * the first part so `printLiquidDoc`'s tag grouping still treats this as a + * `@param` node and does not insert a blank line before the next param. + */ + if (node.paramName.value === '') { + const raw = node.source.slice(node.position.start, node.position.end); + return ['@param', raw.slice('@param'.length)]; + } + const parts: Doc[] = ['@param']; if (node.paramType?.value) { - parts.push(' ', `{${node.paramType.value}}`); + parts.push(' ', `{${node.paramType.value.trim()}}`); } if (node.required) { - parts.push(' ', node.paramName.value); + parts.push(' ', node.paramName.value.trim()); } else { - parts.push(' ', `[${node.paramName.value}]`); + parts.push(' ', `[${node.paramName.value.trim()}]`); } if (node.paramDescription?.value) { diff --git a/packages/prettier-plugin-liquid/src/printer/print/tag.ts b/packages/prettier-plugin-liquid/src/printer/print/tag.ts index 3c28506ee..675ce5bda 100644 --- a/packages/prettier-plugin-liquid/src/printer/print/tag.ts +++ b/packages/prettier-plugin-liquid/src/printer/print/tag.ts @@ -437,11 +437,18 @@ function getCompoundName( .map((part) => { if (part.type === NodeTypes.TextNode) { return part.value; - } else if (typeof part.markup === 'string') { - return `{{ ${part.markup.trim()} }}`; - } else { - return `{{ ${part.markup.rawSource} }}`; } + if (part.type === NodeTypes.LiquidVariableOutput) { + return typeof part.markup === 'string' + ? `{{ ${part.markup.trim()} }}` + : `{{ ${part.markup.rawSource} }}`; + } + /* + * Remaining compound-name arms are LiquidTag | LiquidRawTag + * (e.g. `<{% if c %}a{% endif %}>`). Neither carries a `.rawSource`, + * so reproduce the original source span verbatim. + */ + return part.source.slice(part.position.start, part.position.end); }) .join(''); } diff --git a/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts b/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts index 57f4f91bf..1f61701e0 100644 --- a/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts +++ b/packages/prettier-plugin-liquid/src/printer/printer-liquid-html.ts @@ -194,6 +194,18 @@ function printNode( args: LiquidPrinterArgs = {}, ): Doc { const node = path.getValue(); + + if ((node as any).type === 'BlockArrayLiteral') { + return [ + '[', + join( + [',', line], + (path as any).map((p: any) => print(p), 'elements'), + ), + ']', + ]; + } + switch (node.type) { case NodeTypes.Document: { return [printChildren(path as AstPath, options, print, args), hardline]; @@ -343,7 +355,7 @@ function printNode( whitespace, join( [',', whitespace], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ); @@ -362,7 +374,7 @@ function printNode( line, join( line, - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ]); } @@ -384,7 +396,7 @@ function printNode( line, join( [',', line], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ]); } @@ -401,7 +413,29 @@ function printNode( line, join( [',', line], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), + ), + ); + } + + return doc; + } + + /* + * `block` and `section` markup share the same shape: a name (a + * LiquidString) followed by optional named arguments. We print them + * the same way `content_for` prints its markup. + */ + case NodeTypes.BlockMarkup: + case NodeTypes.SectionMarkup: { + const doc: Doc = [path.call((p: any) => print(p), 'name')]; + if (node.args.length > 0) { + doc.push( + ',', + line, + join( + [',', line], + path.map((p: any) => print(p), 'args'), ), ); } @@ -428,7 +462,7 @@ function printNode( line, join( [',', line], - path.map((p) => print(p), 'args'), + path.map((p: any) => print(p), 'args'), ), ); } @@ -479,7 +513,7 @@ function printNode( let args: Doc[] = []; if (node.args.length > 0) { - const printed = path.map((p) => print(p), 'args'); + const printed = path.map((p: any) => print(p), 'args'); const shouldPrintFirstArgumentSameLine = node.args[0].type !== NodeTypes.NamedArgument; if (shouldPrintFirstArgumentSameLine) { @@ -590,6 +624,10 @@ function printNode( return printLiquidDocPrompt(path as AstPath, options, print, args); } + case NodeTypes.LiquidErrorNode: { + return node.source.slice(node.position.start, node.position.end); + } + default: { return assertNever(node); } diff --git a/packages/prettier-plugin-liquid/src/printer/utils/node.ts b/packages/prettier-plugin-liquid/src/printer/utils/node.ts index 42c723bc1..5341de3e8 100644 --- a/packages/prettier-plugin-liquid/src/printer/utils/node.ts +++ b/packages/prettier-plugin-liquid/src/printer/utils/node.ts @@ -1,4 +1,10 @@ -import { NodeTypes, LiquidNodeTypes, HtmlNodeTypes, Position } from '@shopify/liquid-html-parser'; +import { + NodeTypes, + LiquidNodeTypes, + HtmlNodeTypes, + Position, + CompoundNameSegment, +} from '@shopify/liquid-html-parser'; import { HtmlSelfClosingElement, LiquidHtmlNode, @@ -330,10 +336,7 @@ export function getLastDescendant(node: LiquidHtmlNode): LiquidHtmlNode { return node.lastChild ? getLastDescendant(node.lastChild) : node; } -function isTagNameIncluded( - collection: string[], - name: (TextNode | LiquidVariableOutput)[], -): boolean { +function isTagNameIncluded(collection: string[], name: CompoundNameSegment[]): boolean { if (name.length !== 1 || name[0].type !== NodeTypes.TextNode) return false; return collection.includes(name[0].value); } diff --git a/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid new file mode 100644 index 000000000..a6b10d22f --- /dev/null +++ b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/fixed.liquid @@ -0,0 +1,5 @@ +It should format ifchanged block tags +{% ifchanged %}content{% endifchanged %} + +It should normalize whitespace control on ifchanged tags +{%- ifchanged -%}content{%- endifchanged -%} diff --git a/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid new file mode 100644 index 000000000..f8130f74a --- /dev/null +++ b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.liquid @@ -0,0 +1,5 @@ +It should format ifchanged block tags +{% ifchanged %}content{% endifchanged %} + +It should normalize whitespace control on ifchanged tags +{%- ifchanged -%}content{%- endifchanged -%} diff --git a/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts new file mode 100644 index 000000000..0a3e8d617 --- /dev/null +++ b/packages/prettier-plugin-liquid/src/test/liquid-tag-ifchanged/index.spec.ts @@ -0,0 +1,7 @@ +import { test } from 'vitest'; +import { assertFormattedEqualsFixed } from '../test-helpers'; +import * as path from 'path'; + +test('Unit: liquid-tag-ifchanged', async () => { + await assertFormattedEqualsFixed(__dirname); +}); diff --git a/packages/prettier-plugin-liquid/vitest.config.mjs b/packages/prettier-plugin-liquid/vitest.config.mjs index 8c27203ac..488dbfd86 100644 --- a/packages/prettier-plugin-liquid/vitest.config.mjs +++ b/packages/prettier-plugin-liquid/vitest.config.mjs @@ -9,6 +9,5 @@ export default defineConfig({ maxWorkers: 1, isolate: true, globalSetup: ['./src/test/test-setup.js'], - setupFiles: ['../liquid-html-parser/build/shims.js'], }, }); diff --git a/packages/theme-check-common/src/checks/block-argument-setting-collision/index.spec.ts b/packages/theme-check-common/src/checks/block-argument-setting-collision/index.spec.ts new file mode 100644 index 000000000..c73e19a4c --- /dev/null +++ b/packages/theme-check-common/src/checks/block-argument-setting-collision/index.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { BlockArgumentSettingCollision } from './index'; +import { runLiquidCheck } from '../../test'; + +const PRODUCT_BLOCK = [ + '{% schema %}', + '{"settings":[{"id":"foo","type":"text"},{"id":"bar","type":"text"}]}', + '{% endschema %}', + '
{{ block.content }}
', +].join('\n'); + +const NO_SCHEMA_BLOCK = '
{{ block.content }}
'; + +const EMPTY_SCHEMA_BLOCK = ['{% schema %}', '{}', '{% endschema %}'].join('\n'); + +const NO_SETTINGS_KEY_BLOCK = [ + '{% schema %}', + '{"name":"Product"}', + '{% endschema %}', + '
{{ block.content }}
', +].join('\n'); + +const HEADER_ONLY_BLOCK = [ + '{% schema %}', + '{"settings":[{"type":"header","content":"X"}]}', + '{% endschema %}', + '
{{ block.content }}
', +].join('\n'); + +async function collisionOffenses(template: string, blockSource?: string) { + const existingThemeFiles = + blockSource !== undefined ? { 'blocks/product.liquid': blockSource } : undefined; + + return runLiquidCheck( + BlockArgumentSettingCollision, + template, + 'templates/test.liquid', + {}, + existingThemeFiles, + ); +} + +describe('BlockArgumentSettingCollision', () => { + it('reports a plain arg that collides with a setting id', async () => { + const offenses = await collisionOffenses( + "{% block 'product', foo: 'bar' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('foo'); + expect(offenses[0].message).toContain('block.settings.foo'); + }); + + it('does not report a plain arg that is not a setting id', async () => { + const offenses = await collisionOffenses( + "{% block 'product', notasetting: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report block.settings.* system args', async () => { + const offenses = await collisionOffenses( + "{% block 'product', block.settings.foo: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when the block file is missing', async () => { + const offenses = await collisionOffenses("{% block 'missing', foo: 'x' %}x{% endblock %}"); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when the block has no schema', async () => { + const offenses = await collisionOffenses( + "{% block 'product', foo: 'x' %}x{% endblock %}", + NO_SCHEMA_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not collision-report when the schema has no settings array', async () => { + const offenses = await collisionOffenses( + "{% block 'product', foo: 'x' %}x{% endblock %}", + EMPTY_SCHEMA_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not collision-report against a schema that omits the settings key', async () => { + const offenses = await collisionOffenses( + "{% block 'product', foo: 'x' %}x{% endblock %}", + NO_SETTINGS_KEY_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not count header or paragraph entries that lack an id', async () => { + const offenses = await collisionOffenses( + "{% block 'product', header_text: 'x' %}x{% endblock %}", + HEADER_ONLY_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('reports each colliding arg', async () => { + const offenses = await collisionOffenses( + "{% block 'product', foo: 'a', bar: 'b' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(2); + }); +}); diff --git a/packages/theme-check-common/src/checks/block-argument-setting-collision/index.ts b/packages/theme-check-common/src/checks/block-argument-setting-collision/index.ts new file mode 100644 index 000000000..47763f4b7 --- /dev/null +++ b/packages/theme-check-common/src/checks/block-argument-setting-collision/index.ts @@ -0,0 +1,45 @@ +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { BlockMarkup } from '@shopify/liquid-html-parser'; +import { isSystemArg } from '../common/block-doc'; +import { getBlockSchemaSettings } from '../common/block-schema'; + +export const BlockArgumentSettingCollision: LiquidCheckDefinition = { + meta: { + code: 'BlockArgumentSettingCollision', + name: 'Block Argument Setting Collision', + docs: { + description: + "Reports a plain block tag argument whose name matches a setting id in the target block's schema. The author likely intended block.settings.. May overlap with UnrecognizedBlockArguments, which reports the same argument as undeclared.", + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidTag(node) { + if (node.name !== 'block') return; + if (typeof node.markup === 'string') return; + + const markup = node.markup as BlockMarkup; + const blockName = markup.name.value; + const settings = await getBlockSchemaSettings(context, blockName); + if (!settings) return; + + for (const arg of markup.args) { + if (isSystemArg(arg.name)) continue; + if (!settings.has(arg.name)) continue; + + context.report({ + message: `The argument '${arg.name}' matches a setting on block '${blockName}'. Did you mean 'block.settings.${arg.name}'?`, + startIndex: arg.position.start, + endIndex: arg.position.end, + }); + } + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/cdn-preconnect/index.ts b/packages/theme-check-common/src/checks/cdn-preconnect/index.ts index 0bb04972a..12b4b60e1 100644 --- a/packages/theme-check-common/src/checks/cdn-preconnect/index.ts +++ b/packages/theme-check-common/src/checks/cdn-preconnect/index.ts @@ -1,5 +1,6 @@ +import { HtmlSelfClosingElement, HtmlVoidElement } from '@shopify/liquid-html-parser'; import { Severity, SourceCodeType, LiquidCheckDefinition } from '../../types'; -import { isAttr, isValuedHtmlAttribute, valueIncludes } from '../utils'; +import { getHtmlNodeName, isAttr, isValuedHtmlAttribute, valueIncludes } from '../utils'; export const CdnPreconnect: LiquidCheckDefinition = { meta: { @@ -17,26 +18,37 @@ export const CdnPreconnect: LiquidCheckDefinition = { }, create(context) { - return { - async HtmlVoidElement(node) { - if (node.name !== 'link') return; + function checkNode(node: HtmlVoidElement | HtmlSelfClosingElement) { + if (getHtmlNodeName(node) !== 'link') return; + + const isPreconnect = node.attributes + .filter(isValuedHtmlAttribute) + .some((attr) => isAttr(attr, 'rel') && valueIncludes(attr, 'preconnect')); + if (!isPreconnect) return; - const isPreconnect = node.attributes - .filter(isValuedHtmlAttribute) - .some((attr) => isAttr(attr, 'rel') && valueIncludes(attr, 'preconnect')); - if (!isPreconnect) return; + const isShopifyCdn = node.attributes + .filter(isValuedHtmlAttribute) + .some((attr) => isAttr(attr, 'href') && valueIncludes(attr, '.+cdn.shopify.com.+')); + if (!isShopifyCdn) return; - const isShopifyCdn = node.attributes - .filter(isValuedHtmlAttribute) - .some((attr) => isAttr(attr, 'href') && valueIncludes(attr, '.+cdn.shopify.com.+')); - if (!isShopifyCdn) return; + context.report({ + message: + 'Preconnecting to cdn.shopify.com is unnecessary and can lead to worse performance', + startIndex: node.position.start, + endIndex: node.position.end, + }); + } - context.report({ - message: - 'Preconnecting to cdn.shopify.com is unnecessary and can lead to worse performance', - startIndex: node.position.start, - endIndex: node.position.end, - }); + return { + async HtmlVoidElement(node) { + checkNode(node); + }, + // The ported parser emits `HtmlSelfClosingElement` for self-closed + // void tags such as ``, whereas the previous parser emitted + // `HtmlVoidElement` regardless of the trailing slash. Visit both so the + // check still fires on self-closing markup. + async HtmlSelfClosingElement(node) { + checkNode(node); }, }; }, diff --git a/packages/theme-check-common/src/checks/common/block-doc.ts b/packages/theme-check-common/src/checks/common/block-doc.ts new file mode 100644 index 000000000..234d69af8 --- /dev/null +++ b/packages/theme-check-common/src/checks/common/block-doc.ts @@ -0,0 +1,47 @@ +import type { LiquidCheckDefinition } from '../../types'; +import { extractDocDefinition } from '../../liquid-doc/liquidDoc'; +import type { LiquidDocParameter } from '../../liquid-doc/liquidDoc'; +import { toLiquidHtmlAST } from '@shopify/liquid-html-parser'; + +export type CheckContext = Parameters< + Extract any> +>[0]; + +export function isSystemArg(name: string): boolean { + return name.startsWith('block.'); +} + +/* + * Resolves the {% doc %} parameters for a block + * template by reading and parsing its source file. + * + * Returns undefined if the block file does not + * exist, cannot be parsed, or has no {% doc %} tag. + */ +export async function getBlockDocParams( + context: CheckContext, + blockName: string, +): Promise | undefined> { + const relativePath = `blocks/${blockName}.liquid`; + const uri = context.toUri(relativePath); + + let source: string; + try { + source = await context.fs.readFile(uri); + } catch { + return undefined; + } + + let ast: ReturnType; + try { + ast = toLiquidHtmlAST(source); + } catch { + return undefined; + } + + const docDef = extractDocDefinition(uri, ast); + const params = docDef?.liquidDoc?.parameters; + if (!params || params.length === 0) return undefined; + + return new Map(params.map((p) => [p.name, p])); +} diff --git a/packages/theme-check-common/src/checks/common/block-schema.ts b/packages/theme-check-common/src/checks/common/block-schema.ts new file mode 100644 index 000000000..52783c979 --- /dev/null +++ b/packages/theme-check-common/src/checks/common/block-schema.ts @@ -0,0 +1,62 @@ +import { isArrayNode, isLiteralNode, isObjectNode, SourceCodeType } from '../../types'; +import { toJSONAST } from '../../to-source-code'; +import { visit } from '../../visitor'; +import { toLiquidHtmlAST, type LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { CheckContext } from './block-doc'; + +/* + * Resolves the {% schema %} setting ids for a block template by reading + * and parsing its source file. Only top-level settings entries that carry + * an id are returned; header and paragraph entries and settings nested + * under blocks or presets are excluded, mirroring ExcessiveSettingsCount. + * + * Returns undefined only when the schema cannot be resolved: the block + * file is missing, the source is unparseable, there is no {% schema %} + * tag, or the schema JSON does not parse to an object. Returns an empty + * Set when the schema parses to an object but has no settings array, or + * when the settings array declares no id-bearing entries -- both mean + * the schema is known and carries zero setting ids. + */ +export async function getBlockSchemaSettings( + context: CheckContext, + blockName: string, +): Promise | undefined> { + const uri = context.toUri(`blocks/${blockName}.liquid`); + + let source: string; + try { + source = await context.fs.readFile(uri); + } catch { + return undefined; + } + + let ast: ReturnType; + try { + ast = toLiquidHtmlAST(source); + } catch { + return undefined; + } + + const schemaNode = visit(ast, { + LiquidRawTag(node) { + if (node.name === 'schema') return node; + }, + })[0]; + if (!schemaNode) return undefined; + + const schemaAst = toJSONAST(schemaNode.body.value); + if (schemaAst instanceof Error || !isObjectNode(schemaAst)) return undefined; + + const settingsProperty = schemaAst.children.find((property) => property.key.value === 'settings'); + if (!settingsProperty || !isArrayNode(settingsProperty.value)) return new Set(); + + const ids = new Set(); + for (const setting of settingsProperty.value.children) { + if (!isObjectNode(setting)) continue; + const idProp = setting.children.find((property) => property.key.value === 'id'); + if (idProp && isLiteralNode(idProp.value) && typeof idProp.value.value === 'string') { + ids.add(idProp.value.value); + } + } + return ids; +} diff --git a/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts b/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts index 783582d32..2986faa48 100644 --- a/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts +++ b/packages/theme-check-common/src/checks/deprecate-lazysizes/index.ts @@ -1,6 +1,8 @@ +import { HtmlSelfClosingElement, HtmlVoidElement } from '@shopify/liquid-html-parser'; import { Severity, SourceCodeType, LiquidCheckDefinition } from '../../types'; import { ValuedHtmlAttribute, + getHtmlNodeName, isAttr, isValuedHtmlAttribute, isHtmlAttribute, @@ -28,30 +30,41 @@ export const DeprecateLazysizes: LiquidCheckDefinition = { }, create(context) { + function checkNode(node: HtmlVoidElement | HtmlSelfClosingElement) { + if (getHtmlNodeName(node) !== 'img') return; + + const attributes = node.attributes.filter(isHtmlAttribute); + const hasSrc = attributes.some((attr) => isAttr(attr, 'src')); + const hasNativeLoading = attributes.some((attr) => isAttr(attr, 'loading')); + if (hasSrc && hasNativeLoading) return; + + const hasLazyloadClass = node.attributes + .filter(isValuedHtmlAttribute) + .some((attr) => isAttr(attr, 'class') && valueIncludes(attr, 'lazyload')); + if (!hasLazyloadClass) return; + + const hasLazysizesAttribute = node.attributes + .filter(isValuedHtmlAttribute) + .some(showsLazysizesUsage); + if (!hasLazysizesAttribute) return; + + context.report({ + message: 'Use the native loading="lazy" attribute instead of lazysizes', + startIndex: node.position.start, + endIndex: node.position.end, + }); + } + return { async HtmlVoidElement(node) { - if (node.name !== 'img') return; - - const attributes = node.attributes.filter(isHtmlAttribute); - const hasSrc = attributes.some((attr) => isAttr(attr, 'src')); - const hasNativeLoading = attributes.some((attr) => isAttr(attr, 'loading')); - if (hasSrc && hasNativeLoading) return; - - const hasLazyloadClass = node.attributes - .filter(isValuedHtmlAttribute) - .some((attr) => isAttr(attr, 'class') && valueIncludes(attr, 'lazyload')); - if (!hasLazyloadClass) return; - - const hasLazysizesAttribute = node.attributes - .filter(isValuedHtmlAttribute) - .some(showsLazysizesUsage); - if (!hasLazysizesAttribute) return; - - context.report({ - message: 'Use the native loading="lazy" attribute instead of lazysizes', - startIndex: node.position.start, - endIndex: node.position.end, - }); + checkNode(node); + }, + // The ported parser emits `HtmlSelfClosingElement` for self-closed + // void tags such as ``, whereas the previous parser emitted + // `HtmlVoidElement` regardless of the trailing slash. Visit both so the + // check still fires on self-closing markup. + async HtmlSelfClosingElement(node) { + checkNode(node); }, }; }, diff --git a/packages/theme-check-common/src/checks/duplicate-block-arguments/index.spec.ts b/packages/theme-check-common/src/checks/duplicate-block-arguments/index.spec.ts new file mode 100644 index 000000000..ac2aae1c0 --- /dev/null +++ b/packages/theme-check-common/src/checks/duplicate-block-arguments/index.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { DuplicateBlockArguments } from './index'; +import { runLiquidCheck } from '../../test'; + +const BUTTON_BLOCK = [ + '{% doc %}', + ' @param {String} [variant] - Button variant', + ' @param {String} [class] - Additional CSS classes', + ' @param {String} [url] - Button URL', + '{% enddoc %}', + '', +].join('\n'); + +const NO_DOC_BLOCK = ''; + +describe('DuplicateBlockArguments', () => { + it('reports duplicate argument', async () => { + const offenses = await runLiquidCheck( + DuplicateBlockArguments, + "{% block 'button', variant: 'a', variant: 'b' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': BUTTON_BLOCK }, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('variant'); + }); + + it('does not report unique arguments', async () => { + const offenses = await runLiquidCheck( + DuplicateBlockArguments, + "{% block 'button', variant: 'a', class: 'b' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': BUTTON_BLOCK }, + ); + + expect(offenses).toHaveLength(0); + }); + + it('reports duplicate even without a block file', async () => { + const offenses = await runLiquidCheck( + DuplicateBlockArguments, + "{% block 'nonexistent', variant: 'a', variant: 'b' %}x{% endblock %}", + 'templates/test.liquid', + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('variant'); + }); + + it('reports duplicate even when block file has no doc tag', async () => { + const offenses = await runLiquidCheck( + DuplicateBlockArguments, + "{% block 'button', variant: 'a', variant: 'b' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': NO_DOC_BLOCK }, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('variant'); + }); +}); diff --git a/packages/theme-check-common/src/checks/duplicate-block-arguments/index.ts b/packages/theme-check-common/src/checks/duplicate-block-arguments/index.ts new file mode 100644 index 000000000..7ce01507b --- /dev/null +++ b/packages/theme-check-common/src/checks/duplicate-block-arguments/index.ts @@ -0,0 +1,43 @@ +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { BlockMarkup } from '@shopify/liquid-html-parser'; + +export const DuplicateBlockArguments: LiquidCheckDefinition = { + meta: { + code: 'DuplicateBlockArguments', + name: 'Duplicate Block Arguments', + docs: { + description: 'Reports duplicate argument names in a block tag.', + recommended: true, + url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/duplicate-block-arguments', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidTag(node) { + if (node.name !== 'block') return; + if (typeof node.markup === 'string') return; + + const markup = node.markup as BlockMarkup; + const blockName = markup.name.value; + const seen = new Set(); + + for (const arg of markup.args) { + if (seen.has(arg.name)) { + context.report({ + message: `Duplicate argument '${arg.name}' in block tag for '${blockName}'.`, + startIndex: arg.position.start, + endIndex: arg.position.end, + }); + } else { + seen.add(arg.name); + } + } + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/excessive-settings-count/index.spec.ts b/packages/theme-check-common/src/checks/excessive-settings-count/index.spec.ts new file mode 100644 index 000000000..d5b99cefc --- /dev/null +++ b/packages/theme-check-common/src/checks/excessive-settings-count/index.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { ExcessiveSettingsCount, TOLERATED_SETTINGS_COUNT } from './index'; +import { Severity } from '../../types'; +import { check, runLiquidCheck } from '../../test'; +import type { Offense } from '../../types'; + +const DEFAULT_PATH = 'sections/test.liquid'; + +async function checkWithMaxSettings(template: string, maxSettings: number): Promise { + return check( + { [DEFAULT_PATH]: template }, + [ExcessiveSettingsCount], + {}, + { ExcessiveSettingsCount: { enabled: true, maxSettings } }, + ); +} + +function settingEntries( + count: number, + { indent = ' ', idPrefix = 'setting', labelPrefix = 'Setting ' } = {}, +): string[] { + return Array.from({ length: count }, (_, index) => { + const separator = index === count - 1 ? '' : ','; + + return `${indent}{ "type": "text", "id": "${idPrefix}_${index}", "label": "${labelPrefix}${index}" }${separator}`; + }); +} + +function schemaWithSettings(count: number): string { + return [ + '
section
', + '{% schema %}', + '{', + ' "name": "Test",', + ' "settings": [', + ...settingEntries(count), + ' ]', + '}', + '{% endschema %}', + ].join('\n'); +} + +describe('ExcessiveSettingsCount', () => { + describe('wrapper tolerated maximum', () => { + it('does not report at the tolerated maximum', async () => { + const offenses = await runLiquidCheck( + ExcessiveSettingsCount, + schemaWithSettings(TOLERATED_SETTINGS_COUNT), + DEFAULT_PATH, + ); + + expect(offenses).toEqual([]); + }); + + it('reports when settings exceed the tolerated maximum', async () => { + const offenses = await runLiquidCheck( + ExcessiveSettingsCount, + schemaWithSettings(TOLERATED_SETTINGS_COUNT + 1), + DEFAULT_PATH, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'ExcessiveSettingsCount', + severity: Severity.WARNING, + }); + expect(offenses[0].message).toBe( + `This schema declares ${TOLERATED_SETTINGS_COUNT + 1} settings, which exceeds the maximum of ${TOLERATED_SETTINGS_COUNT}. Consider splitting this section or block into smaller pieces, or grouping related options with a header.`, + ); + }); + }); + + it('reports when count exceeds a custom maximum', async () => { + const offenses = await checkWithMaxSettings(schemaWithSettings(3), 2); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toBe( + `This schema declares 3 settings, which exceeds the maximum of 2. Consider splitting this section or block into smaller pieces, or grouping related options with a header.`, + ); + }); + + it('does not report when count equals the configured maximum', async () => { + await expect(checkWithMaxSettings(schemaWithSettings(40), 40)).resolves.toEqual([]); + }); + + it('excludes header and paragraph entries that have no id', async () => { + const template = [ + '
section
', + '{% schema %}', + '{', + ' "settings": [', + ' { "type": "header", "content": "Group" },', + ' { "type": "text", "id": "a", "label": "A" },', + ' { "type": "paragraph", "content": "Note" },', + ' { "type": "text", "id": "b", "label": "B" },', + ' { "type": "text", "id": "c", "label": "C" }', + ' ]', + '}', + '{% endschema %}', + ].join('\n'); + + await expect(checkWithMaxSettings(template, 3)).resolves.toEqual([]); + }); + + it('ignores settings nested in blocks', async () => { + const blockSettings = settingEntries(20, { + indent: ' ', + idPrefix: 'nested', + labelPrefix: 'N', + }); + const template = [ + '
section
', + '{% schema %}', + '{', + ' "settings": [', + ...settingEntries(5), + ' ],', + ' "blocks": [', + ' {', + ' "type": "child",', + ' "settings": [', + ...blockSettings, + ' ]', + ' }', + ' ]', + '}', + '{% endschema %}', + ].join('\n'); + + await expect(checkWithMaxSettings(template, 6)).resolves.toEqual([]); + }); + + it('reports above the threshold and not at the threshold', async () => { + const over = await checkWithMaxSettings(schemaWithSettings(6), 5); + expect(over).toHaveLength(1); + + const atBoundary = await checkWithMaxSettings(schemaWithSettings(5), 5); + expect(atBoundary).toEqual([]); + }); + + it('does not report when schema is absent', async () => { + await expect(checkWithMaxSettings('
{{ product.title }}
', 1)).resolves.toEqual([]); + }); +}); diff --git a/packages/theme-check-common/src/checks/excessive-settings-count/index.ts b/packages/theme-check-common/src/checks/excessive-settings-count/index.ts new file mode 100644 index 000000000..cea47624a --- /dev/null +++ b/packages/theme-check-common/src/checks/excessive-settings-count/index.ts @@ -0,0 +1,96 @@ +import { + isArrayNode, + isObjectNode, + SchemaProp, + Severity, + SourceCodeType, + type LiquidCheckDefinition, +} from '../../types'; +import { toJSONAST } from '../../to-source-code'; +import { visit } from '../../visitor'; +import { type LiquidRawTag } from '@shopify/liquid-html-parser'; + +/** + * 40 sits just above Horizon's per-file maximum of 36 top-level settings, + * mirroring LiquidNestingDepth: the default rests a small step above the + * largest healthy theme so well-formed sections and blocks never trip it. + * + * +------------+-------+ + * | Theme | Count | + * +------------+-------+ + * | base-theme | 9 | + * | Dawn | 23 | + * | Horizon | 36 | + * +------------+-------+ + * + * Measured: + * - Dawn 9ccdacf81f175c7caeebc28348e50bcb02ef8fc7 + * - Horizon 70c27a8050f66d653c4d30a3974ff07d919e4310 + * - base-theme 31b1e1c (ose-next-theme) + */ +export const TOLERATED_SETTINGS_COUNT = 40; + +const schema = { + maxSettings: SchemaProp.number(TOLERATED_SETTINGS_COUNT), +}; + +export const ExcessiveSettingsCount: LiquidCheckDefinition = { + meta: { + code: 'ExcessiveSettingsCount', + name: 'ExcessiveSettingsCount', + docs: { + description: + 'Reports section or block schemas that declare more top-level settings than the configured maximum.', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema, + targets: [], + }, + + create(context) { + const maxSettings = context.settings.maxSettings; + + return { + async onCodePathEnd() { + if (context.file.ast instanceof Error) return; + + const schemaNode = visit(context.file.ast, { + LiquidRawTag(node) { + if (node.name === 'schema') return node; + }, + })[0]; + if (!schemaNode) return; + + const schemaAst = toJSONAST(schemaNode.body.value); + if (schemaAst instanceof Error || !isObjectNode(schemaAst)) return; + + const settingsProperty = schemaAst.children.find( + (property) => property.key.value === 'settings', + ); + if (!settingsProperty || !isArrayNode(settingsProperty.value)) return; + + /* + * Count only top-level settings that carry an id. Presentation-only + * entries such as header and paragraph have no id and are skipped, + * and settings nested inside blocks or presets are authored + * separately, so they are not folded into this file's count. + */ + const settingsCount = settingsProperty.value.children.filter( + (setting) => + isObjectNode(setting) && + setting.children.some((property) => property.key.value === 'id'), + ).length; + + if (settingsCount <= maxSettings) return; + + context.report({ + message: `This schema declares ${settingsCount} settings, which exceeds the maximum of ${maxSettings}. Consider splitting this section or block into smaller pieces, or grouping related options with a header.`, + startIndex: schemaNode.blockStartPosition.start, + endIndex: schemaNode.blockStartPosition.end, + }); + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/index.ts b/packages/theme-check-common/src/checks/index.ts index 6396762ed..5a72f045b 100644 --- a/packages/theme-check-common/src/checks/index.ts +++ b/packages/theme-check-common/src/checks/index.ts @@ -69,6 +69,25 @@ import { ValidVisibleIf, ValidVisibleIfSettingsSchema } from './valid-visible-if import { VariableName } from './variable-name'; import { AppBlockMissingSchema } from './app-block-missing-schema'; import { UniqueSettingIds } from './unique-settings-id'; +import { + JavascriptOncePerFile, + JavascriptSectionOrBlockOnly, + SchemaOncePerFile, + SchemaSectionOrBlockOnly, + StylesheetOncePerFile, + StylesheetSectionOrBlockOnly, +} from './raw-tags'; +import { BlockArgumentSettingCollision } from './block-argument-setting-collision'; +import { DuplicateBlockArguments } from './duplicate-block-arguments'; +import { ExcessiveSettingsCount } from './excessive-settings-count'; +import { LiquidComplexity } from './liquid-complexity'; +import { LiquidNestingDepth } from './liquid-nesting-depth'; +import { LiquidSyntaxError } from './liquid-syntax-error'; +import { MaxFileSize, MaxFileSizeJSON } from './max-file-size'; +import { MissingBlockArguments } from './missing-block-arguments'; +import { UnknownBlockSetting } from './unknown-block-setting'; +import { UnrecognizedBlockArguments } from './unrecognized-block-arguments'; +import { ValidBlockArgumentTypes } from './valid-block-argument-types'; export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ AppBlockValidTags, @@ -141,6 +160,24 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [ VariableName, ValidSchemaName, ValidSchemaTranslations, + JavascriptOncePerFile, + JavascriptSectionOrBlockOnly, + SchemaOncePerFile, + SchemaSectionOrBlockOnly, + StylesheetOncePerFile, + StylesheetSectionOrBlockOnly, + BlockArgumentSettingCollision, + DuplicateBlockArguments, + ExcessiveSettingsCount, + LiquidComplexity, + LiquidNestingDepth, + LiquidSyntaxError, + MaxFileSize, + MaxFileSizeJSON, + MissingBlockArguments, + UnknownBlockSetting, + UnrecognizedBlockArguments, + ValidBlockArgumentTypes, ]; /** diff --git a/packages/theme-check-common/src/checks/liquid-complexity/index.spec.ts b/packages/theme-check-common/src/checks/liquid-complexity/index.spec.ts new file mode 100644 index 000000000..b11e904d5 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-complexity/index.spec.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; +import { LiquidComplexity } from './index'; +import { Severity } from '../../types'; +import { check, runLiquidCheck } from '../../test'; +import type { Offense } from '../../types'; + +const DEFAULT_PATH = 'snippets/test.liquid'; + +async function checkWithMaxComplexity(template: string, maxComplexity: number): Promise { + return checkSources([{ path: DEFAULT_PATH, source: template }], maxComplexity); +} + +async function checkSources( + sources: { path: string; source: string }[], + maxComplexity: number, +): Promise { + const themeDesc = Object.fromEntries(sources.map(({ path, source }) => [path, source])); + + return check( + themeDesc, + [LiquidComplexity], + {}, + { LiquidComplexity: { enabled: true, maxComplexity } }, + ); +} + +function repeatedIfs(count: number): string { + return Array.from({ length: count }, (_, index) => `{% if enabled_${index} %}{% endif %}`).join( + '\n', + ); +} + +function expectComplexityMessage( + offense: Offense, + complexity: number, + maxComplexity: number, +): void { + expect(offense).toMatchObject({ + check: 'LiquidComplexity', + severity: Severity.WARNING, + }); + expect(offense.message).toContain( + `Liquid complexity is ${complexity}, which exceeds the maximum of ${maxComplexity}.`, + ); + expect(offense.message).toContain('pushed it over the limit'); + expect(offense.message).toContain('simplifying conditional logic'); + expect(offense.message).toContain( + 'moving self-contained decision logic into snippets with the render tag', + ); +} + +describe('LiquidComplexity', () => { + describe('wrapper tolerated maximum', () => { + it('does not report when complexity equals 120', async () => { + const offenses = await runLiquidCheck(LiquidComplexity, repeatedIfs(119), DEFAULT_PATH); + + expect(offenses).toEqual([]); + }); + + it('reports when complexity exceeds 120', async () => { + const offenses = await runLiquidCheck(LiquidComplexity, repeatedIfs(120), DEFAULT_PATH); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 121, 120); + }); + }); + + it('counts nested logical expressions', async () => { + const template = ` + {% if available and visible %} + Featured product + {% elsif featured or highlighted %} + Highlighted product + {% endif %} + `.trim(); + + // 1 base + 1 if + 1 and + 1 elsif + 1 or = 5. + await expect(checkWithMaxComplexity(template, 5)).resolves.toEqual([]); + + const offenses = await checkWithMaxComplexity(template, 4); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 5, 4); + }); + + it('counts logical expressions in unless conditions', async () => { + const template = ` + {% unless hidden or archived %} + Visible product + {% endunless %} + `.trim(); + + // 1 base + 1 unless + 1 or = 3. + await expect(checkWithMaxComplexity(template, 3)).resolves.toEqual([]); + + const offenses = await checkWithMaxComplexity(template, 2); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 3, 2); + }); + + it('does not count logical expressions in variable output or assign tags', async () => { + const template = ` + {% assign visible = product.available and customer %} + {{ product.available or customer }} + `.trim(); + + // 1 base. Non-branching assign and output boolean expressions do not count. + await expect(checkWithMaxComplexity(template, 1)).resolves.toEqual([]); + }); + + it('counts decision points inside liquid tags', async () => { + const template = ` + {% liquid + if enabled + echo 'Enabled' + elsif archived + echo 'Archived' + endif + %} + `.trim(); + + // 1 base + 1 if + 1 elsif = 3. + await expect(checkWithMaxComplexity(template, 3)).resolves.toEqual([]); + + const offenses = await checkWithMaxComplexity(template, 2); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 3, 2); + }); + + it('counts each nested condition as another decision point', async () => { + const template = ` + {% if product.available and product.published %} + {% unless customer.banned or customer.guest %} + {% if customer.tags contains 'vip' and settings.vip_enabled %} + VIP offer + {% elsif product.tags contains 'sale' or product.compare_at_price > product.price %} + Sale offer + {% endif %} + {% endunless %} + {% endif %} + `.trim(); + + // 1 base + 1 if + 1 and + 1 unless + 1 or + 1 nested if + 1 and + 1 elsif + 1 or = 9. + await expect(checkWithMaxComplexity(template, 9)).resolves.toEqual([]); + + const offenses = await checkWithMaxComplexity(template, 8); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 9, 8); + }); + + it('resets complexity for each file', async () => { + const source = '{% if enabled %}{% endif %}'; + + const offenses = await checkSources( + [ + { path: 'snippets/first.liquid', source }, + { path: 'snippets/second.liquid', source }, + ], + 2, + ); + + expect(offenses).toEqual([]); + }); + + it('tolerates parse errors', async () => { + const template = '{% if enabled %}'; + + await expect(checkWithMaxComplexity(template, 1)).resolves.toEqual([]); + }); + + it('reports plain files when maxComplexity is 0', async () => { + const template = 'Hello'; + + const offenses = await checkWithMaxComplexity(template, 0); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 1, 0); + expect(offenses[0].start.index).toBe(0); + expect(offenses[0].end.index).toBe(template.length); + }); + + it('counts case consistently with if and elsif', async () => { + const template = ` + {% case status %} + {% when 'active' %} + Active + {% when 'draft' %} + Draft + {% endcase %} + `.trim(); + + // 1 base + 1 case + 2 when branches = 4. + await expect(checkWithMaxComplexity(template, 4)).resolves.toEqual([]); + + const offenses = await checkWithMaxComplexity(template, 3); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 4, 3); + }); + + it('reports the range of the decision point that first exceeds the maximum', async () => { + const firstDecision = '{% if first %}'; + const secondDecision = '{% if second %}'; + const template = `${firstDecision}{% endif %}\n${secondDecision}{% endif %}`; + const expectedStartIndex = template.indexOf(secondDecision); + const expectedEndIndex = expectedStartIndex + secondDecision.length; + + const offenses = await checkWithMaxComplexity(template, 2); + + expect(offenses).toHaveLength(1); + expectComplexityMessage(offenses[0], 3, 2); + expect(offenses[0].start.index).toBe(expectedStartIndex); + expect(offenses[0].end.index).toBe(expectedEndIndex); + }); +}); diff --git a/packages/theme-check-common/src/checks/liquid-complexity/index.ts b/packages/theme-check-common/src/checks/liquid-complexity/index.ts new file mode 100644 index 000000000..fdabec10f --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-complexity/index.ts @@ -0,0 +1,157 @@ +import { SchemaProp, Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import { + NodeTypes, + type LiquidBranch, + type LiquidHtmlNode, + type LiquidLogicalExpression, + type LiquidTag, +} from '@shopify/liquid-html-parser'; +import { findLastIndex } from '../../utils'; + +/** + * 120 tolerates Horizon's current maximum while still flagging Dawn's top + * outlier. Threshold research top 3 measured files: + * + * +------------+-----------------------------------------------+------------+ + * | Theme | File | Complexity | + * +------------+-----------------------------------------------+------------+ + * | Dawn | snippets/facets.liquid | 134 | + * | Dawn | snippets/card-product.liquid | 112 | + * | Dawn | sections/main-product.liquid | 105 | + * | Horizon | snippets/header-drawer.liquid | 114 | + * | Horizon | sections/hero.liquid | 109 | + * | Horizon | snippets/product-media-gallery-content.liquid | 108 | + * | base-theme | layout/theme.liquid | 51 | + * | base-theme | blocks/_text-field.liquid | 42 | + * | base-theme | blocks/video.liquid | 33 | + * +------------+-----------------------------------------------+------------+ + * + * Measured: + * - Dawn 9ccdacf81f175c7caeebc28348e50bcb02ef8fc7 + * - Horizon 70c27a8050f66d653c4d30a3974ff07d919e4310 + * - base-theme 91dc493f91e968b23ed00d6ab8ef569ca720d1a4 (ose-next-theme) + */ +export const TOLERATED_LIQUID_COMPLEXITY = 120; + +const schema = { + maxComplexity: SchemaProp.number(TOLERATED_LIQUID_COMPLEXITY), +}; + +// Rules: start each file at 1, count each branching/looping Liquid tag, count +// each elsif/when branch, and count each logical and/or expression in Liquid +// control-flow conditions. Non-branching tags such as render, assign, echo, and +// else are intentionally excluded. +const COUNTED_TAGS = new Set(['if', 'unless', 'case', 'for', 'tablerow', 'paginate']); +const COUNTED_BRANCHES = new Set(['elsif', 'when']); +const TAGS_WITH_CONDITIONS = new Set(['if', 'unless']); +const BRANCHES_WITH_CONDITIONS = new Set(['elsif']); + +interface SourceRange { + startIndex: number; + endIndex: number; +} + +export const LiquidComplexity: LiquidCheckDefinition = { + meta: { + code: 'LiquidComplexity', + name: 'LiquidComplexity', + docs: { + description: 'Reports Liquid files with high cyclomatic complexity.', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema, + targets: [], + }, + + create(context) { + const maxComplexity = context.settings.maxComplexity; + const state = { complexity: 1 }; + let firstOverThresholdRange: SourceRange | undefined; + + function rangeFor(node: LiquidTag | LiquidBranch | LiquidLogicalExpression): SourceRange { + if ('blockStartPosition' in node) { + return { + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }; + } + + return { + startIndex: node.position.start, + endIndex: node.position.end, + }; + } + + function incrementComplexity(node: LiquidTag | LiquidBranch | LiquidLogicalExpression): void { + state.complexity += 1; + + if (!firstOverThresholdRange && state.complexity > maxComplexity) { + firstOverThresholdRange = rangeFor(node); + } + } + + function isLogicalExpressionInControlFlowCondition(ancestors: LiquidHtmlNode[]): boolean { + const nearestLiquidAncestorIndex = findLastIndex(ancestors, (ancestor) => + [NodeTypes.LiquidTag, NodeTypes.LiquidBranch, NodeTypes.LiquidVariableOutput].includes( + ancestor.type, + ), + ); + + if (nearestLiquidAncestorIndex === -1) return false; + + const nearestLiquidAncestor = ancestors[nearestLiquidAncestorIndex]; + + if (nearestLiquidAncestor.type === NodeTypes.LiquidTag) { + return TAGS_WITH_CONDITIONS.has(nearestLiquidAncestor.name); + } + + if (nearestLiquidAncestor.type === NodeTypes.LiquidBranch) { + return BRANCHES_WITH_CONDITIONS.has(nearestLiquidAncestor.name ?? ''); + } + + return false; + } + + function lineForIndex(index: number): number { + return context.file.source.slice(0, index).split('\n').length; + } + + return { + async LiquidTag(node: LiquidTag) { + if (COUNTED_TAGS.has(node.name)) { + incrementComplexity(node); + } + }, + + async LiquidBranch(node: LiquidBranch) { + if (COUNTED_BRANCHES.has(node.name ?? '')) { + incrementComplexity(node); + } + }, + + async LogicalExpression(node: LiquidLogicalExpression, ancestors: LiquidHtmlNode[]) { + if (!isLogicalExpressionInControlFlowCondition(ancestors)) return; + + incrementComplexity(node); + }, + + async onCodePathEnd() { + if (state.complexity <= maxComplexity) return; + + const range = firstOverThresholdRange ?? { + startIndex: 0, + endIndex: context.file.source.length, + }; + const line = lineForIndex(range.startIndex); + + context.report({ + message: `Liquid complexity is ${state.complexity}, which exceeds the maximum of ${maxComplexity}. The decision at line ${line} pushed it over the limit. Consider simplifying conditional logic, or moving self-contained decision logic into snippets with the render tag.`, + startIndex: range.startIndex, + endIndex: range.endIndex, + }); + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts index 88af441fc..fc0afd32a 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidBooleanExpression.spec.ts @@ -28,7 +28,7 @@ describe('detectTrailingAssignValue', async () => { [`{% assign foo = something == else %}`, '{% assign foo = something %}'], [`{% echo foo != bar %}`, '{% echo foo %}'], [`{{ this > that }}`, '{{ this }}'], - [`{{ bool and cond }}`, '{{ bool}}'], + [`{{ bool and cond }}`, '{{ bool }}'], ]; for (const [sourceCode, expected] of testCases) { diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts index a1a62ac83..ed20aa5ef 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts @@ -70,6 +70,16 @@ function isOperatorToken(token: Token): boolean { function checkInvalidStartingToken(tokens: Token[]): ExpressionIssue | null { const firstToken = tokens[0]; + // `contains` is the only word-operator in the comparison pattern that is + // also a valid identifier (the symbolic operators == != >= <= > < never + // are). When it stands alone it is a bare variable named `contains`, not + // the comparison operator, so it must not be flagged as an invalid + // starting token. This mirrors the previous parser, which produced a + // structured VariableLookup here; the ported parser falls back to string + // markup instead. + if (tokens.length === 1 && firstToken.value === 'contains') { + return null; + } if (firstToken.type === 'invalid' || firstToken.type === 'comparison') { return { message: `Conditional cannot start with '${firstToken.value}'. Use a variable or value instead`, diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts index ee98a086d..66458117c 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidFilterName.ts @@ -1,4 +1,10 @@ -import { LiquidVariableOutput, LiquidTag, NodeTypes, NamedTags } from '@shopify/liquid-html-parser'; +import { + LiquidVariableOutput, + LiquidTag, + LiquidVariable, + NodeTypes, + NamedTags, +} from '@shopify/liquid-html-parser'; import { Problem, SourceCodeType, Context, FilterEntry } from '../../..'; import { INVALID_SYNTAX_MESSAGE } from './utils'; @@ -11,30 +17,90 @@ export async function detectInvalidFilterName( } if (node.type === NodeTypes.LiquidVariableOutput) { - if (typeof node.markup !== 'string') { - return []; + // When the markup parses cleanly the parser hands us a structured + // `LiquidVariable`; only a bail-out leaves the raw markup as a string. + // Both carry the same offenses, they just live in different places. + if (typeof node.markup === 'string') { + return detectInvalidFilterNameInMarkup(node, node.markup, filters); } - return detectInvalidFilterNameInMarkup(node, node.markup, filters); + return detectInvalidFilterNameInFilters(node, node.markup, filters); } if (node.type === NodeTypes.LiquidTag) { - if (node.name === NamedTags.echo && typeof node.markup !== 'string') { - return []; - } - if (node.name === NamedTags.assign && typeof node.markup !== 'string') { - return []; + if (node.name === NamedTags.echo) { + if (typeof node.markup === 'string') { + return detectInvalidFilterNameInMarkup(node, node.markup, filters); + } + if (node.markup.type === NodeTypes.LiquidVariable) { + return detectInvalidFilterNameInFilters(node, node.markup, filters); + } } - if ( - typeof node.markup === 'string' && - (node.name === NamedTags.echo || node.name === NamedTags.assign) - ) { - return detectInvalidFilterNameInMarkup(node, node.markup, filters); + + if (node.name === NamedTags.assign) { + if (typeof node.markup === 'string') { + return detectInvalidFilterNameInMarkup(node, node.markup, filters); + } + if (node.markup.type === NodeTypes.AssignMarkup) { + return detectInvalidFilterNameInFilters(node, node.markup.value, filters); + } } } return []; } +// When the tokenizer meets an invalid trailing character (`@`, `!`, `#`, ...) +// it silently drops the byte, so the filter parses cleanly and the markup ends +// up as a structured `LiquidVariable` — the raw-markup regex above never sees +// it. The dropped byte still lives in the document `source` right after the +// filter name, so we recover the offense from the source span instead. +async function detectInvalidFilterNameInFilters( + node: LiquidVariableOutput | LiquidTag, + variable: LiquidVariable, + filters: FilterEntry[], +): Promise[]> { + const knownFilters = filters; + const source = node.source; + const markupEnd = variable.position.end; + const problems: Problem[] = []; + + for (const filter of variable.filters) { + if (!knownFilters.some((known) => known.name === filter.name)) { + continue; + } + + // `filter.position.start` sits before this filter's pipe, so the first + // occurrence of the name from there is this filter's name. + const nameStartInSource = source.indexOf(filter.name, filter.position.start); + if (nameStartInSource === -1) { + continue; + } + + const trailingStartInSource = nameStartInSource + filter.name.length; + + // Capture the run of characters wedged between the filter name and its + // next valid boundary (whitespace, `:` before arguments, `|` before the + // next filter, or the end of the markup). Anything there is junk. + const trailing = source.slice(trailingStartInSource, markupEnd).match(/^([^\s:|]+)/)?.[1]; + if (!trailing) { + continue; + } + + const trailingEndInSource = trailingStartInSource + trailing.length; + + problems.push({ + message: `${INVALID_SYNTAX_MESSAGE} Filter '${filter.name}' has trailing characters '${trailing}' that should be removed.`, + startIndex: trailingStartInSource, + endIndex: trailingEndInSource, + fix: (corrector) => { + corrector.replace(trailingStartInSource, trailingEndInSource, ''); + }, + }); + } + + return problems; +} + async function detectInvalidFilterNameInMarkup( node: LiquidVariableOutput | LiquidTag, markup: string, diff --git a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts index 566b273c8..0858f1b35 100644 --- a/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts +++ b/packages/theme-check-common/src/checks/liquid-html-syntax-error/index.spec.ts @@ -54,7 +54,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); expect(offenses[0].message).to.equal( - `Attempting to close LiquidTag 'if' before HtmlElement 'a' was closed`, + `Attempting to close LiquidTag 'endif' before HtmlElement 'a' was closed`, ); }); @@ -65,7 +65,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal(`SyntaxError: expected "%}"`); + expect(offenses[0].message).to.equal(`Expected LiquidTagClose but got EndOfInput`); }); it('should report unexpected tokens (2)', async () => { @@ -75,7 +75,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal(`SyntaxError: expected ">", not """`); + expect(offenses[0].message).to.equal(`Expected HtmlTagClose but got EndOfInput`); }); it('should report unexpected tokens (3)', async () => { @@ -85,9 +85,7 @@ describe('Module: LiquidHTMLSyntaxError', () => { const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode); expect(offenses).to.have.length(1); - expect(offenses[0].message).to.equal( - `SyntaxError: expected "#", a letter, "when", "sections", "section", "render", "liquid", "layout", "increment", "include", "elsif", "else", "echo", "decrement", "content_for", "cycle", "continue", "break", "assign", "tablerow", "unless", "if", "ifchanged", "for", "case", "capture", "paginate", "form", "end", "style", "stylesheet", "schema", "javascript", "raw", "comment", or "doc"`, - ); + expect(offenses[0].message).to.equal(`Expected LiquidTagClose but got EndOfInput`); }); it('should not report syntax error in valid Liquid code', async () => { @@ -114,16 +112,16 @@ describe('Module: LiquidHTMLSyntaxError', () => { source = `
`; offenses = await runLiquidCheck(LiquidHTMLSyntaxError, source); highlights = highlightedOffenses({ 'file.liquid': source }, offenses); - expect(highlights).to.include(''); + expect(highlights).to.include(''); source = ``; offenses = await runLiquidCheck(LiquidHTMLSyntaxError, source); highlights = highlightedOffenses({ 'file.liquid': source }, offenses); - expect(highlights).to.include('{% endif %}'); + expect(highlights).to.include(''); source = ``; offenses = await runLiquidCheck(LiquidHTMLSyntaxError, source); highlights = highlightedOffenses({ 'file.liquid': source }, offenses); - expect(highlights).to.include('"'); + expect(highlights).to.include('>'); }); }); diff --git a/packages/theme-check-common/src/checks/liquid-nesting-depth/index.spec.ts b/packages/theme-check-common/src/checks/liquid-nesting-depth/index.spec.ts new file mode 100644 index 000000000..43de8b9c4 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-nesting-depth/index.spec.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { LiquidNestingDepth, TOLERATED_LIQUID_NESTING_DEPTH } from './index'; +import { Severity } from '../../types'; +import { check, runLiquidCheck } from '../../test'; +import type { Offense } from '../../types'; + +const DEFAULT_PATH = 'snippets/test.liquid'; + +async function checkWithMaxDepth(template: string, maxDepth: number): Promise { + return check( + { [DEFAULT_PATH]: template }, + [LiquidNestingDepth], + {}, + { LiquidNestingDepth: { enabled: true, maxDepth } }, + ); +} + +function nestedIfs(depth: number): string { + return [ + ...Array.from({ length: depth }, (_, index) => `{% if condition_${index} %}`), + 'Nested content', + ...Array.from({ length: depth }, () => '{% endif %}'), + ].join('\n'); +} + +function expectNestingMessage(offense: Offense, depth: number, maxDepth: number): void { + expect(offense).toMatchObject({ + check: 'LiquidNestingDepth', + severity: Severity.WARNING, + }); + expect(offense.message).toBe( + `This Liquid block is nested ${depth} levels deep, which exceeds the maximum allowed depth of ${maxDepth}.`, + ); +} + +describe('LiquidNestingDepth', () => { + describe('wrapper tolerated maximum', () => { + it('does not report when nesting equals the tolerated depth', async () => { + const offenses = await runLiquidCheck( + LiquidNestingDepth, + nestedIfs(TOLERATED_LIQUID_NESTING_DEPTH), + DEFAULT_PATH, + ); + + expect(offenses).toEqual([]); + }); + + it('reports when nesting exceeds the tolerated depth', async () => { + const offenses = await runLiquidCheck( + LiquidNestingDepth, + nestedIfs(TOLERATED_LIQUID_NESTING_DEPTH + 1), + DEFAULT_PATH, + ); + + expect(offenses).toHaveLength(1); + expectNestingMessage( + offenses[0], + TOLERATED_LIQUID_NESTING_DEPTH + 1, + TOLERATED_LIQUID_NESTING_DEPTH, + ); + }); + }); + + it('does not report a plain Liquid template', async () => { + const template = '
{{ product.title }}
'; + + await expect(checkWithMaxDepth(template, 1)).resolves.toEqual([]); + }); + + it('reports the first tag that exceeds a custom threshold', async () => { + const threshold = 2; + const nestedUnless = '{% unless hidden %}'; + const template = ` + {% if product.available %} + {% for variant in product.variants %} + ${nestedUnless} + Available variant + {% endunless %} + {% endfor %} + {% endif %} + `.trim(); + const expectedStartIndex = template.indexOf(nestedUnless); + const expectedEndIndex = expectedStartIndex + nestedUnless.length; + + const offenses = await checkWithMaxDepth(template, threshold); + + expect(offenses).toHaveLength(1); + expectNestingMessage(offenses[0], 3, threshold); + expect(offenses[0].start.index).toBe(expectedStartIndex); + expect(offenses[0].end.index).toBe(expectedEndIndex); + }); + + it('reports each nested control-flow tag deeper than the threshold', async () => { + const offenses = await checkWithMaxDepth(nestedIfs(5), 3); + + expect(offenses).toHaveLength(2); + expectNestingMessage(offenses[0], 4, 3); + expectNestingMessage(offenses[1], 5, 3); + }); + + it('does not count elsif and else branches as additional nesting', async () => { + const template = ` + {% if product.available %} + Available + {% elsif product.tags contains 'coming-soon' %} + Coming soon + {% else %} + Unavailable + {% endif %} + `.trim(); + + await expect(checkWithMaxDepth(template, 1)).resolves.toEqual([]); + }); + + it('counts tablerow as a nesting tag', async () => { + const template = ` + {% if section.blocks.size > 0 %} + {% tablerow block in section.blocks %} + {% unless block.settings.hidden %} + {{ block.settings.title }} + {% endunless %} + {% endtablerow %} + {% endif %} + `.trim(); + + const offenses = await checkWithMaxDepth(template, 2); + + expect(offenses).toHaveLength(1); + expectNestingMessage(offenses[0], 3, 2); + }); + + it('counts nested case blocks consistently with if, unless, and for blocks', async () => { + const template = ` + {% case section.settings.layout %} + {% when 'grid' %} + {% for product in collection.products %} + {% if product.available %} + {% unless product.tags contains 'hidden' %} + {{ product.title }} + {% endunless %} + {% endif %} + {% endfor %} + {% endcase %} + `.trim(); + + const offenses = await checkWithMaxDepth(template, 3); + + expect(offenses).toHaveLength(1); + expectNestingMessage(offenses[0], 4, 3); + }); + + it('does not count HTML nesting as Liquid nesting depth', async () => { + const template = ` +
+
+
+
+ {% if product.available %} + Available + {% endif %} +
+
+
+
+ `.trim(); + + await expect(checkWithMaxDepth(template, 1)).resolves.toEqual([]); + }); +}); diff --git a/packages/theme-check-common/src/checks/liquid-nesting-depth/index.ts b/packages/theme-check-common/src/checks/liquid-nesting-depth/index.ts new file mode 100644 index 000000000..78571786b --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-nesting-depth/index.ts @@ -0,0 +1,70 @@ +import { SchemaProp, Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import { NodeTypes, type LiquidHtmlNode, type LiquidTag } from '@shopify/liquid-html-parser'; + +/** + * 10 allows a small buffer above Dawn and Horizon's current maximum while + * still preventing deeper Liquid control-flow nesting. + * + * +------------+-------------------------------+-------+ + * | Theme | File | Depth | + * +------------+-------------------------------+-------+ + * | Dawn | sections/footer.liquid | 8 | + * | Horizon | snippets/header-drawer.liquid | 8 | + * | base-theme | blocks/_pagination.liquid | 7 | + * +------------+-------------------------------+-------+ + * + * Measured: + * - Dawn 9ccdacf81f175c7caeebc28348e50bcb02ef8fc7 + * - Horizon 70c27a8050f66d653c4d30a3974ff07d919e4310 + * - base-theme f1bcb38b4f03ea64a12eaf5e8a79d2927602e8d7 (ose-next-theme) + */ +export const TOLERATED_LIQUID_NESTING_DEPTH = 10; + +const schema = { + maxDepth: SchemaProp.number(TOLERATED_LIQUID_NESTING_DEPTH), +}; + +const NESTING_TAGS = new Set(['if', 'unless', 'for', 'case', 'tablerow']); + +export const LiquidNestingDepth: LiquidCheckDefinition = { + meta: { + code: 'LiquidNestingDepth', + name: 'LiquidNestingDepth', + docs: { + description: 'Reports Liquid files with deeply nested control-flow structures.', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema, + targets: [], + }, + + create(context) { + const maxDepth = context.settings.maxDepth; + + function nestingDepthFor(ancestors: LiquidHtmlNode[]): number { + const nestingAncestors = ancestors.filter( + (ancestor) => + ancestor.type === NodeTypes.LiquidTag && NESTING_TAGS.has(ancestor.name as string), + ); + + return nestingAncestors.length + 1; + } + + return { + async LiquidTag(node: LiquidTag, ancestors: LiquidHtmlNode[]) { + if (!NESTING_TAGS.has(node.name)) return; + + const depth = nestingDepthFor(ancestors); + if (depth <= maxDepth) return; + + context.report({ + message: `This Liquid block is nested ${depth} levels deep, which exceeds the maximum allowed depth of ${maxDepth}.`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/assign.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/assign.ts new file mode 100644 index 000000000..e20c28eb7 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/assign.ts @@ -0,0 +1,63 @@ +import type { LiquidTag, AssignMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + variableHasBareArrayAccess, + hasSkippedCharacters, + hasSkippedPrefixCharacters, + hasRubyAcceptedEmptyFirstFilterArgument, + hasRubyAcceptedFilterArgumentTrailingComma, + hasRubyAcceptedEmptyAssignRhs, + hasRubyAcceptedAssignLhsExtraIdentifier, + rawMarkup, +} from './utils'; + +export function checkAssignTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + if (hasRubyAcceptedEmptyAssignRhs(node.markup)) return; + if (hasRubyAcceptedEmptyFirstFilterArgument(node.markup)) return; + if (hasRubyAcceptedFilterArgumentTrailingComma(node.markup)) return; + if (hasRubyAcceptedAssignLhsExtraIdentifier(node.markup)) return; + + context.report({ + message: `Syntax error in 'assign' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as AssignMarkup; + + if (variableHasBareArrayAccess(markup.value)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + context.report({ + message: `Syntax error in 'assign' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const raw = rawMarkup(node); + if (hasRubyAcceptedEmptyAssignRhs(raw)) return; + if (hasRubyAcceptedEmptyFirstFilterArgument(raw)) return; + if (hasRubyAcceptedFilterArgumentTrailingComma(raw)) return; + if (hasRubyAcceptedAssignLhsExtraIdentifier(raw)) return; + + const parsedRaw = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(parsedRaw)) { + context.report({ + message: `Syntax error in 'assign' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/base.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/base.ts new file mode 100644 index 000000000..97926d941 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/base.ts @@ -0,0 +1,73 @@ +import { + builtinTags, + NodeTypes, + type LiquidStatement, + type LiquidTag, +} from '@shopify/liquid-html-parser'; +import type { Context } from '.'; + +const reportedUnknownLiquidBlockTags = new WeakSet(); + +export async function checkBaseTag(node: LiquidTag, context: Context): Promise { + if ('reason' in node && typeof node.reason === 'string') { + context.report({ + message: node.reason, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (node.name === 'liquid' && Array.isArray(node.markup)) { + await checkUnknownTagsInsideLiquidBlock(node.markup, context); + return; + } + + const knownLiquidTags = await knownLiquidTagsFor(context); + if (reportedUnknownLiquidBlockTags.has(node)) return; + + if (isUnknownTagInsideLiquidBlock(node, knownLiquidTags)) { + reportedUnknownLiquidBlockTags.add(node); + context.report({ + message: `Unknown tag '${node.name}'`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} + +async function checkUnknownTagsInsideLiquidBlock( + statements: LiquidStatement[], + context: Context, +): Promise { + const knownLiquidTags = await knownLiquidTagsFor(context); + + for (const statement of statements) { + if ( + statement.type === NodeTypes.LiquidTag && + !reportedUnknownLiquidBlockTags.has(statement) && + isUnknownTagInsideLiquidBlock(statement, knownLiquidTags) + ) { + reportedUnknownLiquidBlockTags.add(statement); + context.report({ + message: `Unknown tag '${statement.name}'`, + startIndex: statement.position.start, + endIndex: statement.position.end, + }); + } + } +} + +async function knownLiquidTagsFor(context: Context): Promise> { + const tags = context.themeDocset + ? await context.themeDocset.tags() + : Object.keys(builtinTags).map((name) => ({ name })); + return new Set(['#', 'else', 'elsif', 'when', ...tags.map((tag) => tag.name)]); +} + +function isUnknownTagInsideLiquidBlock(node: LiquidTag, knownLiquidTags: Set): boolean { + // Statements inside `{% liquid %}` do not have their own `{%` delimiter. + if (node.source.startsWith('{%', node.position.start)) return false; + + return !knownLiquidTags.has(node.name); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/block.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/block.ts new file mode 100644 index 000000000..fec5688e4 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/block.ts @@ -0,0 +1,101 @@ +import { NodeTypes, type BlockMarkup, type LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasBareArrayAccess, + hasSkippedCharacters, + liquidLineTagLocation, + rawMarkup, + resolveErrorLocation, +} from './utils'; + +const SYNTAX_ERROR = "Syntax error in 'block' tag"; +const BARE_ARRAY_ACCESS = 'Bare bracket access is not allowed in strict2 mode'; +const UNCLOSED_BLOCK_PARSER_ERROR = "Attempting to end parsing before LiquidTag 'block' was closed"; +const UNCLOSED_BLOCK_IN_LIQUID_PARSER_ERROR = "Unclosed block tag 'block' in {% liquid %} block"; +const BLOCK_PARSER_ERROR_MESSAGES = new Set([ + UNCLOSED_BLOCK_PARSER_ERROR, + UNCLOSED_BLOCK_IN_LIQUID_PARSER_ERROR, + "Attempting to close LiquidTag 'block' before it was opened without a matching 'block'", +]); + +export function checkBlockTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + report(node, context, SYNTAX_ERROR); + return; + } + + const markup = node.markup as BlockMarkup; + + if (hasInvalidBlockName(markup.name.value)) { + report(node, context, "Liquid syntax error: in 'block' - Valid syntax: block '[file_name]'"); + return; + } + + if (hasInvalidBlockArguments(markup)) { + report(node, context, SYNTAX_ERROR); + return; + } + + /* + * A +BlockArrayLiteral+ value (e.g. +size: [1, 2]+) is a first-class array + * literal in this parser, not a bare bracket lookup, so it never counts as + * bare array access. Skip it to narrow +arg.value+ down to the plain + * +LiquidExpression+ that +hasBareArrayAccess+ expects. + */ + if ( + markup.args.some( + (arg) => arg.value.type !== 'BlockArrayLiteral' && hasBareArrayAccess(arg.value), + ) + ) { + report(node, context, BARE_ARRAY_ACCESS); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + report(node, context, SYNTAX_ERROR); + } +} + +export function checkBlockParserError(error: Error, context: Context, source: string): void { + if (!BLOCK_PARSER_ERROR_MESSAGES.has(error.message)) return; + + const [startIndex, endIndex] = error.message.includes( + "Unclosed block tag 'block' in {% liquid %} block", + ) + ? (liquidLineTagLocation(source, 'block') ?? resolveErrorLocation(error, source)) + : resolveErrorLocation(error, source); + + context.report({ + message: + error.message === UNCLOSED_BLOCK_PARSER_ERROR + ? "Liquid syntax error: 'block' tag was never closed" + : error.message, + startIndex, + endIndex, + }); +} + +function hasInvalidBlockName(value: string): boolean { + return value.includes('/') || value.includes('.'); +} + +function hasInvalidBlockArguments(markup: BlockMarkup): boolean { + return markup.args.some((arg) => { + if (arg.name === 'block.content') return false; + if (arg.name === 'block.name') return arg.value.type !== NodeTypes.String; + if (arg.name.startsWith('block.settings.')) { + return arg.name.slice('block.settings.'.length).includes('.'); + } + if (arg.name.startsWith('block.')) return true; + + return false; + }); +} + +function report(node: LiquidTag, context: Context, message: string): void { + context.report({ + message, + startIndex: node.position.start, + endIndex: node.position.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/branch.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/branch.ts new file mode 100644 index 000000000..ae9a95b2c --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/branch.ts @@ -0,0 +1,118 @@ +import type { + LiquidBranch, + LiquidConditionalExpression, + LiquidExpression, + LiquidTag, +} from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + conditionalHasBareArrayAccess, + hasBareArrayAccess, + hasSkippedCharacters, + hasSkippedPrefixCharacters, + hasUnclosedQuotedString, +} from './utils'; + +const ELSIF_BARE_ARRAY_ACCESS = 'Bare bracket access is not allowed in strict2 mode'; + +export function checkBranchTag(node: LiquidBranch, context: Context): void { + if (node.name === 'elsif') { + checkElsifBranch(node, context); + return; + } + + if (node.name === 'when') { + checkWhenBranch(node, context); + } +} + +export function checkMisplacedBranchTag(node: LiquidTag, context: Context): void { + context.report({ + message: `Unknown tag '${node.name}'`, + startIndex: node.position.start, + endIndex: node.position.end, + }); +} + +function checkWhenBranch(node: LiquidBranch, context: Context): void { + if (typeof node.markup === 'string') { + reportWhenSyntaxError(node, context); + return; + } + + const markup = node.markup as LiquidExpression[]; + const rawMarkup = node.source.slice(node.markupPosition.start, node.markupPosition.end); + + if (hasUnclosedQuotedString(rawMarkup)) { + reportWhenSyntaxError(node, context); + return; + } + + if (markup.some(hasBareArrayAccess)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + if (markup.length === 0) { + return; + } + + const parsedMarkup = node.source.slice(markup[0].position.start, node.markupPosition.end); + if (hasSkippedCharacters(parsedMarkup)) { + reportWhenSyntaxError(node, context); + } +} + +function reportWhenSyntaxError(node: LiquidBranch, context: Context): void { + context.report({ + message: "Syntax error in 'when' tag", + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); +} + +function checkElsifBranch(node: LiquidBranch, context: Context): void { + if (typeof node.markup === 'string') { + reportSyntaxError(node, context); + return; + } + + const markup = node.markup as LiquidConditionalExpression; + const rawMarkup = node.source.slice(node.markupPosition.start, node.markupPosition.end); + + if (hasUnclosedQuotedString(rawMarkup)) { + reportSyntaxError(node, context); + return; + } + + if (conditionalHasBareArrayAccess(markup)) { + context.report({ + message: ELSIF_BARE_ARRAY_ACCESS, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + reportSyntaxError(node, context); + return; + } + + const parsedMarkup = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(parsedMarkup)) { + reportSyntaxError(node, context); + } +} + +function reportSyntaxError(node: LiquidBranch, context: Context): void { + context.report({ + message: "Syntax error in 'elsif' tag", + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/capture.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/capture.ts new file mode 100644 index 000000000..5e15735dd --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/capture.ts @@ -0,0 +1,31 @@ +import type { LiquidTag, LiquidExpression } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasBareArrayAccess, hasSkippedCharacters, rawMarkup } from './utils'; + +export function checkCaptureTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'capture' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasBareArrayAccess(node.markup as LiquidExpression)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'capture' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/case.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/case.ts new file mode 100644 index 000000000..be1d06ec3 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/case.ts @@ -0,0 +1,47 @@ +import type { LiquidTag, LiquidExpression } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasBareArrayAccess, + hasSkippedCharacters, + hasUnclosedQuotedString, + rawMarkup, +} from './utils'; + +export function checkCaseTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: "Syntax error in 'case' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as LiquidExpression; + + if (hasUnclosedQuotedString(rawMarkup(node))) { + context.report({ + message: "Syntax error in 'case' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasBareArrayAccess(markup)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(node.source.slice(markup.position.start, node.markupPosition.end))) { + context.report({ + message: "Syntax error in 'case' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/comment.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/comment.ts new file mode 100644 index 000000000..4f319542a --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/comment.ts @@ -0,0 +1,234 @@ +import { TokenType, tokenize, type LiquidRawTag, type Token } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { liquidTagBodies, liquidTagMarkup, resolveErrorLocation } from './utils'; + +const COMMENT_RAW_BODY_ERROR = "Liquid syntax error: 'comment' tag was never closed"; +export const UNMATCHED_COMMENT_CLOSE_PARSER_ERROR = + "Attempting to close LiquidTag 'comment' before it was opened without a matching 'comment'"; +export const UNMATCHED_RAW_CLOSE_PARSER_ERROR = + "Attempting to close LiquidTag 'raw' before it was opened without a matching 'raw'"; +const UNCLOSED_COMMENT_PARSER_ERROR = + "Attempting to end parsing before LiquidRawTag 'comment' was closed"; + +export function checkCommentTag(error: Error, context: Context, source: string): void { + if (!isCommentParserError(error, source)) return; + + const [startIndex, endIndex] = resolveErrorLocation(error, source); + + if ( + error.message === UNCLOSED_COMMENT_PARSER_ERROR && + hasNestedCommentTag(commentTagBody(source, startIndex)) + ) { + context.report({ message: COMMENT_RAW_BODY_ERROR, startIndex, endIndex }); + return; + } + + if ( + error.message === UNMATCHED_COMMENT_CLOSE_PARSER_ERROR && + hasRubyAcceptedInertCommentBodyCloser(source, 'comment', startIndex) + ) { + return; + } + + context.report({ + message: error.message, + startIndex, + endIndex, + }); +} + +// The body of a raw tag is everything after its opening `{% ... %}`. For an +// unclosed comment that runs to end-of-source, that is the remainder. +function commentTagBody(source: string, startIndex: number): string { + const openTagEnd = source.indexOf('%}', startIndex); + return openTagEnd === -1 ? '' : source.slice(openTagEnd + 2); +} + +// True when the (unclosed) comment body itself opens another comment tag — +// i.e. the failure is nesting, not a plain later-unclosed comment. +function hasNestedCommentTag(body: string): boolean { + for (const tag of liquidTagBodies(body)) { + const markup = liquidTagMarkup(tag.body); + if (markup?.tagName === 'comment' && !markup.hasSkippedCharacters) { + return true; + } + } + return false; +} + +export function checkCommentRawTag(node: LiquidRawTag, context: Context): void { + const body = node.body.value; + + if ( + hasUnbalancedCommentTags(body, node.source, node.blockEndPosition.end) || + hasUnbalancedRawTags(body, node.source, node.blockEndPosition.end) || + hasUnclosedLiquidDelimiter(body) + ) { + context.report({ + message: COMMENT_RAW_BODY_ERROR, + startIndex: node.body.position.start, + endIndex: node.body.position.end, + }); + } +} + +function isCommentParserError(error: Error, source: string): boolean { + if (!hasCompleteLiquidTag(source, 'comment') && !hasCompleteLiquidTag(source, 'endcomment')) { + return false; + } + + return ( + error.message === "Attempting to end parsing before LiquidRawTag 'comment' was closed" || + error.message === UNMATCHED_COMMENT_CLOSE_PARSER_ERROR + ); +} + +function hasUnclosedLiquidDelimiter(body: string): boolean { + const tokens = tokenize(body); + + for (let i = 0; i < tokens.length; i++) { + const open = tokens[i]; + const text = tokens[i + 1]; + const close = text?.type === TokenType.Text ? tokens[i + 2] : text; + + if (open.type === TokenType.LiquidTagOpen) { + if (close?.type !== TokenType.LiquidTagClose) return true; + continue; + } + + if (open.type === TokenType.LiquidVariableOutputOpen) { + if (close?.type !== TokenType.LiquidVariableOutputClose) return true; + } + } + + return false; +} + +function hasUnbalancedCommentTags( + body: string, + source: string, + sourceAfterClosingTagStart: number, +): boolean { + return hasUnbalancedNestedTags(body, source, sourceAfterClosingTagStart, 'comment'); +} + +function hasUnbalancedRawTags( + body: string, + source: string, + sourceAfterClosingTagStart: number, +): boolean { + return hasUnbalancedNestedTags(body, source, sourceAfterClosingTagStart, 'raw'); +} + +function hasUnbalancedNestedTags( + body: string, + source: string, + sourceAfterClosingTagStart: number, + tagName: 'comment' | 'raw', +): boolean { + let depth = tagNestingDepth(body, tagName); + if (depth === 0) return false; + + for (const tag of liquidTagBodies(source, sourceAfterClosingTagStart)) { + const markup = liquidTagMarkup(tag.body); + if (!markup || markup.hasSkippedCharacters) continue; + + if (markup.tagName === tagName) { + depth++; + continue; + } + + if (markup.tagName === `end${tagName}`) { + depth--; + if (depth === 0) return false; + } + } + + return true; +} + +function tagNestingDepth(body: string, tagName: 'comment' | 'raw'): number { + let depth = 0; + + for (const tag of liquidTagBodies(body)) { + const markup = liquidTagMarkup(tag.body); + if (!markup || markup.hasSkippedCharacters) continue; + + if (markup.tagName === tagName) { + depth++; + continue; + } + + if (markup.tagName === `end${tagName}` && depth > 0) { + depth--; + } + } + + return depth; +} + +function countLiquidTag( + source: string, + tagName: string, + startIndex = 0, + endIndex = source.length, +): number { + return liquidTagBodies(source, startIndex).filter((tag) => { + if (tag.bodyStart >= endIndex) return false; + + const markup = liquidTagMarkup(tag.body); + return markup?.tagName === tagName && !markup.hasSkippedCharacters; + }).length; +} + +export function hasRubyAcceptedInertCommentBodyCloser( + source: string, + tagName: 'comment' | 'raw', + startIndex: number, +): boolean { + const endTagName = `end${tagName}`; + const closer = liquidTagAt(source, startIndex); + + if (closer?.tagName !== endTagName || closer.hasSkippedCharacters) { + return false; + } + + if (tagName === 'comment') { + return ( + countLiquidTag(source, 'comment', 0, startIndex) > + countLiquidTag(source, 'endcomment', 0, startIndex) + ); + } + + return ( + countLiquidTag(source, 'raw', 0, startIndex) > + countLiquidTag(source, 'endraw', 0, startIndex) && + countLiquidTag(source, 'comment', 0, startIndex) > 0 && + countLiquidTag(source, 'endcomment', startIndex) > 0 + ); +} + +function hasCompleteLiquidTag(source: string, tagName: string): boolean { + return countLiquidTag(source, tagName) > 0; +} + +function liquidTagAt(source: string, startIndex: number): ReturnType { + const tokens = tokenize(source); + + for (let i = 0; i < tokens.length; i++) { + const open = tokens[i]; + if (open.type !== TokenType.LiquidTagOpen || open.start !== startIndex) continue; + + const text = tokens[i + 1]; + const close = text?.type === TokenType.Text ? tokens[i + 2] : text; + if (!close || close.type !== TokenType.LiquidTagClose) return undefined; + + return liquidTagMarkup(liquidTagBody(source, text?.type === TokenType.Text ? text : undefined)); + } + + return undefined; +} + +function liquidTagBody(source: string, text: Token | undefined): string { + return text ? source.substring(text.start, text.end) : ''; +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/content-for.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/content-for.ts new file mode 100644 index 000000000..426c14afd --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/content-for.ts @@ -0,0 +1,147 @@ +import { NodeTypes, type ContentForMarkup, type LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { argHasBareArrayAccess } from './utils'; + +const ACCEPTED_CONTENT_FOR_TYPES = new Set(['block', 'blocks']); +const RESERVED_BLOCK_ATTRIBUTES = new Set(['block', 'schema']); +const CONTENT_FOR_CLOSEST_PREFIX = 'closest.'; +const CONTENT_FOR_METAOBJECT_CLOSEST_PREFIX = 'metaobject.'; + +const CONTENT_FOR_CLOSEST_RESOURCE_TYPES = new Set([ + 'article', + 'blog', + 'collection', + 'page', + 'product', +]); + +export function checkContentForTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: "Syntax error in 'content_for' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as ContentForMarkup; + const contentForType = markup.contentForType.value; + + // Parseable but unsupported values + if (!ACCEPTED_CONTENT_FOR_TYPES.has(contentForType)) { + reportUnsupportedContentForType(node, context, contentForType); + return; + } + + if (hasInvalidSharedArguments(markup)) { + report(node, context); + return; + } + + if (contentForType === 'block' && hasInvalidBlockArguments(markup)) { + report(node, context); + return; + } + + if (contentForType === 'blocks' && hasInvalidBlocksArguments(markup)) { + report(node, context); + } +} + +function hasInvalidSharedArguments(markup: ContentForMarkup): boolean { + return hasDuplicateArguments(markup) || markup.args.some(argHasBareArrayAccess); +} + +function hasDuplicateArguments(markup: ContentForMarkup): boolean { + const seen = new Set(); + for (const arg of markup.args) { + if (seen.has(arg.name)) return true; + seen.add(arg.name); + } + return false; +} + +function hasInvalidBlockArguments(markup: ContentForMarkup): boolean { + return ( + hasReservedBlockArgument(markup) || + hasInvalidBlockStaticArguments(markup) || + hasInvalidBlockClosestArguments(markup) + ); +} + +function hasReservedBlockArgument(markup: ContentForMarkup): boolean { + return markup.args.some((arg) => RESERVED_BLOCK_ATTRIBUTES.has(arg.name)); +} + +function hasInvalidBlockStaticArguments(markup: ContentForMarkup): boolean { + const typeArg = markup.args.find((arg) => arg.name === 'type'); + const idArg = markup.args.find((arg) => arg.name === 'id'); + + if (!typeArg || !idArg) return true; + if (typeArg.value.type !== NodeTypes.String || idArg.value.type !== NodeTypes.String) return true; + if (typeArg.value.value === '' || idArg.value.value === '') return true; + + return false; +} + +function hasInvalidBlocksArguments(markup: ContentForMarkup): boolean { + let contextArgumentCount = 0; + + for (const arg of markup.args) { + if (!arg.name.startsWith(CONTENT_FOR_CLOSEST_PREFIX)) return true; + if (arg.value.type === NodeTypes.String) return true; + + contextArgumentCount += 1; + if (contextArgumentCount > 1) return true; + + if (!isSupportedClosestArgument(arg.name)) return true; + } + + return false; +} + +function hasInvalidBlockClosestArguments(markup: ContentForMarkup): boolean { + let contextArgumentCount = 0; + + for (const arg of markup.args) { + if (!arg.name.startsWith(CONTENT_FOR_CLOSEST_PREFIX)) continue; + if (arg.value.type === NodeTypes.String) return true; + + contextArgumentCount += 1; + if (contextArgumentCount > 1) return true; + + if (!isSupportedClosestArgument(arg.name)) return true; + } + + return false; +} + +function isSupportedClosestArgument(name: string): boolean { + const closestType = name.slice(CONTENT_FOR_CLOSEST_PREFIX.length); + + return ( + CONTENT_FOR_CLOSEST_RESOURCE_TYPES.has(closestType) || + closestType.startsWith(CONTENT_FOR_METAOBJECT_CLOSEST_PREFIX) + ); +} + +function reportUnsupportedContentForType( + node: LiquidTag, + context: Context, + contentForType: string, +): void { + context.report({ + message: `Invalid content_for type "${contentForType}"; expected "block" or "blocks"`, + startIndex: node.position.start, + endIndex: node.position.end, + }); +} + +function report(node: LiquidTag, context: Context): void { + context.report({ + message: "Syntax error in 'content_for' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/cycle.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/cycle.ts new file mode 100644 index 000000000..a2a2f1d81 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/cycle.ts @@ -0,0 +1,45 @@ +import { type CycleMarkup, type LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasBareArrayAccess, + hasRubyAcceptedCycleTrailingComma, + hasSkippedCharacters, + rawMarkup, +} from './utils'; + +export function checkCycleTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + if (hasRubyAcceptedCycleTrailingComma(node.markup)) { + return; + } + + context.report({ + message: `Syntax error in 'cycle' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as CycleMarkup; + + if ( + (markup.groupName && hasBareArrayAccess(markup.groupName)) || + markup.args.some(hasBareArrayAccess) + ) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'cycle' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/decrement.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/decrement.ts new file mode 100644 index 000000000..765d36aee --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/decrement.ts @@ -0,0 +1,31 @@ +import type { LiquidTag, LiquidExpression } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasBareArrayAccess, hasSkippedCharacters, rawMarkup } from './utils'; + +export function checkDecrementTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'decrement' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasBareArrayAccess(node.markup as LiquidExpression)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'decrement' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/doc.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/doc.ts new file mode 100644 index 000000000..ee7dbc0ad --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/doc.ts @@ -0,0 +1,95 @@ +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasLiquidTagNamed, + hasRubyAcceptedRawTagCloserWithMarkup, + liquidTagBodies, + liquidTagMarkup, + resolveErrorLocation, +} from './utils'; + +const UNCLOSED_DOC_PARSER_ERROR = "Attempting to end parsing before LiquidRawTag 'doc' was closed"; +const UNOPENED_DOC_PARSER_ERROR = + "Attempting to close LiquidTag 'doc' before it was opened without a matching 'doc'"; + +export function checkDocTag(node: LiquidRawTag, context: Context): void { + if (node.markup !== '') { + context.report({ + message: `Syntax error in 'doc' tag`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + if (hasNestedDocTag(node.body.value)) { + context.report({ + message: 'Nested doc tags are not allowed', + startIndex: node.body.position.start, + endIndex: node.body.position.end, + }); + } +} + +function hasNestedDocTag(body: string): boolean { + for (const tag of liquidTagBodies(body)) { + const markup = liquidTagMarkup(tag.body); + if ( + markup?.tagName === 'enddoc' && + markup.remainingTokens.length > 0 && + !markup.hasSkippedCharacters + ) { + return false; + } + + if (markup?.tagName === 'doc' && !markup.hasSkippedCharacters) { + return true; + } + } + + return false; +} + +export function checkDocParserError(error: Error, context: Context, source: string): void { + if (!isDocParserError(error, source)) return; + + const [startIndex, endIndex] = resolveErrorLocation(error, source); + + // A doc tag that fails to close because it wraps another doc tag is a + // nesting error, not a generic unclosed-tag error. `hasNestedDocTag` + // ignores an `enddoc` that carries markup (a Ruby-accepted closer), so a + // genuinely-later-unclosed doc still falls through to the raw passthrough. + if ( + error.message === UNCLOSED_DOC_PARSER_ERROR && + hasNestedDocTag(docTagBody(source, startIndex)) + ) { + context.report({ + message: 'Nested doc tags are not allowed', + startIndex, + endIndex, + }); + return; + } + + if ( + error.message === UNCLOSED_DOC_PARSER_ERROR && + hasRubyAcceptedRawTagCloserWithMarkup(source, 'doc', startIndex) + ) { + return; + } + + context.report({ message: error.message, startIndex, endIndex }); +} + +// The body of a raw tag is everything after its opening `{% ... %}`. For an +// unclosed tag that runs to end-of-source, that is the remainder of the file. +function docTagBody(source: string, startIndex: number): string { + const openTagEnd = source.indexOf('%}', startIndex); + return openTagEnd === -1 ? '' : source.slice(openTagEnd + 2); +} + +export function isDocParserError(error: Error, source: string): boolean { + if (!hasLiquidTagNamed(source, 'doc') && !hasLiquidTagNamed(source, 'enddoc')) return false; + + return error.message === UNCLOSED_DOC_PARSER_ERROR || error.message === UNOPENED_DOC_PARSER_ERROR; +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/echo.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/echo.ts new file mode 100644 index 000000000..99bd152a1 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/echo.ts @@ -0,0 +1,48 @@ +import type { LiquidTag, LiquidVariable } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + variableHasBareArrayAccess, + hasEmptyMarkup, + hasSkippedCharacters, + rawMarkup, + hasRubyAcceptedEmptyFirstFilterArgument, + hasRubyAcceptedFilterArgumentTrailingComma, +} from './utils'; + +export function checkEchoTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + if (hasEmptyMarkup(rawMarkup(node))) return; + if (hasRubyAcceptedEmptyFirstFilterArgument(node.markup)) return; + if (hasRubyAcceptedFilterArgumentTrailingComma(node.markup)) return; + + context.report({ + message: `Syntax error in 'echo' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as LiquidVariable; + + if (variableHasBareArrayAccess(markup)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const raw = rawMarkup(node); + if (hasRubyAcceptedEmptyFirstFilterArgument(raw)) return; + if (hasRubyAcceptedFilterArgumentTrailingComma(raw)) return; + + if (hasSkippedCharacters(raw)) { + context.report({ + message: `Syntax error in 'echo' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/for.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/for.ts new file mode 100644 index 000000000..a2db8e929 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/for.ts @@ -0,0 +1,43 @@ +import type { LiquidTag, ForMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasBareArrayAccess, hasSkippedCharacters, hasSkippedPrefixCharacters } from './utils'; + +export function checkForTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'for' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as ForMarkup; + + if (hasBareArrayAccess(markup.collection)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + context.report({ + message: `Syntax error in 'for' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const rawMarkup = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(rawMarkup)) { + context.report({ + message: `Syntax error in 'for' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/form.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/form.ts new file mode 100644 index 000000000..e3329567e --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/form.ts @@ -0,0 +1,60 @@ +import { NodeTypes, type LiquidArgument, type LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { argHasBareArrayAccess, hasSkippedCharacters, rawMarkup } from './utils'; + +const SYNTAX_ERROR = `Syntax error in 'form' tag`; +const BARE_ARRAY_ACCESS = 'Bare bracket access is not allowed in strict2 mode'; + +export function checkFormTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + report(node, context, SYNTAX_ERROR); + return; + } + + const args = node.markup as LiquidArgument[]; + + if (args.some(argHasBareArrayAccess)) { + report(node, context, BARE_ARRAY_ACCESS); + return; + } + + if (hasInvalidArgumentOrder(args)) { + report(node, context, SYNTAX_ERROR); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + report(node, context, SYNTAX_ERROR); + } +} + +function hasInvalidArgumentOrder(args: LiquidArgument[]): boolean { + if (args.length === 0 || args[0].type === NodeTypes.NamedArgument) { + return true; + } + + let positionalCount = 1; + let seenNamedArgument = false; + + for (const arg of args.slice(1)) { + if (arg.type === NodeTypes.NamedArgument) { + seenNamedArgument = true; + continue; + } + + positionalCount += 1; + if (seenNamedArgument || positionalCount > 2) { + return true; + } + } + + return false; +} + +function report(node: LiquidTag, context: Context, message: string): void { + context.report({ + message, + startIndex: node.position.start, + endIndex: node.position.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/if.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/if.ts new file mode 100644 index 000000000..124b4f155 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/if.ts @@ -0,0 +1,59 @@ +import type { LiquidTag, LiquidConditionalExpression } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + conditionalHasBareArrayAccess, + hasSkippedCharacters, + hasSkippedPrefixCharacters, + hasUnclosedQuotedString, + rawMarkup, +} from './utils'; + +export function checkIfTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: "Syntax error in 'if' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as LiquidConditionalExpression; + const tagMarkup = rawMarkup(node); + + if (hasUnclosedQuotedString(tagMarkup)) { + context.report({ + message: "Syntax error in 'if' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (conditionalHasBareArrayAccess(markup)) { + context.report({ + message: 'Bare bracket access is not allowed in strict2 mode', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + context.report({ + message: "Syntax error in 'if' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const parsedMarkup = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(parsedMarkup)) { + context.report({ + message: "Syntax error in 'if' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/include.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/include.ts new file mode 100644 index 000000000..53fe762e6 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/include.ts @@ -0,0 +1,45 @@ +import type { LiquidTag, RenderMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + argHasBareArrayAccess, + hasBareArrayAccess, + hasRubyAcceptedIncludeMarkup, + hasSkippedCharacters, + rawMarkup, +} from './utils'; + +export function checkIncludeTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + if (hasRubyAcceptedIncludeMarkup(node.markup)) return; + + context.report({ + message: `Syntax error in 'include' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as RenderMarkup; + + if ( + hasBareArrayAccess(markup.snippet) || + (markup.variable && hasBareArrayAccess(markup.variable.name)) || + markup.args.some(argHasBareArrayAccess) + ) { + context.report({ + message: 'Bare bracket access is not allowed in strict2 mode', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'include' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/increment.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/increment.ts new file mode 100644 index 000000000..2feaae620 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/increment.ts @@ -0,0 +1,35 @@ +import { NodeTypes, type LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasBareArrayAccess, hasSkippedCharacters, rawMarkup } from './utils'; + +export function checkIncrementTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'increment' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasIncrementBareArrayAccess(node) || hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'increment' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} + +function hasIncrementBareArrayAccess(node: LiquidTag): boolean { + if ( + !node.markup || + typeof node.markup !== 'object' || + Array.isArray(node.markup) || + !('type' in node.markup) + ) { + return false; + } + + return node.markup.type === NodeTypes.VariableLookup && hasBareArrayAccess(node.markup); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/index.spec.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/index.spec.ts new file mode 100644 index 000000000..c210893d8 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/index.spec.ts @@ -0,0 +1,1698 @@ +import { describe, it, expect } from 'vitest'; +import { runLiquidCheck } from '../../test'; +import { LiquidSyntaxError } from './index'; +import { checkBlockParserError } from './block'; +import { isDocParserError } from './doc'; +import { checkJavascriptParserError } from './javascript'; +import { checkPartialParserError } from './partial'; +import { checkRawParserError } from './raw'; +import { hasRubyAcceptedWhitespaceSeparatedQuotePrefix, liquidLineTagLocation } from './utils'; + +const RENDER_SYNTAX_ERROR = "Syntax error in 'render' tag"; +const BARE_BRACKET_ACCESS = 'Bare bracket access is not allowed in strict2 mode'; + +const UNCLOSED_DOC_PARSER_ERROR = "Attempting to end parsing before LiquidRawTag 'doc' was closed"; +const UNOPENED_DOC_PARSER_ERROR = + "Attempting to close LiquidTag 'doc' before it was opened without a matching 'doc'"; +const UNCLOSED_JAVASCRIPT_PARSER_ERROR = + "Attempting to end parsing before LiquidRawTag 'javascript' was closed"; +const UNOPENED_JAVASCRIPT_PARSER_ERROR = + "Attempting to close LiquidTag 'javascript' before it was opened without a matching 'javascript'"; + +/* + * The target harness's `runLiquidCheck` runs only the `LiquidSyntaxError` + * check, so every returned offense already carries `check: 'LiquidSyntaxError'`. + * We drive tag-name recognition through the real `builtinTags` fallback in + * base.ts by passing `themeDocset: undefined` (the harness would otherwise + * inject a docset whose `tags()` returns `[]`). + */ +const NO_DOCSET = { themeDocset: undefined } as const; + +describe('LiquidSyntaxError', () => { + describe('unknown tags', () => { + it('does not report unknown tags like {% foobar %}', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% foobar %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('uses injected tag docs for unknown tags inside liquid blocks', async () => { + const customThemeDocset = { + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [{ name: 'custom_tag' }], + systemTranslations: async () => ({}), + }; + + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% liquid\n custom_tag\n%}', + 'snippets/test.liquid', + { themeDocset: customThemeDocset }, + ); + + expect(offenses).toEqual([]); + }); + + it('reports unknown tags inside liquid blocks', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% liquid\n hello world\n%}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Unknown tag 'hello'", + }); + }); + }); + + describe('misplaced branch keywords', () => { + it.each([ + ['else', '{% else %}', "Unknown tag 'else'"], + ['when', '{% when x %}', "Unknown tag 'when'"], + ['when inside if', '{% if true %}{% when x %}{% endif %}', "Unknown tag 'when'"], + ['when inside for', '{% for x in xs %}{% when x %}{% endfor %}', "Unknown tag 'when'"], + ['when inside liquid', '{% liquid\n when x\n%}', "Unknown tag 'when'"], + ['elsif inside liquid', '{% liquid\n elsif x\n%}', "Unknown tag 'elsif'"], + ])('reports misplaced branch keyword %s', async (_name, template, message) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message, + }); + }); + + it.each([ + '{% if true %}{% else %}{% endif %}', + '{% case x %}{% when x %}{% else %}{% endcase %}', + '{% for x in xs %}{% else %}{% endfor %}', + ])('does not report valid else branches in %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + }); + + describe('inline comment tags', () => { + it('reports no-space multiline comments without prefixed continuation lines', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% #hello\nworld %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe( + "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character", + ); + }); + + it('accepts no-space multiline comments with prefixed continuation lines', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% #hello\n#world %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it("accepts Ruby's blank-first-line single-continuation shape", async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% #\nworld %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + }); + + describe('valid assign tags', () => { + it('produces no diagnostics for {% assign x = 1 %}', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x = 1 %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it("produces no diagnostics for {% assign x = 'hello' %}", async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% assign x = 'hello' %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for {% assign x = foo.bar %}', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x = foo.bar %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for {% assign x = foo | filter %}', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x = foo | filter %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for {% assign x = %} (empty RHS)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x = %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for {% assign x extra = x %}', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x extra = x %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each(['{% assign "x = y %}', "{% assign 'x = y %}"])( + 'matches Ruby Liquid parity for unclosed assign quote prefix in %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }, + ); + }); + + describe('invalid assign tags', () => { + it('reports LiquidSyntaxError for {% assign = 1 %} (missing LHS)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign = 1 %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for {% assign x.y = 1 %} (dotted LHS)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x.y = 1 %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for {% assign !x = 1 %} (garbage before target)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign !x = 1 %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it.each(['{% assign x 42 = x %}', "{% assign x 'hi' = x %}", '{% assign x ? = x %}'])( + 'reports diagnostics for %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBeGreaterThan(0); + }, + ); + + it('reports LiquidSyntaxError for {% assign x = foo | %} (empty filter)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x = foo | %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + }); + + describe('capture tags', () => { + it('produces no diagnostics for valid capture tags inside liquid blocks', async () => { + const template = '{% liquid\n capture x\n endcapture\n%}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports LiquidSyntaxError for skipped capture markup inside liquid blocks', async () => { + const template = '{% liquid\n capture @x\n endcapture\n%}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + }); + + describe('include tags', () => { + it.each([ + '{% include 42 %}', + '{% include 3.14 %}', + '{% include -5 %}', + '{% include true %}', + '{% include false %}', + '{% include nil %}', + '{% include blank %}', + '{% include empty %}', + '{% include (1..5) %}', + ])('produces no diagnostics for %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports LiquidSyntaxError for {% include ? %}', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% include ? %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for bare bracket access in include bindings', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% include 42 for [0] %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for multipart include named arguments', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% include 42, key.secondkey: value %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + }); + + describe('empty echo and variable output markup', () => { + it.each(['{% echo %}', '{% echo %}', '{{ }}', '{{ }}'])( + 'produces no diagnostics for %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }, + ); + + it('reports LiquidSyntaxError for garbage-only variable output', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{{ @ }}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe('Syntax error in variable output'); + }); + }); + + describe('empty first filter arguments', () => { + it.each([ + '{{ product.title | append: }}', + '{% echo product.title | append: %}', + '{% assign title = product.title | append: %}', + ])('produces no diagnostics for %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each(['{{ x y | append: }}', '{% echo x y | append: %}', '{% assign a = x y | append: %}'])( + 'reports diagnostics for malformed prefixes in %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + }, + ); + + it.each(['{{ product.title | }}', '{% echo product.title | %}'])( + 'reports diagnostics for %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + }, + ); + }); + + describe('section tags', () => { + it('produces no diagnostics for section keyword arguments', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% section 'header', color: 'red' %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports LiquidSyntaxError for block-form section tags with body content', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% section 'wrap' %}body{% endsection %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Unknown tag 'endsection'"); + }); + + it('reports LiquidSyntaxError for empty block-form section tags', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% section 'foo' %}{% endsection %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Unknown tag 'endsection'"); + }); + + /* + * Bare bracket access in section keyword argument values is caught by the + * parser / LiquidHTMLSyntaxError layer, not by the `LiquidSyntaxError` + * section check. Running `LiquidSyntaxError` alone reports nothing here. + */ + it('does not report bare bracket section keyword argument values on its own', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% section 'header', foo: [0] %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + }); + + describe('tablerow tags', () => { + it('reports LiquidSyntaxError for bare bracket tablerow named argument values', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% tablerow product in collection.products cols: [0] %}{% endtablerow %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('uses injected tag docs to allow known loop arguments', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% tablerow product in collection.products cols: 3 limit: 6 %}{% endtablerow %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + }); + + describe('trailing comma parity', () => { + it.each([ + '{% cycle x, %}', + '{% tablerow product in collection.products, %}{% endtablerow %}', + '{% paginate collection.products by 12, %}{% endpaginate %}', + ])('produces no diagnostics for %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each([ + '{% cycle x,, %}', + '{% cycle x @, %}', + '{% tablerow product in collection.products,, %}{% endtablerow %}', + '{% tablerow product in collection.products @, %}{% endtablerow %}', + '{% paginate collection.products by 12,, %}{% endpaginate %}', + '{% paginate collection.products by 12 @, %}{% endpaginate %}', + ])('reports diagnostics for %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBeGreaterThan(0); + }); + }); + + describe('check precedence', () => { + /* + * In the target only the `LiquidSyntaxError` check runs, so the world's + * dual-check dedup no longer applies. The single check emits one offense + * for the malformed filter on this line. + */ + it('emits a single LiquidSyntaxError on the same line', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% assign x = foo | %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('keeps one offense per line when errors are on different lines', async () => { + const template = '{% assign x = foo | %}\n{% assign = 1 %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + const checks = offenses.map((o) => o.check).sort(); + expect(checks).toEqual(['LiquidSyntaxError', 'LiquidSyntaxError']); + }); + }); + + describe('bare bracket access', () => { + it('reports LiquidSyntaxError for {% case [0] %} (bare bracket in case expression)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% case [0] %}{% when 1 %}{% endcase %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for {% when [0] %} (bare bracket in when value)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% case x %}{% when [0] %}{% endcase %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for {% capture [0] %} (bare bracket in capture tag)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% capture [0] %}{% endcapture %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for {% decrement [0] %} (bare bracket in decrement tag)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% decrement [0] %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('reports LiquidSyntaxError for {{ [0] }} (bare bracket in variable output)', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{{ [0] }}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + }); + + describe('unless tags', () => { + it('produces no diagnostics for valid unless conditions', async () => { + const templates = [ + '{% unless product.available %}Sold out{% endunless %}', + "{% unless product.available or customer.tags contains 'vip' %}Hidden{% endunless %}", + '{% unless items[0] %}Empty{% endunless %}', + '{% unless\n product.available\n%}Sold out{% endunless %}', + "{% liquid\n unless product.available\n echo 'Sold out'\n endunless\n%}", + ]; + + for (const template of templates) { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + } + }); + + it('reports LiquidSyntaxError for skipped unless garbage', async () => { + const templates = [ + '{% unless product.available @ %}Sold out{% endunless %}', + '{% unless product.available @ and customer %}Hidden{% endunless %}', + '{% unless\n product.available\n @\n%}Sold out{% endunless %}', + ]; + + for (const template of templates) { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Syntax error in 'unless' tag"); + } + }); + + it('reports LiquidSyntaxError for bare unless bracket access', async () => { + const template = '{% unless [0] %}Hidden{% endunless %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe('Bare bracket access is not allowed in strict2 mode'); + }); + + it('reports LiquidSyntaxError for whitespace-separated unless quote prefixes', async () => { + const templates = [ + "{% unless ' product.available %}Sold out{% endunless %}", + '{% unless " product.available %}Sold out{% endunless %}', + ]; + + for (const template of templates) { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Syntax error in 'unless' tag"); + } + }); + + it.each([ + "{% unless 'product.available %}Sold out{% endunless %}", + '{% unless "product.available %}Sold out{% endunless %}', + ])('reports LiquidSyntaxError for adjacent unless quote prefixes in %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Syntax error in 'unless' tag"); + }); + }); + + describe('quote detection parity', () => { + it('classifies Ruby-accepted quote prefixes with tokenizer coverage', () => { + expect(hasRubyAcceptedWhitespaceSeparatedQuotePrefix("' ")).toBe(true); + expect(hasRubyAcceptedWhitespaceSeparatedQuotePrefix("'")).toBe(false); + expect(hasRubyAcceptedWhitespaceSeparatedQuotePrefix("' foo ")).toBe(false); + }); + + it.each([ + "{% if product.title == 'Hat' %}Hat{% endif %}", + "{% case product.type %}{% when 'shirt' %}Shirt{% endcase %}", + "{{ 'hello' }}", + ])('produces no diagnostics for closed string markup in %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each([ + "{% if ' product.available %}Available{% endif %}", + "{% case product.type %}{% when ' shirt %}Shirt{% endcase %}", + "{{ 'hello }}", + ])('reports LiquidSyntaxError for unclosed string markup in %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBeGreaterThan(0); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + }); + + describe('block and partial parser errors', () => { + it('locates line-mode block tags inside liquid blocks', () => { + expect(liquidLineTagLocation("{% liquid\n block 'hero'\n%}", 'block')).toEqual([12, 24]); + }); + + it.each([ + { + tagName: 'block', + markup: "block 'hero'", + message: "Unclosed block tag 'block' in {% liquid %} block", + }, + { + tagName: 'partial', + markup: "partial 'product-card'", + message: "Unclosed block tag 'partial' in {% liquid %} block", + }, + ])( + 'reports LiquidSyntaxError for unclosed $tagName tags inside liquid blocks', + async ({ tagName, markup, message }) => { + const template = `{% liquid\n ${markup}\n%}`; + const reports: { message: string; startIndex: number; endIndex: number }[] = []; + const context = { report: (offense: any) => reports.push(offense) } as never; + + if (tagName === 'block') { + checkBlockParserError(new Error(message), context, template); + } else { + checkPartialParserError(new Error(message), context, template); + } + + expect(reports).toEqual([ + { + startIndex: template.indexOf(markup), + endIndex: template.indexOf(markup) + markup.length, + message, + }, + ]); + + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + expect(offenses[0]).toMatchObject({ + message, + }); + }, + ); + + it.each([ + { + tagName: 'block', + template: '{% endblock %}', + message: + "Attempting to close LiquidTag 'block' before it was opened without a matching 'block'", + }, + { + tagName: 'partial', + template: '{% endpartial %}', + message: + "Attempting to close LiquidTag 'partial' before it was opened without a matching 'partial'", + }, + ])( + 'reports LiquidSyntaxError for standalone end$tagName tags', + async ({ template, message }) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].message).toBe(message); + }, + ); + }); + + describe('doc tags', () => { + it('produces no diagnostics for doc closing markup', async () => { + const template = '{% doc %}content{% enddoc foo %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports later unclosed doc blocks after accepted doc closing markup', async () => { + const template = '{% doc %}content{% enddoc foo %}{% doc %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Attempting to end parsing before LiquidRawTag 'doc' was closed", + }); + }); + + it('accepts later balanced doc blocks after accepted doc closing markup', async () => { + const template = '{% doc %}content{% enddoc foo %}{% doc %}later{% enddoc %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for whitespace-control doc closing markup', async () => { + const template = '{% doc %}content{%- enddoc foo -%}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports nested doc tags with whitespace-control delimiters', async () => { + const template = '{% doc %}{%- doc -%}{% enddoc %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: 'Nested doc tags are not allowed', + }); + }); + + it('does not treat similarly-prefixed doc tag names as nested doc tags', async () => { + const template = '{% doc %}{% docx %}{% enddoc %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('does not treat text-only doc mentions as doc parser errors', () => { + const error = new Error(UNCLOSED_DOC_PARSER_ERROR); + + expect(isDocParserError(error, 'plain doc and enddoc text')).toBe(false); + }); + + it('does not treat similarly-prefixed doc tags as doc parser errors', () => { + const error = new Error(UNCLOSED_DOC_PARSER_ERROR); + + expect(isDocParserError(error, '{% docx %}')).toBe(false); + }); + + it('treats real doc and enddoc tags as doc parser errors', () => { + expect(isDocParserError(new Error(UNCLOSED_DOC_PARSER_ERROR), '{% doc %}')).toBe(true); + expect(isDocParserError(new Error(UNOPENED_DOC_PARSER_ERROR), '{% enddoc %}')).toBe(true); + }); + }); + + describe('comment tags', () => { + it('reports nested comment blocks masked by a later independent comment block', async () => { + const template = + '{% comment %}{% comment %}inner{% endcomment %}{% comment %}second{% endcomment %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Liquid syntax error: 'comment' tag was never closed", + }); + }); + + it('reports nested raw blocks masked by a later independent raw block', async () => { + const template = '{% comment %}{% raw %}inner{% endcomment %}{% raw %}second{% endraw %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBeGreaterThan(0); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('accepts nested comment and raw blocks closed immediately after the parsed comment closer', async () => { + const templates = [ + '{% comment %}{% comment %}inner{% endcomment %}{% endcomment %}', + '{% comment %}{% raw %}{% endcomment %}{% endraw %}{% endcomment %}', + ]; + + for (const template of templates) { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + } + }); + }); + + describe('raw tags', () => { + it('produces no diagnostics for Liquid syntax inside a valid raw body', async () => { + const template = '{% raw %}{% if broken %}{% endraw %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for raw closing markup', async () => { + const template = '{% raw %}c{% endraw foo %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports later unclosed raw blocks after accepted raw closing markup', async () => { + const template = '{% raw %}c{% endraw foo %}{% raw %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Attempting to end parsing before LiquidRawTag 'raw' was closed", + }); + }); + + it('accepts later balanced raw blocks after accepted raw closing markup', async () => { + const template = '{% raw %}c{% endraw foo %}{% raw %}later{% endraw %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for whitespace-control raw closing markup', async () => { + const template = '{% raw %}c{%- endraw foo -%}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports parser errors for similarly-prefixed raw closing tags', async () => { + const template = '{% raw %}c{% endrawx foo %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + message: "Attempting to end parsing before LiquidRawTag 'raw' was closed", + }); + }); + + it('reports LiquidSyntaxError for raw opening markup', async () => { + const template = '{% raw foo %}{% endraw %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Syntax error in 'raw' tag", + start: { index: template.indexOf('foo') }, + end: { index: template.indexOf('foo') + 'foo'.length }, + }); + }); + + it('reports LiquidSyntaxError for skipped raw opening characters', async () => { + const template = '{% raw @#$ %}{% endraw %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Syntax error in 'raw' tag", + start: { index: template.indexOf('@#$') }, + end: { index: template.indexOf('@#$') + '@#$'.length }, + }); + }); + + it('reports LiquidSyntaxError for quoted raw opening markup', async () => { + const template = "{% raw 'hello' %}{% endraw %}"; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Syntax error in 'raw' tag"); + }); + + it('reports LiquidSyntaxError for unclosed quoted raw opening markup', async () => { + const template = "{% raw 'hello %}{% endraw %}"; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe("Syntax error in 'raw' tag"); + }); + + it('reports the raw parser error for unclosed raw tags', () => { + const reports: { message: string }[] = []; + + checkRawParserError( + new Error("Attempting to end parsing before LiquidRawTag 'raw' was closed"), + { report: (offense: any) => reports.push(offense) } as never, + '{% raw %}', + ); + + expect(reports).toEqual([ + { + message: "Attempting to end parsing before LiquidRawTag 'raw' was closed", + startIndex: 0, + endIndex: '{% raw %}'.length, + }, + ]); + }); + + it('reports the raw parser error for standalone endraw tags', () => { + const reports: { message: string }[] = []; + + checkRawParserError( + new Error( + "Attempting to close LiquidTag 'raw' before it was opened without a matching 'raw'", + ), + { report: (offense: any) => reports.push(offense) } as never, + '{% endraw %}', + ); + + expect(reports).toEqual([ + { + message: + "Attempting to close LiquidTag 'raw' before it was opened without a matching 'raw'", + startIndex: 0, + endIndex: '{% endraw %}'.length, + }, + ]); + }); + + it('reports LiquidSyntaxError for unclosed raw tags inside liquid blocks', async () => { + const template = '{% liquid\n raw\n%}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Unclosed raw tag 'raw' in {% liquid %} block", + }); + }); + + it('does not report raw parser errors when source text merely mentions raw', async () => { + const template = 'This text mentions raw and endraw without Liquid tags.'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + }); + + describe('javascript tags', () => { + it.each([ + '{% javascript %}{% endjavascript %}', + '{% javascript %}{% endjavascript %}', + '{%- javascript -%}{%- endjavascript -%}', + ])('produces no diagnostics for empty javascript markup in %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'sections/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each(['{% javascript foo %}{% endjavascript %}', '{% javascript @#$ %}{% endjavascript %}'])( + 'reports LiquidSyntaxError for invalid javascript opening markup: %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'sections/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: "Syntax Error in 'javascript' - Valid syntax: javascript", + }); + }, + ); + + it('does not treat similarly-prefixed javascript tags as javascript openings', async () => { + const template = '{% javascriptx foo %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('accepts javascript closing markup in parser-error handling', () => { + const reports: { message: string }[] = []; + + checkJavascriptParserError( + new Error(UNCLOSED_JAVASCRIPT_PARSER_ERROR), + { report: (offense: any) => reports.push(offense) } as never, + "{% javascript %}console.log('x');{% endjavascript foo %}", + ); + + expect(reports).toEqual([]); + }); + + it('produces no diagnostics for javascript closing markup', async () => { + const template = "{% javascript %}console.log('x');{% endjavascript foo %}"; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'sections/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports parser errors for similarly-prefixed javascript closing tags', async () => { + const template = "{% javascript %}console.log('x');{% endjavascriptx foo %}"; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + message: "'javascript' tag was never closed", + }); + }); + + it('does not report parser errors when source text merely mentions javascript', () => { + const reports: { message: string }[] = []; + + checkJavascriptParserError( + new Error(UNCLOSED_JAVASCRIPT_PARSER_ERROR), + { report: (offense: any) => reports.push(offense) } as never, + 'plain javascript and endjavascript text', + ); + + expect(reports).toEqual([]); + }); + + it('does not treat similarly-prefixed javascript tags as parser errors', () => { + const reports: { message: string }[] = []; + + checkJavascriptParserError( + new Error(UNCLOSED_JAVASCRIPT_PARSER_ERROR), + { report: (offense: any) => reports.push(offense) } as never, + '{% javascriptx %}', + ); + + expect(reports).toEqual([]); + }); + + it('treats real javascript and endjavascript tags as parser errors', () => { + const reports: { message: string }[] = []; + + checkJavascriptParserError( + new Error(UNCLOSED_JAVASCRIPT_PARSER_ERROR), + { report: (offense: any) => reports.push(offense) } as never, + '{% javascript %}', + ); + checkJavascriptParserError( + new Error(UNOPENED_JAVASCRIPT_PARSER_ERROR), + { report: (offense: any) => reports.push(offense) } as never, + '{% endjavascript %}', + ); + + expect(reports).toEqual([ + { + message: "'javascript' tag was never closed", + startIndex: 0, + endIndex: '{% javascript %}'.length, + }, + { + message: "Unknown tag 'endjavascript'", + startIndex: 0, + endIndex: '{% endjavascript %}'.length, + }, + ]); + }); + }); + + describe('style and stylesheet tags', () => { + it.each(['{% style %}{% endstyle %}', '{% style %}{% endstyle %}'])( + 'produces no diagnostics for empty style markup in %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }, + ); + + it('reports LiquidSyntaxError for style arguments', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% style extra %}{% endstyle %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: `'style' tag does not accept any arguments in "extra"`, + }); + }); + + it.each([ + '{% stylesheet %}{% endstylesheet %}', + '{% stylesheet %}{% endstylesheet %}', + "{% stylesheet 'scss' %}{% endstylesheet %}", + ])('produces no diagnostics for valid stylesheet markup in %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'sections/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each([ + ['{% stylesheet scss %}{% endstylesheet %}', 'scss'], + ['{% stylesheet scssx %}{% endstylesheet %}', 'scssx'], + ['{% stylesheet scss extra %}{% endstylesheet %}', 'scss extra'], + ['{% stylesheet scss? %}{% endstylesheet %}', 'scss?'], + ])( + 'reports LiquidSyntaxError for invalid stylesheet markup in %s', + async (template, markup) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'sections/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: `'stylesheet' tag can only accept the string argument 'scss' in "${markup}"`, + }); + }, + ); + }); + + describe('comment tags (raw bodies)', () => { + it.each([ + '{% comment %}{% comment %}inner{% endcomment %}', + '{%- comment -%}{%- comment -%}inner{%- endcomment -%}', + '{% comment %}{% raw %}hello{% endcomment %}', + '{% comment %}this is {{{ not }}} valid{{ liquid{% endcomment %}', + '{% comment %}this is {% if liquid{% endcomment %}', + ])('reports LiquidSyntaxError for Ruby-stricter comment raw bodies: %s', async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + }); + + it('produces no diagnostics for inert closed Liquid tags inside comment bodies', async () => { + const template = '{% comment %}{% if %}{% endcomment %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for closed raw tags inside comment bodies', async () => { + const template = '{% comment %}{% raw %}hello{% endraw %}{% endcomment %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('does not treat similarly-prefixed names as comment or raw tags', async () => { + const template = '{% comment %}{% commentx %}{% rawx %}{% endcomment %}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('does not report parser errors when source text merely mentions comment tags', async () => { + const template = 'This text mentions comment and endcomment without Liquid tags.'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it.each([ + '{% comment %}{% comment %}inner{% endcomment %}{% endcomment %}', + '{% comment %}{% comment %}{% comment %}deep{% endcomment %}{% endcomment %}{% endcomment %}', + '{% comment %}{% raw %}{% endcomment %}{% endraw %}{% endcomment %}', + '{%- comment -%}{%- raw -%}{%- endcomment -%}{%- endraw -%}{%- endcomment -%}', + ])( + 'produces no diagnostics for Ruby-accepted inert comment raw bodies: %s', + async (template) => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }, + ); + }); + + describe('non-assign valid Liquid', () => { + it('produces no diagnostics for valid Liquid', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% if true %}hello{% endif %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for Liquid output tags', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{{ shop.name }}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + }); + + describe('valid render tags', () => { + it('produces no diagnostics for valid render markup', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% render 'snippet' %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('produces no diagnostics for valid render markup inside liquid blocks', async () => { + const template = "{% liquid\n render 'snippet', product: product\n%}"; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses).toEqual([]); + }); + + it('reports LiquidSyntaxError for skipped quote prefixes', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% render \"'snippet' %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(RENDER_SYNTAX_ERROR); + }); + }); + + describe('invalid render tags', () => { + it('reports LiquidSyntaxError for malformed string markup', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% render %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(RENDER_SYNTAX_ERROR); + }); + + it('reports LiquidSyntaxError for malformed string markup inside liquid blocks', async () => { + const template = '{% liquid\n render\n%}'; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(RENDER_SYNTAX_ERROR); + }); + + it('reports LiquidSyntaxError for bare bracket access in render snippet lookups', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + '{% render [0] %}', + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(BARE_BRACKET_ACCESS); + }); + + it('reports LiquidSyntaxError for bare bracket access in render binding values', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% render 'snippet' for [0] %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(BARE_BRACKET_ACCESS); + }); + + it('reports LiquidSyntaxError for bare bracket access in render keyword values', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% render 'snippet', x: [0] %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(BARE_BRACKET_ACCESS); + }); + + it('reports LiquidSyntaxError for skipped trailing garbage in render markup', async () => { + const offenses = await runLiquidCheck( + LiquidSyntaxError, + "{% render 'snippet' ? %}", + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0].check).toBe('LiquidSyntaxError'); + expect(offenses[0].message).toBe(RENDER_SYNTAX_ERROR); + }); + + it('reports LiquidSyntaxError for skipped trailing garbage inside liquid blocks', async () => { + const template = "{% liquid\n render 'snippet' ?\n%}"; + const offenses = await runLiquidCheck( + LiquidSyntaxError, + template, + 'snippets/test.liquid', + NO_DOCSET, + ); + + expect(offenses.length).toBe(1); + expect(offenses[0]).toMatchObject({ + check: 'LiquidSyntaxError', + message: RENDER_SYNTAX_ERROR, + start: { index: template.indexOf('render') }, + end: { index: template.indexOf('?') + '?'.length }, + }); + }); + }); +}); diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/index.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/index.ts new file mode 100644 index 000000000..53fcf5140 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/index.ts @@ -0,0 +1,145 @@ +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { LiquidRawTag, LiquidTag, LiquidVariableOutput } from '@shopify/liquid-html-parser'; +import { checkAssignTag } from './assign'; +import { checkBaseTag } from './base'; +import { checkBranchTag, checkMisplacedBranchTag } from './branch'; +import { checkBlockParserError, checkBlockTag } from './block'; +import { checkCaptureTag } from './capture'; +import { checkCaseTag } from './case'; +import { checkCommentRawTag, checkCommentTag } from './comment'; +import { checkContentForTag } from './content-for'; +import { checkCycleTag } from './cycle'; +import { checkDecrementTag } from './decrement'; +import { checkDocParserError, checkDocTag } from './doc'; +import { checkEchoTag } from './echo'; +import { checkFormTag } from './form'; +import { checkForTag } from './for'; +import { checkIfTag } from './if'; +import { checkIncludeTag } from './include'; +import { checkIncrementTag } from './increment'; +import { checkInlineCommentTag } from './inline_comment'; +import { checkJavascriptParserError, checkJavascriptTag } from './javascript'; +import { checkLayoutTag } from './layout'; +import { checkPaginateTag } from './paginate'; +import { checkPartialParserError, checkPartialTag } from './partial'; +import { checkRawParserError, checkRawTag } from './raw'; +import { checkRenderTag } from './render'; +import { checkSchemaTag } from './schema'; +import { checkSectionsTag } from './sections'; +import { checkSectionTag } from './section'; +import { checkStyleTag } from './style'; +import { checkStylesheetTag } from './stylesheet'; +import { checkTablerowTag } from './tablerow'; +import { checkUnlessTag } from './unless'; +import { checkVariableOutput } from './variable'; + +export type Context = Parameters[0]; + +type TagChecker = (node: LiquidTag, context: Context) => void; +type RawTagChecker = (node: LiquidRawTag, context: Context) => void; + +const noop = (_n: LiquidTag, _c: Context) => {}; + +const tagCheckers: Record = { + '#': checkInlineCommentTag, + assign: checkAssignTag, + block: checkBlockTag, + break: noop, + capture: checkCaptureTag, + case: checkCaseTag, + continue: noop, + content_for: checkContentForTag, + cycle: checkCycleTag, + decrement: checkDecrementTag, + echo: checkEchoTag, + form: checkFormTag, + for: checkForTag, + tablerow: checkTablerowTag, + if: checkIfTag, + ifchanged: noop, + include: checkIncludeTag, + increment: checkIncrementTag, + layout: checkLayoutTag, + paginate: checkPaginateTag, + partial: checkPartialTag, + render: checkRenderTag, + section: checkSectionTag, + sections: checkSectionsTag, + unless: checkUnlessTag, +}; + +const misplacedTagCheckers: Record = { + // Branch keywords reach this path only when they are out of context. + else: checkMisplacedBranchTag, + elsif: checkMisplacedBranchTag, + when: checkMisplacedBranchTag, +}; + +const rawTagCheckers: Record = { + comment: checkCommentRawTag, + doc: checkDocTag, + javascript: checkJavascriptTag, + schema: checkSchemaTag, + style: checkStyleTag, + stylesheet: checkStylesheetTag, + raw: checkRawTag, +}; + +export const LiquidSyntaxError: LiquidCheckDefinition = { + meta: { + code: 'LiquidSyntaxError', + name: 'LiquidSyntaxError', + docs: { + description: 'Reports Liquid syntax errors.', + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + create(context) { + if (context.file.ast instanceof Error) { + const error = context.file.ast as Error; + return { + async onCodePathStart(file) { + checkCommentTag(error, context, file.source); + checkBlockParserError(error, context, file.source); + checkDocParserError(error, context, file.source); + checkPartialParserError(error, context, file.source); + checkRawParserError(error, context, file.source); + checkJavascriptParserError(error, context, file.source); + }, + }; + } + + return { + async LiquidRawTag(node) { + const rawTagCheck = rawTagCheckers[node.name]; + + if (rawTagCheck) { + rawTagCheck(node, context); + } + }, + + async LiquidTag(node) { + const tagCheck = tagCheckers[node.name] ?? misplacedTagCheckers[node.name]; + + if (tagCheck) { + tagCheck(node, context); + return; + } + + await checkBaseTag(node, context); + }, + + async LiquidBranch(node) { + checkBranchTag(node, context); + }, + + async LiquidVariableOutput(node: LiquidVariableOutput) { + checkVariableOutput(node, context); + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/inline_comment.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/inline_comment.ts new file mode 100644 index 000000000..aba4a8ba3 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/inline_comment.ts @@ -0,0 +1,14 @@ +import type { LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasRubyValidInlineCommentMarkup, rawMarkup } from './utils'; + +export function checkInlineCommentTag(node: LiquidTag, context: Context): void { + if (hasRubyValidInlineCommentMarkup(rawMarkup(node))) return; + + context.report({ + message: + "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character", + startIndex: node.position.start, + endIndex: node.position.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/javascript.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/javascript.ts new file mode 100644 index 000000000..0cbe8cb41 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/javascript.ts @@ -0,0 +1,100 @@ +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasEmptyMarkup, + hasLiquidTagNamed, + liquidTagBodies, + liquidTagMarkup, + rawMarkup, + resolveErrorLocation, +} from './utils'; + +const SYNTAX_ERROR = "Syntax Error in 'javascript' - Valid syntax: javascript"; +const UNCLOSED_ERROR = "'javascript' tag was never closed"; +const UNKNOWN_END_ERROR = "Unknown tag 'endjavascript'"; +const UNCLOSED_PARSER_ERROR = + "Attempting to end parsing before LiquidRawTag 'javascript' was closed"; +const UNOPENED_PARSER_ERROR = + "Attempting to close LiquidTag 'javascript' before it was opened without a matching 'javascript'"; + +export function checkJavascriptTag(node: LiquidRawTag, context: Context): void { + if (hasEmptyMarkup(rawMarkup(node))) return; + + context.report({ + message: SYNTAX_ERROR, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); +} + +export function checkJavascriptParserError(error: Error, context: Context, source: string): void { + if (!hasJavascriptParserTag(source)) return; + + if (error.message === UNOPENED_PARSER_ERROR) { + reportParserError(error, context, source, UNKNOWN_END_ERROR); + return; + } + + if (error.message !== UNCLOSED_PARSER_ERROR) return; + + const invalidOpening = findInvalidJavascriptOpening(source); + if (invalidOpening) { + context.report({ + message: SYNTAX_ERROR, + startIndex: invalidOpening.start, + endIndex: invalidOpening.end, + }); + return; + } + + const [startIndex, endIndex] = resolveErrorLocation(error, source); + + // Ruby accepts markup on the closing javascript tag, while the local raw-tag + // scanner only recognizes bare closing tags. Treat that parser gap as valid. + if (hasJavascriptClosingTagAfter(source, startIndex)) return; + + context.report({ message: UNCLOSED_ERROR, startIndex, endIndex }); +} + +function reportParserError(error: Error, context: Context, source: string, message: string): void { + const [startIndex, endIndex] = resolveErrorLocation(error, source); + context.report({ message, startIndex, endIndex }); +} + +function findInvalidJavascriptOpening(source: string): { start: number; end: number } | null { + for (const tag of liquidTagBodies(source)) { + const markup = liquidTagMarkup(tag.body); + if ( + markup?.tagName === 'javascript' && + (markup.remainingTokens.length > 0 || markup.hasSkippedCharacters) + ) { + return { start: tag.start, end: tag.end }; + } + } + + return null; +} + +export function hasJavascriptClosingTagAfter(source: string, startIndex: number): boolean { + for (const tag of liquidTagBodies(source, startIndex)) { + const markup = liquidTagMarkup(tag.body); + if (markup?.tagName === 'endjavascript') return true; + } + + return false; +} + +function hasJavascriptParserTag(source: string): boolean { + if (hasLiquidTagNamed(source, 'javascript') || hasLiquidTagNamed(source, 'endjavascript')) { + return true; + } + + for (const tag of liquidTagBodies(source)) { + const markup = liquidTagMarkup(tag.body); + if (markup?.tagName === 'javascript' || markup?.tagName === 'endjavascript') { + return true; + } + } + + return false; +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/layout.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/layout.ts new file mode 100644 index 000000000..72d5bd35b --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/layout.ts @@ -0,0 +1,33 @@ +import type { LiquidExpression, LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasSkippedCharacters, hasBareArrayAccess, rawMarkup } from './utils'; + +export function checkLayoutTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'layout' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as LiquidExpression; + + if (hasBareArrayAccess(markup)) { + context.report({ + message: 'Bare bracket access is not allowed in strict2 mode', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'layout' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/paginate.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/paginate.ts new file mode 100644 index 000000000..a818ca292 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/paginate.ts @@ -0,0 +1,57 @@ +import type { LiquidTag, PaginateMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasSkippedCharacters, + hasBareArrayAccess, + argHasBareArrayAccess, + hasSkippedPrefixCharacters, + hasRubyAcceptedPaginateTrailingComma, +} from './utils'; + +export function checkPaginateTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + if (hasRubyAcceptedPaginateTrailingComma(node.markup)) { + return; + } + + context.report({ + message: `Syntax error in 'paginate' tag`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + const markup = node.markup as PaginateMarkup; + + if ( + hasBareArrayAccess(markup.collection) || + hasBareArrayAccess(markup.pageSize) || + markup.args.some(argHasBareArrayAccess) + ) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + context.report({ + message: `Syntax error in 'paginate' tag`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + const rawMarkup = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(rawMarkup)) { + context.report({ + message: `Syntax error in 'paginate' tag`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/partial.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/partial.ts new file mode 100644 index 000000000..152f63ec7 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/partial.ts @@ -0,0 +1,72 @@ +import type { LiquidString, LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasSkippedCharacters, + liquidLineTagLocation, + rawMarkup, + resolveErrorLocation, +} from './utils'; + +const SYNTAX_ERROR = "Syntax error in 'partial' tag"; +const UNCLOSED_PARTIAL_PARSER_ERROR = + "Attempting to end parsing before LiquidTag 'partial' was closed"; +const UNCLOSED_PARTIAL_IN_LIQUID_PARSER_ERROR = + "Unclosed block tag 'partial' in {% liquid %} block"; +const PARTIAL_PARSER_ERROR_MESSAGES = new Set([ + UNCLOSED_PARTIAL_PARSER_ERROR, + UNCLOSED_PARTIAL_IN_LIQUID_PARSER_ERROR, + "Attempting to close LiquidTag 'partial' before it was opened without a matching 'partial'", +]); + +export function checkPartialTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + report(node, context, SYNTAX_ERROR); + return; + } + + const markup = node.markup as LiquidString; + + if (hasInvalidPartialName(markup.value)) { + report( + node, + context, + "Liquid syntax error: Error in tag 'partial' - Valid syntax: partial '[name]'", + ); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + report(node, context, SYNTAX_ERROR); + } +} + +export function checkPartialParserError(error: Error, context: Context, source: string): void { + if (!PARTIAL_PARSER_ERROR_MESSAGES.has(error.message)) return; + + const [startIndex, endIndex] = error.message.includes( + "Unclosed block tag 'partial' in {% liquid %} block", + ) + ? (liquidLineTagLocation(source, 'partial') ?? resolveErrorLocation(error, source)) + : resolveErrorLocation(error, source); + + context.report({ + message: + error.message === UNCLOSED_PARTIAL_PARSER_ERROR + ? "Liquid syntax error: 'partial' tag was never closed" + : error.message, + startIndex, + endIndex, + }); +} + +function hasInvalidPartialName(value: string): boolean { + return value === '' || value.includes('/'); +} + +function report(node: LiquidTag, context: Context, message: string): void { + context.report({ + message, + startIndex: node.position.start, + endIndex: node.position.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/raw.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/raw.ts new file mode 100644 index 000000000..4f2d85a09 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/raw.ts @@ -0,0 +1,58 @@ +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasRubyAcceptedInertCommentBodyCloser, UNMATCHED_RAW_CLOSE_PARSER_ERROR } from './comment'; +import { + hasRubyAcceptedRawTagCloserWithMarkup, + hasSkippedCharacters, + hasSkippedPrefixCharacters, + rawMarkup, + resolveErrorLocation, +} from './utils'; + +const UNCLOSED_RAW_PARSER_ERROR = "Attempting to end parsing before LiquidRawTag 'raw' was closed"; + +const RAW_PARSER_ERROR_MESSAGES = new Set([ + UNCLOSED_RAW_PARSER_ERROR, + UNMATCHED_RAW_CLOSE_PARSER_ERROR, + "Unclosed raw tag 'raw' in {% liquid %} block", +]); + +export function checkRawTag(node: LiquidRawTag, context: Context): void { + if ( + node.markup !== '' || + hasSkippedPrefixCharacters(node.source, node.markupPosition.start, node.markupPosition.end) || + hasSkippedCharacters(rawMarkup(node)) + ) { + context.report({ + message: `Syntax error in 'raw' tag`, + startIndex: node.markupPosition.start, + endIndex: node.markupPosition.end, + }); + } +} + +export function checkRawParserError(error: Error, context: Context, source: string): void { + if (!RAW_PARSER_ERROR_MESSAGES.has(error.message)) return; + + const [startIndex, endIndex] = resolveErrorLocation(error, source); + + if ( + error.message === UNCLOSED_RAW_PARSER_ERROR && + hasRubyAcceptedRawTagCloserWithMarkup(source, 'raw', startIndex) + ) { + return; + } + + if ( + error.message === UNMATCHED_RAW_CLOSE_PARSER_ERROR && + hasRubyAcceptedInertCommentBodyCloser(source, 'raw', startIndex) + ) { + return; + } + + context.report({ + message: error.message, + startIndex, + endIndex, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/render.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/render.ts new file mode 100644 index 000000000..5cfd62328 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/render.ts @@ -0,0 +1,61 @@ +import { NodeTypes, type LiquidTag, type RenderMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + hasSkippedCharacters, + hasSkippedPrefixCharacters, + hasBareArrayAccess, + hasUnclosedQuotedString, + rawMarkup, +} from './utils'; + +const SYNTAX_ERROR = `Syntax error in 'render' tag`; +const BARE_ARRAY_ACCESS = 'Bare bracket access is not allowed in strict2 mode'; + +export function checkRenderTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + report(node, context, SYNTAX_ERROR); + return; + } + + const markup = node.markup as RenderMarkup; + + if (hasUnclosedQuotedString(rawMarkup(node))) { + report(node, context, SYNTAX_ERROR); + return; + } + + if (hasRenderBareArrayAccess(markup)) { + report(node, context, BARE_ARRAY_ACCESS); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + report(node, context, SYNTAX_ERROR); + return; + } + + const rawMarkupRemainder = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(rawMarkupRemainder)) { + report(node, context, SYNTAX_ERROR); + } +} + +function hasRenderBareArrayAccess(markup: RenderMarkup): boolean { + if (markup.snippet.type === NodeTypes.VariableLookup && hasBareArrayAccess(markup.snippet)) { + return true; + } + + if (markup.variable && hasBareArrayAccess(markup.variable.name)) { + return true; + } + + return markup.args.some((arg) => hasBareArrayAccess(arg.value)); +} + +function report(node: LiquidTag, context: Context, message: string): void { + context.report({ + message, + startIndex: node.position.start, + endIndex: node.position.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/schema.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/schema.ts new file mode 100644 index 000000000..bdcf6531c --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/schema.ts @@ -0,0 +1,12 @@ +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; + +export function checkSchemaTag(node: LiquidRawTag, context: Context): void { + if (node.markup !== '') { + context.report({ + message: `Syntax error in 'schema' tag`, + startIndex: node.markupPosition.start, + endIndex: node.markupPosition.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/section.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/section.ts new file mode 100644 index 000000000..a8459c3be --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/section.ts @@ -0,0 +1,52 @@ +import type { LiquidTag, SectionMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasBareArrayAccess, hasSkippedCharacters, rawMarkup } from './utils'; + +export function checkSectionTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'section' tag`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + if (node.blockEndPosition) { + context.report({ + message: "Unknown tag 'endsection'", + startIndex: node.blockEndPosition.start, + endIndex: node.blockEndPosition.end, + }); + return; + } + + const markup = node.markup as SectionMarkup; + + /* + * A +BlockArrayLiteral+ value (e.g. +size: [1, 2]+) is a first-class array + * literal in this parser, not a bare bracket lookup, so it never counts as + * bare array access. Skip it to narrow +arg.value+ down to the plain + * +LiquidExpression+ that +hasBareArrayAccess+ expects. + */ + if ( + markup.args.some( + (arg) => arg.value.type !== 'BlockArrayLiteral' && hasBareArrayAccess(arg.value), + ) + ) { + context.report({ + message: 'Bare bracket access is not allowed in strict2 mode', + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'section' tag`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/sections.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/sections.ts new file mode 100644 index 000000000..c301b557b --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/sections.ts @@ -0,0 +1,22 @@ +import type { LiquidTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasSkippedCharacters, rawMarkup } from './utils'; + +export function checkSectionsTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: `Syntax error in 'sections' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup(node))) { + context.report({ + message: `Syntax error in 'sections' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/style.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/style.ts new file mode 100644 index 000000000..eff6f6912 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/style.ts @@ -0,0 +1,14 @@ +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasEmptyMarkup, rawMarkup } from './utils'; + +export function checkStyleTag(node: LiquidRawTag, context: Context): void { + const markup = rawMarkup(node); + if (hasEmptyMarkup(markup)) return; + + context.report({ + message: `'style' tag does not accept any arguments in "${markup}"`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/stylesheet.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/stylesheet.ts new file mode 100644 index 000000000..f4d46bb5d --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/stylesheet.ts @@ -0,0 +1,14 @@ +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { hasEmptyMarkup, hasSingleStringMarkup, rawMarkup } from './utils'; + +export function checkStylesheetTag(node: LiquidRawTag, context: Context): void { + const markup = rawMarkup(node); + if (hasEmptyMarkup(markup) || hasSingleStringMarkup(markup, 'scss')) return; + + context.report({ + message: `'stylesheet' tag can only accept the string argument 'scss' in "${markup}"`, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/tablerow.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/tablerow.ts new file mode 100644 index 000000000..a0aadd198 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/tablerow.ts @@ -0,0 +1,53 @@ +import type { LiquidTag, ForMarkup } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + argHasBareArrayAccess, + hasBareArrayAccess, + hasRubyAcceptedLoopTrailingComma, + hasSkippedCharacters, + hasSkippedPrefixCharacters, +} from './utils'; + +export function checkTablerowTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + if (hasRubyAcceptedLoopTrailingComma(node.markup)) { + return; + } + + context.report({ + message: `Syntax error in 'tablerow' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup as ForMarkup; + + if (hasBareArrayAccess(markup.collection) || markup.args.some(argHasBareArrayAccess)) { + context.report({ + message: 'Bare bracket access is not allowed', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) { + context.report({ + message: `Syntax error in 'tablerow' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const rawMarkup = node.source.slice(markup.position.start, node.markupPosition.end); + if (hasSkippedCharacters(rawMarkup)) { + context.report({ + message: `Syntax error in 'tablerow' tag`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/unless.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/unless.ts new file mode 100644 index 000000000..77f78779f --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/unless.ts @@ -0,0 +1,59 @@ +import type { LiquidTag, LiquidConditionalExpression } from '@shopify/liquid-html-parser'; +import type { Context } from '.'; +import { + conditionalHasBareArrayAccess, + hasSkippedCharacters, + hasSkippedPrefixCharacters, + hasUnclosedQuotedString, + rawMarkup, +} from './utils'; + +export function checkUnlessTag(node: LiquidTag, context: Context): void { + if (typeof node.markup === 'string') { + context.report({ + message: "Syntax error in 'unless' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const source = node.source; + const markup = node.markup as LiquidConditionalExpression; + const tagMarkup = rawMarkup(node); + + if (hasUnclosedQuotedString(tagMarkup)) { + context.report({ + message: "Syntax error in 'unless' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (conditionalHasBareArrayAccess(markup)) { + context.report({ + message: 'Bare bracket access is not allowed in strict2 mode', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedPrefixCharacters(source, node.markupPosition.start, markup.position.start)) { + context.report({ + message: "Syntax error in 'unless' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(source.slice(markup.position.start, node.markupPosition.end))) { + context.report({ + message: "Syntax error in 'unless' tag", + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/utils.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/utils.ts new file mode 100644 index 000000000..78be45344 --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/utils.ts @@ -0,0 +1,960 @@ +import { + MarkupParser, + NodeTypes, + TokenType, + tokenizeMarkup, + tokenize, + MarkupTokenType, + type ComplexLiquidExpression, + type LiquidArgument, + type LiquidRawTag, + type LiquidTag, + type LiquidVariable, + type LiquidConditionalExpression, + type MarkupToken, + type Token, +} from '@shopify/liquid-html-parser'; +import { findLastIndex } from '../../utils'; + +/** + * Resolves an error's location to a +[startIndex, endIndex]+ tuple. + * + * If the error carries a +loc+ property (with +start+ and +end+ + * sub-objects containing 1-indexed +line+/+column+ pairs), those + * coordinates are converted to byte offsets via +getOffset+. + * Otherwise the fallback +[0, source.length]+ is returned so that + * the entire source is highlighted. + */ +export function resolveErrorLocation(error: Error, source: string): [number, number] { + if ('loc' in error && error.loc) { + const loc = error.loc as { + start: { line: number; column: number }; + end: { line: number; column: number }; + }; + return [ + getOffset(source, loc.start.line, loc.start.column), + getOffset(source, loc.end.line, loc.end.column), + ]; + } + return [0, source.length]; +} + +/** + * Converts a 1-indexed line and column back to a byte offset in +source+. + * + * The parser's +LiquidHTMLASTParsingError+ stores error locations as + * 1-indexed +{line, column}+ pairs (via the +line-column+ library). + * This helper reverses that conversion so we can report offsets in the + * +startIndex+/+endIndex+ format that theme-check expects. + */ +export function getOffset(source: string, line: number, column: number): number { + let currentLine = 1; + for (let i = 0; i < source.length; i++) { + if (currentLine === line) { + return i + (column - 1); + } + if (source[i] === '\n') { + currentLine++; + } + } + return source.length; +} + +/** + * Returns +true+ when +expr+ is a bare bracket lookup. + * + * Ruby Liquid rejects a +VariableLookup+ whose name is +null+. Keep the + * strict2 check AST-based so valid indexed lookups are not confused with + * bare array access. + * + * Liquid examples: + * + * +{% assign x = items[0] %}+ => false + * +{% assign x = [0] %}+ => true + */ +export function hasBareArrayAccess(expr: ComplexLiquidExpression): boolean { + return expr.type === NodeTypes.VariableLookup && expr.name === null; +} + +/** + * Returns +true+ if +arg+ contains a bare bracket access. + * + * For named arguments (e.g. +size: [0]+), unwraps the + * value first. For positional arguments, checks the + * argument directly. + * + * {{ x | filter: items[0] }} => false + * {{ x | filter: [0] }} => true + */ +export function argHasBareArrayAccess(arg: LiquidArgument): boolean { + const expr = arg.type === NodeTypes.NamedArgument ? arg.value : arg; + return hasBareArrayAccess(expr); +} + +/** + * Returns +true+ if a +LiquidVariable+ contains any bare + * bracket access -- either in the main expression or in + * any filter argument. + * + * {{ items[0] | upcase }} => false + * {{ [0] | upcase }} => true + * {{ x | slice: [0] }} => true + */ +export function variableHasBareArrayAccess(variable: LiquidVariable): boolean { + return ( + hasBareArrayAccess(variable.expression) || + variable.filters.some((f) => f.args.some(argHasBareArrayAccess)) + ); +} + +/** + * Returns +true+ if a +LiquidConditionalExpression+ tree + * contains any bare bracket access. + * + * Walks the conditional tree recursively: + * - +LogicalExpression+: recurse into +left+ and +right+ + * - +Comparison+: check +left+ and +right+ expressions + * - Plain expression: delegate to +hasBareArrayAccess+ + * + * {% if [0] %} => true + * {% if x == [0] %} => true + * {% if x and [0] %} => true + * {% if x == y %} => false + */ +export function conditionalHasBareArrayAccess(expr: LiquidConditionalExpression): boolean { + if (expr.type === NodeTypes.LogicalExpression) { + return conditionalHasBareArrayAccess(expr.left) || conditionalHasBareArrayAccess(expr.right); + } + if (expr.type === NodeTypes.Comparison) { + return hasBareArrayAccess(expr.left) || hasBareArrayAccess(expr.right); + } + return hasBareArrayAccess(expr); +} + +/** + * Returns the full raw markup slice for a Liquid tag. + * + * This uses +markupPosition+ rather than parsed markup node positions so + * tokenizer-skipped bytes remain visible to syntax checks. + */ +export function rawMarkup(node: LiquidTag | LiquidRawTag): string { + return node.source.slice(node.markupPosition.start, node.markupPosition.end); +} + +/** + * Returns +true+ when +markup+ has unclaimed non-whitespace bytes. + * + * Ruby Liquid does not accept garbage that the tokenizer skipped. Tokenize + * the markup, mark every byte claimed by a token, then report any remaining + * non-whitespace byte. + * + * Liquid examples: + * + * +{% cycle foo, 'bar' %}+ => false (fully covered) + * +{% cycle @foo, 'bar' %}+ => true (+@+ is uncovered) + * +{% assign x = #val %}+ => true (+#+ is uncovered) + */ +export function hasSkippedCharacters(markup: string): boolean { + return uncoveredCharacters(markup).length > 0; +} + +/** + * Returns +true+ when +markup+ contains no parsed tokens and no garbage. + * + * Empty and whitespace-only markup are Ruby-accepted. Tokenize the markup + * instead of trimming it so unclaimed bytes are still rejected. + * + * Liquid examples: + * + * +{% echo %}+ => true + * +{{ }}+ => true + * +{{ @ }}+ => false + */ +export function hasEmptyMarkup(markup: string): boolean { + return meaningfulTokens(markup).length === 0 && !hasSkippedCharacters(markup); +} + +/** + * Returns +true+ when +markup+ is exactly one identifier token. + * + * This uses tokenizer tokens and skipped-character coverage rather than + * trimming or comparing normalized markup strings. Callers can use it for + * raw-tag arguments that accept one specific identifier. + * + * Liquid examples: + * + * +{% stylesheet scss %}+ => true for +scss+ + * +{% stylesheet scss extra %}+ => false + * +{% stylesheet scss? %}+ => false + */ +export function hasSingleIdMarkup(markup: string, id: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const tokens = meaningfulTokens(markup); + + return tokens.length === 1 && tokens[0].type === MarkupTokenType.Id && tokens[0].value === id; +} + +/** + * Returns +true+ when +markup+ is exactly one string token. + * + * This uses tokenizer tokens and skipped-character coverage rather than + * trimming or comparing normalized markup strings. The expected +value+ + * excludes the surrounding quote characters. + * + * Liquid examples: + * + * +{% stylesheet 'scss' %}+ => true for +scss+ + * +{% stylesheet "scss" %}+ => true for +scss+ + * +{% stylesheet scss %}+ => false + * +{% stylesheet 'scss' extra %}+ => false + */ +export function hasSingleStringMarkup(markup: string, value: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const tokens = meaningfulTokens(markup); + const token = tokens[0]; + + if (tokens.length !== 1 || token.type !== MarkupTokenType.String) { + return false; + } + + return token.value.slice(1, -1) === value; +} + +/** + * Returns +true+ when inline comment +markup+ is Ruby-accepted. + * + * Ruby Liquid accepts any first line, then requires each nonblank + * continuation line to start with an uncovered +#+ byte. A blank first + * line with one nonblank continuation is accepted for parity. + * + * Liquid examples: + * + * +{% # hello\n# world %}+ => true + * +{% # hello\nworld %}+ => false + * +{% #\nworld %}+ => true + */ +export function hasRubyValidInlineCommentMarkup(markup: string): boolean { + const lines = inlineCommentLines(markup); + let continuationLines = 0; + let prefixedContinuationLines = 0; + + for (let i = 1; i < lines.length; i++) { + const event = lines[i].firstEvent; + if (!event) continue; + + continuationLines++; + if (event.type === 'uncovered' && event.value === '#') { + prefixedContinuationLines++; + } + } + + if (continuationLines === 0) return true; + if (!lines[0].firstEvent && continuationLines === 1) return true; + + return continuationLines === prefixedContinuationLines; +} + +/** + * Returns +true+ for a Ruby-accepted trailing comma in +cycle+ markup. + * + * Ruby Liquid accepts a single terminal comma after a value expression. Keep + * the check tokenizer/parser-backed so double commas and skipped bytes still + * report. + * + * Liquid examples: + * + * +{% cycle product.handle, %}+ => true + * +{% cycle product.handle,, %}+ => false + */ +export function hasRubyAcceptedCycleTrailingComma(markup: string): boolean { + const tokens = tokensBeforeSingleTerminalComma(markup); + return tokens !== null && parsesCompleteValueExpression(tokens, markup); +} + +/** + * Returns +true+ for a Ruby-accepted trailing comma in loop markup. + * + * This is used for +tablerow+ fallback markup. It validates the tokens before + * the terminal comma with the parser's loop grammar instead of reconstructing + * the shape from whitespace and delimiters. + * + * Liquid examples: + * + * +{% tablerow product in products, %}+ => true + * +{% tablerow product in products,, %}+ => false + */ +export function hasRubyAcceptedLoopTrailingComma(markup: string): boolean { + const tokens = tokensBeforeSingleTerminalComma(markup); + if (tokens === null) return false; + + const parser = markupParserForTokens(tokens, markup); + + try { + parser.consume(MarkupTokenType.Id); + if (!parser.id('in')) return false; + parser.valueExpression(); + parser.id('reversed'); + + while (!parser.isAtEnd()) { + parser.consumeOptional(MarkupTokenType.Comma); + parser.namedArgument(); + } + } catch { + return false; + } + + return parser.isAtEnd(); +} + +/** + * Returns +true+ for a Ruby-accepted trailing comma in +paginate+ markup. + * + * The parser still owns the +collection by page_size+ grammar. This helper + * only treats one tokenizer-confirmed terminal comma as Ruby-accepted. + * + * Liquid examples: + * + * +{% paginate products by 12, %}+ => true + * +{% paginate products by 12,, %}+ => false + */ +export function hasRubyAcceptedPaginateTrailingComma(markup: string): boolean { + const tokens = tokensBeforeSingleTerminalComma(markup); + if (tokens === null) return false; + + const parser = markupParserForTokens(tokens, markup); + + try { + parser.valueExpression(); + if (!parser.id('by')) return false; + parser.valueExpression(); + + if (parser.consumeOptional(MarkupTokenType.Comma)) { + parser.namedArguments(); + } + } catch { + return false; + } + + return parser.isAtEnd(); +} + +/** + * Returns +true+ for a Ruby-accepted empty first filter argument. + * + * Ruby Liquid accepts markup ending in a filter argument separator without + * an argument value. Keep this tokenizer-based so skipped bytes still fail. + * + * Liquid examples: + * + * +{{ product.title | append: }}+ => true + * +{{ product.title | append }}+ => false + * +{{ product.title foo | append: }}+ => false + */ +export function hasRubyAcceptedEmptyFirstFilterArgument(markup: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const tokens = meaningfulTokens(markup); + const tail = tokens.slice(-3); + + if ( + !( + tail.length === 3 && + tail[0].type === MarkupTokenType.Pipe && + tail[1].type === MarkupTokenType.Id && + tail[2].type === MarkupTokenType.Colon + ) + ) { + return false; + } + + const prefixTokens = tokens.slice(0, -3); + + return ( + parsesCompleteLiquidVariable(prefixTokens, markup) || + parsesCompleteAssignWithLiquidVariable(prefixTokens, markup) + ); +} + +/** + * Returns +true+ for a Ruby-accepted trailing comma in filter arguments. + * + * Ruby Liquid strict2 accepts one comma after a real filter argument when + * followed by another filter or the end of the variable markup. + * + * Liquid examples: + * + * +{{ n | f1: 1, 2, 3, | f2: }}+ => true + * +{{ n | f1: , | f2 }}+ => false + */ +export function hasRubyAcceptedFilterArgumentTrailingComma(markup: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const tokens = removeFilterArgumentTrailingCommas(meaningfulTokens(markup)); + if (tokens === null) return false; + + return ( + parsesCompleteLiquidVariable(tokens, markup) || + parsesCompleteAssignWithLiquidVariable(tokens, markup) || + hasRubyAcceptedEmptyFirstFilterArgumentWithTokens(tokens, markup) + ); +} + +function removeFilterArgumentTrailingCommas(tokens: MarkupToken[]): MarkupToken[] | null { + let changed = false; + const result: MarkupToken[] = []; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + const nextToken = tokens[index + 1]; + + if ( + token.type === MarkupTokenType.Comma && + (nextToken === undefined || nextToken.type === MarkupTokenType.Pipe) + ) { + if ( + !result + .slice(findLastIndex(result, (token) => token.type === MarkupTokenType.Pipe) + 1) + .some((token) => token.type === MarkupTokenType.Colon) + ) { + return null; + } + + const previousToken = result[result.length - 1]; + + if ( + previousToken === undefined || + previousToken.type === MarkupTokenType.Comma || + previousToken.type === MarkupTokenType.Colon || + previousToken.type === MarkupTokenType.Pipe + ) { + return null; + } + + changed = true; + continue; + } + + result.push(token); + } + + return changed ? result : null; +} + +function hasRubyAcceptedEmptyFirstFilterArgumentWithTokens( + tokens: MarkupToken[], + markup: string, +): boolean { + const tail = tokens.slice(-3); + + if ( + !( + tail.length === 3 && + tail[0].type === MarkupTokenType.Pipe && + tail[1].type === MarkupTokenType.Id && + tail[2].type === MarkupTokenType.Colon + ) + ) { + return false; + } + + const prefixTokens = tokens.slice(0, -3); + + return ( + parsesCompleteLiquidVariable(prefixTokens, markup) || + parsesCompleteAssignWithLiquidVariable(prefixTokens, markup) + ); +} + +function parsesCompleteLiquidVariable(tokens: MarkupToken[], source: string): boolean { + const parser = markupParserForTokens(tokens, source); + + try { + parser.liquidVariable(); + } catch { + return false; + } + + return parser.isAtEnd(); +} + +function parsesCompleteAssignWithLiquidVariable(tokens: MarkupToken[], source: string): boolean { + if ( + tokens.length < 3 || + tokens[0].type !== MarkupTokenType.Id || + tokens[1].type !== MarkupTokenType.Equality || + tokens[1].value !== '=' + ) { + return false; + } + + const parser = markupParserForTokens(tokens.slice(2), source); + + try { + parser.liquidVariable(); + } catch { + return false; + } + + return parser.isAtEnd(); +} + +/** + * Returns +true+ for a Ruby-accepted +assign+ with an empty RHS. + * + * Liquid examples: + * + * +{% assign handle = %}+ => true + * +{% assign = product %}+ => false + */ +export function hasRubyAcceptedEmptyAssignRhs(markup: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const tokens = meaningfulTokens(markup); + + return ( + tokens.length === 2 && + tokens[0].type === MarkupTokenType.Id && + tokens[1].type === MarkupTokenType.Equality && + tokens[1].value === '=' + ); +} + +/** + * Returns +true+ for a Ruby-accepted extra identifier before +=+. + * + * Ruby Liquid accepts an extra identifier between the assign target and + * equals sign, then evaluates the RHS normally. + * + * Liquid examples: + * + * +{% assign handle extra = product %}+ => true + * +{% assign handle 42 = product %}+ => false + */ +export function hasRubyAcceptedAssignLhsExtraIdentifier(markup: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const tokens = meaningfulTokens(markup); + if ( + tokens.length < 4 || + tokens[0].type !== MarkupTokenType.Id || + tokens[1].type !== MarkupTokenType.Id || + tokens[2].type !== MarkupTokenType.Equality || + tokens[2].value !== '=' + ) { + return false; + } + + return parsesCompleteValueExpression(tokens.slice(3), markup); +} + +/** + * Returns +true+ for Ruby-accepted +include+ markup. + * + * The JavaScript parser normally expects include snippets to be strings. + * Ruby Liquid also accepts any complete value expression, followed by + * optional +for+/+with+ bindings, +as+ aliases, and named arguments. + * + * Liquid examples: + * + * +{% include 42 %}+ => true + * +{% include (1..5) for products as item %}+ => true + * +{% include ? %}+ => false + */ +export function hasRubyAcceptedIncludeMarkup(markup: string): boolean { + if (hasSkippedCharacters(markup)) return false; + + const parser = new MarkupParser(tokenizeMarkup(markup), markup); + + try { + const snippet = parser.valueExpression(); + if (hasBareArrayAccess(snippet)) return false; + + if (parser.id('for') || parser.id('with')) { + const binding = parser.valueExpression(); + if (hasBareArrayAccess(binding)) return false; + } + + if (parser.id('as')) { + parser.consume(MarkupTokenType.Id); + } + + parser.consumeOptional(MarkupTokenType.Comma); + while (parser.look(MarkupTokenType.Id)) { + parser.consume(MarkupTokenType.Id); + parser.consume(MarkupTokenType.Colon); + + const value = parser.valueExpression(); + if (hasBareArrayAccess(value)) return false; + + if (!parser.consumeOptional(MarkupTokenType.Comma)) break; + } + } catch { + return false; + } + + return parser.isAtEnd(); +} + +export function hasRubyAcceptedRawTagCloserWithMarkup( + source: string, + tagName: 'doc' | 'raw', + startIndex = 0, +): boolean { + for (const tag of liquidTagBodies(source, startIndex)) { + const markup = liquidTagMarkup(tag.body); + if ( + markup?.tagName === `end${tagName}` && + markup.remainingTokens.length > 0 && + !markup.hasSkippedCharacters + ) { + return hasBalancedRawTagRemainder(source, tagName, tag.end); + } + } + + return false; +} + +function hasBalancedRawTagRemainder( + source: string, + tagName: 'doc' | 'raw', + startIndex: number, +): boolean { + let depth = 0; + + for (const tag of liquidTagBodies(source, startIndex)) { + const markup = liquidTagMarkup(tag.body); + if (!markup || markup.hasSkippedCharacters) continue; + + if (markup.tagName === tagName) { + depth++; + continue; + } + + if (markup.tagName === `end${tagName}`) { + depth--; + if (depth < 0) return false; + } + } + + return depth === 0; +} + +/** + * Returns each tokenizer-owned Liquid tag body in +source+. + * + * The document tokenizer owns the +{%+ and +%}+ boundaries, including + * whitespace-control delimiters. This helper exposes only complete tag + * bodies and ranges so callers do not reconstruct tag boundaries with + * delimiter scans. + * + * Liquid examples: + * + * +{% raw %}{% endraw foo %}+ => bodies +" raw "+, +" endraw foo "+ + * +{%- doc -%}+ => body +" doc "+ + */ +export function liquidTagBodies(source: string, startIndex = 0): LiquidTagBody[] { + const tokens = tokenize(source); + const tags: LiquidTagBody[] = []; + + for (let i = 0; i < tokens.length; i++) { + const open = tokens[i]; + if (open.type !== TokenType.LiquidTagOpen || open.start < startIndex) { + continue; + } + + const text = tokens[i + 1]; + const close = text?.type === TokenType.Text ? tokens[i + 2] : text; + if (!close || close.type !== TokenType.LiquidTagClose) continue; + + tags.push(liquidTagBody(source, open, text?.type === TokenType.Text ? text : undefined, close)); + } + + return tags; +} + +/** + * Returns the tokenizer-classified tag name and remaining markup tokens. + * + * The first markup +Id+ token is the tag name. The rest of the structure is + * classified by markup tokens and skipped-character coverage, not by string + * prefixes, whitespace slicing, or identifier character lists. + * + * Liquid examples: + * + * +{% endraw foo %}+ => tagName +"endraw"+, one remaining token + * +{% docx %}+ => tagName +"docx"+, no remaining tokens + */ +export function liquidTagMarkup(body: string): LiquidTagMarkup | undefined { + const tokens = meaningfulTokens(body); + const tagName = tokens[0]; + + if (!tagName || tagName.type !== MarkupTokenType.Id) return undefined; + + return { + tagName: tagName.value, + remainingTokens: tokens.slice(1), + hasSkippedCharacters: hasSkippedCharacters(body), + }; +} + +/** + * Returns +true+ when +source+ contains a complete Liquid tag named + * +tagName+. + * + * The document tokenizer owns tag boundaries, and the markup tokenizer owns + * tag-name classification. This avoids raw substring checks that would + * confuse text-only mentions or similarly-prefixed tag names with tags. + * + * Liquid examples: + * + * +{% doc %}+ => true for +doc+ + * +{% docx %}+ => false for +doc+ + * +doc text only+ => false for +doc+ + */ +export function hasLiquidTagNamed(source: string, tagName: string): boolean { + for (const tag of liquidTagBodies(source)) { + const markup = liquidTagMarkup(tag.body); + if (markup?.tagName === tagName && !markup.hasSkippedCharacters) { + return true; + } + } + + return false; +} + +export function liquidLineTagLocation(source: string, tagName: string): [number, number] | null { + for (const tag of liquidTagBodies(source)) { + const firstLineEnd = tag.body.indexOf('\n'); + if (firstLineEnd === -1) continue; + + const firstLineTokens = tokenizeMarkup(tag.body.slice(0, firstLineEnd), tag.bodyStart).filter( + (token) => token.type !== MarkupTokenType.EndOfString, + ); + const firstToken = firstLineTokens[0]; + if (firstToken?.type !== MarkupTokenType.Id || firstToken.value !== 'liquid') continue; + + let lineStart = tag.bodyStart; + for (const line of tag.body.split('\n')) { + const tokens = tokenizeMarkup(line, lineStart).filter( + (token) => token.type !== MarkupTokenType.EndOfString, + ); + const firstToken = tokens[0]; + + if (firstToken?.type === MarkupTokenType.Id && firstToken.value === tagName) { + return [firstToken.start, lineStart + line.length]; + } + + lineStart += line.length + 1; + } + } + + return null; +} + +/** + * Returns +true+ when the skipped prefix has unsupported bytes. + * + * Ruby Liquid ignores unmatched +'+ and +"+ bytes before the first parsed + * token. Preserve that parity while still reporting any other skipped byte + * in the prefix between +from+ and +to+. + * + * Liquid examples: + * + * +{% assign @x = 1 %}+ => true (+@+ is garbage) + * +{% assign "hello = x %}+ => false (quote allowed) + */ +export function hasSkippedPrefixCharacters(source: string, from: number, to: number): boolean { + return uncoveredCharacters(source.slice(from, to), true).length > 0; +} + +/** + * Returns +true+ for a Ruby-accepted skipped quote prefix. + * + * Ruby Liquid ignores unmatched quotes before the first parsed token only + * when the quotes are separated from that token by whitespace. Keep the + * check tokenizer-backed so token-bearing or garbage prefixes still fail. + * + * Liquid examples: + * + * +{% unless ' product.available %}+ => true + * +{% unless 'product.available %}+ => false + * +{% unless ' foo product %}+ => false + */ +export function hasRubyAcceptedWhitespaceSeparatedQuotePrefix(prefix: string): boolean { + if (meaningfulTokens(prefix).length > 0) return false; + + const uncovered = uncoveredCharacters(prefix); + if (uncovered.length === 0 || uncovered.some(({ value }) => !isQuote(value))) { + return false; + } + + const lastQuote = uncovered[uncovered.length - 1]; + + for (let index = lastQuote.end; index < prefix.length; index++) { + if (isWhitespace(prefix[index])) return true; + } + + return false; +} + +export function hasUnclosedQuotedString(markup: string): boolean { + return uncoveredCharacters(markup).some(({ value }) => isQuote(value)); +} + +function uncoveredCharacters(markup: string, allowPrefixQuotes = false): UncoveredCharacter[] { + const tokens = tokenizeMarkup(markup); + const covered = new Set(); + const uncovered: UncoveredCharacter[] = []; + + for (const token of tokens) { + if (token.type === MarkupTokenType.EndOfString) continue; + + for (let i = token.start; i < token.end; i++) { + covered.add(i); + } + } + + for (let i = 0; i < markup.length; i++) { + const ch = markup[i]; + if (isWhitespace(ch) || covered.has(i)) continue; + if (allowPrefixQuotes && isQuote(ch)) continue; + uncovered.push({ value: ch, start: i, end: i + 1 }); + } + + return uncovered; +} + +function meaningfulTokens(markup: string): MarkupToken[] { + return tokenizeMarkup(markup).filter((token) => token.type !== MarkupTokenType.EndOfString); +} + +function inlineCommentLines(markup: string): InlineCommentLine[] { + const tokenByOffset = tokenCoverage(markup); + const lines: InlineCommentLine[] = [{ firstEvent: undefined }]; + let line = lines[0]; + + for (let i = 0; i < markup.length; i++) { + const ch = markup[i]; + + if (ch === '\n') { + line = { firstEvent: undefined }; + lines.push(line); + continue; + } + + if (isWhitespace(ch) || line.firstEvent) continue; + + const token = tokenByOffset.get(i); + line.firstEvent = token + ? { type: 'token', start: i, end: i + 1 } + : { type: 'uncovered', value: ch, start: i, end: i + 1 }; + } + + return lines; +} + +function tokenCoverage(markup: string): Map { + const covered = new Map(); + + for (const token of meaningfulTokens(markup)) { + for (let i = token.start; i < token.end; i++) { + covered.set(i, token); + } + } + + return covered; +} + +function parsesCompleteValueExpression(tokens: MarkupToken[], source: string): boolean { + const parser = markupParserForTokens(tokens, source); + + try { + parser.valueExpression(); + } catch { + return false; + } + + return parser.isAtEnd(); +} + +function tokensBeforeSingleTerminalComma(markup: string): MarkupToken[] | null { + if (hasSkippedCharacters(markup)) return null; + + const tokens = meaningfulTokens(markup); + const lastToken = tokens[tokens.length - 1]; + const previousToken = tokens[tokens.length - 2]; + + if ( + tokens.length < 2 || + lastToken.type !== MarkupTokenType.Comma || + previousToken.type === MarkupTokenType.Comma + ) { + return null; + } + + return tokens.slice(0, -1); +} + +function markupParserForTokens(tokens: MarkupToken[], source: string): MarkupParser { + return new MarkupParser([...tokens, endOfStringToken(source)], source); +} + +function endOfStringToken(source: string): MarkupToken { + return { + type: MarkupTokenType.EndOfString, + value: '', + start: source.length, + end: source.length, + }; +} + +function isWhitespace(ch: string): boolean { + return ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t'; +} + +function isQuote(ch: string): boolean { + return ch === "'" || ch === '"'; +} + +function liquidTagBody( + source: string, + open: Token, + text: Token | undefined, + close: Token, +): LiquidTagBody { + return { + start: open.start, + body: text ? source.slice(text.start, text.end) : '', + bodyStart: text?.start ?? open.end, + end: close.end, + }; +} + +interface LiquidTagBody { + start: number; + body: string; + bodyStart: number; + end: number; +} + +interface LiquidTagMarkup { + tagName: string; + remainingTokens: MarkupToken[]; + hasSkippedCharacters: boolean; +} + +interface UncoveredCharacter { + value: string; + start: number; + end: number; +} + +interface InlineCommentLine { + firstEvent: InlineCommentLineEvent | undefined; +} + +type InlineCommentLineEvent = + | { type: 'token'; start: number; end: number } + | { type: 'uncovered'; value: string; start: number; end: number }; diff --git a/packages/theme-check-common/src/checks/liquid-syntax-error/variable.ts b/packages/theme-check-common/src/checks/liquid-syntax-error/variable.ts new file mode 100644 index 000000000..668ac456f --- /dev/null +++ b/packages/theme-check-common/src/checks/liquid-syntax-error/variable.ts @@ -0,0 +1,58 @@ +import { type LiquidVariableOutput } from '@shopify/liquid-html-parser'; +import { + hasEmptyMarkup, + hasRubyAcceptedFilterArgumentTrailingComma, + hasRubyAcceptedEmptyFirstFilterArgument, + hasSkippedCharacters, + hasUnclosedQuotedString, + variableHasBareArrayAccess, +} from './utils'; +import type { Context } from '.'; + +export function checkVariableOutput(node: LiquidVariableOutput, context: Context): void { + const rawMarkup = node.source.slice(node.markupPosition.start, node.markupPosition.end); + + if (typeof node.markup === 'string') { + if (hasEmptyMarkup(rawMarkup)) return; + if (hasRubyAcceptedEmptyFirstFilterArgument(node.markup)) return; + if (hasRubyAcceptedFilterArgumentTrailingComma(node.markup)) return; + + context.report({ + message: 'Syntax error in variable output', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + const markup = node.markup; + + if (hasRubyAcceptedEmptyFirstFilterArgument(rawMarkup)) return; + if (hasRubyAcceptedFilterArgumentTrailingComma(rawMarkup)) return; + + if (hasUnclosedQuotedString(rawMarkup)) { + context.report({ + message: 'Syntax error in variable output', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (variableHasBareArrayAccess(markup)) { + context.report({ + message: 'Bare bracket access is not allowed in strict2 mode', + startIndex: node.position.start, + endIndex: node.position.end, + }); + return; + } + + if (hasSkippedCharacters(rawMarkup)) { + context.report({ + message: 'Syntax error in variable output', + startIndex: node.position.start, + endIndex: node.position.end, + }); + } +} diff --git a/packages/theme-check-common/src/checks/max-file-size/index.spec.ts b/packages/theme-check-common/src/checks/max-file-size/index.spec.ts new file mode 100644 index 000000000..43933482b --- /dev/null +++ b/packages/theme-check-common/src/checks/max-file-size/index.spec.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import { MaxFileSize, MaxFileSizeJSON } from './index'; +import { runLiquidCheck, runJSONCheck } from '../../test'; + +const KILOBYTE = 1024; +const MEGABYTE = 1024 * KILOBYTE; + +type FileLimitCase = { + key: string; + path: string; + limitBytes: number; +}; + +/* prettier-ignore */ /// ignoring Prettier here to keep comments readable. +const FILE_LIMIT_CASES: FileLimitCase[] = [ + { key: 'assets', path: 'assets/theme.css', limitBytes: 20 * MEGABYTE }, + { key: 'blocks', path: 'blocks/product-card.liquid', limitBytes: 256 * KILOBYTE }, + { key: 'config/settings_data.json', path: 'config/settings_data.json', limitBytes: 1.5 * MEGABYTE }, + { key: 'config/settings_schema.json', path: 'config/settings_schema.json', limitBytes: 512 * KILOBYTE }, + { key: 'config', path: 'config/markets.json', limitBytes: 256 * KILOBYTE }, + { key: 'layout', path: 'layout/theme.liquid', limitBytes: 256 * KILOBYTE }, + { key: 'snippets', path: 'snippets/card.liquid', limitBytes: 256 * KILOBYTE }, + { key: 'templates/*.json', path: 'templates/product.json', limitBytes: 512 * KILOBYTE }, + { key: 'templates', path: 'templates/product.liquid', limitBytes: 256 * KILOBYTE }, + { key: 'locales', path: 'locales/en.default.json', limitBytes: 1.5 * MEGABYTE }, + { key: 'sections/*.json', path: 'sections/product.json', limitBytes: 512 * KILOBYTE }, + { key: 'sections', path: 'sections/product.liquid', limitBytes: 256 * KILOBYTE }, +]; + +describe('MaxFileSize', () => { + it.each(FILE_LIMIT_CASES)( + 'accepts $key at the exact byte limit', + async ({ path, limitBytes }) => { + const offenses = await lint(path, sourceWithByteSize(path, limitBytes)); + + expect(offenses).toEqual([]); + }, + ); + + it.each(FILE_LIMIT_CASES)( + 'reports $key one byte over the limit', + async ({ path, limitBytes }) => { + const offenses = await lint(path, sourceWithByteSize(path, limitBytes + 1)); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'MaxFileSize', + uri: `file:///${path}`, + message: expect.stringContaining(`the limit is ${formatBytes(limitBytes)}`), + }); + }, + ); + + it('reports oversized files under a VFS-prefixed theme path', async () => { + const path = 'workspace/themes/123/templates/index.liquid'; + const offenses = await lint(path, 'a'.repeat(256 * KILOBYTE + 1)); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'MaxFileSize', + uri: `file:///${path}`, + message: expect.stringContaining('the limit is 256 KB'), + }); + }); + + it('excludes schema block bytes for template-table files', async () => { + const source = + 'a'.repeat(256 * KILOBYTE) + + '{% schema %}' + + JSON.stringify({ + name: 'Large schema', + settings: [{ type: 'textarea', id: 'copy', label: 'Copy', default: 'b'.repeat(KILOBYTE) }], + }) + + '{% endschema %}'; + + const offenses = await lint('sections/schema-test.liquid', source); + + expect(offenses).toEqual([]); + }); + + it('excludes whitespace-controlled schema block bytes for template-table files', async () => { + const source = + 'a'.repeat(256 * KILOBYTE) + + '{%- schema -%}' + + JSON.stringify({ + name: 'Large schema', + settings: [{ type: 'textarea', id: 'copy', label: 'Copy', default: 'b'.repeat(KILOBYTE) }], + }) + + '{%- endschema -%}'; + + const offenses = await lint('sections/schema-whitespace-control-test.liquid', source); + + expect(offenses).toEqual([]); + }); + + it.each(['_drafts/bfcm/templates/product.json', '_drafts/bfcm/sections/product.json'])( + 'ignores oversized draft file %s', + async (path) => { + const offenses = await lint(path, jsonWithByteSize(20 * MEGABYTE + 1)); + + expect(offenses).toEqual([]); + }, + ); + + it('measures UTF-8 bytes instead of JavaScript string length', async () => { + const source = 'é'.repeat(128 * KILOBYTE + 1); + + expect(source.length).toBeLessThan(256 * KILOBYTE); + expect(Buffer.byteLength(source)).toBeGreaterThan(256 * KILOBYTE); + + const offenses = await lint('snippets/multibyte.liquid', source); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'MaxFileSize', + message: expect.stringContaining('262146 bytes'), + }); + }); + + it('reports oversized files even when the Liquid parser cannot build an AST', async () => { + const source = 'a'.repeat(256 * KILOBYTE + 1) + '{% raw %}'; + + const offenses = await lint('snippets/broken.liquid', source); + + expect(offenses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + check: 'MaxFileSize', + uri: 'file:///snippets/broken.liquid', + message: expect.stringContaining('the limit is 256 KB'), + }), + ]), + ); + }); +}); + +async function lint(path: string, source: string) { + // The check harness parses only `.liquid` files as LiquidHtml; every other + // extension (`.json`, `.css`, ...) is treated as a JSON source (see + // to-source-code.ts). Route each file to the check that will actually visit + // it, so the Liquid-vs-JSON selection matches the harness's own file typing. + if (path.endsWith('.liquid')) { + return runLiquidCheck(MaxFileSize, source, path); + } + + return runJSONCheck(MaxFileSizeJSON, source, path); +} + +function sourceWithByteSize(path: string, bytes: number): string { + if (path === 'layout/theme.liquid') { + const layoutSource = + '{{ content_for_header }}{{ content_for_layout }}'; + return layoutSource + 'a'.repeat(bytes - Buffer.byteLength(layoutSource)); + } + + if (path === 'config/settings_schema.json') { + return jsonArrayWithByteSize(bytes); + } + + if (path.endsWith('.json')) { + return jsonWithByteSize(bytes); + } + + return 'a'.repeat(bytes); +} + +function jsonArrayWithByteSize(bytes: number): string { + // Wrapping the object form in `[]` adds exactly 2 bytes. + return `[${jsonWithByteSize(bytes - 2)}]`; +} + +function jsonWithByteSize(bytes: number): string { + // Build {"d":"aaa..."} where the filler length is adjusted to hit the exact byte target. + const shell = '{"d":""}'; // 8 bytes + const filler = 'a'.repeat(bytes - shell.length); + return `{"d":"${filler}"}`; +} + +function formatBytes(bytes: number): string { + if (bytes % MEGABYTE === 0) { + return `${bytes / MEGABYTE} MB`; + } + + if (bytes % KILOBYTE === 0) { + return `${bytes / KILOBYTE} KB`; + } + + return `${bytes} bytes`; +} diff --git a/packages/theme-check-common/src/checks/max-file-size/index.ts b/packages/theme-check-common/src/checks/max-file-size/index.ts new file mode 100644 index 000000000..3bca4b9a7 --- /dev/null +++ b/packages/theme-check-common/src/checks/max-file-size/index.ts @@ -0,0 +1,144 @@ +import { + Severity, + SourceCodeType, + type JSONCheckDefinition, + type LiquidCheckDefinition, +} from '../../types'; + +const KILOBYTE = 1024; +const MEGABYTE = 1024 * KILOBYTE; +const DRAFTS_DIRECTORY = '_drafts'; +const FULL_FILE_BYTES = 'full-file-bytes'; +const SCHEMALESS_BYTES = 'schemaless-bytes'; + +type MaxFileSizeMeasurement = typeof FULL_FILE_BYTES | typeof SCHEMALESS_BYTES; + +type MaxFileSizeLimit = { + bytes: number; + measurement: MaxFileSizeMeasurement; +}; + +type MaxFileSizeLimitKey = keyof typeof MAX_FILE_SIZE_LIMITS; + +/* + * Shopify core parity for per-file theme limits: + */ +/* prettier-ignore */ /// ignoring Prettier here to keep comments readable. +const MAX_FILE_SIZE_LIMITS = { + assets: { bytes: 20.0 * MEGABYTE, measurement: FULL_FILE_BYTES }, + locales: { bytes: 1.5 * MEGABYTE, measurement: SCHEMALESS_BYTES }, + "config/settings_data.json": { bytes: 1.5 * MEGABYTE, measurement: SCHEMALESS_BYTES }, + "config/settings_schema.json": { bytes: 512.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + "templates/*.json": { bytes: 512.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + "sections/*.json": { bytes: 512.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + blocks: { bytes: 256.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + config: { bytes: 256.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + layout: { bytes: 256.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + snippets: { bytes: 256.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + templates: { bytes: 256.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, + sections: { bytes: 256.0 * KILOBYTE, measurement: SCHEMALESS_BYTES }, +} as const satisfies Record; + +const MAX_FILE_SIZE_LIMIT_KEYS = Object.keys(MAX_FILE_SIZE_LIMITS) as MaxFileSizeLimitKey[]; + +const SCHEMA_BLOCK_REGEX = /{%-?\s*schema\s*-?%}[\s\S]*?{%-?\s*endschema\s*-?%}/g; + +export const MaxFileSize: LiquidCheckDefinition = { + meta: { + code: 'MaxFileSize', + name: 'MaxFileSize', + docs: { + description: "Reports theme files that exceed Shopify's maximum file size.", + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + + create(context) { + return { + async onCodePathStart() { + const limit = maxFileSizeLimit(context.toRelativePath(context.file.uri)); + if (!limit) return; + + const measuredBytes = measuredFileBytes(context.file.source, limit.measurement); + if (measuredBytes <= limit.bytes) return; + + context.report({ + message: `Theme file is too large. It is ${formatBytes(measuredBytes)} but the limit is ${formatBytes(limit.bytes)}.`, + startIndex: 0, + endIndex: context.file.source.length, + }); + }, + }; + }, +}; + +function maxFileSizeLimit(relativePath: string): MaxFileSizeLimit | undefined { + if (relativePath === DRAFTS_DIRECTORY || relativePath.startsWith(`${DRAFTS_DIRECTORY}/`)) { + return undefined; + } + + const key = maxFileSizeLimitKey(relativePath); + if (!key) return undefined; + + return MAX_FILE_SIZE_LIMITS[key]; +} + +function maxFileSizeLimitKey(relativePath: string): MaxFileSizeLimitKey | undefined { + return MAX_FILE_SIZE_LIMIT_KEYS.find((key) => { + if (key.endsWith('*.json')) { + const [prefix, extension] = key.split('*'); + return ( + matchesThemePathPrefix(relativePath, prefix) && + relativePath.toLowerCase().endsWith(extension) + ); + } + + return matchesThemePath(relativePath, key); + }); +} + +function matchesThemePath(relativePath: string, themePath: string): boolean { + return ( + relativePath === themePath || + relativePath.startsWith(`${themePath}/`) || + relativePath.endsWith(`/${themePath}`) || + relativePath.includes(`/${themePath}/`) + ); +} + +function matchesThemePathPrefix(relativePath: string, themePathPrefix: string): boolean { + return relativePath.startsWith(themePathPrefix) || relativePath.includes(`/${themePathPrefix}`); +} + +function measuredFileBytes(source: string, measurement: MaxFileSizeLimit['measurement']): number { + if (measurement === FULL_FILE_BYTES) { + return Buffer.byteLength(source); + } + + const schemaBytes = [...source.matchAll(SCHEMA_BLOCK_REGEX)].reduce( + (bytes, match) => bytes + Buffer.byteLength(match[0]), + 0, + ); + return Buffer.byteLength(source) - schemaBytes; +} + +function formatBytes(bytes: number): string { + if (bytes % MEGABYTE === 0) { + return `${bytes / MEGABYTE} MB`; + } + + if (bytes % KILOBYTE === 0) { + return `${bytes / KILOBYTE} KB`; + } + + return `${bytes} bytes`; +} + +export const MaxFileSizeJSON: JSONCheckDefinition = { + ...MaxFileSize, + meta: { ...MaxFileSize.meta, type: SourceCodeType.JSON }, +} as unknown as JSONCheckDefinition; diff --git a/packages/theme-check-common/src/checks/missing-block-arguments/index.spec.ts b/packages/theme-check-common/src/checks/missing-block-arguments/index.spec.ts new file mode 100644 index 000000000..e72e6a2a7 --- /dev/null +++ b/packages/theme-check-common/src/checks/missing-block-arguments/index.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { MissingBlockArguments } from './index'; +import { runLiquidCheck } from '../../test'; + +const CARD_BLOCK = [ + '{% doc %}', + ' @param {String} title - Card title', + ' @param {String} [subtitle] - Card subtitle', + '{% enddoc %}', + '
{{ title }}
', +].join('\n'); + +const NO_DOC_BLOCK = ''; + +describe('MissingBlockArguments', () => { + it('reports when required param is missing', async () => { + const offenses = await runLiquidCheck( + MissingBlockArguments, + "{% block 'card', subtitle: 'sub' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/card.liquid': CARD_BLOCK }, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('title'); + }); + + it('does not report when required param is provided', async () => { + const offenses = await runLiquidCheck( + MissingBlockArguments, + "{% block 'card', title: 'Hello' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/card.liquid': CARD_BLOCK }, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when block file has no doc tag', async () => { + const offenses = await runLiquidCheck( + MissingBlockArguments, + "{% block 'card' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/card.liquid': NO_DOC_BLOCK }, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when block file does not exist', async () => { + const offenses = await runLiquidCheck( + MissingBlockArguments, + "{% block 'missing' %}x{% endblock %}", + 'templates/test.liquid', + ); + + expect(offenses).toHaveLength(0); + }); +}); diff --git a/packages/theme-check-common/src/checks/missing-block-arguments/index.ts b/packages/theme-check-common/src/checks/missing-block-arguments/index.ts new file mode 100644 index 000000000..86849f434 --- /dev/null +++ b/packages/theme-check-common/src/checks/missing-block-arguments/index.ts @@ -0,0 +1,47 @@ +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { BlockMarkup } from '@shopify/liquid-html-parser'; +import { getBlockDocParams } from '../common/block-doc'; + +export const MissingBlockArguments: LiquidCheckDefinition = { + meta: { + code: 'MissingBlockArguments', + name: 'Missing Block Arguments', + docs: { + description: + "Reports when required arguments declared in a block's {% doc %} tag are not provided.", + recommended: true, + url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/missing-block-arguments', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidTag(node) { + if (node.name !== 'block') return; + if (typeof node.markup === 'string') return; + + const markup = node.markup as BlockMarkup; + const blockName = markup.name.value; + const docParams = await getBlockDocParams(context, blockName); + if (!docParams) return; + + const providedParams = new Set(markup.args.map((arg) => arg.name)); + + for (const [paramName, param] of docParams) { + if (!param.required) continue; + if (providedParams.has(paramName)) continue; + + context.report({ + message: `Missing required argument '${paramName}' in block tag for '${blockName}'.`, + startIndex: node.position.start, + endIndex: node.position.end, + }); + } + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/missing-template/index.ts b/packages/theme-check-common/src/checks/missing-template/index.ts index f1cda6a07..d2b3c4b67 100644 --- a/packages/theme-check-common/src/checks/missing-template/index.ts +++ b/packages/theme-check-common/src/checks/missing-template/index.ts @@ -58,7 +58,12 @@ export const MissingTemplate: LiquidCheckDefinition = { return { async RenderMarkup(node) { - if (node.snippet.type === NodeTypes.VariableLookup) return; + if ( + node.snippet.type === NodeTypes.VariableLookup || + node.snippet.type === NodeTypes.Range + ) { + return; + } const snippet = node.snippet; const relativePath = `snippets/${snippet.value}.liquid`; @@ -71,9 +76,14 @@ export const MissingTemplate: LiquidCheckDefinition = { if (node.name !== NamedTags.section) return; const markup = node.markup; - const relativePath = `sections/${markup.value}.liquid`; + // The ported parser wraps the section name in a `SectionMarkup` node + // whose name lives at `markup.name` (a String node); the previous + // parser exposed the name directly as `markup.value`. Read the name and + // report against the String node so the offense covers the quoted + // string rather than the whole markup. + const relativePath = `sections/${markup.name.value}.liquid`; - await maybeReportMissing(relativePath, markup); + await maybeReportMissing(relativePath, markup.name); }, }; }, diff --git a/packages/theme-check-common/src/checks/raw-tags/index.spec.ts b/packages/theme-check-common/src/checks/raw-tags/index.spec.ts new file mode 100644 index 000000000..1a11ecc45 --- /dev/null +++ b/packages/theme-check-common/src/checks/raw-tags/index.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + JavascriptOncePerFile, + JavascriptSectionOrBlockOnly, + SchemaOncePerFile, + SchemaSectionOrBlockOnly, + StylesheetOncePerFile, + StylesheetSectionOrBlockOnly, +} from './index'; +import { runLiquidCheck } from '../../test'; + +const SCHEMA_TAG = '{% schema %}{% endschema %}'; +const JAVASCRIPT_TAG = '{% javascript %}{% endjavascript %}'; +const STYLESHEET_TAG = '{% stylesheet %}{% endstylesheet %}'; + +describe('raw tag checks', () => { + describe('schema', () => { + it.each(['sections/test.liquid', 'blocks/test.liquid'])( + 'allows schema tags in %s', + async (path) => { + const offenses = await runLiquidCheck(SchemaSectionOrBlockOnly, SCHEMA_TAG, path); + + expect(offenses).toEqual([]); + }, + ); + + it.each(['templates/index.liquid', 'snippets/test.liquid'])( + 'reports schema tags in %s', + async (path) => { + const offenses = await runLiquidCheck(SchemaSectionOrBlockOnly, SCHEMA_TAG, path); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'schema-section-or-block-only', + message: '{% schema %} is only valid in section or block files.', + }); + }, + ); + + it('reports second and subsequent schema tags in a file', async () => { + const source = [SCHEMA_TAG, SCHEMA_TAG, SCHEMA_TAG].join('\n'); + + const offenses = await runLiquidCheck(SchemaOncePerFile, source, 'sections/test.liquid'); + + expect(offenses).toHaveLength(2); + expect(offenses).toEqual([ + expect.objectContaining({ + check: 'schema-once-per-file', + message: '{% schema %} can only appear once per file.', + }), + expect.objectContaining({ + check: 'schema-once-per-file', + message: '{% schema %} can only appear once per file.', + }), + ]); + }); + }); + + describe('javascript', () => { + it.each(['sections/test.liquid', 'blocks/test.liquid'])( + 'allows javascript tags in %s', + async (path) => { + const offenses = await runLiquidCheck(JavascriptSectionOrBlockOnly, JAVASCRIPT_TAG, path); + + expect(offenses).toEqual([]); + }, + ); + + it.each(['templates/index.liquid', 'snippets/test.liquid'])( + 'reports javascript tags in %s', + async (path) => { + const offenses = await runLiquidCheck(JavascriptSectionOrBlockOnly, JAVASCRIPT_TAG, path); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'javascript-section-or-block-only', + message: '{% javascript %} is only valid in section or block files.', + }); + }, + ); + + it('reports second and subsequent javascript tags in a file', async () => { + const source = [JAVASCRIPT_TAG, JAVASCRIPT_TAG, JAVASCRIPT_TAG].join('\n'); + + const offenses = await runLiquidCheck(JavascriptOncePerFile, source, 'sections/test.liquid'); + + expect(offenses).toHaveLength(2); + expect(offenses).toEqual([ + expect.objectContaining({ + check: 'javascript-once-per-file', + message: '{% javascript %} can only appear once per file.', + }), + expect.objectContaining({ + check: 'javascript-once-per-file', + message: '{% javascript %} can only appear once per file.', + }), + ]); + }); + }); + + describe('stylesheet', () => { + it.each(['sections/test.liquid', 'blocks/test.liquid'])( + 'allows stylesheet tags in %s', + async (path) => { + const offenses = await runLiquidCheck(StylesheetSectionOrBlockOnly, STYLESHEET_TAG, path); + + expect(offenses).toEqual([]); + }, + ); + + it.each(['templates/index.liquid', 'snippets/test.liquid'])( + 'reports stylesheet tags in %s', + async (path) => { + const offenses = await runLiquidCheck(StylesheetSectionOrBlockOnly, STYLESHEET_TAG, path); + + expect(offenses).toHaveLength(1); + expect(offenses[0]).toMatchObject({ + check: 'stylesheet-section-or-block-only', + message: '{% stylesheet %} is only valid in section or block files.', + }); + }, + ); + + it('reports second and subsequent stylesheet tags in a file', async () => { + const source = [STYLESHEET_TAG, STYLESHEET_TAG, STYLESHEET_TAG].join('\n'); + + const offenses = await runLiquidCheck(StylesheetOncePerFile, source, 'sections/test.liquid'); + + expect(offenses).toHaveLength(2); + expect(offenses).toEqual([ + expect.objectContaining({ + check: 'stylesheet-once-per-file', + message: '{% stylesheet %} can only appear once per file.', + }), + expect.objectContaining({ + check: 'stylesheet-once-per-file', + message: '{% stylesheet %} can only appear once per file.', + }), + ]); + }); + }); +}); diff --git a/packages/theme-check-common/src/checks/raw-tags/index.ts b/packages/theme-check-common/src/checks/raw-tags/index.ts new file mode 100644 index 000000000..7ec1f1681 --- /dev/null +++ b/packages/theme-check-common/src/checks/raw-tags/index.ts @@ -0,0 +1,107 @@ +import { isBlock, isSection } from '../../to-schema'; +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { LiquidRawTag } from '@shopify/liquid-html-parser'; + +type RawTagName = 'schema' | 'javascript' | 'stylesheet'; + +interface RawTagCheckOptions { + tagName: RawTagName; + code: string; + message: string; +} + +function rawTagCheck({ tagName, code, message }: RawTagCheckOptions): LiquidCheckDefinition { + return { + meta: { + code, + name: code, + docs: { + description: message, + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + create(context) { + return { + async LiquidRawTag(node: LiquidRawTag) { + if (node.name !== tagName) return; + + context.report({ + message, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + }, + }; + }, + }; +} + +function sectionOrBlockOnlyCheck(tagName: RawTagName): LiquidCheckDefinition { + return rawTagCheck({ + tagName, + code: `${tagName}-section-or-block-only`, + message: `{% ${tagName} %} is only valid in section or block files.`, + }); +} + +function oncePerFileCheck(tagName: RawTagName): LiquidCheckDefinition { + const code = `${tagName}-once-per-file`; + const message = `{% ${tagName} %} can only appear once per file.`; + + return { + meta: { + code, + name: code, + docs: { + description: message, + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.ERROR, + schema: {}, + targets: [], + }, + create(context) { + let count = 0; + + return { + async LiquidRawTag(node: LiquidRawTag) { + if (node.name !== tagName) return; + + count += 1; + if (count === 1) return; + + context.report({ + message, + startIndex: node.blockStartPosition.start, + endIndex: node.blockStartPosition.end, + }); + }, + }; + }, + }; +} + +function sectionOrBlockOnlyUnlessAllowed(tagName: RawTagName): LiquidCheckDefinition { + const check = sectionOrBlockOnlyCheck(tagName); + + return { + ...check, + create(context) { + if (isSection(context.file.uri) || isBlock(context.file.uri)) return {}; + + return check.create(context); + }, + }; +} + +export const SchemaSectionOrBlockOnly = sectionOrBlockOnlyUnlessAllowed('schema'); +export const SchemaOncePerFile = oncePerFileCheck('schema'); +export const JavascriptSectionOrBlockOnly = sectionOrBlockOnlyUnlessAllowed('javascript'); +export const JavascriptOncePerFile = oncePerFileCheck('javascript'); +export const StylesheetSectionOrBlockOnly = sectionOrBlockOnlyUnlessAllowed('stylesheet'); +export const StylesheetOncePerFile = oncePerFileCheck('stylesheet'); diff --git a/packages/theme-check-common/src/checks/remote-asset/index.ts b/packages/theme-check-common/src/checks/remote-asset/index.ts index 31ff07da3..3baefd36d 100644 --- a/packages/theme-check-common/src/checks/remote-asset/index.ts +++ b/packages/theme-check-common/src/checks/remote-asset/index.ts @@ -1,5 +1,6 @@ import { HtmlRawNode, + HtmlSelfClosingElement, HtmlVoidElement, TextNode, LiquidVariable, @@ -14,7 +15,13 @@ import { LiquidHtmlNode, SchemaProp, } from '../../types'; -import { isAttr, isValuedHtmlAttribute, isNodeOfType, ValuedHtmlAttribute } from '../utils'; +import { + getHtmlNodeName, + isAttr, + isValuedHtmlAttribute, + isNodeOfType, + ValuedHtmlAttribute, +} from '../utils'; import { last } from '../../utils'; const RESOURCE_TAGS = ['img', 'link', 'source', 'script']; @@ -51,6 +58,10 @@ function isDataUri(url: string): boolean { return /^data:/i.test(url); } +function cleanUrlText(url: string): string { + return url.replace(/[“”‘’]/g, ''); +} + /** * Checks if the attribute value starts with a variable lookup. * When a value starts with a VariableLookup (e.g., {{ source.url }}, {{ image }}), @@ -89,8 +100,8 @@ function valueIsDefinitelyNotShopifyHosted( allowedDomains: string[] = [], ): boolean { return attr.value.some((node) => { - if (node.type === NodeTypes.TextNode && /^(https?:)?\/\//.test(node.value)) { - if (!isUrlHostedbyShopify(node.value, allowedDomains)) { + if (node.type === NodeTypes.TextNode && /^(https?:)?\/\//.test(cleanUrlText(node.value))) { + if (!isUrlHostedbyShopify(cleanUrlText(node.value), allowedDomains)) { return true; } } @@ -175,8 +186,9 @@ export const RemoteAsset: LiquidCheckDefinition = { create(context) { const allowedDomains = normaliseAllowedDomains(context.settings.allowedDomains || []); - function checkHtmlNode(node: HtmlVoidElement | HtmlRawNode) { - if (!RESOURCE_TAGS.includes(node.name)) return; + function checkHtmlNode(node: HtmlVoidElement | HtmlSelfClosingElement | HtmlRawNode) { + const nodeName = getHtmlNodeName(node); + if (!nodeName || !RESOURCE_TAGS.includes(nodeName)) return; const urlAttribute: ValuedHtmlAttribute | undefined = node.attributes .filter(isValuedHtmlAttribute) @@ -187,14 +199,14 @@ export const RemoteAsset: LiquidCheckDefinition = { const firstTextNode = urlAttribute.value.find( (node): node is TextNode => node.type === NodeTypes.TextNode, ); - if (firstTextNode && isHashUrl(firstTextNode.value)) return; - if (firstTextNode && isDataUri(firstTextNode.value)) return; + if (firstTextNode && isHashUrl(cleanUrlText(firstTextNode.value))) return; + if (firstTextNode && isDataUri(cleanUrlText(firstTextNode.value))) return; if (startsWithVariableLookup(urlAttribute)) return; const isShopifyUrl = urlAttribute.value .filter((node): node is TextNode => node.type === NodeTypes.TextNode) - .some((textNode) => isUrlHostedbyShopify(textNode.value, allowedDomains)); + .some((textNode) => isUrlHostedbyShopify(cleanUrlText(textNode.value), allowedDomains)); if (isShopifyUrl) return; @@ -265,6 +277,13 @@ export const RemoteAsset: LiquidCheckDefinition = { async HtmlVoidElement(node) { checkHtmlNode(node); }, + // The ported parser emits `HtmlSelfClosingElement` for self-closed + // void tags such as `` and ``, whereas the previous + // parser emitted `HtmlVoidElement` regardless of the trailing slash. + // Visit both so the check still fires on self-closing markup. + async HtmlSelfClosingElement(node) { + checkHtmlNode(node); + }, async HtmlRawNode(node) { checkHtmlNode(node); }, diff --git a/packages/theme-check-common/src/checks/required-layout-theme-object/index.spec.ts b/packages/theme-check-common/src/checks/required-layout-theme-object/index.spec.ts index b19930a8f..a3b4235ff 100644 --- a/packages/theme-check-common/src/checks/required-layout-theme-object/index.spec.ts +++ b/packages/theme-check-common/src/checks/required-layout-theme-object/index.spec.ts @@ -89,6 +89,34 @@ describe('Module: RequiredLayoutThemeObject', () => { expect(offenses).to.have.length(0); }); + it('should report for any layout/*.liquid file, not only layout/theme.liquid', async () => { + /* + * The check scope was broadened: LAYOUT_PATH_PATTERN now matches every + * +layout/*.liquid+ asset, not just +layout/theme.liquid+. A non-theme + * layout file missing the required objects must therefore be reported. + */ + const input = ` + + + + + + {{ content_for_layout }} + + + `; + + const offenses = await runLiquidCheck( + RequiredLayoutThemeObject, + input, + 'layout/checkout.liquid', + ); + expect(offenses).to.have.length(1); + expect(offenses[0].message).to.equal( + "The required object '{{ content_for_header }}' is missing in layout/theme.liquid", + ); + }); + it('should not report an error if the file is unparseable', async () => { const input = ` diff --git a/packages/theme-check-common/src/checks/required-layout-theme-object/index.ts b/packages/theme-check-common/src/checks/required-layout-theme-object/index.ts index 1400fd749..e8be6c524 100644 --- a/packages/theme-check-common/src/checks/required-layout-theme-object/index.ts +++ b/packages/theme-check-common/src/checks/required-layout-theme-object/index.ts @@ -1,8 +1,16 @@ -// src/checks/required-layout-theme-object/index.ts -import { HtmlElement, LiquidVariableLookup } from '@shopify/liquid-html-parser'; -import { ConfigTarget, LiquidCheckDefinition, Severity, SourceCodeType } from '../../types'; +import { ConfigTarget, Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import { type HtmlElement, type LiquidVariableLookup } from '@shopify/liquid-html-parser'; import { isHtmlTag } from '../utils'; +const LAYOUT_PATH_PATTERN = /^layout\/[^/]+\.liquid$/i; + +/** + * Copied from ../../types's RequiredLayoutThemeObject. + * + * When @editor/theme-check-common and @shopify/theme-check-common are merged, + * merge this fork back into the upstream check instead of keeping two + * layout-object implementations. + */ export const RequiredLayoutThemeObject: LiquidCheckDefinition = { meta: { code: 'RequiredLayoutThemeObject', @@ -20,7 +28,12 @@ export const RequiredLayoutThemeObject: LiquidCheckDefinition = { }, create(context) { - if (context.toRelativePath(context.file.uri) !== 'layout/theme.liquid') { + /** + * @editor/theme-check-common runs this upstream check for every layout + * asset, not only layout/theme.liquid. Merge this scope difference back + * into @shopify/theme-check-common when the packages are unified. + */ + if (!LAYOUT_PATH_PATTERN.test(context.toRelativePath(context.file.uri))) { return {}; } diff --git a/packages/theme-check-common/src/checks/unknown-block-setting/index.spec.ts b/packages/theme-check-common/src/checks/unknown-block-setting/index.spec.ts new file mode 100644 index 000000000..42d4c26a1 --- /dev/null +++ b/packages/theme-check-common/src/checks/unknown-block-setting/index.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { UnknownBlockSetting } from './index'; +import { runLiquidCheck } from '../../test'; + +const PRODUCT_BLOCK = [ + '{% schema %}', + '{"settings":[{"id":"foo","type":"text"},{"id":"bar","type":"text"}]}', + '{% endschema %}', + '
{{ block.content }}
', +].join('\n'); + +const NO_SCHEMA_BLOCK = '
{{ block.content }}
'; + +const EMPTY_SETTINGS_BLOCK = [ + '{% schema %}', + '{"settings":[]}', + '{% endschema %}', + '
{{ block.content }}
', +].join('\n'); + +const NO_SETTINGS_KEY_BLOCK = [ + '{% schema %}', + '{"name":"Product"}', + '{% endschema %}', + '
{{ block.content }}
', +].join('\n'); + +async function unknownSettingOffenses(template: string, blockSource?: string) { + const existingThemeFiles = + blockSource !== undefined ? { 'blocks/product.liquid': blockSource } : undefined; + + return runLiquidCheck( + UnknownBlockSetting, + template, + 'templates/test.liquid', + {}, + existingThemeFiles, + ); +} + +describe('UnknownBlockSetting', () => { + it('reports a block.settings. that is not a setting id', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.baz: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('baz'); + }); + + it('does not report a known setting', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.foo: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report a plain (non-system) arg', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', baz: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report block.content or block.name', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.content: c, block.name: 'n' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report a nested path (owned by the syntax-error layer)', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.foo.bar: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report an empty setting suffix', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.: 'x' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when the block file is missing', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'missing', block.settings.baz: 'x' %}x{% endblock %}", + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when the block has no schema', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.baz: 'x' %}x{% endblock %}", + NO_SCHEMA_BLOCK, + ); + + expect(offenses).toHaveLength(0); + }); + + it('reports against an explicit empty settings array', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.baz: 'x' %}x{% endblock %}", + EMPTY_SETTINGS_BLOCK, + ); + + expect(offenses).toHaveLength(1); + }); + + it('reports against a schema that omits the settings key', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.baz: 'x' %}x{% endblock %}", + NO_SETTINGS_KEY_BLOCK, + ); + + expect(offenses).toHaveLength(1); + }); + + it('reports each unknown setting', async () => { + const offenses = await unknownSettingOffenses( + "{% block 'product', block.settings.baz: 'a', block.settings.qux: 'b' %}x{% endblock %}", + PRODUCT_BLOCK, + ); + + expect(offenses).toHaveLength(2); + }); +}); diff --git a/packages/theme-check-common/src/checks/unknown-block-setting/index.ts b/packages/theme-check-common/src/checks/unknown-block-setting/index.ts new file mode 100644 index 000000000..b0717e132 --- /dev/null +++ b/packages/theme-check-common/src/checks/unknown-block-setting/index.ts @@ -0,0 +1,50 @@ +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { BlockMarkup } from '@shopify/liquid-html-parser'; +import { getBlockSchemaSettings } from '../common/block-schema'; + +const PREFIX = 'block.settings.'; + +export const UnknownBlockSetting: LiquidCheckDefinition = { + meta: { + code: 'UnknownBlockSetting', + name: 'Unknown Block Setting', + docs: { + description: + "Reports a block.settings. argument in a block tag where is not a setting id in the target block's schema.", + recommended: true, + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidTag(node) { + if (node.name !== 'block') return; + if (typeof node.markup === 'string') return; + + const markup = node.markup as BlockMarkup; + const blockName = markup.name.value; + const settings = await getBlockSchemaSettings(context, blockName); + if (!settings) return; + + for (const arg of markup.args) { + if (!arg.name.startsWith(PREFIX)) continue; + + const settingId = arg.name.slice(PREFIX.length); + if (settingId.length === 0) continue; + if (settingId.includes('.')) continue; + if (settings.has(settingId)) continue; + + context.report({ + message: `Unknown setting '${settingId}' referenced via 'block.settings.${settingId}' on block '${blockName}'.`, + startIndex: arg.position.start, + endIndex: arg.position.end, + }); + } + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/unrecognized-block-arguments/index.spec.ts b/packages/theme-check-common/src/checks/unrecognized-block-arguments/index.spec.ts new file mode 100644 index 000000000..09d45c08e --- /dev/null +++ b/packages/theme-check-common/src/checks/unrecognized-block-arguments/index.spec.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { UnrecognizedBlockArguments } from './index'; +import { runLiquidCheck } from '../../test'; + +const BUTTON_BLOCK = [ + '{% doc %}', + ' @param {String} [variant] - Button variant', + ' @param {String} [class] - Additional CSS classes', + ' @param {String} [url] - Button URL', + '{% enddoc %}', + '', +].join('\n'); + +const NO_DOC_BLOCK = ''; + +describe('UnrecognizedBlockArguments', () => { + it('reports unknown argument', async () => { + const offenses = await runLiquidCheck( + UnrecognizedBlockArguments, + "{% block 'button', variant: 'primary', size: 'large' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': BUTTON_BLOCK }, + ); + + expect(offenses).toHaveLength(1); + expect(offenses[0].message).toContain('size'); + }); + + it('does not report known arguments', async () => { + const offenses = await runLiquidCheck( + UnrecognizedBlockArguments, + "{% block 'button', variant: 'primary', class: 'mb-2' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': BUTTON_BLOCK }, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report system arguments', async () => { + const offenses = await runLiquidCheck( + UnrecognizedBlockArguments, + "{% block 'button', block.settings.variant: 'xl', block.content: content %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': BUTTON_BLOCK }, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when block file has no doc tag', async () => { + const offenses = await runLiquidCheck( + UnrecognizedBlockArguments, + "{% block 'button', unknown: 'val' %}x{% endblock %}", + 'templates/test.liquid', + {}, + { 'blocks/button.liquid': NO_DOC_BLOCK }, + ); + + expect(offenses).toHaveLength(0); + }); + + it('does not report when block file does not exist', async () => { + const offenses = await runLiquidCheck( + UnrecognizedBlockArguments, + "{% block 'missing', foo: 'bar' %}x{% endblock %}", + 'templates/test.liquid', + ); + + expect(offenses).toHaveLength(0); + }); +}); diff --git a/packages/theme-check-common/src/checks/unrecognized-block-arguments/index.ts b/packages/theme-check-common/src/checks/unrecognized-block-arguments/index.ts new file mode 100644 index 000000000..a173cf7ce --- /dev/null +++ b/packages/theme-check-common/src/checks/unrecognized-block-arguments/index.ts @@ -0,0 +1,45 @@ +import { Severity, SourceCodeType, type LiquidCheckDefinition } from '../../types'; +import type { BlockMarkup } from '@shopify/liquid-html-parser'; +import { getBlockDocParams, isSystemArg } from '../common/block-doc'; + +export const UnrecognizedBlockArguments: LiquidCheckDefinition = { + meta: { + code: 'UnrecognizedBlockArguments', + name: 'Unrecognized Block Arguments', + docs: { + description: + "Reports arguments in a block tag that are not declared in the block's {% doc %} tag.", + recommended: true, + url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/unrecognized-block-arguments', + }, + type: SourceCodeType.LiquidHtml, + severity: Severity.WARNING, + schema: {}, + targets: [], + }, + + create(context) { + return { + async LiquidTag(node) { + if (node.name !== 'block') return; + if (typeof node.markup === 'string') return; + + const markup = node.markup as BlockMarkup; + const blockName = markup.name.value; + const docParams = await getBlockDocParams(context, blockName); + if (!docParams) return; + + for (const arg of markup.args) { + if (isSystemArg(arg.name)) continue; + if (docParams.has(arg.name)) continue; + + context.report({ + message: `Unknown argument '${arg.name}' in block tag for '${blockName}'.`, + startIndex: arg.position.start, + endIndex: arg.position.end, + }); + } + }, + }; + }, +}; diff --git a/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts b/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts index e7588831d..16eef77e5 100644 --- a/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts +++ b/packages/theme-check-common/src/checks/unrecognized-render-snippet-arguments/index.ts @@ -34,7 +34,7 @@ export const UnrecognizedRenderSnippetArguments: LiquidCheckDefinition = { const variable = node.variable; if (alias && !liquidDocParameters.has(alias.value) && variable) { - const startIndex = variable.position.start + 1; + const startIndex = variable.position.start; context.report({ message: `Unknown argument '${alias.value}' in render tag for snippet '${snippetName}'.`, @@ -45,7 +45,7 @@ export const UnrecognizedRenderSnippetArguments: LiquidCheckDefinition = { message: `Remove '${alias.value}'`, fix: (fixer: any) => { if (variable) { - return fixer.remove(variable.position.start, alias.position.end); + return fixer.remove(variable.position.start - 1, alias.position.end); } }, }, diff --git a/packages/theme-check-common/src/checks/utils.ts b/packages/theme-check-common/src/checks/utils.ts index 73a89500c..bb7a483c1 100644 --- a/packages/theme-check-common/src/checks/utils.ts +++ b/packages/theme-check-common/src/checks/utils.ts @@ -2,6 +2,9 @@ import { Position, NodeTypes, HtmlElement, + HtmlSelfClosingElement, + HtmlVoidElement, + HtmlRawNode, TextNode, AttrEmpty, AttrSingleQuoted, @@ -31,6 +34,25 @@ export function isLiquidBranch(node: LiquidHtmlNode): node is LiquidBranch { return isNodeOfType(NodeTypes.LiquidBranch, node); } +/** + * Returns the static tag name of an HTML node as a string. + * + * Void and raw nodes (``, `