Skip to content

Commit 5ff6277

Browse files
authored
Merge pull request #1040 from Shopify/invalid-range-in-loops
Detect and report invalid ranges in loops
2 parents a3b8b3b + c222836 commit 5ff6277

File tree

4 files changed

+192
-1
lines changed

4 files changed

+192
-1
lines changed

.changeset/new-tomatoes-sort.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+
Detect and report invalid ranges in loops
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { expect, describe, it, vi, beforeEach } from 'vitest';
2+
import { applyFix, runLiquidCheck } from '../../../test';
3+
import { LiquidHTMLSyntaxError } from '../index';
4+
import { toLiquidAST } from '@shopify/liquid-html-parser';
5+
import { INVALID_LOOP_RANGE_MESSAGE } from './InvalidLoopRange';
6+
7+
vi.mock('@shopify/liquid-html-parser', async (importOriginal) => {
8+
const original: any = await importOriginal();
9+
10+
return {
11+
...original,
12+
13+
// we will be mocked later on
14+
toLiquidAST: vi.fn().mockImplementation((source, options) => {
15+
return original.toLiquidAST(source, options);
16+
}),
17+
};
18+
});
19+
20+
describe('detectInvalidLoopRange', async () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks();
23+
});
24+
25+
it('should not report when range is valid', async () => {
26+
const testCases = [
27+
`{% for i in (1..10) %}{% endfor %}`,
28+
`{% tablerow x in (a..b) %}{% endtablerow %}`,
29+
];
30+
31+
for (const sourceCode of testCases) {
32+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
33+
expect(offenses).to.have.length(0);
34+
}
35+
});
36+
37+
it('should report when there is an extra `.` character in range', async () => {
38+
const testCases = [
39+
[`{% for i in (1...10) %}{% endfor %}`, '{% for i in (1..10) %}{% endfor %}'],
40+
[
41+
`{% tablerow x in (a...b) %}{% endtablerow %}`,
42+
'{% tablerow x in (a..b) %}{% endtablerow %}',
43+
],
44+
[
45+
`{% tablerow x in (a .. b) %}{% endtablerow %}`,
46+
'{% tablerow x in (a..b) %}{% endtablerow %}',
47+
],
48+
[
49+
`{% tablerow x in ( a..b ) %}{% endtablerow %}`,
50+
'{% tablerow x in (a..b) %}{% endtablerow %}',
51+
],
52+
];
53+
54+
for (const [sourceCode, expected] of testCases) {
55+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
56+
expect(offenses).to.have.length(1);
57+
expect(offenses[0].message).to.equal(INVALID_LOOP_RANGE_MESSAGE);
58+
59+
const fixed = applyFix(sourceCode, offenses[0]);
60+
expect(fixed).to.equal(expected);
61+
}
62+
});
63+
64+
it('should report when there are multiple instances of the error', async () => {
65+
const sourceCode = `{% for i in (1...10) %}{% endfor %} {% tablerow x in (a...b) %}{% endtablerow %}`;
66+
67+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
68+
expect(offenses).to.have.length(2);
69+
expect(offenses[0].message).to.equal(INVALID_LOOP_RANGE_MESSAGE);
70+
expect(offenses[1].message).to.equal(INVALID_LOOP_RANGE_MESSAGE);
71+
72+
expect(toLiquidAST).toHaveBeenCalledTimes(2);
73+
expect(toLiquidAST).toHaveBeenCalledWith(`{% for i in (1..10) %}{% endfor %}`, {
74+
allowUnclosedDocumentNode: false,
75+
mode: 'strict',
76+
});
77+
expect(toLiquidAST).toHaveBeenCalledWith(`{% tablerow x in (a..b) %}{% endtablerow %}`, {
78+
allowUnclosedDocumentNode: false,
79+
mode: 'strict',
80+
});
81+
82+
const fixed = applyFix(sourceCode, offenses[0]);
83+
expect(fixed).to.equal(
84+
`{% for i in (1..10) %}{% endfor %} {% tablerow x in (a...b) %}{% endtablerow %}`,
85+
);
86+
87+
const fixed2 = applyFix(sourceCode, offenses[1]);
88+
expect(fixed2).to.equal(
89+
`{% for i in (1...10) %}{% endfor %} {% tablerow x in (a..b) %}{% endtablerow %}`,
90+
);
91+
});
92+
93+
it('should not report when the fixed code produces an invalid AST', async () => {
94+
const sourceCode = `{% for i in (1..10) %}{% endfor %}`;
95+
96+
vi.mocked(toLiquidAST).mockImplementation((_source, _options) => {
97+
throw new SyntaxError('Invalid AST');
98+
});
99+
100+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
101+
expect(offenses).to.have.length(0);
102+
});
103+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { LiquidTag, LiquidTagFor, LiquidTagTablerow, NodeTypes } from '@shopify/liquid-html-parser';
2+
import { Problem, SourceCodeType } from '../../..';
3+
import { ensureValidAst } from './utils';
4+
import { isLoopLiquidTag } from '../../utils';
5+
6+
// Lax parser does NOT complain about leading/trailing spaces inside ranges (e.g. `(1 .. 10 )`) and
7+
// within the parenthesis (e.g. `( 1 .. 10 )`), but fails to render the liquid when using the gem.
8+
// Strict parser does NOT complain, but still renders it.
9+
// To avoid any issues, we will remove extra spaces.
10+
const RANGE_MARKUP_REGEX = /\(\s*(\w+)\s*(\.{2,})\s*(\w+)\s*\)/;
11+
12+
export const INVALID_LOOP_RANGE_MESSAGE =
13+
'Ranges must be in the following format: (<start>..<end>)';
14+
15+
export function detectInvalidLoopRange(
16+
node: LiquidTag,
17+
): Problem<SourceCodeType.LiquidHtml> | undefined {
18+
if (node.type === NodeTypes.LiquidTag && !isLoopLiquidTag(node)) {
19+
return;
20+
}
21+
22+
const markup = (node as LiquidTagFor | LiquidTagTablerow).markup;
23+
24+
if (!markup) {
25+
return;
26+
}
27+
28+
if (typeof markup === 'string') {
29+
return validateMarkup(node, markup);
30+
}
31+
32+
if (markup.collection.type === NodeTypes.Range) {
33+
return validateMarkup(
34+
node,
35+
node.source.slice(markup.collection.position.start, markup.collection.position.end),
36+
);
37+
}
38+
}
39+
40+
function validateMarkup(
41+
node: LiquidTag,
42+
markup: string,
43+
): Problem<SourceCodeType.LiquidHtml> | undefined {
44+
const match = markup.match(RANGE_MARKUP_REGEX);
45+
if (!match || match.index === undefined) {
46+
return;
47+
}
48+
49+
const [, start, , end] = match;
50+
51+
const expectedRangeMarkup = `(${start}..${end})`;
52+
53+
if (markup.slice(match.index) === expectedRangeMarkup) {
54+
return;
55+
}
56+
57+
const markupIndex = node.source.indexOf(markup, node.position.start);
58+
59+
const startIndex = markupIndex + match.index;
60+
const endIndex = markupIndex + markup.length;
61+
62+
if (
63+
!ensureValidAst(
64+
node.source.slice(node.position.start, startIndex) +
65+
expectedRangeMarkup +
66+
node.source.slice(endIndex, node.position.end),
67+
)
68+
) {
69+
// If the new AST is invalid, we don't want to auto-fix it
70+
return;
71+
}
72+
73+
return {
74+
message: INVALID_LOOP_RANGE_MESSAGE,
75+
startIndex,
76+
endIndex,
77+
fix: (corrector) => {
78+
corrector.replace(startIndex, endIndex, expectedRangeMarkup);
79+
},
80+
};
81+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { detectMultipleAssignValues } from './checks/MultipleAssignValues';
44
import { detectInvalidBooleanExpressions } from './checks/InvalidBooleanExpressions';
55
import { detectInvalidEchoValue } from './checks/InvalidEchoValue';
66
import { detectInvalidConditionalNode } from './checks/InvalidConditionalNode';
7+
import { detectInvalidLoopRange } from './checks/InvalidLoopRange';
78

89
type LineColPosition = {
910
line: number;
@@ -54,7 +55,8 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
5455
const problem =
5556
detectMultipleAssignValues(node) ||
5657
detectInvalidEchoValue(node) ||
57-
detectInvalidConditionalNode(node);
58+
detectInvalidConditionalNode(node) ||
59+
detectInvalidLoopRange(node);
5860

5961
if (!problem) {
6062
return;

0 commit comments

Comments
 (0)