Skip to content

Commit 3cada1c

Browse files
authored
Merge pull request #1054 from Shopify/invalid-pipe-check
Invalid pipe check
2 parents f00a162 + a05e072 commit 3cada1c

File tree

7 files changed

+293
-3
lines changed

7 files changed

+293
-3
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('detectInvalidEchoValue', async () => {
2525
});
2626

2727
it('should not report when there are no filters provided', async () => {
28-
const sourceCode = `{% echo '123' | %}`;
28+
const sourceCode = `{% echo '123' %}`;
2929
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
3030
expect(offenses).to.have.length(0);
3131
});

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ async function detectInvalidFilterNameInMarkup(
5757
const filterEndIndex = match.index! + match[0].length;
5858
const afterFilter = trimmedMarkup.slice(filterEndIndex);
5959

60+
// This regex finds invalid trailing characters after a filter name using lookaheads:
61+
// 1. Skip valid syntax like ": parameter" or "| nextfilter"
62+
// 2. Capture any junk characters that shouldn't be there
63+
// 3. Stop before valid boundaries like colons or pipes
64+
// e.g. "upcase xyz" finds "xyz", but "upcase | downcase" is ignored as valid
6065
const invalidSegment = afterFilter.match(
61-
/^(?!\s*(?::|$|\|\s*[a-zA-Z]))([^:|]+?)(?=\s*(?::|$|\|))/,
66+
/^(?!\s*(?::|$|\|\s*[a-zA-Z]|\|\s*\||\s*\|\s*(?:[}%]|$)))([^:|]+?)(?=\s*(?::|$|\|))/,
6267
)?.[1];
6368
if (!invalidSegment) {
6469
continue;
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { runLiquidCheck, applyFix } from '../../../test';
3+
import { LiquidHTMLSyntaxError } from '../index';
4+
5+
describe('Module: InvalidPipeSyntax', () => {
6+
describe('Double pipe patterns', () => {
7+
it('should detect and fix double pipes in variable output', async () => {
8+
const sourceCode = `{{ 'hello' | upcase | | downcase }}`;
9+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
10+
expect(offenses).toHaveLength(1);
11+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
12+
expect(offenses[0].fix).toBeDefined();
13+
14+
const fixedCode = applyFix(sourceCode, offenses[0]);
15+
expect(fixedCode).toBe(`{{ 'hello' | upcase | downcase }}`);
16+
});
17+
18+
it('should detect and fix double pipes with extra whitespace', async () => {
19+
const sourceCode = `{{ 'hello' | upcase | | downcase }}`;
20+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
21+
expect(offenses).toHaveLength(1);
22+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
23+
expect(offenses[0].fix).toBeDefined();
24+
25+
const fixedCode = applyFix(sourceCode, offenses[0]);
26+
expect(fixedCode).toBe(`{{ 'hello' | upcase | downcase }}`);
27+
});
28+
29+
it('should detect and fix double pipes in assign tag', async () => {
30+
const sourceCode = `{% assign result = 'hello' | upcase | | downcase %}`;
31+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
32+
expect(offenses).toHaveLength(1);
33+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
34+
expect(offenses[0].fix).toBeDefined();
35+
36+
const fixedCode = applyFix(sourceCode, offenses[0]);
37+
expect(fixedCode).toBe(`{% assign result = 'hello' | upcase | downcase %}`);
38+
});
39+
40+
it('should detect and fix double pipes in echo tag', async () => {
41+
const sourceCode = `{% echo 'hello' | upcase | | downcase %}`;
42+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
43+
expect(offenses).toHaveLength(1);
44+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
45+
expect(offenses[0].fix).toBeDefined();
46+
47+
const fixedCode = applyFix(sourceCode, offenses[0]);
48+
expect(fixedCode).toBe(`{% echo 'hello' | upcase | downcase %}`);
49+
});
50+
51+
it('should handle multiple double pipes in one expression', async () => {
52+
const sourceCode = `{{ 'hello' | upcase | | downcase | | reverse }}`;
53+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
54+
expect(offenses).toHaveLength(2);
55+
56+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
57+
expect(offenses[1].message).toContain('Remove extra `|` character(s)');
58+
59+
expect(offenses[0].fix).toBeDefined();
60+
expect(offenses[1].fix).toBeDefined();
61+
});
62+
});
63+
64+
describe('Trailing pipe patterns', () => {
65+
it('should detect and fix trailing pipe in variable output', async () => {
66+
const sourceCode = `{{ 'hello' | upcase | }}`;
67+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
68+
expect(offenses).toHaveLength(1);
69+
expect(offenses[0].message).toContain('Remove the trailing `|` character');
70+
expect(offenses[0].fix).toBeDefined();
71+
72+
const fixedCode = applyFix(sourceCode, offenses[0]);
73+
expect(fixedCode).toBe(`{{ 'hello' | upcase }}`);
74+
});
75+
76+
it('should detect and fix trailing pipe in assign tag', async () => {
77+
const sourceCode = `{% assign result = 'hello' | upcase | %}`;
78+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
79+
expect(offenses).toHaveLength(1);
80+
expect(offenses[0].message).toContain('Remove the trailing `|` character');
81+
expect(offenses[0].fix).toBeDefined();
82+
83+
const fixedCode = applyFix(sourceCode, offenses[0]);
84+
expect(fixedCode).toBe(`{% assign result = 'hello' | upcase %}`);
85+
});
86+
87+
it('should detect and fix trailing pipe in echo tag', async () => {
88+
const sourceCode = `{% echo 'hello' | upcase | %}`;
89+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
90+
expect(offenses).toHaveLength(1);
91+
expect(offenses[0].message).toContain('Remove the trailing `|` character');
92+
expect(offenses[0].fix).toBeDefined();
93+
94+
const fixedCode = applyFix(sourceCode, offenses[0]);
95+
expect(fixedCode).toBe(`{% echo 'hello' | upcase %}`);
96+
});
97+
98+
it('should detect and fix multiple trailing pipes', async () => {
99+
const sourceCode = `{{ 'hello' | upcase | | }}`;
100+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
101+
expect(offenses).toHaveLength(2); // Double pipe AND trailing pipe
102+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
103+
expect(offenses[1].message).toContain('Remove the trailing `|` character');
104+
expect(offenses[0].fix).toBeDefined();
105+
expect(offenses[1].fix).toBeDefined();
106+
});
107+
});
108+
109+
describe('Complex pipe scenarios', () => {
110+
it('should handle mixed valid and invalid pipes', async () => {
111+
const sourceCode = `{{ 'hello' | upcase | | downcase | reverse | }}`;
112+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
113+
expect(offenses).toHaveLength(2);
114+
115+
// First should be double pipe
116+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
117+
// Second should be trailing pipe
118+
expect(offenses[1].message).toContain('Remove the trailing `|` character');
119+
});
120+
121+
it('should not interfere with valid filter parameter syntax', async () => {
122+
const sourceCode = `{{ 'hello' | append: 'world' | upcase }}`;
123+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
124+
expect(offenses).toHaveLength(0);
125+
});
126+
127+
it('should handle liquid tag with mixed pipe issues', async () => {
128+
const sourceCode = `{% liquid
129+
assign foo = 'test' | upcase | | downcase |
130+
echo bar | reverse
131+
%}`;
132+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
133+
expect(offenses).toHaveLength(2);
134+
expect(offenses[0].message).toContain('Remove extra `|` character(s)');
135+
expect(offenses[1].message).toContain('Remove the trailing `|` character');
136+
});
137+
});
138+
139+
describe('Valid syntax should not be flagged', () => {
140+
it('should not report on valid filter chains', async () => {
141+
const sourceCode = `{{ 'hello' | upcase | append: 'world' | downcase }}`;
142+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
143+
expect(offenses).toHaveLength(0);
144+
});
145+
146+
it('should not report on simple filters', async () => {
147+
const sourceCode = `{{ 'hello' | upcase }}`;
148+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
149+
expect(offenses).toHaveLength(0);
150+
});
151+
152+
it('should not report on filters with arguments', async () => {
153+
const sourceCode = `{{ product.title | append: ' - ' | append: shop.name }}`;
154+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
155+
expect(offenses).toHaveLength(0);
156+
});
157+
158+
it('should not report on valid assign with filters', async () => {
159+
const sourceCode = `{% assign title = product.title | upcase | truncate: 50 %}`;
160+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
161+
expect(offenses).toHaveLength(0);
162+
});
163+
164+
it('should not report on valid echo with filters', async () => {
165+
const sourceCode = `{% echo product.title | upcase | truncate: 50 %}`;
166+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
167+
expect(offenses).toHaveLength(0);
168+
});
169+
});
170+
171+
describe('Edge cases', () => {
172+
it('should handle pipes in string literals correctly', async () => {
173+
const sourceCode = `{{ 'hello | world' | upcase }}`;
174+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
175+
expect(offenses).toHaveLength(0);
176+
});
177+
178+
it('should not report on conditional expressions with pipes', async () => {
179+
const sourceCode = `{{ product.title | default: 'No title' }}`;
180+
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
181+
expect(offenses).toHaveLength(0);
182+
});
183+
});
184+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { LiquidVariableOutput, LiquidTag, NodeTypes, NamedTags } from '@shopify/liquid-html-parser';
2+
import { Problem, SourceCodeType } from '../../..';
3+
import { INVALID_SYNTAX_MESSAGE } from './utils';
4+
5+
export async function detectInvalidPipeSyntax(
6+
node: LiquidVariableOutput | LiquidTag,
7+
): Promise<Problem<SourceCodeType.LiquidHtml>[]> {
8+
if (node.type === NodeTypes.LiquidVariableOutput) {
9+
if (typeof node.markup !== 'string') {
10+
return [];
11+
}
12+
return detectPipeSyntaxInMarkup(node, node.markup);
13+
}
14+
15+
if (node.type === NodeTypes.LiquidTag) {
16+
if (node.name === NamedTags.echo && typeof node.markup !== 'string') {
17+
return [];
18+
}
19+
if (node.name === NamedTags.assign && typeof node.markup !== 'string') {
20+
return [];
21+
}
22+
if (
23+
typeof node.markup === 'string' &&
24+
(node.name === NamedTags.echo || node.name === NamedTags.assign)
25+
) {
26+
return detectPipeSyntaxInMarkup(node, node.markup);
27+
}
28+
}
29+
30+
return [];
31+
}
32+
33+
async function detectPipeSyntaxInMarkup(
34+
node: LiquidVariableOutput | LiquidTag,
35+
markup: string,
36+
): Promise<Problem<SourceCodeType.LiquidHtml>[]> {
37+
const problems: Problem<SourceCodeType.LiquidHtml>[] = [];
38+
const trimmedMarkup = markup.trim();
39+
40+
// Check for multiple consecutive pipes
41+
const extraPipesPattern = /(\|\s*){2,}/g;
42+
const extraPipesMatches = Array.from(trimmedMarkup.matchAll(extraPipesPattern));
43+
44+
for (const match of extraPipesMatches) {
45+
const markupStartInSource = node.source.indexOf(markup, node.position.start);
46+
if (markupStartInSource === -1) {
47+
continue;
48+
}
49+
const problemStartInSource = markupStartInSource + match.index!;
50+
const problemEndInSource = problemStartInSource + match[0].length;
51+
52+
problems.push({
53+
message: `${INVALID_SYNTAX_MESSAGE}. Remove extra \`|\` character(s).`,
54+
startIndex: problemStartInSource,
55+
endIndex: problemEndInSource,
56+
fix: (corrector) => {
57+
corrector.replace(problemStartInSource, problemEndInSource, '| ');
58+
},
59+
});
60+
}
61+
62+
// Check for trailing pipes
63+
const trailingPipePattern = /\s*\|\s*$/;
64+
const trailingPipeMatch = trimmedMarkup.match(trailingPipePattern);
65+
66+
if (trailingPipeMatch) {
67+
const markupStartInSource = node.source.indexOf(markup, node.position.start);
68+
if (markupStartInSource === -1) {
69+
return problems;
70+
}
71+
const trailingPipeStartInMarkup = trailingPipeMatch.index!;
72+
const problemStartInSource = markupStartInSource + trailingPipeStartInMarkup;
73+
const problemEndInSource = problemStartInSource + trailingPipeMatch[0].length;
74+
75+
problems.push({
76+
message: `${INVALID_SYNTAX_MESSAGE}. Remove the trailing \`|\` character.`,
77+
startIndex: problemStartInSource,
78+
endIndex: problemEndInSource,
79+
fix: (corrector) => {
80+
corrector.replace(problemStartInSource, problemEndInSource, '');
81+
},
82+
});
83+
}
84+
85+
return problems;
86+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ describe('detectMultipleAssignValues', async () => {
3535
});
3636

3737
it('should not report when there are no filters provided', async () => {
38-
const sourceCode = `{% assign foo = '123' | %}`;
38+
const sourceCode = `{% assign foo = '123' %}`;
3939
const offenses = await runLiquidCheck(LiquidHTMLSyntaxError, sourceCode);
4040
expect(offenses).to.have.length(0);
4141
});

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ export function detectMultipleAssignValues(
7171
return;
7272
}
7373

74+
if (endIndex <= startIndex) {
75+
return;
76+
}
77+
7478
return {
7579
message: INVALID_SYNTAX_MESSAGE,
7680
startIndex,

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { detectInvalidLoopRange } from './checks/InvalidLoopRange';
88
import { detectInvalidLoopArguments } from './checks/InvalidLoopArguments';
99
import { detectConditionalNodeUnsupportedParenthesis } from './checks/InvalidConditionalNodeParenthesis';
1010
import { detectInvalidFilterName } from './checks/InvalidFilterName';
11+
import { detectInvalidPipeSyntax } from './checks/InvalidPipeSyntax';
1112

1213
type LineColPosition = {
1314
line: number;
@@ -80,6 +81,11 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
8081
if (filterProblems.length > 0) {
8182
filterProblems.forEach((filterProblem) => context.report(filterProblem));
8283
}
84+
85+
const pipeProblems = await detectInvalidPipeSyntax(node);
86+
if (pipeProblems.length > 0) {
87+
pipeProblems.forEach((pipeProblem) => context.report(pipeProblem));
88+
}
8389
},
8490
async LiquidBranch(node) {
8591
const problem = detectInvalidConditionalNode(node);
@@ -96,6 +102,11 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
96102
filterProblems.forEach((problem) => context.report(problem));
97103
}
98104

105+
const pipeProblems = await detectInvalidPipeSyntax(node);
106+
if (pipeProblems.length > 0) {
107+
pipeProblems.forEach((pipeProblem) => context.report(pipeProblem));
108+
}
109+
99110
const problem = detectInvalidEchoValue(node);
100111
if (problem) {
101112
context.report(problem);

0 commit comments

Comments
 (0)