Skip to content

Commit 3274d57

Browse files
committed
update the check so that it's scope aware
1 parent 6189068 commit 3274d57

File tree

3 files changed

+155
-10
lines changed

3 files changed

+155
-10
lines changed

packages/theme-check-common/src/checks/undefined-object/index.spec.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,96 @@ describe('Module: UndefinedObject', () => {
473473

474474
expect(offenses).toHaveLength(0);
475475
});
476+
477+
describe('Scope-aware checking for inline snippets', () => {
478+
it('No doc tags anywhere - should skip all checks', async () => {
479+
const sourceCode = `
480+
{{ undefined_in_parent }}
481+
482+
{% snippet inline_one %}
483+
{{ undefined_in_inline }}
484+
{% endsnippet %}
485+
`;
486+
487+
const filePath = 'snippets/file.liquid';
488+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode, filePath);
489+
490+
expect(offenses).toHaveLength(0);
491+
});
492+
493+
it('Doc tag in parent only - should check parent, skip inline snippets', async () => {
494+
const sourceCode = `
495+
{% doc %}
496+
@param {string} parent_param
497+
{% enddoc %}
498+
499+
{{ parent_param }}
500+
{{ undefined_in_parent }}
501+
502+
{% snippet inline_one %}
503+
{{ undefined_in_inline }}
504+
{% endsnippet %}
505+
`;
506+
507+
const filePath = 'snippets/file.liquid';
508+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode, filePath);
509+
510+
expect(offenses).toHaveLength(1);
511+
expect(offenses[0].message).toBe("Unknown object 'undefined_in_parent' used.");
512+
});
513+
514+
it('Doc tag in inline snippet only - should skip parent, check inline snippet', async () => {
515+
const sourceCode = `
516+
{{ undefined_in_parent }}
517+
518+
{% snippet inline_one %}
519+
{% doc %}
520+
@param {string} inline_param
521+
{% enddoc %}
522+
523+
{{ inline_param }}
524+
{{ undefined_in_inline }}
525+
{% endsnippet %}
526+
`;
527+
528+
const filePath = 'snippets/file.liquid';
529+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode, filePath);
530+
531+
expect(offenses).toHaveLength(1);
532+
expect(offenses[0].message).toBe("Unknown object 'undefined_in_inline' used.");
533+
});
534+
535+
it('Doc tags in both - should check both, but skip inline snippets without docs', async () => {
536+
const sourceCode = `
537+
{% doc %}
538+
@param {string} parent_param
539+
{% enddoc %}
540+
541+
{{ parent_param }}
542+
{{ undefined_in_parent }}
543+
544+
{% snippet inline_one %}
545+
{% doc %}
546+
@param {string} inline_param
547+
{% enddoc %}
548+
549+
{{ inline_param }}
550+
{{ undefined_in_inline }}
551+
{% endsnippet %}
552+
553+
{% snippet inline_two %}
554+
{{ anything }}
555+
{% endsnippet %}
556+
`;
557+
558+
const filePath = 'snippets/file.liquid';
559+
const offenses = await runLiquidCheck(UndefinedObject, sourceCode, filePath);
560+
561+
expect(offenses).toHaveLength(2);
562+
expect(offenses.map((o) => o.message)).toEqual([
563+
"Unknown object 'undefined_in_parent' used.",
564+
"Unknown object 'undefined_in_inline' used.",
565+
]);
566+
});
567+
});
476568
});

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

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
LiquidTagDecrement,
88
LiquidTagFor,
99
LiquidTagIncrement,
10+
LiquidTagSnippet,
1011
LiquidTagTablerow,
1112
LiquidVariableLookup,
1213
NamedTags,
@@ -16,7 +17,7 @@ import {
1617
import { LiquidCheckDefinition, Severity, SourceCodeType, ThemeDocset } from '../../types';
1718
import { isError, last } from '../../utils';
1819
import { hasLiquidDoc } from '../../liquid-doc/liquidDoc';
19-
import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
20+
import { isWithinRawTagThatDoesNotParseItsContents, findInlineSnippetAncestor } from '../utils';
2021

2122
type Scope = { start?: number; end?: number };
2223

@@ -38,13 +39,14 @@ export const UndefinedObject: LiquidCheckDefinition = {
3839
create(context) {
3940
const relativePath = context.toRelativePath(context.file.uri);
4041
const ast = context.file.ast;
42+
const isSnippetFile = relativePath.startsWith('snippets/');
4143

4244
if (isError(ast)) return {};
4345

4446
/**
4547
* Skip this check when a snippet does not have the presence of doc tags.
4648
*/
47-
if (relativePath.startsWith('snippets/') && !hasLiquidDoc(ast)) return {};
49+
if (isSnippetFile && !hasLiquidDoc(ast)) return {};
4850

4951
/**
5052
* Skip this check when definitions for global objects are unavailable.
@@ -56,7 +58,9 @@ export const UndefinedObject: LiquidCheckDefinition = {
5658
const themeDocset = context.themeDocset;
5759
const scopedVariables: Map<string, Scope[]> = new Map();
5860
const fileScopedVariables: Set<string> = new Set();
59-
const variables: LiquidVariableLookup[] = [];
61+
const variables: Array<{ node: LiquidVariableLookup; inlineSnippet: LiquidTag | null }> = [];
62+
const inlineSnippetsWithDocTags: Set<number> = new Set();
63+
let snippetFileHasDocTag = false;
6064

6165
function indexVariableScope(variableName: string | null, scope: Scope) {
6266
if (!variableName) return;
@@ -66,9 +70,27 @@ export const UndefinedObject: LiquidCheckDefinition = {
6670
}
6771

6872
return {
69-
async LiquidDocParamNode(node: LiquidDocParamNode) {
73+
async LiquidRawTag(node, ancestors) {
74+
if (!isSnippetFile || node.name !== 'doc') return;
75+
76+
const parent = last(ancestors);
77+
if (isLiquidTag(parent) && isLiquidTagSnippet(parent)) {
78+
inlineSnippetsWithDocTags.add(parent.position.start);
79+
} else {
80+
snippetFileHasDocTag = true;
81+
}
82+
},
83+
84+
async LiquidDocParamNode(node: LiquidDocParamNode, ancestors: LiquidHtmlNode[]) {
7085
const paramName = node.paramName?.value;
71-
if (paramName) {
86+
if (!paramName) return;
87+
const snippetAncestor = findInlineSnippetAncestor(ancestors);
88+
if (snippetAncestor) {
89+
indexVariableScope(paramName, {
90+
start: snippetAncestor.blockStartPosition.end,
91+
end: snippetAncestor.blockEndPosition?.start,
92+
});
93+
} else {
7294
fileScopedVariables.add(paramName);
7395
}
7496
},
@@ -134,6 +156,10 @@ export const UndefinedObject: LiquidCheckDefinition = {
134156
end: node.blockEndPosition?.start,
135157
});
136158
}
159+
160+
if (isLiquidTagSnippet(node) && node.markup.name) {
161+
fileScopedVariables.add(node.markup.name);
162+
}
137163
},
138164

139165
async VariableLookup(node, ancestors) {
@@ -142,21 +168,33 @@ export const UndefinedObject: LiquidCheckDefinition = {
142168
const parent = last(ancestors);
143169
if (isLiquidTag(parent) && isLiquidTagCapture(parent)) return;
144170

145-
if (parent?.type === NodeTypes.RenderMarkup && parent.snippet === node) return;
146-
147-
if (isLiquidTag(parent) && parent.name === 'snippet' && parent.markup === node) return;
171+
if (isLiquidTag(parent) && isLiquidTagSnippet(parent)) return;
148172

149-
variables.push(node);
173+
const inlineSnippet = findInlineSnippetAncestor(ancestors);
174+
variables.push({ node, inlineSnippet });
150175
},
151176

152177
async onCodePathEnd() {
153178
const objects = await globalObjects(themeDocset, relativePath);
154179

155180
objects.forEach((obj) => fileScopedVariables.add(obj.name));
156181

157-
variables.forEach((variable) => {
182+
variables.forEach(({ node: variable, inlineSnippet }) => {
158183
if (!variable.name) return;
159184

185+
// For snippet files: only check variables if they're in a scope with a doc tag.
186+
if (isSnippetFile) {
187+
if (inlineSnippet) {
188+
if (!inlineSnippetsWithDocTags.has(inlineSnippet.position.start)) {
189+
return;
190+
}
191+
} else {
192+
if (!snippetFileHasDocTag) {
193+
return;
194+
}
195+
}
196+
}
197+
160198
const isVariableDefined = isDefined(
161199
variable.name,
162200
variable.position,
@@ -272,6 +310,10 @@ function isLiquidTagCapture(node: LiquidTag): node is LiquidTagCapture {
272310
return node.name === NamedTags.capture;
273311
}
274312

313+
function isLiquidTagSnippet(node: LiquidTag): node is LiquidTagSnippet {
314+
return node.name === NamedTags.snippet;
315+
}
316+
275317
function isLiquidTagAssign(node: LiquidTag): node is LiquidTagAssign {
276318
return node.name === NamedTags.assign && typeof node.markup !== 'string';
277319
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
LiquidTagTablerow,
1414
LiquidTag,
1515
LoopNamedTags,
16+
NamedTags,
1617
} from '@shopify/liquid-html-parser';
1718
import { LiquidHtmlNodeOfType as NodeOfType } from '../types';
1819

@@ -104,6 +105,16 @@ export function isLoopLiquidTag(tag: LiquidTag): tag is LiquidTagFor | LiquidTag
104105
return LoopNamedTags.includes(tag.name as any);
105106
}
106107

108+
export function findInlineSnippetAncestor(ancestors: LiquidHtmlNode[]) {
109+
for (let i = ancestors.length - 1; i >= 0; i--) {
110+
const ancestor = ancestors[i];
111+
if (ancestor.type === NodeTypes.LiquidTag && ancestor.name === NamedTags.snippet) {
112+
return ancestor;
113+
}
114+
}
115+
return null;
116+
}
117+
107118
const RawTagsThatDoNotParseTheirContents = ['raw', 'stylesheet', 'javascript', 'schema'];
108119

109120
function isRawTagThatDoesNotParseItsContent(node: LiquidHtmlNode) {

0 commit comments

Comments
 (0)