From 8737b7a4947bb6a5179864df745f9b26fbaee567 Mon Sep 17 00:00:00 2001 From: Orlando Qiu Date: Mon, 29 Sep 2025 15:06:16 -0400 Subject: [PATCH 1/3] introduced snippet tag in ohm From 98203348f3484df6c818bf25558b4a9bd2b5c75b Mon Sep 17 00:00:00 2001 From: Orlando Qiu Date: Wed, 15 Oct 2025 11:13:17 -0400 Subject: [PATCH 2/3] Added hovering support for `render` handle --- .../src/liquid-doc/liquidDoc.spec.ts | 154 ++++++++++++++++++ .../src/liquid-doc/liquidDoc.ts | 31 +++- .../theme-check-common/src/visitor.spec.ts | 56 ++++++- packages/theme-check-common/src/visitor.ts | 28 +++- .../RenderSnippetHoverProvider.spec.ts | 32 ++++ .../providers/RenderSnippetHoverProvider.ts | 56 ++++--- 6 files changed, 330 insertions(+), 27 deletions(-) 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; } } From 95f6e1e3d5aeacc7c47fc685c32841bdc809b83b Mon Sep 17 00:00:00 2001 From: Orlando Qiu Date: Wed, 15 Oct 2025 11:13:28 -0400 Subject: [PATCH 3/3] Added hover support for named arguments for inline snippets --- .changeset/moody-mice-learn.md | 6 ++ ...enderSnippetParameterHoverProvider.spec.ts | 61 +++++++++++++++++++ .../RenderSnippetParameterHoverProvider.ts | 32 +++++++--- 3 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 .changeset/moody-mice-learn.md 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-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(