diff --git a/.changeset/moody-mice-learn.md b/.changeset/moody-mice-learn.md
new file mode 100644
index 000000000..1c5a1c448
--- /dev/null
+++ b/.changeset/moody-mice-learn.md
@@ -0,0 +1,6 @@
+---
+'@shopify/theme-language-server-common': minor
+'@shopify/theme-check-common': minor
+---
+
+Added hover support for `render` handle and named arguments for inline snippets
diff --git a/packages/theme-check-common/src/liquid-doc/liquidDoc.spec.ts b/packages/theme-check-common/src/liquid-doc/liquidDoc.spec.ts
index 24a941efe..dcf38204a 100644
--- a/packages/theme-check-common/src/liquid-doc/liquidDoc.spec.ts
+++ b/packages/theme-check-common/src/liquid-doc/liquidDoc.spec.ts
@@ -2,6 +2,7 @@ import { expect, it, describe } from 'vitest';
import { toSourceCode } from '../to-source-code';
import { LiquidHtmlNode } from '../types';
import { extractDocDefinition } from './liquidDoc';
+import { DocumentNode } from '@shopify/liquid-html-parser';
describe('Unit: extractDocDefinition', () => {
const uri = 'file:///snippets/fake.liquid';
@@ -293,4 +294,157 @@ describe('Unit: extractDocDefinition', () => {
},
});
});
+
+ describe('Inline snippet support', () => {
+ it('should extract doc from inline snippet when passed the snippet node', async () => {
+ const fileAST = toAST(`
+ {% snippet my_snippet %}
+ {% doc %}
+ @param {String} title - The title
+ @param {Number} count - The count
+ {% enddoc %}
+
{{ title }}: {{ count }}
+ {% endsnippet %}
+ `);
+
+ // Get the snippet node from the document
+ const snippetNode = (fileAST as DocumentNode).children.find(
+ (node) => node.type === 'LiquidTag' && (node as any).name === 'snippet',
+ )!;
+
+ const result = extractDocDefinition(uri, snippetNode);
+ expect(result).to.deep.equal({
+ uri,
+ liquidDoc: {
+ parameters: [
+ {
+ name: 'title',
+ description: 'The title',
+ type: 'String',
+ required: true,
+ nodeType: 'param',
+ },
+ {
+ name: 'count',
+ description: 'The count',
+ type: 'Number',
+ required: true,
+ nodeType: 'param',
+ },
+ ],
+ },
+ });
+ });
+
+ it('should NOT include inline snippet docs when extracting from file level', async () => {
+ const ast = toAST(`
+ {% doc %}
+ @param {String} fileParam - File level parameter
+ {% enddoc %}
+
+ {% snippet inline_snippet %}
+ {% doc %}
+ @param {Number} snippetParam - Snippet level parameter
+ {% enddoc %}
+ Snippet content
+ {% endsnippet %}
+ `);
+
+ const result = extractDocDefinition(uri, ast);
+ expect(result).to.deep.equal({
+ uri,
+ liquidDoc: {
+ parameters: [
+ {
+ name: 'fileParam',
+ description: 'File level parameter',
+ type: 'String',
+ required: true,
+ nodeType: 'param',
+ },
+ ],
+ },
+ });
+ });
+
+ it('should NOT include inline snippet docs when multiple inline snippets exist', async () => {
+ const ast = toAST(`
+ {% doc %}
+ @param {String} mainParam - Main file parameter
+ {% enddoc %}
+
+ {% snippet first_snippet %}
+ {% doc %}
+ @param {String} firstParam - First snippet parameter
+ {% enddoc %}
+ First
+ {% endsnippet %}
+
+ {% snippet second_snippet %}
+ {% doc %}
+ @param {Number} secondParam - Second snippet parameter
+ {% enddoc %}
+ Second
+ {% endsnippet %}
+ `);
+
+ const result = extractDocDefinition(uri, ast);
+ expect(result).to.deep.equal({
+ uri,
+ liquidDoc: {
+ parameters: [
+ {
+ name: 'mainParam',
+ description: 'Main file parameter',
+ type: 'String',
+ required: true,
+ nodeType: 'param',
+ },
+ ],
+ },
+ });
+ });
+
+ it('should extract docs from specific inline snippet when passed that snippet node', async () => {
+ const fileAST = toAST(`
+ {% snippet first_snippet %}
+ {% doc %}
+ @param {String} first - First parameter
+ {% enddoc %}
+ First
+ {% endsnippet %}
+
+ {% snippet second_snippet %}
+ {% doc %}
+ @param {Number} second - Second parameter
+ {% enddoc %}
+ Second
+ {% endsnippet %}
+ `);
+
+ // Get the second snippet node
+ const secondSnippet = (fileAST as DocumentNode).children.find(
+ (node) =>
+ node.type === 'LiquidTag' &&
+ (node as any).name === 'snippet' &&
+ (node as any).markup.name === 'second_snippet',
+ )!;
+
+ const result = extractDocDefinition(uri, secondSnippet);
+ expect(result).to.deep.equal({
+ uri,
+ liquidDoc: {
+ parameters: [
+ {
+ name: 'second',
+ description: 'Second parameter',
+ type: 'Number',
+ required: true,
+ nodeType: 'param',
+ },
+ ],
+ },
+ });
+ });
+ });
});
diff --git a/packages/theme-check-common/src/liquid-doc/liquidDoc.ts b/packages/theme-check-common/src/liquid-doc/liquidDoc.ts
index 923e95982..068fdfaea 100644
--- a/packages/theme-check-common/src/liquid-doc/liquidDoc.ts
+++ b/packages/theme-check-common/src/liquid-doc/liquidDoc.ts
@@ -5,6 +5,10 @@ import {
LiquidDocExampleNode,
LiquidDocParamNode,
LiquidDocDescriptionNode,
+ LiquidTagSnippet,
+ NamedTags,
+ LiquidTag,
+ NodeTypes,
} from '@shopify/liquid-html-parser';
export type GetDocDefinitionForURI = (
@@ -58,15 +62,28 @@ export function hasLiquidDoc(snippet: LiquidHtmlNode): boolean {
export function extractDocDefinition(uri: UriString, ast: LiquidHtmlNode): DocDefinition {
let hasDocTag = false;
+
+ const isSnippetTag = (node: LiquidHtmlNode): boolean => {
+ return node.type === NodeTypes.LiquidTag && (node as LiquidTag).name === NamedTags.snippet;
+ };
+
+ const isInsideInlineSnippet = (ancestors: LiquidHtmlNode[]) => {
+ return !isSnippetTag(ast) && ancestors.some(isSnippetTag);
+ };
+
const nodes: (LiquidDocParameter | LiquidDocExample | LiquidDocDescription)[] = visit<
SourceCodeType.LiquidHtml,
LiquidDocParameter | LiquidDocExample | LiquidDocDescription
>(ast, {
- LiquidRawTag(node) {
- if (node.name === 'doc') hasDocTag = true;
+ LiquidRawTag(node, ancestors) {
+ if (node.name === 'doc' && !isInsideInlineSnippet(ancestors)) {
+ hasDocTag = true;
+ }
return undefined;
},
- LiquidDocParamNode(node: LiquidDocParamNode) {
+ LiquidDocParamNode(node: LiquidDocParamNode, ancestors) {
+ if (isInsideInlineSnippet(ancestors)) return undefined;
+
return {
name: node.paramName.value,
description: node.paramDescription?.value ?? null,
@@ -75,13 +92,17 @@ export function extractDocDefinition(uri: UriString, ast: LiquidHtmlNode): DocDe
nodeType: 'param',
};
},
- LiquidDocExampleNode(node: LiquidDocExampleNode) {
+ LiquidDocExampleNode(node: LiquidDocExampleNode, ancestors) {
+ if (isInsideInlineSnippet(ancestors)) return undefined;
+
return {
content: handleMultilineIndentation(node.content.value.trim()),
nodeType: 'example',
};
},
- LiquidDocDescriptionNode(node: LiquidDocDescriptionNode) {
+ LiquidDocDescriptionNode(node: LiquidDocDescriptionNode, ancestors) {
+ if (isInsideInlineSnippet(ancestors)) return undefined;
+
return {
content: handleMultilineIndentation(node.content.value.trim()),
nodeType: 'description',
diff --git a/packages/theme-check-common/src/visitor.spec.ts b/packages/theme-check-common/src/visitor.spec.ts
index a7983e5fa..15303e7f7 100644
--- a/packages/theme-check-common/src/visitor.spec.ts
+++ b/packages/theme-check-common/src/visitor.spec.ts
@@ -1,7 +1,7 @@
import { LiquidHtmlNode, LiquidHtmlNodeTypes, SourceCodeType } from './types';
import { toJSONAST, toLiquidHTMLAST, toSourceCode } from './to-source-code';
import { expect, describe, it, assert } from 'vitest';
-import { Visitor, findCurrentNode, findJSONNode, visit } from './visitor';
+import { Visitor, findCurrentNode, findJSONNode, visit, findInlineSnippet } from './visitor';
import { NodeTypes } from '@shopify/liquid-html-parser';
describe('Module: visitor', () => {
@@ -161,3 +161,57 @@ describe('findCurrentNode', () => {
);
});
});
+
+describe('findInlineSnippet', () => {
+ function toAST(code: string) {
+ return toSourceCode('/tmp/foo.liquid', code).ast as LiquidHtmlNode;
+ }
+
+ it('should find an inline snippet by name', () => {
+ const ast = toAST(`
+ {% snippet my_snippet %}
+ Content
+ {% endsnippet %}
+ `);
+
+ const result = findInlineSnippet(ast, 'my_snippet');
+
+ expect(result).not.to.be.null;
+ expect(result?.name).to.equal('snippet');
+ expect(result?.markup.type).to.equal(NodeTypes.VariableLookup);
+ expect(result?.markup.name).to.equal('my_snippet');
+ });
+
+ it('should return null if snippet is not found', () => {
+ const ast = toAST(`
+ {% snippet my_snippet %}
+ Content
+ {% endsnippet %}
+ `);
+
+ const result = findInlineSnippet(ast, 'nonexistent');
+
+ expect(result).to.be.null;
+ });
+
+ it('should find the correct snippet when multiple snippets exist', () => {
+ const ast = toAST(`
+ {% snippet first_snippet %}
+ First
+ {% endsnippet %}
+
+ {% snippet second_snippet %}
+ Second
+ {% endsnippet %}
+
+ {% snippet third_snippet %}
+ Third
+ {% endsnippet %}
+ `);
+
+ const result = findInlineSnippet(ast, 'second_snippet');
+
+ expect(result).not.to.be.null;
+ expect(result?.markup.name).to.equal('second_snippet');
+ });
+});
diff --git a/packages/theme-check-common/src/visitor.ts b/packages/theme-check-common/src/visitor.ts
index badc16c60..b3e5346f4 100644
--- a/packages/theme-check-common/src/visitor.ts
+++ b/packages/theme-check-common/src/visitor.ts
@@ -1,4 +1,8 @@
-import { NodeTypes as LiquidHtmlNodeTypes } from '@shopify/liquid-html-parser';
+import {
+ NodeTypes as LiquidHtmlNodeTypes,
+ NamedTags,
+ LiquidTagSnippet,
+} from '@shopify/liquid-html-parser';
import { AST, LiquidHtmlNode, NodeOfType, SourceCodeType, NodeTypes, JSONNode } from './types';
export type VisitorMethod = (
@@ -154,3 +158,25 @@ export function findJSONNode(
return [current, ancestors];
}
+
+export function findInlineSnippet(
+ ast: LiquidHtmlNode,
+ snippetName: string,
+): LiquidTagSnippet | null {
+ let foundSnippet: LiquidTagSnippet | null = null;
+
+ visit(ast, {
+ LiquidTag(node) {
+ if (
+ node.name === NamedTags.snippet &&
+ typeof node.markup !== 'string' &&
+ node.markup.type === 'VariableLookup' &&
+ node.markup.name === snippetName
+ ) {
+ foundSnippet = node as LiquidTagSnippet;
+ }
+ },
+ });
+
+ return foundSnippet;
+}
diff --git a/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.spec.ts
index fe052e0be..f19d97714 100644
--- a/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.spec.ts
+++ b/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.spec.ts
@@ -60,6 +60,38 @@ This is a description
await expect(provider).to.hover(`{% assign asdf = 'snip█pet' %}`, null);
await expect(provider).to.hover(`{{ 'snip█pet' }}`, null);
});
+
+ it('should return snippet definition with all parameters for inline snippet', async () => {
+ provider = createProvider(async () => undefined); // No file-based snippets
+
+ const source = `
+ {% snippet inline_product_card %}
+ {% doc %}
+ @param {string} title - The title of the inline product card
+
+ @example
+ {% render inline_product_card, title: 'My Product' %}
+ {% enddoc %}
+ {{ title }}
+ {% endsnippet %}
+
+ {% render inline_produc█t_card %}
+ `;
+
+ // prettier-ignore
+ const expectedHoverContent =
+`### inline_product_card
+
+**Parameters:**
+- \`title\`: string - The title of the inline product card
+
+**Examples:**
+\`\`\`liquid
+{% render inline_product_card, title: 'My Product' %}
+\`\`\``;
+
+ await expect(provider).to.hover(source, expectedHoverContent);
+ });
});
});
diff --git a/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.ts
index 63cbe7d05..8dba606d6 100644
--- a/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.ts
+++ b/packages/theme-language-server-common/src/hover/providers/RenderSnippetHoverProvider.ts
@@ -1,5 +1,10 @@
import { NodeTypes } from '@shopify/liquid-html-parser';
-import { LiquidHtmlNode, GetDocDefinitionForURI } from '@shopify/theme-check-common';
+import {
+ LiquidHtmlNode,
+ GetDocDefinitionForURI,
+ findInlineSnippet,
+ extractDocDefinition,
+} from '@shopify/theme-check-common';
import { Hover, HoverParams } from 'vscode-languageserver';
import { BaseHoverProvider } from '../BaseHoverProvider';
import { formatLiquidDocContentMarkdown } from '../../utils/liquidDoc';
@@ -13,26 +18,37 @@ export class RenderSnippetHoverProvider implements BaseHoverProvider {
params: HoverParams,
): Promise {
const parentNode = ancestors.at(-1);
- if (
- currentNode.type !== NodeTypes.String ||
- !parentNode ||
- parentNode.type !== NodeTypes.RenderMarkup
- ) {
- return null;
- }
- const snippetName = currentNode.value;
- const docDefinition = await this.getDocDefinitionForURI(
- params.textDocument.uri,
- 'snippets',
- snippetName,
- );
+ if (parentNode?.type === NodeTypes.RenderMarkup) {
+ let snippetName: string;
+ let docDefinition;
+
+ if (currentNode.type === NodeTypes.String) {
+ snippetName = currentNode.value;
+ docDefinition = await this.getDocDefinitionForURI(
+ params.textDocument.uri,
+ 'snippets',
+ snippetName,
+ );
+ } else if (currentNode.type === NodeTypes.VariableLookup) {
+ snippetName = currentNode.name || '';
+ const rootNode = ancestors[0];
+ const snippetNode = findInlineSnippet(rootNode, snippetName);
+ if (snippetNode) {
+ docDefinition = extractDocDefinition(params.textDocument.uri, snippetNode);
+ }
+ } else {
+ return null;
+ }
+
+ return {
+ contents: {
+ kind: 'markdown',
+ value: formatLiquidDocContentMarkdown(snippetName, docDefinition || undefined),
+ },
+ };
+ }
- return {
- contents: {
- kind: 'markdown',
- value: formatLiquidDocContentMarkdown(snippetName, docDefinition),
- },
- };
+ return null;
}
}
diff --git a/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.spec.ts
index fcf464e20..b694c0ad7 100644
--- a/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.spec.ts
+++ b/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.spec.ts
@@ -46,6 +46,67 @@ describe('Module: RenderSnippetParameterHoverProvider', async () => {
);
});
});
+
+ describe('hover on inline snippet parameters', () => {
+ it('should return parameter info for inline snippet', async () => {
+ provider = createProvider(async () => undefined); // No file-based snippets
+
+ const source = `
+ {% snippet product_card %}
+ {% doc %}
+ @param {string} title - The title of the product
+ @param {number} price - The price of the product
+ {% enddoc %}
+ {{ title }}: {{ price }}
+ {% endsnippet %}
+
+ {% render product_card, ti█tle: 'My Product', price: 99 %}
+ `;
+
+ await expect(provider).to.hover(source, '### `title`: string\n\nThe title of the product');
+ });
+
+ it('should return null if parameter not found in inline snippet doc', async () => {
+ provider = createProvider(async () => undefined);
+
+ const source = `
+ {% snippet product_card %}
+ {% doc %}
+ @param {string} title - The title of the product
+ {% enddoc %}
+ {{ title }}
+ {% endsnippet %}
+
+ {% render product_card, unknown_para█m: 'value' %}
+ `;
+
+ await expect(provider).to.hover(source, null);
+ });
+
+ it('should handle multiple inline snippets correctly', async () => {
+ provider = createProvider(async () => undefined);
+
+ const source = `
+ {% snippet first_snippet %}
+ {% doc %}
+ @param {string} first_param - First parameter
+ {% enddoc %}
+ {{ first_param }}
+ {% endsnippet %}
+
+ {% snippet second_snippet %}
+ {% doc %}
+ @param {number} second_param - Second parameter
+ {% enddoc %}
+ {{ second_param }}
+ {% endsnippet %}
+
+ {% render second_snippet, second_para█m: 42 %}
+ `;
+
+ await expect(provider).to.hover(source, '### `second_param`: number\n\nSecond parameter');
+ });
+ });
});
const createProvider = (getSnippetDefinition: GetDocDefinitionForURI) => {
diff --git a/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.ts
index 3cf2f1b9d..fbd3ef9b8 100644
--- a/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.ts
+++ b/packages/theme-language-server-common/src/hover/providers/RenderSnippetParameterHoverProvider.ts
@@ -1,5 +1,10 @@
import { NodeTypes } from '@shopify/liquid-html-parser';
-import { LiquidHtmlNode, GetDocDefinitionForURI } from '@shopify/theme-check-common';
+import {
+ LiquidHtmlNode,
+ GetDocDefinitionForURI,
+ findInlineSnippet,
+ extractDocDefinition,
+} from '@shopify/theme-check-common';
import { Hover, HoverParams } from 'vscode-languageserver';
import { BaseHoverProvider } from '../BaseHoverProvider';
import { formatLiquidDocParameter } from '../../utils/liquidDoc';
@@ -13,20 +18,31 @@ export class RenderSnippetParameterHoverProvider implements BaseHoverProvider {
params: HoverParams,
): Promise {
const parentNode = ancestors.at(-1);
+
if (
currentNode.type !== NodeTypes.NamedArgument ||
!parentNode ||
- parentNode.type !== NodeTypes.RenderMarkup ||
- parentNode.snippet.type !== NodeTypes.String
+ parentNode.type !== NodeTypes.RenderMarkup
) {
return null;
}
- const docDefinition = await this.getDocDefinitionForURI(
- params.textDocument.uri,
- 'snippets',
- parentNode.snippet.value,
- );
+ let docDefinition;
+
+ if (parentNode.snippet.type === NodeTypes.String) {
+ docDefinition = await this.getDocDefinitionForURI(
+ params.textDocument.uri,
+ 'snippets',
+ parentNode.snippet.value,
+ );
+ } else if (parentNode.snippet.type === NodeTypes.VariableLookup) {
+ const snippetName = parentNode.snippet.name || '';
+ const rootNode = ancestors[0];
+ const snippetNode = findInlineSnippet(rootNode, snippetName);
+ if (snippetNode) {
+ docDefinition = extractDocDefinition(params.textDocument.uri, snippetNode);
+ }
+ }
const paramName = currentNode.name;
const hoveredParameter = docDefinition?.liquidDoc?.parameters?.find(