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, + }), + ); +}