Skip to content

Commit e75654e

Browse files
committed
Update liquid-html-syntax-error check
1 parent 2063dd8 commit e75654e

42 files changed

Lines changed: 5998 additions & 17 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/blue-comics-return.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopify/theme-check-common": patch
3+
---
4+
5+
Improve LiquidHTMLSyntaxError parity with Ruby Liquid syntax errors

packages/theme-check-common/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"@types/line-column": "^1.0.0",
4343
"@types/lodash": "^4.17.20",
4444
"@types/postcss-safe-parser": "^5.0.4",
45-
"@vitest/expect": "2.1.9"
45+
"@vitest/expect": "2.1.9",
46+
"yaml": "^2.8.3"
4647
}
4748
}

packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.spec.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ describe('Module: InvalidConditionalBooleanExpression', () => {
3131
'{% if 1 %}hello{% endif %}',
3232
'{% if 0 %}hello{% endif %}',
3333
"{% if 'string' %}hello{% endif %}",
34-
'{% if contains %}hello{% endif %}',
3534
];
3635

3736
for (const testCase of testCases) {
@@ -370,6 +369,39 @@ describe('Module: InvalidConditionalBooleanExpression', () => {
370369
}
371370
});
372371

372+
it('should report an offense for invalid operators in unless expressions', async () => {
373+
const testCases = [
374+
{
375+
source: '{% unless x && y %}{% endunless %}',
376+
expectedMessage:
377+
"Conditional is invalid. Anything after 'x' will be ignored. Use 'and'/'or' instead of '&&'/'||' for multiple conditions",
378+
},
379+
{
380+
source: '{% unless x || y %}{% endunless %}',
381+
expectedMessage:
382+
"Conditional is invalid. Anything after 'x' will be ignored. Use 'and'/'or' instead of '&&'/'||' for multiple conditions",
383+
},
384+
{
385+
source: '{% unless x startswith "foo" %}{% endunless %}',
386+
expectedMessage:
387+
"Conditional is invalid. Anything after 'x' will be ignored: 'startswith \"foo\"'",
388+
},
389+
{
390+
source: '{% unless x === y %}{% endunless %}',
391+
expectedMessage: "Conditional is invalid. Anything after 'x' will be ignored: '=== y'",
392+
},
393+
];
394+
395+
for (const testCase of testCases) {
396+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, testCase.source);
397+
expect(offenses).to.have.length(1);
398+
expect(offenses[0].message).to.equal(`Syntax is not supported: ${testCase.expectedMessage}`);
399+
400+
const fixed = applyFix(testCase.source, offenses[0]);
401+
expect(fixed).to.equal('{% unless x %}{% endunless %}');
402+
}
403+
});
404+
373405
it('should NOT report an offense for valid logical operators', async () => {
374406
const validCases = [
375407
'{% if price > 100 and discount < 50 %}hello{% endif %}',

packages/theme-check-common/src/checks/liquid-html-syntax-error/checks/InvalidConditionalNode.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface ExpressionIssue {
1717
const TOKEN_PATTERNS = {
1818
logical: /^(and|or)$/,
1919
comparison: /^(==|!=|>=|<=|>|<|contains)$/,
20+
invalidOperator: /^(&&|\|\||===|startswith)$/,
2021
invalid: /^[@#$&]$/,
2122
literal: /^(['"][^'"]*['"]|\d+(?:\.\d+)?|true|false|nil|empty|blank)$/,
2223
} as const;
@@ -68,6 +69,10 @@ function isOperatorToken(token: Token): boolean {
6869
return token.type === 'logical' || token.type === 'comparison';
6970
}
7071

72+
function isJavaScriptLogicalOperator(token: Token): boolean {
73+
return token.value === '&&' || token.value === '||';
74+
}
75+
7176
function checkInvalidStartingToken(tokens: Token[]): ExpressionIssue | null {
7277
const firstToken = tokens[0];
7378
if (firstToken.type === 'invalid' || firstToken.type === 'comparison') {
@@ -146,6 +151,38 @@ function checkLaxParsingIssues(tokens: Token[]): ExpressionIssue | null {
146151
return null;
147152
}
148153

154+
function checkInvalidOperatorsAfterValue(tokens: Token[]): ExpressionIssue | null {
155+
for (let i = 1; i < tokens.length; i++) {
156+
const current = tokens[i];
157+
const previous = tokens[i - 1];
158+
159+
if (!isValueToken(previous) || current.type !== 'invalidOperator') continue;
160+
161+
const validExpr = tokens
162+
.slice(0, i)
163+
.map((t) => t.value)
164+
.join(' ');
165+
const ignored = tokens
166+
.slice(i)
167+
.map((t) => t.value)
168+
.join(' ');
169+
170+
if (isJavaScriptLogicalOperator(current)) {
171+
return {
172+
message: `Conditional is invalid. Anything after '${validExpr}' will be ignored. Use 'and'/'or' instead of '&&'/'||' for multiple conditions`,
173+
fix: validExpr,
174+
};
175+
}
176+
177+
return {
178+
message: `Conditional is invalid. Anything after '${validExpr}' will be ignored: '${ignored}'`,
179+
fix: validExpr,
180+
};
181+
}
182+
183+
return null;
184+
}
185+
149186
function analyzeConditionalExpression(markup: string): ExpressionIssue | null {
150187
const trimmed = markup.trim();
151188
if (!trimmed) return null;
@@ -160,6 +197,7 @@ function analyzeConditionalExpression(markup: string): ExpressionIssue | null {
160197
return (
161198
checkInvalidStartingToken(tokens) ||
162199
checkTrailingTokensAfterComparison(tokens) ||
163-
checkLaxParsingIssues(tokens)
200+
checkLaxParsingIssues(tokens) ||
201+
checkInvalidOperatorsAfterValue(tokens)
164202
);
165203
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { LiquidTag, AssignMarkup } from './parser-compat';
2+
import type { Context } from './context';
3+
import {
4+
variableHasBareArrayAccess,
5+
hasSkippedCharacters,
6+
hasSkippedPrefixCharacters,
7+
hasRubyAcceptedEmptyFirstFilterArgument,
8+
hasRubyAcceptedEmptyAssignRhs,
9+
hasRubyAcceptedAssignLhsExtraIdentifier,
10+
hasRubyAcceptedAssignLhsUnclosedQuote,
11+
variableHasBareContainsValueExpression,
12+
rawMarkup,
13+
} from './utils';
14+
15+
export function checkAssignTag(node: LiquidTag, context: Context): void {
16+
if (typeof node.markup === 'string') {
17+
if (hasRubyAcceptedEmptyAssignRhs(node.markup)) return;
18+
if (hasRubyAcceptedEmptyFirstFilterArgument(node.markup)) return;
19+
if (hasRubyAcceptedAssignLhsExtraIdentifier(node.markup)) return;
20+
if (hasRubyAcceptedAssignLhsUnclosedQuote(node.markup)) return;
21+
22+
context.report({
23+
message: `Syntax error in 'assign' tag`,
24+
startIndex: node.position.start,
25+
endIndex: node.position.end,
26+
});
27+
return;
28+
}
29+
30+
const markup = node.markup as AssignMarkup;
31+
32+
if (variableHasBareArrayAccess(markup.value)) {
33+
context.report({
34+
message: 'Bare bracket access is not allowed',
35+
startIndex: node.position.start,
36+
endIndex: node.position.end,
37+
});
38+
return;
39+
}
40+
41+
if (variableHasBareContainsValueExpression(markup.value)) {
42+
context.report({
43+
message: `Syntax error in 'assign' tag`,
44+
startIndex: node.position.start,
45+
endIndex: node.position.end,
46+
});
47+
return;
48+
}
49+
50+
if (hasSkippedPrefixCharacters(node.source, node.markupPosition.start, markup.position.start)) {
51+
context.report({
52+
message: `Syntax error in 'assign' tag`,
53+
startIndex: node.position.start,
54+
endIndex: node.position.end,
55+
});
56+
return;
57+
}
58+
59+
const raw = rawMarkup(node);
60+
if (hasRubyAcceptedEmptyAssignRhs(raw)) return;
61+
if (hasRubyAcceptedEmptyFirstFilterArgument(raw)) return;
62+
if (hasRubyAcceptedAssignLhsExtraIdentifier(raw)) return;
63+
if (hasRubyAcceptedAssignLhsUnclosedQuote(raw)) return;
64+
65+
const parsedRaw = node.source.slice(markup.position.start, node.markupPosition.end);
66+
if (hasSkippedCharacters(parsedRaw)) {
67+
context.report({
68+
message: `Syntax error in 'assign' tag`,
69+
startIndex: node.position.start,
70+
endIndex: node.position.end,
71+
});
72+
}
73+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { LiquidTag } from './parser-compat';
2+
import { builtinTags } from './builtin-tags';
3+
import type { Context } from './context';
4+
5+
const KNOWN_LIQUID_TAGS = new Set(['#', ...Object.keys(builtinTags)]);
6+
7+
export function checkBaseTag(node: LiquidTag, context: Context): void {
8+
if ('reason' in node && typeof node.reason === 'string') {
9+
context.report({
10+
message: node.reason,
11+
startIndex: node.position.start,
12+
endIndex: node.position.end,
13+
});
14+
return;
15+
}
16+
17+
if (isUnknownTagInsideLiquidBlock(node)) {
18+
context.report({
19+
message: `Unknown tag '${node.name}'`,
20+
startIndex: node.position.start,
21+
endIndex: node.position.end,
22+
});
23+
}
24+
}
25+
26+
function isUnknownTagInsideLiquidBlock(node: LiquidTag): boolean {
27+
// Statements inside `{% liquid %}` do not have their own `{%` delimiter.
28+
return !node.source.startsWith('{%', node.position.start) && !KNOWN_LIQUID_TAGS.has(node.name);
29+
}

0 commit comments

Comments
 (0)