Skip to content

Commit 75ab8e0

Browse files
committed
Refactor: Move render snippet type validations into their own check. This allows users to disable it on its own
1 parent 536a1b2 commit 75ab8e0

File tree

7 files changed

+291
-135
lines changed

7 files changed

+291
-135
lines changed

packages/theme-check-common/src/checks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { ValidHTMLTranslation } from './valid-html-translation';
4242
import { ValidJSON } from './valid-json';
4343
import { ValidLocalBlocks } from './valid-local-blocks';
4444
import { ValidRenderSnippetParams } from './valid-render-snippet-params';
45+
import { ValidRenderSnippetParamTypes } from './valid-render-snippet-param-types';
4546
import { ValidSchema } from './valid-schema';
4647
import { ValidSchemaName } from './valid-schema-name';
4748
import { ValidSettingsKey } from './valid-settings-key';
@@ -102,6 +103,7 @@ export const allChecks: (LiquidCheckDefinition | JSONCheckDefinition)[] = [
102103
ValidVisibleIfSettingsSchema,
103104
VariableName,
104105
ValidRenderSnippetParams,
106+
ValidRenderSnippetParamTypes,
105107
ValidSchemaName,
106108
];
107109

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { runLiquidCheck } from '../../test';
3+
import { ValidRenderSnippetParamTypes } from '.';
4+
import { MockFileSystem } from '../../test';
5+
import { SupportedParamTypes } from '../../liquid-doc/utils';
6+
7+
describe('Module: ValidRenderSnippetParamTypes', () => {
8+
describe('type validation', () => {
9+
const typeTests = [
10+
{
11+
type: 'string',
12+
validValues: ["'hello'", "''", 'product'],
13+
invalidValues: [
14+
{ value: '123', actualType: SupportedParamTypes.Number },
15+
{ value: 'true', actualType: SupportedParamTypes.Boolean },
16+
],
17+
},
18+
{
19+
type: 'number',
20+
validValues: ['0', '123', '-1', 'product'],
21+
invalidValues: [
22+
{ value: "'hello'", actualType: SupportedParamTypes.String },
23+
{ value: 'true', actualType: SupportedParamTypes.Boolean },
24+
],
25+
},
26+
{
27+
type: 'boolean',
28+
validValues: ['true', 'false', 'nil', 'empty', 'product'],
29+
invalidValues: [
30+
{ value: "'hello'", actualType: SupportedParamTypes.String },
31+
{ value: '123', actualType: SupportedParamTypes.Number },
32+
],
33+
},
34+
{
35+
type: 'object',
36+
validValues: ['product', '(1..3)'],
37+
invalidValues: [
38+
{ value: "'hello'", actualType: SupportedParamTypes.String },
39+
{ value: '123', actualType: SupportedParamTypes.Number },
40+
{ value: 'true', actualType: SupportedParamTypes.Boolean },
41+
{ value: 'empty', actualType: SupportedParamTypes.Boolean },
42+
],
43+
},
44+
];
45+
46+
for (const test of typeTests) {
47+
describe(`${test.type} validation`, () => {
48+
const makeSnippet = (type: string) => `
49+
{% doc %}
50+
@param {${type}} param - Description
51+
{% enddoc %}
52+
<div>{{ param }}</div>
53+
`;
54+
55+
test.validValues.forEach((value) => {
56+
it(`should accept ${value} for ${test.type}`, async () => {
57+
const fs = new MockFileSystem({
58+
'snippets/card.liquid': makeSnippet(test.type),
59+
});
60+
61+
const sourceCode = `{% render 'card', param: ${value} %}`;
62+
const offenses = await runLiquidCheck(
63+
ValidRenderSnippetParamTypes,
64+
sourceCode,
65+
undefined,
66+
{
67+
fs,
68+
},
69+
);
70+
expect(offenses).toHaveLength(0);
71+
});
72+
});
73+
74+
test.invalidValues.forEach(({ value, actualType: expectedType }) => {
75+
it(`should reject ${value} for ${test.type}`, async () => {
76+
const fs = new MockFileSystem({
77+
'snippets/card.liquid': makeSnippet(test.type),
78+
});
79+
80+
const sourceCode = `{% render 'card', param: ${value} %}`;
81+
const offenses = await runLiquidCheck(
82+
ValidRenderSnippetParamTypes,
83+
sourceCode,
84+
undefined,
85+
{
86+
fs,
87+
},
88+
);
89+
expect(offenses).toHaveLength(1);
90+
expect(offenses[0].message).toBe(
91+
`Type mismatch for parameter 'param': expected ${test.type}, got ${expectedType}`,
92+
);
93+
});
94+
});
95+
});
96+
}
97+
});
98+
99+
describe('edge cases', () => {
100+
it('should handle mixed case type annotations', async () => {
101+
const fs = new MockFileSystem({
102+
'snippets/card.liquid': `
103+
{% doc %}
104+
@param {String} text - The text
105+
@param {NUMBER} count - The count
106+
@param {BOOLEAN} flag - The flag
107+
@param {Object} data - The data
108+
{% enddoc %}
109+
<div>{{ text }}{{ count }}{{ flag }}{{ data }}</div>
110+
`,
111+
});
112+
113+
const sourceCode = `{% render 'card', text: "hello", count: 5, flag: true, data: product %}`;
114+
const offenses = await runLiquidCheck(ValidRenderSnippetParamTypes, sourceCode, undefined, {
115+
fs,
116+
});
117+
expect(offenses).toHaveLength(0);
118+
});
119+
120+
it('should ignore variable lookups', async () => {
121+
const fs = new MockFileSystem({
122+
'snippets/card.liquid': `
123+
{% doc %}
124+
@param {String} title - The title
125+
{% enddoc %}
126+
<div>{{ title }}</div>
127+
`,
128+
});
129+
130+
const sourceCode = `{% render 'card', title: product_title %}`;
131+
const offenses = await runLiquidCheck(ValidRenderSnippetParamTypes, sourceCode, undefined, {
132+
fs,
133+
});
134+
expect(offenses).toHaveLength(0);
135+
});
136+
137+
it('should not report when snippet has no doc comment', async () => {
138+
const fs = new MockFileSystem({
139+
'snippets/card.liquid': `<h1>This snippet has no doc comment</h1>`,
140+
});
141+
142+
const sourceCode = `{% render 'card', title: 123 %}`;
143+
const offenses = await runLiquidCheck(ValidRenderSnippetParamTypes, sourceCode, undefined, {
144+
fs,
145+
});
146+
expect(offenses).toHaveLength(0);
147+
});
148+
149+
it('should not report for unrelated parameters', async () => {
150+
const fs = new MockFileSystem({
151+
'snippets/card.liquid': `
152+
{% doc %}
153+
@param {String} title - The title
154+
{% enddoc %}
155+
<div>{{ title }}</div>
156+
`,
157+
});
158+
159+
const sourceCode = `{% render 'card', title: "hello", unrelated: 123 %}`;
160+
const offenses = await runLiquidCheck(ValidRenderSnippetParamTypes, sourceCode, undefined, {
161+
fs,
162+
});
163+
expect(offenses).toHaveLength(0);
164+
});
165+
});
166+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
2+
import { LiquidNamedArgument, NodeTypes, RenderMarkup } from '@shopify/liquid-html-parser';
3+
import { toLiquidHtmlAST } from '@shopify/liquid-html-parser';
4+
import { getSnippetDefinition, LiquidDocParameter } from '../../liquid-doc/liquidDoc';
5+
import { isLiquidString } from '../utils';
6+
import { inferArgumentType, getDefaultValueForType } from '../../liquid-doc/utils';
7+
8+
export const ValidRenderSnippetParamTypes: LiquidCheckDefinition = {
9+
meta: {
10+
code: 'ValidRenderSnippetParamTypes',
11+
name: 'Valid Render Snippet Parameter Types',
12+
13+
docs: {
14+
description:
15+
'This check ensures that parameters passed to snippet match the expected types defined in the liquidDoc header if present.',
16+
recommended: true,
17+
url: 'https://shopify.dev/docs/storefronts/themes/tools/theme-check/checks/valid-render-snippet-param-types',
18+
},
19+
type: SourceCodeType.LiquidHtml,
20+
severity: Severity.WARNING,
21+
schema: {},
22+
targets: [],
23+
},
24+
25+
create(context) {
26+
function findTypeMismatchParams(
27+
liquidDocParameters: Map<string, LiquidDocParameter>,
28+
providedParams: LiquidNamedArgument[],
29+
) {
30+
const typeMismatchParams: LiquidNamedArgument[] = [];
31+
32+
for (const arg of providedParams) {
33+
const liquidDocParamDef = liquidDocParameters.get(arg.name);
34+
if (liquidDocParamDef && liquidDocParamDef.type) {
35+
if (arg.value.type !== NodeTypes.VariableLookup) {
36+
if (inferArgumentType(arg) !== liquidDocParamDef.type?.toLowerCase()) {
37+
typeMismatchParams.push(arg);
38+
}
39+
}
40+
}
41+
}
42+
43+
return typeMismatchParams;
44+
}
45+
46+
function reportTypeMismatches(
47+
typeMismatchParams: LiquidNamedArgument[],
48+
liquidDocParameters: Map<string, LiquidDocParameter>,
49+
) {
50+
for (const arg of typeMismatchParams) {
51+
const paramDef = liquidDocParameters.get(arg.name);
52+
if (!paramDef || !paramDef.type) continue;
53+
54+
const expectedType = paramDef.type.toLowerCase();
55+
const actualType = inferArgumentType(arg);
56+
57+
context.report({
58+
message: `Type mismatch for parameter '${arg.name}': expected ${expectedType}, got ${actualType}`,
59+
startIndex: arg.value.position.start,
60+
endIndex: arg.value.position.end,
61+
suggest: [
62+
{
63+
message: `Replace with default value '${getDefaultValueForType(
64+
expectedType,
65+
)}' for ${expectedType}`,
66+
fix: (fixer) => {
67+
return fixer.replace(
68+
arg.value.position.start,
69+
arg.value.position.end,
70+
getDefaultValueForType(expectedType),
71+
);
72+
},
73+
},
74+
{
75+
message: `Remove value`,
76+
fix: (fixer) => {
77+
return fixer.remove(arg.value.position.start, arg.value.position.end);
78+
},
79+
},
80+
],
81+
});
82+
}
83+
}
84+
85+
return {
86+
async RenderMarkup(node: RenderMarkup) {
87+
if (!isLiquidString(node.snippet) || node.variable) {
88+
return;
89+
}
90+
91+
const snippetName = node.snippet.value;
92+
const snippetPath = `snippets/${snippetName}.liquid`;
93+
const snippetUri = context.toUri(snippetPath);
94+
95+
const snippetContent = await context.fs.readFile(snippetUri);
96+
const snippetAst = toLiquidHtmlAST(snippetContent);
97+
const snippetDef = getSnippetDefinition(snippetAst, snippetName);
98+
99+
if (!snippetDef.liquidDoc?.parameters) {
100+
return;
101+
}
102+
103+
const liquidDocParameters = new Map(
104+
snippetDef.liquidDoc.parameters.map((p) => [p.name, p]),
105+
);
106+
107+
const typeMismatchParams = findTypeMismatchParams(liquidDocParameters, node.args);
108+
reportTypeMismatches(typeMismatchParams, liquidDocParameters);
109+
},
110+
};
111+
},
112+
};

0 commit comments

Comments
 (0)