From 50e2939a3da38b744229bdf1b225c9c717ba3d9b Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 17 Oct 2025 16:02:06 -0600 Subject: [PATCH] Support code completion for inline snippet render arguments Argument completion suggestions should be offered while rendering an inline snippet. This commit adds this functionality by selecting and displaying arguments defined in an inline snippets doc tag --- .changeset/eleven-melons-compete.md | 5 + ...SnippetParameterCompletionProvider.spec.ts | 91 +++++++++++++++++++ ...enderSnippetParameterCompletionProvider.ts | 26 ++++-- .../src/utils/liquidDoc.ts | 53 +++++++++++ 4 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 .changeset/eleven-melons-compete.md diff --git a/.changeset/eleven-melons-compete.md b/.changeset/eleven-melons-compete.md new file mode 100644 index 000000000..8947d456f --- /dev/null +++ b/.changeset/eleven-melons-compete.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-language-server-common': minor +--- + +Support code completion for inline snippet render arguments diff --git a/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.spec.ts index 447be6101..85f644eb4 100644 --- a/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.spec.ts @@ -87,4 +87,95 @@ describe('Module: RenderSnippetParameterCompletionProvider', async () => { it('does not provide completion options if the snippet does not exist', async () => { await expect(provider).to.complete(`{% render 'fake-snippet', █ %}`, []); }); + + describe('inline snippets', () => { + it('provide completion options', async () => { + const content = ` + {% snippet example %} + {% doc %} + @param {string} title + @param {number} count + @param description + {% enddoc %} +
{{ title }} - {{ count }}
+ {% endsnippet %} + + {% render example, █ %} + `; + await expect(provider).to.complete(content, ['title', 'count', 'description']); + }); + + it('provide completion options and exclude already specified params', async () => { + const content = ` + {% snippet example %} + {% doc %} + @param {string} title + @param {number} count + @param {boolean} active + {% enddoc %} +
{{ title }}
+ {% endsnippet %} + + {% render example, title: 'foo', █ %} + `; + await expect(provider).to.complete(content, ['count', 'active']); + }); + + it('do not provide completion options if there is no doc tag', async () => { + const content = ` + {% snippet example %} +
No doc block here
+ {% endsnippet %} + + {% render example, █ %} + `; + await expect(provider).to.complete(content, []); + }); + + it('do not provide completion options if the snippet does not exist', async () => { + const content = ` + {% snippet example %} + {% doc %} + @param {string} title + {% enddoc %} + {% endsnippet %} + + {% render nonexistent, █ %} + `; + await expect(provider).to.complete(content, []); + }); + + it('provide completion options from the doc tag in the current scope', async () => { + let content = ` + {% snippet outer %} + {% doc %} + @param {string} outerParam + {% enddoc %} + {% snippet inner %} + {% doc %} + @param {string} innerParam + {% enddoc %} +
{{ innerParam }}
+ {% endsnippet %} + {% render inner, █ %} + {% endsnippet %} + `; + await expect(provider).to.complete(content, ['innerParam']); + content = ` + {% snippet outer %} + {% doc %} + @param {string} outerParam + {% enddoc %} + {% snippet inner %} + {% doc %} + @param {string} innerParam + {% enddoc %} +
{{ innerParam }}
+ {% endsnippet %} + {% endsnippet %} + {% render outer, █ %} + `; + await expect(provider).to.complete(content, ['outerParam']); + }); + }); }); diff --git a/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts index b12197c58..691087c1e 100644 --- a/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/RenderSnippetParameterCompletionProvider.ts @@ -9,7 +9,11 @@ import { } from 'vscode-languageserver'; import { CURSOR, LiquidCompletionParams } from '../params'; import { Provider } from './common'; -import { formatLiquidDocParameter, getParameterCompletionTemplate } from '../../utils/liquidDoc'; +import { + formatLiquidDocParameter, + getInlineSnippetDocParams, + getParameterCompletionTemplate, +} from '../../utils/liquidDoc'; import { GetDocDefinitionForURI } from '@shopify/theme-check-common'; export type GetSnippetNamesForURI = (uri: string) => Promise; @@ -27,21 +31,25 @@ export class RenderSnippetParameterCompletionProvider implements Provider { !node || !parentNode || node.type !== NodeTypes.VariableLookup || - parentNode.type !== NodeTypes.RenderMarkup || - parentNode.snippet.type !== 'String' + parentNode.type !== NodeTypes.RenderMarkup ) { return []; } const userInputStr = node.name?.replace(CURSOR, '') || ''; + let liquidDocParams; - const snippetDefinition = await this.getDocDefinitionForURI( - params.textDocument.uri, - 'snippets', - parentNode.snippet.value, - ); + if (parentNode.snippet.type === 'String') { + const snippetDefinition = await this.getDocDefinitionForURI( + params.textDocument.uri, + 'snippets', + parentNode.snippet.value, + ); - const liquidDocParams = snippetDefinition?.liquidDoc?.parameters; + liquidDocParams = snippetDefinition?.liquidDoc?.parameters; + } else if (parentNode.snippet.type === NodeTypes.VariableLookup && parentNode.snippet.name) { + liquidDocParams = getInlineSnippetDocParams(params.document.ast, parentNode.snippet.name); + } if (!liquidDocParams) { return []; diff --git a/packages/theme-language-server-common/src/utils/liquidDoc.ts b/packages/theme-language-server-common/src/utils/liquidDoc.ts index 66c62b235..ac90db013 100644 --- a/packages/theme-language-server-common/src/utils/liquidDoc.ts +++ b/packages/theme-language-server-common/src/utils/liquidDoc.ts @@ -4,7 +4,16 @@ import { getDefaultValueForType, LiquidDocParameter, SupportedDocTagTypes, + visit, } from '@shopify/theme-check-common'; +import { + LiquidDocParamNode, + LiquidHtmlNode, + LiquidRawTag, + LiquidTag, + LiquidTagSnippet, + NodeTypes, +} from '@shopify/liquid-html-parser'; export function formatLiquidDocParameter( { name, type, description, required }: LiquidDocParameter, @@ -100,3 +109,47 @@ export function formatLiquidDocContentMarkdown( return parts.join('\n'); } + +export function getInlineSnippetDocParams( + ast: LiquidHtmlNode | Error, + snippetName: string, +): LiquidDocParameter[] { + if (ast instanceof Error || ast.type !== NodeTypes.Document) return []; + + let snippetNode: LiquidTagSnippet | undefined; + + visit(ast, { + LiquidTag(node: LiquidTag) { + if ( + node.name === 'snippet' && + typeof node.markup !== 'string' && + node.markup.type === NodeTypes.VariableLookup && + node.markup.name === snippetName + ) { + snippetNode = node as LiquidTagSnippet; + } + }, + }); + + if (!snippetNode?.children) return []; + + const docNode = snippetNode.children.find( + (node): node is LiquidRawTag => node.type === NodeTypes.LiquidRawTag && node.name === 'doc', + ); + + if (!docNode) return []; + + const paramNodes = (docNode.body.nodes as LiquidHtmlNode[]).filter( + (node): node is LiquidDocParamNode => node.type === NodeTypes.LiquidDocParamNode, + ); + + return paramNodes.map( + (node): LiquidDocParameter => ({ + nodeType: 'param', + name: node.paramName.value, + description: node.paramDescription?.value ?? null, + type: node.paramType?.value ?? null, + required: node.required, + }), + ); +}