Skip to content

Commit 26c0bd4

Browse files
committed
Added hovering support for render handle
1 parent 43a3e6d commit 26c0bd4

File tree

6 files changed

+336
-27
lines changed

6 files changed

+336
-27
lines changed

packages/theme-check-common/src/liquid-doc/liquidDoc.spec.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, it, describe } from 'vitest';
22
import { toSourceCode } from '../to-source-code';
33
import { LiquidHtmlNode } from '../types';
44
import { extractDocDefinition } from './liquidDoc';
5+
import { DocumentNode } from '@shopify/liquid-html-parser';
56

67
describe('Unit: extractDocDefinition', () => {
78
const uri = 'file:///snippets/fake.liquid';
@@ -293,4 +294,157 @@ describe('Unit: extractDocDefinition', () => {
293294
},
294295
});
295296
});
297+
298+
describe('Inline snippet support', () => {
299+
it('should extract doc from inline snippet when passed the snippet node', async () => {
300+
const fileAST = toAST(`
301+
{% snippet my_snippet %}
302+
{% doc %}
303+
@param {String} title - The title
304+
@param {Number} count - The count
305+
{% enddoc %}
306+
<div>{{ title }}: {{ count }}</div>
307+
{% endsnippet %}
308+
`);
309+
310+
// Get the snippet node from the document
311+
const snippetNode = (fileAST as DocumentNode).children.find(
312+
(node) => node.type === 'LiquidTag' && (node as any).name === 'snippet',
313+
)!;
314+
315+
const result = extractDocDefinition(uri, snippetNode);
316+
expect(result).to.deep.equal({
317+
uri,
318+
liquidDoc: {
319+
parameters: [
320+
{
321+
name: 'title',
322+
description: 'The title',
323+
type: 'String',
324+
required: true,
325+
nodeType: 'param',
326+
},
327+
{
328+
name: 'count',
329+
description: 'The count',
330+
type: 'Number',
331+
required: true,
332+
nodeType: 'param',
333+
},
334+
],
335+
},
336+
});
337+
});
338+
339+
it('should NOT include inline snippet docs when extracting from file level', async () => {
340+
const ast = toAST(`
341+
{% doc %}
342+
@param {String} fileParam - File level parameter
343+
{% enddoc %}
344+
345+
{% snippet inline_snippet %}
346+
{% doc %}
347+
@param {Number} snippetParam - Snippet level parameter
348+
{% enddoc %}
349+
<div>Snippet content</div>
350+
{% endsnippet %}
351+
`);
352+
353+
const result = extractDocDefinition(uri, ast);
354+
expect(result).to.deep.equal({
355+
uri,
356+
liquidDoc: {
357+
parameters: [
358+
{
359+
name: 'fileParam',
360+
description: 'File level parameter',
361+
type: 'String',
362+
required: true,
363+
nodeType: 'param',
364+
},
365+
],
366+
},
367+
});
368+
});
369+
370+
it('should NOT include inline snippet docs when multiple inline snippets exist', async () => {
371+
const ast = toAST(`
372+
{% doc %}
373+
@param {String} mainParam - Main file parameter
374+
{% enddoc %}
375+
376+
{% snippet first_snippet %}
377+
{% doc %}
378+
@param {String} firstParam - First snippet parameter
379+
{% enddoc %}
380+
<div>First</div>
381+
{% endsnippet %}
382+
383+
{% snippet second_snippet %}
384+
{% doc %}
385+
@param {Number} secondParam - Second snippet parameter
386+
{% enddoc %}
387+
<div>Second</div>
388+
{% endsnippet %}
389+
`);
390+
391+
const result = extractDocDefinition(uri, ast);
392+
expect(result).to.deep.equal({
393+
uri,
394+
liquidDoc: {
395+
parameters: [
396+
{
397+
name: 'mainParam',
398+
description: 'Main file parameter',
399+
type: 'String',
400+
required: true,
401+
nodeType: 'param',
402+
},
403+
],
404+
},
405+
});
406+
});
407+
408+
it('should extract docs from specific inline snippet when passed that snippet node', async () => {
409+
const fileAST = toAST(`
410+
{% snippet first_snippet %}
411+
{% doc %}
412+
@param {String} first - First parameter
413+
{% enddoc %}
414+
<div>First</div>
415+
{% endsnippet %}
416+
417+
{% snippet second_snippet %}
418+
{% doc %}
419+
@param {Number} second - Second parameter
420+
{% enddoc %}
421+
<div>Second</div>
422+
{% endsnippet %}
423+
`);
424+
425+
// Get the second snippet node
426+
const secondSnippet = (fileAST as DocumentNode).children.find(
427+
(node) =>
428+
node.type === 'LiquidTag' &&
429+
(node as any).name === 'snippet' &&
430+
(node as any).markup.name === 'second_snippet',
431+
)!;
432+
433+
const result = extractDocDefinition(uri, secondSnippet);
434+
expect(result).to.deep.equal({
435+
uri,
436+
liquidDoc: {
437+
parameters: [
438+
{
439+
name: 'second',
440+
description: 'Second parameter',
441+
type: 'Number',
442+
required: true,
443+
nodeType: 'param',
444+
},
445+
],
446+
},
447+
});
448+
});
449+
});
296450
});

packages/theme-check-common/src/liquid-doc/liquidDoc.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
LiquidDocExampleNode,
66
LiquidDocParamNode,
77
LiquidDocDescriptionNode,
8+
LiquidTagSnippet,
9+
NamedTags,
10+
LiquidTag,
11+
NodeTypes,
812
} from '@shopify/liquid-html-parser';
913

1014
export type GetDocDefinitionForURI = (
@@ -58,15 +62,28 @@ export function hasLiquidDoc(snippet: LiquidHtmlNode): boolean {
5862

5963
export function extractDocDefinition(uri: UriString, ast: LiquidHtmlNode): DocDefinition {
6064
let hasDocTag = false;
65+
66+
const isSnippetTag = (node: LiquidHtmlNode): boolean => {
67+
return node.type === NodeTypes.LiquidTag && (node as LiquidTag).name === NamedTags.snippet;
68+
};
69+
70+
const isInsideInlineSnippet = (ancestors: LiquidHtmlNode[]) => {
71+
return !isSnippetTag(ast) && ancestors.some(isSnippetTag);
72+
};
73+
6174
const nodes: (LiquidDocParameter | LiquidDocExample | LiquidDocDescription)[] = visit<
6275
SourceCodeType.LiquidHtml,
6376
LiquidDocParameter | LiquidDocExample | LiquidDocDescription
6477
>(ast, {
65-
LiquidRawTag(node) {
66-
if (node.name === 'doc') hasDocTag = true;
78+
LiquidRawTag(node, ancestors) {
79+
if (node.name === 'doc' && !isInsideInlineSnippet(ancestors)) {
80+
hasDocTag = true;
81+
}
6782
return undefined;
6883
},
69-
LiquidDocParamNode(node: LiquidDocParamNode) {
84+
LiquidDocParamNode(node: LiquidDocParamNode, ancestors) {
85+
if (isInsideInlineSnippet(ancestors)) return undefined;
86+
7087
return {
7188
name: node.paramName.value,
7289
description: node.paramDescription?.value ?? null,
@@ -75,13 +92,17 @@ export function extractDocDefinition(uri: UriString, ast: LiquidHtmlNode): DocDe
7592
nodeType: 'param',
7693
};
7794
},
78-
LiquidDocExampleNode(node: LiquidDocExampleNode) {
95+
LiquidDocExampleNode(node: LiquidDocExampleNode, ancestors) {
96+
if (isInsideInlineSnippet(ancestors)) return undefined;
97+
7998
return {
8099
content: handleMultilineIndentation(node.content.value.trim()),
81100
nodeType: 'example',
82101
};
83102
},
84-
LiquidDocDescriptionNode(node: LiquidDocDescriptionNode) {
103+
LiquidDocDescriptionNode(node: LiquidDocDescriptionNode, ancestors) {
104+
if (isInsideInlineSnippet(ancestors)) return undefined;
105+
85106
return {
86107
content: handleMultilineIndentation(node.content.value.trim()),
87108
nodeType: 'description',

packages/theme-check-common/src/visitor.spec.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { LiquidHtmlNode, LiquidHtmlNodeTypes, SourceCodeType } from './types';
22
import { toJSONAST, toLiquidHTMLAST, toSourceCode } from './to-source-code';
33
import { expect, describe, it, assert } from 'vitest';
4-
import { Visitor, findCurrentNode, findJSONNode, visit } from './visitor';
4+
import { Visitor, findCurrentNode, findJSONNode, visit, findInlineSnippet } from './visitor';
55
import { NodeTypes } from '@shopify/liquid-html-parser';
66

77
describe('Module: visitor', () => {
@@ -161,3 +161,57 @@ describe('findCurrentNode', () => {
161161
);
162162
});
163163
});
164+
165+
describe('findInlineSnippet', () => {
166+
function toAST(code: string) {
167+
return toSourceCode('/tmp/foo.liquid', code).ast as LiquidHtmlNode;
168+
}
169+
170+
it('should find an inline snippet by name', () => {
171+
const ast = toAST(`
172+
{% snippet my_snippet %}
173+
<div>Content</div>
174+
{% endsnippet %}
175+
`);
176+
177+
const result = findInlineSnippet(ast, 'my_snippet');
178+
179+
expect(result).not.to.be.null;
180+
expect(result?.name).to.equal('snippet');
181+
expect(result?.markup.type).to.equal(NodeTypes.VariableLookup);
182+
expect(result?.markup.name).to.equal('my_snippet');
183+
});
184+
185+
it('should return null if snippet is not found', () => {
186+
const ast = toAST(`
187+
{% snippet my_snippet %}
188+
<div>Content</div>
189+
{% endsnippet %}
190+
`);
191+
192+
const result = findInlineSnippet(ast, 'nonexistent');
193+
194+
expect(result).to.be.null;
195+
});
196+
197+
it('should find the correct snippet when multiple snippets exist', () => {
198+
const ast = toAST(`
199+
{% snippet first_snippet %}
200+
<div>First</div>
201+
{% endsnippet %}
202+
203+
{% snippet second_snippet %}
204+
<div>Second</div>
205+
{% endsnippet %}
206+
207+
{% snippet third_snippet %}
208+
<div>Third</div>
209+
{% endsnippet %}
210+
`);
211+
212+
const result = findInlineSnippet(ast, 'second_snippet');
213+
214+
expect(result).not.to.be.null;
215+
expect(result?.markup.name).to.equal('second_snippet');
216+
});
217+
});

packages/theme-check-common/src/visitor.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { NodeTypes as LiquidHtmlNodeTypes } from '@shopify/liquid-html-parser';
1+
import {
2+
NodeTypes as LiquidHtmlNodeTypes,
3+
NamedTags,
4+
LiquidTagSnippet,
5+
} from '@shopify/liquid-html-parser';
26
import { AST, LiquidHtmlNode, NodeOfType, SourceCodeType, NodeTypes, JSONNode } from './types';
37

48
export type VisitorMethod<S extends SourceCodeType, T, R> = (
@@ -154,3 +158,31 @@ export function findJSONNode(
154158

155159
return [current, ancestors];
156160
}
161+
162+
/**
163+
* Find an inline snippet by name in the AST
164+
* @param ast The AST to search
165+
* @param snippetName The name of the snippet to find
166+
* @returns The snippet node or null if not found
167+
*/
168+
export function findInlineSnippet(
169+
ast: LiquidHtmlNode,
170+
snippetName: string,
171+
): LiquidTagSnippet | null {
172+
let foundSnippet: LiquidTagSnippet | null = null;
173+
174+
visit<SourceCodeType.LiquidHtml, void>(ast, {
175+
LiquidTag(node) {
176+
if (
177+
node.name === NamedTags.snippet &&
178+
typeof node.markup !== 'string' &&
179+
node.markup.type === 'VariableLookup' &&
180+
node.markup.name === snippetName
181+
) {
182+
foundSnippet = node as LiquidTagSnippet;
183+
}
184+
},
185+
});
186+
187+
return foundSnippet;
188+
}

packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,38 @@ This is a description
6060
await expect(provider).to.hover(`{% assign asdf = 'snip█pet' %}`, null);
6161
await expect(provider).to.hover(`{{ 'snip█pet' }}`, null);
6262
});
63+
64+
it('should return snippet definition with all parameters for inline snippet', async () => {
65+
provider = createProvider(async () => undefined); // No file-based snippets
66+
67+
const source = `
68+
{% snippet inline_product_card %}
69+
{% doc %}
70+
@param {string} title - The title of the inline product card
71+
72+
@example
73+
{% render inline_product_card, title: 'My Product' %}
74+
{% enddoc %}
75+
<div>{{ title }}</div>
76+
{% endsnippet %}
77+
78+
{% render inline_produc█t_card %}
79+
`;
80+
81+
// prettier-ignore
82+
const expectedHoverContent =
83+
`### inline_product_card
84+
85+
**Parameters:**
86+
- \`title\`: string - The title of the inline product card
87+
88+
**Examples:**
89+
\`\`\`liquid
90+
{% render inline_product_card, title: 'My Product' %}
91+
\`\`\``;
92+
93+
await expect(provider).to.hover(source, expectedHoverContent);
94+
});
6395
});
6496
});
6597

0 commit comments

Comments
 (0)