From 3e1788ea4a1d872110bb1e33eeed6165ed3d5461 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 14:03:00 -0400 Subject: [PATCH 1/7] Add block parameter language features --- .../block-parameter-language-features.md | 7 + .../src/completions/CompletionsProvider.ts | 13 +- .../params/LiquidCompletionParams.spec.ts | 48 ++ .../params/LiquidCompletionParams.ts | 144 ++++- .../BlockParameterCompletionProvider.spec.ts | 512 ++++++++++++++++++ .../BlockParameterCompletionProvider.ts | 142 +++++ .../LiquidTagsCompletionProvider.spec.ts | 88 +++ .../providers/LiquidTagsCompletionProvider.ts | 34 +- .../providers/ObjectCompletionProvider.ts | 5 +- .../src/completions/providers/index.ts | 1 + .../src/hover/HoverProvider.ts | 7 + .../BlockParameterHoverProvider.spec.ts | 211 ++++++++ .../providers/BlockParameterHoverProvider.ts | 39 ++ .../src/hover/providers/index.ts | 1 + .../src/server/startServer.ts | 3 + .../src/utils/blockParameters.ts | 83 +++ 16 files changed, 1326 insertions(+), 12 deletions(-) create mode 100644 .changeset/block-parameter-language-features.md create mode 100644 packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts create mode 100644 packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts create mode 100644 packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts create mode 100644 packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts create mode 100644 packages/theme-language-server-common/src/utils/blockParameters.ts diff --git a/.changeset/block-parameter-language-features.md b/.changeset/block-parameter-language-features.md new file mode 100644 index 000000000..91e729ba3 --- /dev/null +++ b/.changeset/block-parameter-language-features.md @@ -0,0 +1,7 @@ +--- +'@shopify/theme-language-server-common': patch +--- + +Complete and hover `block` tag arguments from the target block's schema settings, LiquidDoc parameters, and built-in `content`. + +Completion offers plain parameter names with schema-derived value templates and skips arguments the call already passes. Hover shows the schema type, merchant-facing setting details, LiquidDoc text and requiredness, and marks LiquidDoc-only parameters as developer-only. The `block` tag is offered only in `templates/**/*.liquid` and `layout/*.liquid` files. diff --git a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts index 64704916d..667b2cb1a 100644 --- a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts +++ b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts @@ -8,10 +8,14 @@ import { import { CompletionItem, CompletionParams } from 'vscode-languageserver'; import { TypeSystem } from '../TypeSystem'; import { DocumentManager } from '../documents'; +import { FindThemeRootURI } from '../internal-types'; +import { GetThemeBlockSchema } from '../json/JSONContributions'; import { GetThemeSettingsSchemaForURI } from '../settings'; import { GetTranslationsForURI } from '../translations'; +import { makeGetBlockParametersForURI } from '../utils/blockParameters'; import { createLiquidCompletionParams } from './params'; import { + BlockParameterCompletionProvider, ContentForCompletionProvider, ContentForBlockTypeCompletionProvider, ContentForParameterCompletionProvider, @@ -41,7 +45,9 @@ export interface CompletionProviderDependencies { getMetafieldDefinitions: (rootUri: string) => Promise; getDocDefinitionForURI?: GetDocDefinitionForURI; getThemeBlockNames?: (rootUri: string, includePrivate: boolean) => Promise; + getThemeBlockSchema?: GetThemeBlockSchema; getModeForURI?: (uri: string) => Promise; + findThemeRootURI?: FindThemeRootURI; log?: (message: string) => void; } @@ -60,7 +66,9 @@ export class CompletionsProvider { getThemeSettingsSchemaForURI = async () => [], getDocDefinitionForURI = async (uri, _relativePath) => ({ uri }), getThemeBlockNames = async (_rootUri: string, _includePrivate: boolean) => [], + getThemeBlockSchema = async (_uri: string, _name: string) => undefined, getModeForURI, + findThemeRootURI = async (_uri: string) => null, log = () => {}, }: CompletionProviderDependencies) { this.documentManager = documentManager; @@ -77,10 +85,13 @@ export class CompletionsProvider { new ContentForCompletionProvider(), new ContentForBlockTypeCompletionProvider(getThemeBlockNames), new ContentForParameterCompletionProvider(getDocDefinitionForURI), + new BlockParameterCompletionProvider( + makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), + ), new HtmlTagCompletionProvider(), new HtmlAttributeCompletionProvider(documentManager), new HtmlAttributeValueCompletionProvider(), - new LiquidTagsCompletionProvider(themeDocset), + new LiquidTagsCompletionProvider(themeDocset, findThemeRootURI), new ObjectCompletionProvider(typeSystem), new ObjectAttributeCompletionProvider(typeSystem, getThemeSettingsSchemaForURI), new FilterCompletionProvider(typeSystem), diff --git a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts index 5eefc756b..fa8fbb4c3 100644 --- a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts +++ b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.spec.ts @@ -224,6 +224,54 @@ describe('Module: LiquidCompletionParams', async () => { } }); + it('returns an argument-name lookup whose parent is the block markup', async () => { + const contexts: [context: string, name: string][] = [ + [`{% block 'card', █ %}{% endblock %}`, ''], + [`{% block 'card', he█ %}{% endblock %}`, 'he'], + [`{% block 'card', he█ading: 'x' %}{% endblock %}`, 'he'], + [`{% block 'card', █heading: 'x' %}{% endblock %}`, ''], + [`{% block 'card', heading: 'x', █ %}{% endblock %}`, ''], + [`{% block 'card', heading: 'x', fe█ %}{% endblock %}`, 'fe'], + [`{% block 'card', heading: 'x', █, id: 'y' %}{% endblock %}`, ''], + [`{% block 'card',\n heading: 'x',\n █\n%}{% endblock %}`, ''], + [`{% block 'card', █`, ''], + [`{% block 'card', heading: 'x', he█`, 'he'], + [`{% liquid\n block 'card', he█\n endblock\n%}`, 'he'], + ]; + for (const [context, name] of contexts) { + const { completionContext } = createLiquidParamsFromContext(context); + const { node, ancestors } = completionContext!; + expectPath(node, 'type', context).to.eql('VariableLookup'); + expectPath(node, 'name', context).to.eql(name); + expectPath(ancestors.at(-1), 'type', context).to.eql('BlockMarkup'); + expectPath(ancestors.at(-1), 'name.value', context).to.eql('card'); + } + }); + + it('recovers the argument names of unfinished block markup', async () => { + const context = `{% block 'card', heading: 'x', █, block.settings.id: 'y', id: 'z' %}{% endblock %}`; + const { completionContext } = createLiquidParamsFromContext(context); + const { ancestors } = completionContext!; + expectPath(ancestors.at(-1), 'args.0.name').to.eql('heading'); + expectPath(ancestors.at(-1), 'args.1.name').to.eql('id'); + expectPath(ancestors.at(-1), 'args.2').to.eql(undefined); + }); + + it('does not return an argument-name lookup outside a block argument-name slot', async () => { + const contexts = [ + `{% block 'card' █ %}{% endblock %}`, + `{% block 'card', heading: 'x' █ %}{% endblock %}`, + `{% block 'card', heading: █ %}{% endblock %}`, + `{% block 'card', heading: pr█ %}{% endblock %}`, + `{% block 'card', heading: 'x', block.█ %}{% endblock %}`, + ]; + for (const context of contexts) { + const { completionContext } = createLiquidParamsFromContext(context); + const { ancestors } = completionContext!; + expectPath(ancestors.at(-1), 'type', context).not.to.eql('BlockMarkup'); + } + }); + it('returns a variable lookup (placeholder mode)', async () => { const contexts = [ `{{ █`, diff --git a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts index 2a2c877e2..2b6b9372e 100644 --- a/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts +++ b/packages/theme-language-server-common/src/completions/params/LiquidCompletionParams.ts @@ -1,11 +1,15 @@ import { + BlockMarkup, findErrorNodeAtOffset, LiquidHtmlNode, LiquidTag, + MarkupToken, + MarkupTokenType, NodeTypes, Position, RAW_TAGS, VOID_ELEMENTS, + tokenizeMarkup, toTolerantLiquidHtmlAST, } from '@shopify/liquid-html-parser'; import { CompletionParams } from 'vscode-languageserver'; @@ -340,6 +344,21 @@ function findCompletionNode( position: current.position, } as any as LiquidHtmlNode; } + const blockMarkup = + current.name === 'block' + ? synthBlockMarkup( + source, + current.markupPosition.start, + current.markupPosition.end, + slot, + ) + : undefined; + if (blockMarkup) { + // The parser leaves `{% block 'card', ti %}` (a bare argument + // name) as raw string markup. Rebuild the `BlockMarkup` as the + // recovered lookup's parent so the block parameter provider fires. + finder.current = blockMarkup; + } finder.current = slot; } } else { @@ -718,9 +737,12 @@ function findCompletionNode( break; } - // `block` and `section` markup carry a name plus optional named - // arguments, so we walk them the same way as `content_for`. - case NodeTypes.BlockMarkup: + case NodeTypes.BlockMarkup: { + const child = blockMarkupChild(current, cursor, source); + if (child) finder.current = child; + break; + } + case NodeTypes.SectionMarkup: { if (isNotEmpty(current.args)) { const arg = last(current.args); @@ -954,9 +976,20 @@ function resolveErrorNodeCompletion( const nameMatch = region.match(/^\{%-?\s*[a-zA-Z_]\w*/); if ((close === -1 || close >= cursor) && nameMatch && /\s/.test(region[nameMatch[0].length])) { const lowerBound = tagOpen + nameMatch[0].length; + const lookup = recoverVariableLookup(source, lowerBound, cursor); + // An unclosed `{% block 'card', ti^` has no markup node, so rebuild the + // `BlockMarkup` the block parameter provider completes against. Only the + // text before the caret belongs to the tag: nothing closes it after. + const blockMarkup = /^\{%-?\s*block$/.test(nameMatch[0]) + ? synthBlockMarkup(source, lowerBound, cursor, lookup) + : undefined; return [ - recoverVariableLookup(source, lowerBound, cursor), - [...ancestors, synthNode(NodeTypes.LiquidTag, cursor)], + lookup, + [ + ...ancestors, + synthNode(NodeTypes.LiquidTag, cursor), + ...(blockMarkup ? [blockMarkup] : []), + ], ]; } } @@ -1781,6 +1814,107 @@ function synthContentForType( } as any as LiquidHtmlNode; } +/* + * Picks the `BlockMarkup` child at the caret. The block parameter provider + * completes a top-level `VariableLookup` whose parent is the `BlockMarkup`, so + * an argument name (`{% block 'card', ti^tle: 'x' %}`) or an empty argument + * slot after a comma (`{% block 'card', ^ %}`) becomes that lookup instead of + * the `NamedArgument` or the markup itself. + */ +function blockMarkupChild( + markup: BlockMarkup, + cursor: number, + source: string, +): LiquidHtmlNode | undefined { + const coveredArg = markup.args.find((arg) => isCovered(cursor, arg.position)); + if (coveredArg && cursor <= coveredArg.position.start + coveredArg.name.length) { + return synthVariableLookup( + cursor, + coveredArg.name.slice(0, cursor - coveredArg.position.start), + ); + } + + if (coveredArg) { + return isBlockArrayArgument(coveredArg) ? coveredArg.value.elements.at(-1) : coveredArg; + } + + if (isCovered(cursor, markup.name.position)) return markup.name; + + const previousEnd = + [markup.name, ...markup.args] + .map((node) => node.position.end) + .filter((end) => end <= cursor) + .at(-1) ?? markup.name.position.end; + if (!isArgumentSeparator(source.slice(previousEnd, cursor))) return undefined; + + return synthVariableLookup(cursor); +} + +/* + * Rebuilds the `BlockMarkup` of a `block` tag whose markup the parser could not + * finish (`{% block 'card', ti^ %}`, `{% block 'card', title: 'x', ^`). The + * rebuilt node carries the block name and the names of the arguments already + * typed, which is what the block parameter provider reads. Returns undefined + * unless the lookup sits in an argument-name slot after the quoted block name. + */ +function synthBlockMarkup( + source: string, + markupStart: number, + markupEnd: number, + lookup: LiquidHtmlNode, +): LiquidHtmlNode | undefined { + if (lookup.type !== NodeTypes.VariableLookup || lookup.lookups.length > 0) return undefined; + + const tokens = tokenizeMarkup(source.slice(markupStart, markupEnd), markupStart).filter( + (token) => token.type !== MarkupTokenType.EndOfString, + ); + const [name, comma] = tokens; + const tokenBeforeLookup = tokens.filter((token) => token.end <= lookup.position.start).at(-1); + if ( + name?.type !== MarkupTokenType.String || + comma?.type !== MarkupTokenType.Comma || + tokenBeforeLookup?.type !== MarkupTokenType.Comma + ) { + return undefined; + } + + return { + type: NodeTypes.BlockMarkup, + name: synthString( + { start: name.start, value: name.value.slice(1, -1), single: name.value[0] === "'" }, + name.end, + ), + args: synthArgumentNames(tokens), + position: { start: markupStart, end: markupEnd }, + } as any as LiquidHtmlNode; +} + +/* + * Recovers the `name:` part of each named argument in unfinished tag markup. + * The values are left out; only the names and their positions are known. + */ +function synthArgumentNames(tokens: MarkupToken[]): LiquidHtmlNode[] { + return tokens + .filter( + (token, i) => + token.type === MarkupTokenType.Id && + tokens[i - 1]?.type === MarkupTokenType.Comma && + tokens[i + 1]?.type === MarkupTokenType.Colon, + ) + .map( + (token) => + ({ + type: NodeTypes.NamedArgument, + name: token.value, + position: { start: token.start, end: token.end }, + }) as any as LiquidHtmlNode, + ); +} + +function isArgumentSeparator(text: string): boolean { + return /^\s*,\s*$/.test(text); +} + /* * The tolerant parser KEEPS a bare trailing `|` inside the markup node's span * (it is not dropped), so the markup covers the caret in both the empty-slot and diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts new file mode 100644 index 000000000..d2f3edb61 --- /dev/null +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts @@ -0,0 +1,512 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MetafieldDefinitionMap, ObjectEntry, SourceCodeType } from '@shopify/theme-check-common'; +import { + CompletionItemKind, + InsertTextFormat, + MarkupKind, + TextEdit, +} from 'vscode-languageserver-protocol'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { DocumentManager } from '../../documents'; +import { CompletionsProvider } from '../CompletionsProvider'; + +const template = (source: string) => ({ source, relativePath: 'templates/index.liquid' }); + +describe('Module: BlockParameterCompletionProvider', () => { + let documentManager: DocumentManager; + let provider: CompletionsProvider; + + beforeEach(() => { + documentManager = new DocumentManager( + undefined, + undefined, + undefined, + async () => 'theme', + async () => true, + ); + provider = createProvider(documentManager); + }); + + describe('when the target block has schema settings and LiquidDoc', () => { + beforeEach(() => { + openBlock( + documentManager, + 'card', + blockSource( + [ + { id: 'heading', type: 'text', label: 'Heading', info: 'Shown above the card' }, + { id: 'featured', type: 'product', label: 'Featured product' }, + ], + ['@param {string} tracking_id - Analytics identifier'], + ), + ); + }); + + it('offers schema setting IDs, LiquidDoc-only parameters, and content in one list', async () => { + await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ + 'content', + 'heading', + 'featured', + 'tracking_id', + ]); + }); + + it('describes each parameter with a property item and markdown documentation', async () => { + await expect(provider).to.complete( + template(`{% block 'card', █ %}{% endblock %}`), + expect.arrayContaining([ + expect.objectContaining({ + label: 'heading', + kind: CompletionItemKind.Property, + documentation: { + kind: MarkupKind.Markdown, + value: [ + '### `heading` (Optional): string', + '**Merchant-facing setting** (`text`)\n- Label: Heading\n- Info: Shown above the card', + ].join('\n\n'), + }, + }), + expect.objectContaining({ + label: 'tracking_id', + kind: CompletionItemKind.Property, + documentation: { + kind: MarkupKind.Markdown, + value: [ + '### `tracking_id`: string', + 'Analytics identifier', + 'Developer-only LiquidDoc parameter. Not merchant-facing.', + ].join('\n\n'), + }, + }), + ]), + ); + }); + + it('inserts a value template derived from the schema type', async () => { + // char 17 ⌄ + const source = `{% block 'card', █ %}{% endblock %}`; + const at17 = { start: { line: 0, character: 17 }, end: { line: 0, character: 17 } }; + + await expect(provider).to.complete(template(source), [ + expect.objectContaining({ + label: 'content', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range: at17, newText: "content: '$1'$0" }, + }), + expect.objectContaining({ + label: 'heading', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range: at17, newText: "heading: '$1'$0" }, + }), + expect.objectContaining({ + label: 'featured', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range: at17, newText: 'featured: ${1:}$0' }, + }), + expect.objectContaining({ + label: 'tracking_id', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: { range: at17, newText: "tracking_id: '$1'$0" }, + }), + ]); + }); + + it('does not offer arguments that the call already passes', async () => { + await expect(provider).to.complete( + template(`{% block 'card', heading: 'Sale', tracking_id: 'x', █ %}{% endblock %}`), + ['content', 'featured'], + ); + }); + + it('does not offer content when the call has a non-empty body', async () => { + await expect(provider).to.complete( + template(`{% block 'card', █ %}

Body

{% endblock %}`), + ['heading', 'featured', 'tracking_id'], + ); + }); + + it('offers content when the body is whitespace only', async () => { + await expect(provider).to.complete(template(`{% block 'card', █ %}\n \n{% endblock %}`), [ + 'content', + 'heading', + 'featured', + 'tracking_id', + ]); + }); + + it('filters by a partially typed argument name and replaces the partial', async () => { + // char 17 ⌄ ⌄ char 19 + const source = `{% block 'card', he█ %}{% endblock %}`; + const textEdit: TextEdit = { + range: { start: { line: 0, character: 17 }, end: { line: 0, character: 19 } }, + newText: "heading: '$1'$0", + }; + + await expect(provider).to.complete(template(source), [ + expect.objectContaining({ + label: 'heading', + insertTextFormat: InsertTextFormat.Snippet, + textEdit, + }), + ]); + expect(applyEdit(source, textEdit)).toBe(`{% block 'card', heading: '$1'$0 %}{% endblock %}`); + }); + + it('replaces only the name of an existing argument', async () => { + // char 17 ⌄ ⌄ char 24 + const source = `{% block 'card', hea█ding: 'Sale' %}{% endblock %}`; + const textEdit: TextEdit = { + range: { start: { line: 0, character: 17 }, end: { line: 0, character: 24 } }, + newText: 'heading', + }; + + await expect(provider).to.complete(template(source), [ + expect.objectContaining({ + label: 'heading', + insertTextFormat: InsertTextFormat.PlainText, + textEdit, + }), + ]); + expect(applyEdit(source, textEdit)).toBe(`{% block 'card', heading: 'Sale' %}{% endblock %}`); + }); + + it('inserts a new argument before an existing one', async () => { + // char 17 ⌄ + const source = `{% block 'card', █heading: 'Sale' %}{% endblock %}`; + const textEdit: TextEdit = { + range: { start: { line: 0, character: 17 }, end: { line: 0, character: 17 } }, + newText: 'featured: ${1:}$0, ', + }; + + await expect(provider).to.complete( + template(source), + expect.arrayContaining([expect.objectContaining({ label: 'featured', textEdit })]), + ); + await expect(provider).to.complete(template(source), ['content', 'featured', 'tracking_id']); + expect(applyEdit(source, textEdit)).toBe( + `{% block 'card', featured: \${1:}$0, heading: 'Sale' %}{% endblock %}`, + ); + }); + + it('completes an empty slot in multiline arguments', async () => { + const source = [ + `{% block 'card',`, + ` heading: 'Sale',`, + ` █`, + ` tracking_id: 'x'`, + `%}{% endblock %}`, + ].join('\n'); + const textEdit: TextEdit = { + range: { start: { line: 2, character: 2 }, end: { line: 2, character: 2 } }, + newText: 'featured: ${1:}$0,', + }; + + await expect(provider).to.complete(template(source), [ + expect.objectContaining({ label: 'content' }), + expect.objectContaining({ label: 'featured', textEdit }), + ]); + expect(applyEdit(source, textEdit)).toBe( + [ + `{% block 'card',`, + ` heading: 'Sale',`, + ` featured: \${1:}$0,`, + ` tracking_id: 'x'`, + `%}{% endblock %}`, + ].join('\n'), + ); + }); + + it('completes a partial name in multiline markup that does not parse yet', async () => { + const source = [`{% block 'card',`, ` heading: 'Sale',`, ` fe█`, `%}{% endblock %}`].join( + '\n', + ); + + await expect(provider).to.complete(template(source), [ + expect.objectContaining({ + label: 'featured', + textEdit: { + range: { start: { line: 2, character: 2 }, end: { line: 2, character: 4 } }, + newText: 'featured: ${1:}$0', + }, + }), + ]); + }); + + it('excludes existing arguments when the markup does not parse yet', async () => { + await expect(provider).to.complete( + template(`{% block 'card', heading: 'Sale', █, tracking_id: 'x' %}{% endblock %}`), + ['content', 'featured'], + ); + }); + + it.each([ + [`{% block 'card', █`, ['content', 'heading', 'featured', 'tracking_id']], + [`{% block 'card', heading: 'Sale', █`, ['content', 'featured', 'tracking_id']], + [`{% block 'card', heading: 'Sale', t█`, ['tracking_id']], + ])('completes argument names in an unclosed tag: %s', async (source, labels) => { + await expect(provider).to.complete(template(source), labels); + }); + + it('offers only plain parameter names, never block.settings or block.content', async () => { + await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ + 'content', + 'heading', + 'featured', + 'tracking_id', + ]); + await expect(provider).to.complete(template(`{% block 'card', block.█ %}{% endblock %}`), []); + }); + + it.each([ + [`{% block 'card' █ %}{% endblock %}`], + [`{% block 'card', heading: 'Sale' █ %}{% endblock %}`], + [`{% block 'card', heading: █ %}{% endblock %}`], + [`{% block 'card', heading: pro█ %}{% endblock %}`], + ])('does not offer parameters outside an argument-name slot: %s', async (source) => { + await expect(provider).to.complete(template(source), []); + }); + + it('reads the latest version of the target block', async () => { + const source = template(`{% block 'card', █ %}{% endblock %}`); + await expect(provider).to.complete(source, ['content', 'heading', 'featured', 'tracking_id']); + + documentManager.change( + 'file:///blocks/card.liquid', + blockSource([{ id: 'subheading', type: 'text', label: 'Subheading' }]), + 2, + ); + + await expect(provider).to.complete(source, ['content', 'subheading']); + }); + }); + + describe('requiredness', () => { + it.each([ + ['an implicit platform default', { id: 'value', type: 'checkbox' }, '${1:false}$0'], + [ + 'an explicit default', + { id: 'value', type: 'range', min: 0, max: 10, step: 1, default: 5 }, + '${1:0}$0', + ], + ['a type that does not permit a default', { id: 'value', type: 'image_picker' }, '${1:}$0'], + ])('marks a schema-only setting with %s optional', async (_case, setting, value) => { + openBlock(documentManager, 'card', blockSource([setting])); + + await expect(provider).to.complete(template(`{% block 'card', val█ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'value', + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### `value` \(Optional\)/), + }), + textEdit: expect.objectContaining({ newText: `value: ${value}` }), + }), + ]); + }); + + it('uses LiquidDoc requiredness for schema-backed parameters', async () => { + openBlock( + documentManager, + 'card', + blockSource( + [ + { id: 'heading', type: 'text', label: 'Heading' }, + { id: 'subheading', type: 'text', label: 'Subheading' }, + ], + ['@param {string} heading - Card heading', '@param {string} [subheading]'], + ), + ); + + await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ + expect.objectContaining({ label: 'content' }), + expect.objectContaining({ + label: 'heading', + documentation: expect.objectContaining({ + value: [ + '### `heading`: string', + 'Card heading', + '**Merchant-facing setting** (`text`)\n- Label: Heading', + ].join('\n\n'), + }), + }), + expect.objectContaining({ + label: 'subheading', + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### `subheading` \(Optional\): string/), + }), + }), + ]); + }); + + it('returns one item with the schema type for a compatible duplicate', async () => { + openBlock( + documentManager, + 'card', + blockSource( + [{ id: 'featured', type: 'product', label: 'Featured product' }], + ['@param {object} featured - The product to feature'], + ), + ); + + await expect(provider).to.complete(template(`{% block 'card', fe█ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'featured', + documentation: expect.objectContaining({ + value: [ + '### `featured`: product', + 'The product to feature', + '**Merchant-facing setting** (`product`)\n- Label: Featured product', + ].join('\n\n'), + }), + textEdit: expect.objectContaining({ newText: 'featured: ${1:}$0' }), + }), + ]); + }); + + it('offers built-in content as an optional string', async () => { + openBlock(documentManager, 'card', blockSource([])); + + await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'content', + documentation: expect.objectContaining({ + value: [ + '### `content` (Optional): string', + 'Built-in parameter. A non-empty block body supplies `content` and takes precedence over a `content:` argument.', + ].join('\n\n'), + }), + textEdit: expect.objectContaining({ newText: "content: '$1'$0" }), + }), + ]); + }); + + it('uses LiquidDoc requiredness for content', async () => { + openBlock(documentManager, 'card', blockSource([], ['@param {string} content - Card body'])); + + await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'content', + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### `content`: string\n\nCard body\n\n/), + }), + }), + ]); + }); + }); + + describe('when the tag cannot be resolved', () => { + it('offers nothing for a missing target block', async () => { + await expect(provider).to.complete(template(`{% block 'missing', █ %}{% endblock %}`), []); + }); + }); + + describe('block tag completion', () => { + beforeEach(() => { + provider = createProvider(documentManager, { tags: [{ name: 'block' }] }); + openBlock(documentManager, 'card', blockSource([{ id: 'heading', type: 'text' }])); + }); + + it('does not offer the block tag in an argument slot of an existing block tag', async () => { + await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ + 'content', + 'heading', + ]); + }); + + it('offers the block tag in the body of an existing block tag', async () => { + await expect(provider).to.complete( + template(`{% block 'card', heading: 'Sale' %}{% bl█ %}{% endblock %}`), + ['block'], + ); + }); + }); + + describe('when completing block.settings in the target block', () => { + beforeEach(() => { + provider = createProvider(documentManager, { objects: [blockObject] }); + }); + + it('offers the schema setting IDs with their schema-derived types', async () => { + await expect(provider).to.complete( + { + relativePath: 'blocks/card.liquid', + source: [ + '{{ block.settings.█ }}', + blockSource([ + { id: 'heading', type: 'text', label: 'Heading' }, + { id: 'featured', type: 'product', label: 'Featured product' }, + ]), + ].join('\n'), + }, + [ + expect.objectContaining({ + label: 'featured', + documentation: { kind: MarkupKind.Markdown, value: '### featured: `product`' }, + }), + expect.objectContaining({ + label: 'heading', + documentation: { kind: MarkupKind.Markdown, value: '### heading: `string`' }, + }), + ], + ); + }); + }); +}); + +const blockObject: ObjectEntry = { + name: 'block', + access: { global: false, parents: [], template: [] }, + return_type: [], + properties: [{ name: 'settings', return_type: [{ type: 'untyped', name: '' }] }], +}; + +function createProvider( + documentManager: DocumentManager, + { tags = [], objects = [] }: { tags?: { name: string }[]; objects?: ObjectEntry[] } = {}, +) { + return new CompletionsProvider({ + documentManager, + themeDocset: { + filters: async () => [], + objects: async () => objects, + liquidDrops: async () => objects, + tags: async () => tags, + systemTranslations: async () => ({}), + }, + getMetafieldDefinitions: async (_rootUri: string) => ({}) as MetafieldDefinitionMap, + findThemeRootURI: async (_uri: string) => 'file:///path/to', + getThemeBlockSchema: async (_uri, name) => { + const block = documentManager.get(blockUri(name)); + if (block?.type !== SourceCodeType.LiquidHtml) return undefined; + return block.getSchema(); + }, + getDocDefinitionForURI: async (_uri, _category, name) => { + const block = documentManager.get(blockUri(name)); + if (block?.type !== SourceCodeType.LiquidHtml) return undefined; + return block.getLiquidDoc(); + }, + }); +} + +function openBlock(documentManager: DocumentManager, name: string, source: string) { + documentManager.open(blockUri(name), source, 1); +} + +function blockUri(name: string) { + return `file:///blocks/${name}.liquid`; +} + +function blockSource(settings: Record[], params: string[] = []): string { + return [ + ...(params.length > 0 + ? ['{% doc %}', ...params.map((param) => ` ${param}`), '{% enddoc %}'] + : []), + '{% schema %}', + JSON.stringify({ name: 'Card', settings }), + '{% endschema %}', + ].join('\n'); +} + +function applyEdit(source: string, textEdit: TextEdit) { + const textDocument = TextDocument.create('', 'liquid', 0, source.replace('█', '')); + return TextDocument.applyEdits(textDocument, [textEdit]); +} diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts new file mode 100644 index 000000000..b8daa2369 --- /dev/null +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts @@ -0,0 +1,142 @@ +import { + BlockMarkup, + LiquidHtmlNode, + LiquidVariableLookup, + NodeTypes, +} from '@shopify/liquid-html-parser'; +import { BLOCK_CONTENT_PARAMETER, BlockParameter } from '@shopify/theme-check-common'; +import { + CompletionItem, + CompletionItemKind, + InsertTextFormat, + MarkupKind, + Range, + TextEdit, +} from 'vscode-languageserver'; +import { AugmentedLiquidSourceCode } from '../../documents'; +import { formatBlockParameter, GetBlockParametersForURI } from '../../utils/blockParameters'; +import { getParameterCompletionTemplate } from '../../utils/liquidDoc'; +import { LiquidCompletionParams } from '../params'; +import { Provider } from './common'; + +/** + * Offers the target block's parameters as named arguments of a `block` tag: + * schema setting IDs, LiquidDoc parameters, and the built-in `content`. + * + * @example {% block 'card', █ %} + */ +export class BlockParameterCompletionProvider implements Provider { + constructor(private readonly getBlockParametersForURI: GetBlockParametersForURI) {} + + async completions(params: LiquidCompletionParams): Promise { + if (!params.completionContext) return []; + + const { node, ancestors } = params.completionContext; + const blockMarkup = ancestors.at(-1); + if (node?.type !== NodeTypes.VariableLookup || node.lookups.length > 0) return []; + if (blockMarkup?.type !== NodeTypes.BlockMarkup) return []; + + const parameters = await this.getBlockParametersForURI( + params.textDocument.uri, + blockMarkup.name.value, + ); + if (!parameters) return []; + + const partial = node.name ?? ''; + const unavailableNames = providedArgumentNames(node, blockMarkup, ancestors.at(-2)); + + return [...parameters.values()] + .filter(({ name }) => name.startsWith(partial) && !unavailableNames.has(name)) + .map((parameter) => toCompletionItem(parameter, node, params.document)); + } +} + +/** + * The names the call already provides, except the argument name being typed + * over. A non-empty parsed body provides `content`. + */ +function providedArgumentNames( + node: LiquidVariableLookup, + blockMarkup: BlockMarkup, + blockTag: LiquidHtmlNode | undefined, +): Set { + const names = blockMarkup.args.filter((arg) => !isTypedOver(arg, node)).map((arg) => arg.name); + + if (blockTag?.type === NodeTypes.LiquidTag && blockTag.children?.length) { + names.push(BLOCK_CONTENT_PARAMETER); + } + + return new Set(names); +} + +function isTypedOver(arg: BlockMarkup['args'][number], node: LiquidVariableLookup): boolean { + return node.name !== '' && arg.position.start === node.position.start; +} + +function toCompletionItem( + parameter: BlockParameter, + node: LiquidVariableLookup, + document: AugmentedLiquidSourceCode, +): CompletionItem { + const { textEdit, insertTextFormat } = argumentNameEdit(parameter, node, document); + + return { + label: parameter.name, + kind: CompletionItemKind.Property, + documentation: { + kind: MarkupKind.Markdown, + value: formatBlockParameter(parameter), + }, + insertTextFormat, + textEdit, + }; +} + +/** + * Builds the edit for one argument-name slot: + * - empty slot (`, █`): insert `name: value`, plus a comma before an + * argument that follows; + * - existing argument name (`ti█tle: 'x'`): replace only the name; + * - partial name (`ti█`): replace the partial with `name: value`. + */ +function argumentNameEdit( + parameter: BlockParameter, + node: LiquidVariableLookup, + document: AugmentedLiquidSourceCode, +): { textEdit: TextEdit; insertTextFormat: InsertTextFormat } { + const { source, textDocument } = document; + const template = getParameterCompletionTemplate(parameter.name, parameter.type ?? null); + const remainingText = source.slice(node.position.end); + + if (node.name === '') { + const cursor = textDocument.positionAt(node.position.end); + return { + textEdit: TextEdit.insert(cursor, template + followingArgumentSeparator(remainingText)), + insertTextFormat: InsertTextFormat.Snippet, + }; + } + + const nameSuffix = remainingText.match(/^[\w-]*/)![0]; + const range = Range.create( + textDocument.positionAt(node.position.start), + textDocument.positionAt(node.position.end + nameSuffix.length), + ); + + if (/^\s*:/.test(remainingText.slice(nameSuffix.length))) { + return { + textEdit: TextEdit.replace(range, parameter.name), + insertTextFormat: InsertTextFormat.PlainText, + }; + } + + return { + textEdit: TextEdit.replace(range, template), + insertTextFormat: InsertTextFormat.Snippet, + }; +} + +function followingArgumentSeparator(remainingText: string): string { + if (/^[a-zA-Z_]/.test(remainingText)) return ', '; + if (/^\s+[a-zA-Z_]/.test(remainingText)) return ','; + return ''; +} diff --git a/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.spec.ts index e627bdfc5..ca466464a 100644 --- a/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.spec.ts @@ -373,4 +373,92 @@ describe('Module: LiquidTagsCompletionProvider', async () => { await expect(provider).to.complete('{% for█ i in (1..3)', ['for']); await expect(provider).to.complete('{% render█ "markup"', ['render']); }); + + describe('block tag placement', () => { + const THEME_ROOT = 'file:///path/to'; + + function createProvider(themeRootUri: string | null) { + return new CompletionsProvider({ + documentManager: new DocumentManager(), + themeDocset: { + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [{ name: 'block' }, { name: 'break' }], + systemTranslations: async () => ({}), + }, + getMetafieldDefinitions: async (_rootUri: string) => ({}) as MetafieldDefinitionMap, + findThemeRootURI: async (_uri: string) => themeRootUri, + }); + } + + it.each([ + 'templates/index.liquid', + 'templates/customers/account.liquid', + 'templates/metaobject/book.liquid', + 'layout/theme.liquid', + ])('offers the block tag in %s', async (relativePath) => { + provider = createProvider(THEME_ROOT); + await expect(provider).to.complete({ relativePath, source: '{% b█ %}' }, ['block', 'break']); + }); + + it.each([ + 'blocks/card.liquid', + 'sections/header.liquid', + 'snippets/card.liquid', + 'layout/nested/theme.liquid', + 'file.liquid', + 'snippets/templates/card.liquid', + 'sections/layout/theme.liquid', + ])('does not offer the block tag in %s', async (relativePath) => { + provider = createProvider(THEME_ROOT); + await expect(provider).to.complete({ relativePath, source: '{% b█ %}' }, ['break']); + }); + + it.each([ + ['file:///path/to/templates', 'templates/snippets/card.liquid'], + ['file:///path/to/templates', 'templates/card.liquid'], + ['file:///path/to/layout', 'layout/card.liquid'], + ['file:///path/to/layout', 'layout/sections/header.liquid'], + ])( + 'does not offer the block tag when the theme root %s names the directory of %s', + async (themeRootUri, relativePath) => { + provider = createProvider(themeRootUri); + await expect(provider).to.complete({ relativePath, source: '{% b█ %}' }, ['break']); + }, + ); + + it.each([ + ['file:///path/to/templates', 'templates/templates/index.liquid'], + ['file:///path/to/layout', 'layout/layout/theme.liquid'], + ])( + 'offers the block tag relative to the theme root %s in %s', + async (themeRootUri, relativePath) => { + provider = createProvider(themeRootUri); + await expect(provider).to.complete({ relativePath, source: '{% b█ %}' }, [ + 'block', + 'break', + ]); + }, + ); + + it('does not offer the block tag when the theme root is unknown', async () => { + provider = createProvider(null); + await expect(provider).to.complete( + { relativePath: 'templates/index.liquid', source: '{% b█ %}' }, + ['break'], + ); + }); + + it('offers the block tag nested in the body of another block tag', async () => { + provider = createProvider(THEME_ROOT); + await expect(provider).to.complete( + { + relativePath: 'templates/index.liquid', + source: "{% block 'card' %}{% b█ %}{% endblock %}", + }, + ['block', 'break'], + ); + }); + }); }); diff --git a/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts index 583ececfb..34a6edb9d 100644 --- a/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/LiquidTagsCompletionProvider.ts @@ -7,7 +7,7 @@ import { NodeTypes, RAW_TAGS, } from '@shopify/liquid-html-parser'; -import { TagEntry, ThemeDocset } from '@shopify/theme-check-common'; +import { path, TagEntry, ThemeDocset } from '@shopify/theme-check-common'; import { CompletionItem, CompletionItemKind, @@ -18,12 +18,16 @@ import { TextEdit, } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import { FindThemeRootURI } from '../../internal-types'; import { findLast } from '../../utils'; import { LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem, sortByName } from './common'; export class LiquidTagsCompletionProvider implements Provider { - constructor(private readonly themeDocset: ThemeDocset) {} + constructor( + private readonly themeDocset: ThemeDocset, + private readonly findThemeRootURI: FindThemeRootURI, + ) {} async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; @@ -40,8 +44,12 @@ export class LiquidTagsCompletionProvider implements Provider { const blockParent = findParentNode(partial, ancestors); const tags = await this.themeDocset.tags(); - return tags - .filter(({ name }) => name.startsWith(partial)) + const matchingTags = tags.filter(({ name }) => name.startsWith(partial)); + const blockTagAllowed = + matchingTags.some(({ name }) => name === NamedTags.block) && + (await this.allowsBlockTag(params.textDocument.uri)); + return matchingTags + .filter(({ name }) => name !== NamedTags.block || blockTagAllowed) .sort(sortByName) .map(toCompletionItem(params, node, ancestors, partial)) .concat( @@ -54,8 +62,26 @@ export class LiquidTagsCompletionProvider implements Provider { : [], ); } + + /** + * Mirrors the ValidBlockTagPlacement check: the `block` tag belongs in + * Liquid files under `templates/`, including nested directories, and + * directly under `layout/`, relative to the theme root. Placement is per + * file, so nested `block` tags in those files are allowed. Without a theme + * root, placement is unknown and `block` is not offered. + */ + private async allowsBlockTag(uri: string): Promise { + const rootUri = await this.findThemeRootURI(uri); + if (!rootUri) return false; + + const relativePath = path.relative(uri, rootUri); + return TEMPLATE_FILE_PATTERN.test(relativePath) || LAYOUT_FILE_PATTERN.test(relativePath); + } } +const TEMPLATE_FILE_PATTERN = /^templates\/(?:[^/]+\/)*[^/]+\.liquid$/; +const LAYOUT_FILE_PATTERN = /^layout\/[^/]+\.liquid$/; + function findParentNode(partial: string, ancestors: LiquidHtmlNode[]): LiquidTag | undefined { if (!'end'.startsWith(partial)) return; diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts index 6fa56ec32..87c506286 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts @@ -24,10 +24,11 @@ export class ObjectCompletionProvider implements Provider { return []; } - // ContentFor and Render uses VariableLookup to support completion of NamedParams. + // ContentFor, Render, and Block use VariableLookup to support completion of NamedParams. if ( parentNode?.type === NodeTypes.ContentForMarkup || - parentNode?.type === NodeTypes.RenderMarkup + parentNode?.type === NodeTypes.RenderMarkup || + parentNode?.type === NodeTypes.BlockMarkup ) { return []; } diff --git a/packages/theme-language-server-common/src/completions/providers/index.ts b/packages/theme-language-server-common/src/completions/providers/index.ts index 33f6181a0..8ff0399a9 100644 --- a/packages/theme-language-server-common/src/completions/providers/index.ts +++ b/packages/theme-language-server-common/src/completions/providers/index.ts @@ -1,3 +1,4 @@ +export { BlockParameterCompletionProvider } from './BlockParameterCompletionProvider'; export { ContentForCompletionProvider } from './ContentForCompletionProvider'; export { ContentForBlockTypeCompletionProvider } from './ContentForBlockTypeCompletionProvider'; export { ContentForParameterCompletionProvider } from './ContentForParameterCompletionProvider'; diff --git a/packages/theme-language-server-common/src/hover/HoverProvider.ts b/packages/theme-language-server-common/src/hover/HoverProvider.ts index 22210611c..af5ffb1a5 100644 --- a/packages/theme-language-server-common/src/hover/HoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/HoverProvider.ts @@ -8,9 +8,11 @@ import { import { Hover, HoverParams } from 'vscode-languageserver'; import { TypeSystem } from '../TypeSystem'; import { DocumentManager } from '../documents'; +import { GetThemeBlockSchema } from '../json/JSONContributions'; import { GetTranslationsForURI } from '../translations'; import { BaseHoverProvider } from './BaseHoverProvider'; import { + BlockParameterHoverProvider, HtmlAttributeHoverProvider, HtmlTagHoverProvider, LiquidFilterArgumentHoverProvider, @@ -28,6 +30,7 @@ import { GetThemeSettingsSchemaForURI } from '../settings'; import { LiquidDocTagHoverProvider } from './providers/LiquidDocTagHoverProvider'; import { ContentForArgumentHoverProvider } from './providers/ContentForArgumentHoverProvider'; import { ContentForTypeHoverProvider } from './providers/ContentForTypeHoverProvider'; +import { makeGetBlockParametersForURI } from '../utils/blockParameters'; export class HoverProvider { private providers: BaseHoverProvider[] = []; @@ -39,6 +42,7 @@ export class HoverProvider { readonly getSettingsSchemaForURI: GetThemeSettingsSchemaForURI = async () => [], readonly getDocDefinitionForURI: GetDocDefinitionForURI = async () => undefined, readonly getModeForURI: (uri: string) => Promise = async () => 'theme', + readonly getThemeBlockSchema: GetThemeBlockSchema = async () => undefined, ) { const typeSystem = new TypeSystem( themeDocset, @@ -49,6 +53,9 @@ export class HoverProvider { this.providers = [ new ContentForArgumentHoverProvider(getDocDefinitionForURI), new ContentForTypeHoverProvider(getDocDefinitionForURI), + new BlockParameterHoverProvider( + makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), + ), new LiquidTagHoverProvider(themeDocset), new LiquidFilterArgumentHoverProvider(themeDocset), new LiquidFilterHoverProvider(themeDocset), diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts new file mode 100644 index 000000000..cdccd9576 --- /dev/null +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MetafieldDefinitionMap, ObjectEntry, SourceCodeType } from '@shopify/theme-check-common'; +import { DocumentManager } from '../../documents'; +import { HoverProvider } from '../HoverProvider'; + +const template = (source: string) => ({ source, relativePath: 'templates/index.liquid' }); + +const HEADING_SETTING = { + id: 'heading', + type: 'text', + label: 'Heading', + info: 'Shown above the card', +}; +const HEADING_MERCHANT_NOTE = + '**Merchant-facing setting** (`text`)\n- Label: Heading\n- Info: Shown above the card'; +const BUILT_IN_CONTENT_NOTE = + 'Built-in parameter. A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; + +describe('Module: BlockParameterHoverProvider', () => { + let documentManager: DocumentManager; + let provider: HoverProvider; + + beforeEach(() => { + documentManager = new DocumentManager( + undefined, + undefined, + undefined, + async () => 'theme', + async () => true, + ); + provider = createProvider(documentManager); + }); + + it('describes a schema-only argument as an optional merchant-facing setting', async () => { + openBlock(documentManager, blockSource([HEADING_SETTING])); + + await expect(provider).to.hover( + template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), + ['### `heading` (Optional): string', HEADING_MERCHANT_NOTE].join('\n\n'), + ); + }); + + it('merges a required LiquidDoc echo into one hover', async () => { + openBlock( + documentManager, + blockSource([HEADING_SETTING], ['@param {string} heading - Card heading']), + ); + + await expect(provider).to.hover( + template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), + ['### `heading`: string', 'Card heading', HEADING_MERCHANT_NOTE].join('\n\n'), + ); + }); + + it('merges an optional LiquidDoc echo into one hover', async () => { + openBlock( + documentManager, + blockSource([HEADING_SETTING], ['@param {string} [heading] - Card heading']), + ); + + await expect(provider).to.hover( + template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), + ['### `heading` (Optional): string', 'Card heading', HEADING_MERCHANT_NOTE].join('\n\n'), + ); + }); + + it('keeps the schema type when LiquidDoc declares a compatible less-specific type', async () => { + openBlock( + documentManager, + blockSource( + [{ id: 'featured', type: 'product', label: 'Featured product' }], + ['@param {object} featured - The product to feature'], + ), + ); + + await expect(provider).to.hover( + template(`{% block 'card', feat█ured: product %}{% endblock %}`), + [ + '### `featured`: product', + 'The product to feature', + '**Merchant-facing setting** (`product`)\n- Label: Featured product', + ].join('\n\n'), + ); + }); + + it('identifies a LiquidDoc-only parameter as developer-only', async () => { + openBlock( + documentManager, + blockSource([], ['@param {string} tracking_id - Analytics identifier']), + ); + + await expect(provider).to.hover( + template(`{% block 'card', track█ing_id: 'x' %}{% endblock %}`), + [ + '### `tracking_id`: string', + 'Analytics identifier', + 'Developer-only LiquidDoc parameter. Not merchant-facing.', + ].join('\n\n'), + ); + }); + + it('describes built-in content as an optional string', async () => { + openBlock(documentManager, blockSource([])); + + await expect(provider).to.hover( + template(`{% block 'card', cont█ent: body %}{% endblock %}`), + ['### `content` (Optional): string', BUILT_IN_CONTENT_NOTE].join('\n\n'), + ); + }); + + it('uses LiquidDoc requiredness and text for content', async () => { + openBlock(documentManager, blockSource([], ['@param {string} content - Card body'])); + + await expect(provider).to.hover( + template(`{% block 'card', cont█ent: body %}{% endblock %}`), + ['### `content`: string', 'Card body', BUILT_IN_CONTENT_NOTE].join('\n\n'), + ); + }); + + it('types schema-backed content as string even when its setting type is not', async () => { + openBlock(documentManager, blockSource([{ id: 'content', type: 'number', label: 'Body' }])); + + await expect(provider).to.hover( + template(`{% block 'card', cont█ent: body %}{% endblock %}`), + [ + '### `content` (Optional): string', + '**Merchant-facing setting** (`number`)\n- Label: Body', + BUILT_IN_CONTENT_NOTE, + ].join('\n\n'), + ); + }); + + it.each([ + [`{% block 'card', unkn█own: 'x' %}{% endblock %}`], + [`{% block 'card', block.settings.hea█ding: 'x' %}{% endblock %}`], + [`{% block 'missing', hea█ding: 'x' %}{% endblock %}`], + ])('returns null for an argument outside the interface: %s', async (source) => { + openBlock(documentManager, blockSource([HEADING_SETTING])); + + await expect(provider).to.hover(template(source), null); + }); + + describe('when hovering block.settings in the target block', () => { + beforeEach(() => { + provider = createProvider(documentManager, [blockObject]); + }); + + it('keeps the schema-derived type', async () => { + await expect(provider).to.hover( + { + relativePath: 'blocks/card.liquid', + source: ['{{ block.settings.hea█ding }}', blockSource([HEADING_SETTING])].join('\n'), + }, + '### heading: `string`', + ); + }); + }); +}); + +const blockObject: ObjectEntry = { + name: 'block', + access: { global: false, parents: [], template: [] }, + return_type: [], + properties: [{ name: 'settings', return_type: [{ type: 'untyped', name: '' }] }], +}; + +function createProvider(documentManager: DocumentManager, objects: ObjectEntry[] = []) { + return new HoverProvider( + documentManager, + { + filters: async () => [], + objects: async () => objects, + liquidDrops: async () => objects, + tags: async () => [], + systemTranslations: async () => ({}), + }, + async (_rootUri: string) => ({}) as MetafieldDefinitionMap, + async () => ({}), + async () => [], + async (_uri, _category, name) => { + const block = documentManager.get(blockUri(name)); + if (block?.type !== SourceCodeType.LiquidHtml) return undefined; + return block.getLiquidDoc(); + }, + async () => 'theme', + async (_uri, name) => { + const block = documentManager.get(blockUri(name)); + if (block?.type !== SourceCodeType.LiquidHtml) return undefined; + return block.getSchema(); + }, + ); +} + +function openBlock(documentManager: DocumentManager, source: string) { + documentManager.open(blockUri('card'), source, 1); +} + +function blockUri(name: string) { + return `file:///blocks/${name}.liquid`; +} + +function blockSource(settings: Record[], params: string[] = []): string { + return [ + ...(params.length > 0 + ? ['{% doc %}', ...params.map((param) => ` ${param}`), '{% enddoc %}'] + : []), + '{% schema %}', + JSON.stringify({ name: 'Card', settings }), + '{% endschema %}', + ].join('\n'); +} diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts new file mode 100644 index 000000000..5d41a265a --- /dev/null +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts @@ -0,0 +1,39 @@ +import { NodeTypes } from '@shopify/liquid-html-parser'; +import { LiquidHtmlNode } from '@shopify/theme-check-common'; +import { Hover, HoverParams, MarkupKind } from 'vscode-languageserver'; +import { formatBlockParameter, GetBlockParametersForURI } from '../../utils/blockParameters'; +import { BaseHoverProvider } from '../BaseHoverProvider'; + +/** + * Documents a named argument of a `block` tag with the target block's merged + * schema, LiquidDoc, and built-in parameter definition. + * + * @example {% block 'card', hea█ding: 'Sale' %} + */ +export class BlockParameterHoverProvider implements BaseHoverProvider { + constructor(private readonly getBlockParametersForURI: GetBlockParametersForURI) {} + + async hover( + currentNode: LiquidHtmlNode, + ancestors: LiquidHtmlNode[], + params: HoverParams, + ): Promise { + const blockMarkup = ancestors.at(-1); + if (currentNode.type !== NodeTypes.NamedArgument) return null; + if (blockMarkup?.type !== NodeTypes.BlockMarkup) return null; + + const parameters = await this.getBlockParametersForURI( + params.textDocument.uri, + blockMarkup.name.value, + ); + const parameter = parameters?.get(currentNode.name); + if (!parameter) return null; + + return { + contents: { + kind: MarkupKind.Markdown, + value: formatBlockParameter(parameter), + }, + }; + } +} diff --git a/packages/theme-language-server-common/src/hover/providers/index.ts b/packages/theme-language-server-common/src/hover/providers/index.ts index 234bce653..09b7ee215 100644 --- a/packages/theme-language-server-common/src/hover/providers/index.ts +++ b/packages/theme-language-server-common/src/hover/providers/index.ts @@ -1,3 +1,4 @@ +export { BlockParameterHoverProvider } from './BlockParameterHoverProvider'; export { LiquidTagHoverProvider } from './LiquidTagHoverProvider'; export { LiquidFilterHoverProvider } from './LiquidFilterHoverProvider'; export { LiquidFilterArgumentHoverProvider } from './LiquidFilterArgumentHoverProvider'; diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts index a13d4164b..419acdd3e 100644 --- a/packages/theme-language-server-common/src/server/startServer.ts +++ b/packages/theme-language-server-common/src/server/startServer.ts @@ -341,6 +341,8 @@ export function startServer( getMetafieldDefinitions, getDocDefinitionForURI, getModeForURI, + getThemeBlockSchema, + findThemeRootURI, }); const hoverProvider = new HoverProvider( documentManager, @@ -350,6 +352,7 @@ export function startServer( getThemeSettingsSchemaForURI, getDocDefinitionForURI, getModeForURI, + getThemeBlockSchema, ); const executeCommandProvider = new ExecuteCommandProvider( diff --git a/packages/theme-language-server-common/src/utils/blockParameters.ts b/packages/theme-language-server-common/src/utils/blockParameters.ts new file mode 100644 index 000000000..e7114f877 --- /dev/null +++ b/packages/theme-language-server-common/src/utils/blockParameters.ts @@ -0,0 +1,83 @@ +import { + BLOCK_CONTENT_PARAMETER, + BlockParameter, + BlockParameters, + GetDocDefinitionForURI, + isBlockSchema, + makeGetBlockParameters, + Setting, +} from '@shopify/theme-check-common'; +import { GetThemeBlockSchema } from '../json/JSONContributions'; +import { formatLiquidDocParameter } from './liquidDoc'; +import { blockName } from './uri'; + +/** Resolves the parameters that a `block` tag in `uri` can pass to `blockName`. */ +export type GetBlockParametersForURI = ( + uri: string, + blockName: string, +) => Promise; + +/** + * Returns a lookup that creates a new block parameter resolver on every call. + * Each completion or hover request makes one call, so the next request sees + * edits to the target block instead of a stale interface. + */ +export function makeGetBlockParametersForURI( + getThemeBlockSchema: GetThemeBlockSchema, + getDocDefinitionForURI: GetDocDefinitionForURI, +): GetBlockParametersForURI { + return (uri, targetBlockName) => { + const getBlockParameters = makeGetBlockParameters({ + async getBlockSchema(name) { + const schema = await getThemeBlockSchema(uri, name); + return isBlockSchema(schema) ? schema : undefined; + }, + getDocDefinition: (relativePath) => + getDocDefinitionForURI(uri, 'blocks', blockName(relativePath)), + }); + + return getBlockParameters(targetBlockName); + }; +} + +/** + * Markdown documentation for a block parameter. Schema owns the type and the + * merchant-facing details; LiquidDoc owns the description and requiredness. + */ +export function formatBlockParameter(parameter: BlockParameter): string { + const heading = formatLiquidDocParameter( + { + nodeType: 'param', + name: parameter.name, + type: parameter.type ?? null, + description: parameter.liquidDoc?.description ?? null, + required: parameter.required, + }, + true, + ); + + return [heading, ...sourceNotes(parameter)].join('\n\n'); +} + +const BUILT_IN_CONTENT_NOTE = + 'Built-in parameter. A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; + +const DEVELOPER_ONLY_NOTE = 'Developer-only LiquidDoc parameter. Not merchant-facing.'; + +function sourceNotes(parameter: BlockParameter): string[] { + const { name, schemaSetting } = parameter; + const notes: string[] = []; + if (schemaSetting) notes.push(merchantSettingNote(schemaSetting)); + if (name === BLOCK_CONTENT_PARAMETER) notes.push(BUILT_IN_CONTENT_NOTE); + if (!schemaSetting && name !== BLOCK_CONTENT_PARAMETER) notes.push(DEVELOPER_ONLY_NOTE); + return notes; +} + +function merchantSettingNote(setting: Setting.InputSetting): string { + const details = [ + setting.label ? `- Label: ${setting.label}` : undefined, + setting.info ? `- Info: ${setting.info}` : undefined, + ].filter((detail): detail is string => detail !== undefined); + + return [`**Merchant-facing setting** (\`${setting.type}\`)`, ...details].join('\n'); +} From 09c8be57e93ae1147451cf2e2ad350bba0cfc9bb Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 15:00:26 -0400 Subject: [PATCH 2/7] Limit block parameter hover range --- .../src/hover/HoverProvider.ts | 1 + .../BlockParameterHoverProvider.spec.ts | 19 +++++++++++++++++++ .../providers/BlockParameterHoverProvider.ts | 15 ++++++++++++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/theme-language-server-common/src/hover/HoverProvider.ts b/packages/theme-language-server-common/src/hover/HoverProvider.ts index af5ffb1a5..c04f984bb 100644 --- a/packages/theme-language-server-common/src/hover/HoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/HoverProvider.ts @@ -54,6 +54,7 @@ export class HoverProvider { new ContentForArgumentHoverProvider(getDocDefinitionForURI), new ContentForTypeHoverProvider(getDocDefinitionForURI), new BlockParameterHoverProvider( + documentManager, makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), ), new LiquidTagHoverProvider(themeDocset), diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts index cdccd9576..5c17ba8e1 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -40,6 +40,25 @@ describe('Module: BlockParameterHoverProvider', () => { ); }); + it('limits the hover range to the argument name', async () => { + openBlock(documentManager, blockSource([HEADING_SETTING])); + const uri = 'file:///templates/index.liquid'; + const source = `{% block 'card', hea█ding: 'Sale' %}

Body

{% endblock %}`; + const cursor = source.indexOf('█'); + documentManager.open(uri, source.replace('█', ''), 0); + const textDocument = documentManager.get(uri)!.textDocument; + + const hover = await provider.hover({ + textDocument: { uri }, + position: textDocument.positionAt(cursor), + }); + + expect(hover?.range).toEqual({ + start: { line: 0, character: 17 }, + end: { line: 0, character: 24 }, + }); + }); + it('merges a required LiquidDoc echo into one hover', async () => { openBlock( documentManager, diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts index 5d41a265a..25ca17256 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts @@ -1,6 +1,7 @@ import { NodeTypes } from '@shopify/liquid-html-parser'; import { LiquidHtmlNode } from '@shopify/theme-check-common'; -import { Hover, HoverParams, MarkupKind } from 'vscode-languageserver'; +import { Hover, HoverParams, MarkupKind, Range } from 'vscode-languageserver'; +import { DocumentManager } from '../../documents'; import { formatBlockParameter, GetBlockParametersForURI } from '../../utils/blockParameters'; import { BaseHoverProvider } from '../BaseHoverProvider'; @@ -11,7 +12,10 @@ import { BaseHoverProvider } from '../BaseHoverProvider'; * @example {% block 'card', hea█ding: 'Sale' %} */ export class BlockParameterHoverProvider implements BaseHoverProvider { - constructor(private readonly getBlockParametersForURI: GetBlockParametersForURI) {} + constructor( + private readonly documentManager: DocumentManager, + private readonly getBlockParametersForURI: GetBlockParametersForURI, + ) {} async hover( currentNode: LiquidHtmlNode, @@ -27,13 +31,18 @@ export class BlockParameterHoverProvider implements BaseHoverProvider { blockMarkup.name.value, ); const parameter = parameters?.get(currentNode.name); - if (!parameter) return null; + const textDocument = this.documentManager.get(params.textDocument.uri)?.textDocument; + if (!parameter || !textDocument) return null; return { contents: { kind: MarkupKind.Markdown, value: formatBlockParameter(parameter), }, + range: Range.create( + textDocument.positionAt(currentNode.position.start), + textDocument.positionAt(currentNode.position.start + currentNode.name.length), + ), }; } } From b57f06c9ba0992fa5356ac7d04422dbfc2df6549 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 15:01:11 -0400 Subject: [PATCH 3/7] Revert "Limit block parameter hover range" This reverts commit cf4e00750353932ea7f621411a2a3d6317521040. --- .../src/hover/HoverProvider.ts | 1 - .../BlockParameterHoverProvider.spec.ts | 19 ------------------- .../providers/BlockParameterHoverProvider.ts | 15 +++------------ 3 files changed, 3 insertions(+), 32 deletions(-) diff --git a/packages/theme-language-server-common/src/hover/HoverProvider.ts b/packages/theme-language-server-common/src/hover/HoverProvider.ts index c04f984bb..af5ffb1a5 100644 --- a/packages/theme-language-server-common/src/hover/HoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/HoverProvider.ts @@ -54,7 +54,6 @@ export class HoverProvider { new ContentForArgumentHoverProvider(getDocDefinitionForURI), new ContentForTypeHoverProvider(getDocDefinitionForURI), new BlockParameterHoverProvider( - documentManager, makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), ), new LiquidTagHoverProvider(themeDocset), diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts index 5c17ba8e1..cdccd9576 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -40,25 +40,6 @@ describe('Module: BlockParameterHoverProvider', () => { ); }); - it('limits the hover range to the argument name', async () => { - openBlock(documentManager, blockSource([HEADING_SETTING])); - const uri = 'file:///templates/index.liquid'; - const source = `{% block 'card', hea█ding: 'Sale' %}

Body

{% endblock %}`; - const cursor = source.indexOf('█'); - documentManager.open(uri, source.replace('█', ''), 0); - const textDocument = documentManager.get(uri)!.textDocument; - - const hover = await provider.hover({ - textDocument: { uri }, - position: textDocument.positionAt(cursor), - }); - - expect(hover?.range).toEqual({ - start: { line: 0, character: 17 }, - end: { line: 0, character: 24 }, - }); - }); - it('merges a required LiquidDoc echo into one hover', async () => { openBlock( documentManager, diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts index 25ca17256..5d41a265a 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts @@ -1,7 +1,6 @@ import { NodeTypes } from '@shopify/liquid-html-parser'; import { LiquidHtmlNode } from '@shopify/theme-check-common'; -import { Hover, HoverParams, MarkupKind, Range } from 'vscode-languageserver'; -import { DocumentManager } from '../../documents'; +import { Hover, HoverParams, MarkupKind } from 'vscode-languageserver'; import { formatBlockParameter, GetBlockParametersForURI } from '../../utils/blockParameters'; import { BaseHoverProvider } from '../BaseHoverProvider'; @@ -12,10 +11,7 @@ import { BaseHoverProvider } from '../BaseHoverProvider'; * @example {% block 'card', hea█ding: 'Sale' %} */ export class BlockParameterHoverProvider implements BaseHoverProvider { - constructor( - private readonly documentManager: DocumentManager, - private readonly getBlockParametersForURI: GetBlockParametersForURI, - ) {} + constructor(private readonly getBlockParametersForURI: GetBlockParametersForURI) {} async hover( currentNode: LiquidHtmlNode, @@ -31,18 +27,13 @@ export class BlockParameterHoverProvider implements BaseHoverProvider { blockMarkup.name.value, ); const parameter = parameters?.get(currentNode.name); - const textDocument = this.documentManager.get(params.textDocument.uri)?.textDocument; - if (!parameter || !textDocument) return null; + if (!parameter) return null; return { contents: { kind: MarkupKind.Markdown, value: formatBlockParameter(parameter), }, - range: Range.create( - textDocument.positionAt(currentNode.position.start), - textDocument.positionAt(currentNode.position.start + currentNode.name.length), - ), }; } } From 37cd27259dfeec7cfc43ef1937ce12a058c3f5bc Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 15:22:45 -0400 Subject: [PATCH 4/7] Refine block parameter documentation --- .../block-parameter-language-features.md | 2 +- .../src/completions/CompletionsProvider.ts | 3 + .../BlockParameterCompletionProvider.spec.ts | 200 ++++++++++++++---- .../BlockParameterCompletionProvider.ts | 44 +++- .../src/hover/HoverProvider.ts | 2 + .../BlockParameterHoverProvider.spec.ts | 101 +++++++-- .../providers/BlockParameterHoverProvider.ts | 32 ++- .../src/server/startServer.ts | 2 + .../src/utils/blockParameters.ts | 111 +++++++--- 9 files changed, 388 insertions(+), 109 deletions(-) diff --git a/.changeset/block-parameter-language-features.md b/.changeset/block-parameter-language-features.md index 91e729ba3..27bd70643 100644 --- a/.changeset/block-parameter-language-features.md +++ b/.changeset/block-parameter-language-features.md @@ -4,4 +4,4 @@ Complete and hover `block` tag arguments from the target block's schema settings, LiquidDoc parameters, and built-in `content`. -Completion offers plain parameter names with schema-derived value templates and skips arguments the call already passes. Hover shows the schema type, merchant-facing setting details, LiquidDoc text and requiredness, and marks LiquidDoc-only parameters as developer-only. The `block` tag is offered only in `templates/**/*.liquid` and `layout/*.liquid` files. +Completion offers plain parameter names with schema-derived value templates and skips arguments the call already passes. Completion and hover show requiredness, the Liquid type, the LiquidDoc description, and the theme setting's label and info, resolving `t:` keys from the default schema locale. The `block` tag is offered only in `templates/**/*.liquid` and `layout/*.liquid` files. diff --git a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts index 667b2cb1a..328861f94 100644 --- a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts +++ b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts @@ -40,6 +40,7 @@ export interface CompletionProviderDependencies { documentManager: DocumentManager; themeDocset: ThemeDocset; getTranslationsForURI?: GetTranslationsForURI; + getSchemaTranslationsForURI?: GetTranslationsForURI; getSnippetNamesForURI?: GetSnippetNamesForURI; getThemeSettingsSchemaForURI?: GetThemeSettingsSchemaForURI; getMetafieldDefinitions: (rootUri: string) => Promise; @@ -62,6 +63,7 @@ export class CompletionsProvider { themeDocset, getMetafieldDefinitions, getTranslationsForURI = async () => ({}), + getSchemaTranslationsForURI = async () => ({}), getSnippetNamesForURI = async () => [], getThemeSettingsSchemaForURI = async () => [], getDocDefinitionForURI = async (uri, _relativePath) => ({ uri }), @@ -87,6 +89,7 @@ export class CompletionsProvider { new ContentForParameterCompletionProvider(getDocDefinitionForURI), new BlockParameterCompletionProvider( makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), + getSchemaTranslationsForURI, ), new HtmlTagCompletionProvider(), new HtmlAttributeCompletionProvider(documentManager), diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts index d2f3edb61..8d0a6be53 100644 --- a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts @@ -1,17 +1,30 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { MetafieldDefinitionMap, ObjectEntry, SourceCodeType } from '@shopify/theme-check-common'; +import { + MetafieldDefinitionMap, + ObjectEntry, + SourceCodeType, + Translations, +} from '@shopify/theme-check-common'; import { CompletionItemKind, InsertTextFormat, + MarkupContent, MarkupKind, TextEdit, } from 'vscode-languageserver-protocol'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { DocumentManager } from '../../documents'; +import { HoverProvider } from '../../hover'; import { CompletionsProvider } from '../CompletionsProvider'; const template = (source: string) => ({ source, relativePath: 'templates/index.liquid' }); +const CONTENT_PRECEDENCE_NOTE = + 'A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; +const SCHEMA_TRANSLATIONS: Translations = { + settings: { heading: { label: 'Translated heading', info: 'Translated info' } }, +}; + describe('Module: BlockParameterCompletionProvider', () => { let documentManager: DocumentManager; let provider: CompletionsProvider; @@ -51,37 +64,49 @@ describe('Module: BlockParameterCompletionProvider', () => { ]); }); - it('describes each parameter with a property item and markdown documentation', async () => { + it('describes each parameter with label details and markdown documentation', async () => { await expect(provider).to.complete( template(`{% block 'card', █ %}{% endblock %}`), expect.arrayContaining([ expect.objectContaining({ label: 'heading', + labelDetails: { detail: ' (optional)', description: 'string' }, kind: CompletionItemKind.Property, documentation: { kind: MarkupKind.Markdown, - value: [ - '### `heading` (Optional): string', - '**Merchant-facing setting** (`text`)\n- Label: Heading\n- Info: Shown above the card', - ].join('\n\n'), + value: '**Theme setting**\n\nHeading\n\nShown above the card', }, }), expect.objectContaining({ label: 'tracking_id', + labelDetails: { description: 'string' }, kind: CompletionItemKind.Property, - documentation: { - kind: MarkupKind.Markdown, - value: [ - '### `tracking_id`: string', - 'Analytics identifier', - 'Developer-only LiquidDoc parameter. Not merchant-facing.', - ].join('\n\n'), - }, + documentation: { kind: MarkupKind.Markdown, value: 'Analytics identifier' }, }), ]), ); }); + it('uses the completion documentation as the hover description', async () => { + const hoverProvider = createHoverProvider(documentManager); + const [completionItem] = await completionItems( + provider, + `{% block 'card', hea█ %}{% endblock %}`, + ); + const hover = await hoverAt( + hoverProvider, + `{% block 'card', hea█ding: 'Sale' %}{% endblock %}`, + ); + + expect(completionItem.label).toBe('heading'); + expect(hover).toBe( + [ + '### `heading` (optional): string', + (completionItem.documentation as MarkupContent).value, + ].join('\n\n'), + ); + }); + it('inserts a value template derived from the schema type', async () => { // char 17 ⌄ const source = `{% block 'card', █ %}{% endblock %}`; @@ -295,9 +320,7 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', val█ %}{% endblock %}`), [ expect.objectContaining({ label: 'value', - documentation: expect.objectContaining({ - value: expect.stringMatching(/^### `value` \(Optional\)/), - }), + labelDetails: expect.objectContaining({ detail: ' (optional)' }), textEdit: expect.objectContaining({ newText: `value: ${value}` }), }), ]); @@ -320,19 +343,14 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'content' }), expect.objectContaining({ label: 'heading', + labelDetails: { description: 'string' }, documentation: expect.objectContaining({ - value: [ - '### `heading`: string', - 'Card heading', - '**Merchant-facing setting** (`text`)\n- Label: Heading', - ].join('\n\n'), + value: ['Card heading', '**Theme setting**\n\nHeading'].join('\n\n'), }), }), expect.objectContaining({ label: 'subheading', - documentation: expect.objectContaining({ - value: expect.stringMatching(/^### `subheading` \(Optional\): string/), - }), + labelDetails: { detail: ' (optional)', description: 'string' }, }), ]); }); @@ -350,12 +368,9 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', fe█ %}{% endblock %}`), [ expect.objectContaining({ label: 'featured', + labelDetails: { description: 'product' }, documentation: expect.objectContaining({ - value: [ - '### `featured`: product', - 'The product to feature', - '**Merchant-facing setting** (`product`)\n- Label: Featured product', - ].join('\n\n'), + value: ['The product to feature', '**Theme setting**\n\nFeatured product'].join('\n\n'), }), textEdit: expect.objectContaining({ newText: 'featured: ${1:}$0' }), }), @@ -368,12 +383,8 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ expect.objectContaining({ label: 'content', - documentation: expect.objectContaining({ - value: [ - '### `content` (Optional): string', - 'Built-in parameter. A non-empty block body supplies `content` and takes precedence over a `content:` argument.', - ].join('\n\n'), - }), + labelDetails: { detail: ' (optional)', description: 'string' }, + documentation: expect.objectContaining({ value: CONTENT_PRECEDENCE_NOTE }), textEdit: expect.objectContaining({ newText: "content: '$1'$0" }), }), ]); @@ -385,14 +396,82 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ expect.objectContaining({ label: 'content', + labelDetails: { description: 'string' }, documentation: expect.objectContaining({ - value: expect.stringMatching(/^### `content`: string\n\nCard body\n\n/), + value: ['Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), }), }), ]); }); }); + describe('schema setting translations', () => { + it('keeps literal label and info unchanged', async () => { + openBlock( + documentManager, + 'card', + blockSource([ + { id: 'heading', type: 'text', label: 'Heading', info: 'Uses t:settings syntax' }, + ]), + ); + + await expect(provider).to.complete(template(`{% block 'card', hea█ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'heading', + documentation: expect.objectContaining({ + value: '**Theme setting**\n\nHeading\n\nUses t:settings syntax', + }), + }), + ]); + }); + + it('resolves translated label and info', async () => { + openBlock( + documentManager, + 'card', + blockSource([ + { + id: 'heading', + type: 'text', + label: 't:settings.heading.label', + info: 't:settings.heading.info', + }, + ]), + ); + + await expect(provider).to.complete(template(`{% block 'card', hea█ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'heading', + documentation: expect.objectContaining({ + value: '**Theme setting**\n\nTranslated heading\n\nTranslated info', + }), + }), + ]); + }); + + it('omits a translation key that has no translation', async () => { + openBlock( + documentManager, + 'card', + blockSource([ + { + id: 'heading', + type: 'text', + label: 't:settings.missing.label', + info: 't:settings.missing.info', + }, + ]), + ); + + await expect(provider).to.complete(template(`{% block 'card', hea█ %}{% endblock %}`), [ + expect.objectContaining({ + label: 'heading', + documentation: expect.objectContaining({ value: '**Theme setting**' }), + }), + ]); + }); + }); + describe('when the tag cannot be resolved', () => { it('offers nothing for a missing target block', async () => { await expect(provider).to.complete(template(`{% block 'missing', █ %}{% endblock %}`), []); @@ -473,6 +552,7 @@ function createProvider( systemTranslations: async () => ({}), }, getMetafieldDefinitions: async (_rootUri: string) => ({}) as MetafieldDefinitionMap, + getSchemaTranslationsForURI: async () => SCHEMA_TRANSLATIONS, findThemeRootURI: async (_uri: string) => 'file:///path/to', getThemeBlockSchema: async (_uri, name) => { const block = documentManager.get(blockUri(name)); @@ -487,6 +567,52 @@ function createProvider( }); } +function createHoverProvider(documentManager: DocumentManager) { + return new HoverProvider( + documentManager, + { + filters: async () => [], + objects: async () => [], + liquidDrops: async () => [], + tags: async () => [], + systemTranslations: async () => ({}), + }, + async (_rootUri: string) => ({}) as MetafieldDefinitionMap, + async () => ({}), + async () => [], + async (_uri, _category, name) => { + const block = documentManager.get(blockUri(name)); + if (block?.type !== SourceCodeType.LiquidHtml) return undefined; + return block.getLiquidDoc(); + }, + async () => 'theme', + async (_uri, name) => { + const block = documentManager.get(blockUri(name)); + if (block?.type !== SourceCodeType.LiquidHtml) return undefined; + return block.getSchema(); + }, + async () => SCHEMA_TRANSLATIONS, + ); +} + +async function completionItems(provider: CompletionsProvider, source: string) { + const position = openTemplate(provider.documentManager, source); + return provider.completions({ textDocument: { uri: TEMPLATE_URI }, position }); +} + +async function hoverAt(provider: HoverProvider, source: string) { + const position = openTemplate(provider.documentManager, source); + const hover = await provider.hover({ textDocument: { uri: TEMPLATE_URI }, position }); + return (hover?.contents as MarkupContent | undefined)?.value; +} + +const TEMPLATE_URI = 'file:///templates/index.liquid'; + +function openTemplate(documentManager: DocumentManager, source: string) { + documentManager.open(TEMPLATE_URI, source.replace('█', ''), 0); + return documentManager.get(TEMPLATE_URI)!.textDocument.positionAt(source.indexOf('█')); +} + function openBlock(documentManager: DocumentManager, name: string, source: string) { documentManager.open(blockUri(name), source, 1); } diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts index b8daa2369..11cd31fcc 100644 --- a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts @@ -4,7 +4,7 @@ import { LiquidVariableLookup, NodeTypes, } from '@shopify/liquid-html-parser'; -import { BLOCK_CONTENT_PARAMETER, BlockParameter } from '@shopify/theme-check-common'; +import { BLOCK_CONTENT_PARAMETER, BlockParameter, Translations } from '@shopify/theme-check-common'; import { CompletionItem, CompletionItemKind, @@ -14,7 +14,12 @@ import { TextEdit, } from 'vscode-languageserver'; import { AugmentedLiquidSourceCode } from '../../documents'; -import { formatBlockParameter, GetBlockParametersForURI } from '../../utils/blockParameters'; +import { GetTranslationsForURI } from '../../translations'; +import { + formatBlockParameterDescription, + getBlockParameterTranslations, + GetBlockParametersForURI, +} from '../../utils/blockParameters'; import { getParameterCompletionTemplate } from '../../utils/liquidDoc'; import { LiquidCompletionParams } from '../params'; import { Provider } from './common'; @@ -26,7 +31,10 @@ import { Provider } from './common'; * @example {% block 'card', █ %} */ export class BlockParameterCompletionProvider implements Provider { - constructor(private readonly getBlockParametersForURI: GetBlockParametersForURI) {} + constructor( + private readonly getBlockParametersForURI: GetBlockParametersForURI, + private readonly getSchemaTranslationsForURI: GetTranslationsForURI, + ) {} async completions(params: LiquidCompletionParams): Promise { if (!params.completionContext) return []; @@ -45,9 +53,18 @@ export class BlockParameterCompletionProvider implements Provider { const partial = node.name ?? ''; const unavailableNames = providedArgumentNames(node, blockMarkup, ancestors.at(-2)); - return [...parameters.values()] - .filter(({ name }) => name.startsWith(partial) && !unavailableNames.has(name)) - .map((parameter) => toCompletionItem(parameter, node, params.document)); + const available = [...parameters.values()].filter( + ({ name }) => name.startsWith(partial) && !unavailableNames.has(name), + ); + const translations = await getBlockParameterTranslations( + this.getSchemaTranslationsForURI, + params.textDocument.uri, + available, + ); + + return available.map((parameter) => + toCompletionItem(parameter, translations, node, params.document), + ); } } @@ -73,20 +90,27 @@ function isTypedOver(arg: BlockMarkup['args'][number], node: LiquidVariableLooku return node.name !== '' && arg.position.start === node.position.start; } +/** + * The label stays the bare parameter name for filtering and insertion. + * Requiredness and type appear as label details. + */ function toCompletionItem( parameter: BlockParameter, + translations: Translations, node: LiquidVariableLookup, document: AugmentedLiquidSourceCode, ): CompletionItem { const { textEdit, insertTextFormat } = argumentNameEdit(parameter, node, document); + const description = formatBlockParameterDescription(parameter, translations); return { label: parameter.name, - kind: CompletionItemKind.Property, - documentation: { - kind: MarkupKind.Markdown, - value: formatBlockParameter(parameter), + labelDetails: { + detail: parameter.required ? undefined : ' (optional)', + description: parameter.type, }, + kind: CompletionItemKind.Property, + documentation: description ? { kind: MarkupKind.Markdown, value: description } : undefined, insertTextFormat, textEdit, }; diff --git a/packages/theme-language-server-common/src/hover/HoverProvider.ts b/packages/theme-language-server-common/src/hover/HoverProvider.ts index af5ffb1a5..ca6501e10 100644 --- a/packages/theme-language-server-common/src/hover/HoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/HoverProvider.ts @@ -43,6 +43,7 @@ export class HoverProvider { readonly getDocDefinitionForURI: GetDocDefinitionForURI = async () => undefined, readonly getModeForURI: (uri: string) => Promise = async () => 'theme', readonly getThemeBlockSchema: GetThemeBlockSchema = async () => undefined, + readonly getSchemaTranslationsForURI: GetTranslationsForURI = async () => ({}), ) { const typeSystem = new TypeSystem( themeDocset, @@ -55,6 +56,7 @@ export class HoverProvider { new ContentForTypeHoverProvider(getDocDefinitionForURI), new BlockParameterHoverProvider( makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), + getSchemaTranslationsForURI, ), new LiquidTagHoverProvider(themeDocset), new LiquidFilterArgumentHoverProvider(themeDocset), diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts index cdccd9576..5d467db2a 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { MetafieldDefinitionMap, ObjectEntry, SourceCodeType } from '@shopify/theme-check-common'; +import { + MetafieldDefinitionMap, + ObjectEntry, + SourceCodeType, + Translations, +} from '@shopify/theme-check-common'; import { DocumentManager } from '../../documents'; +import { GetTranslationsForURI } from '../../translations'; import { HoverProvider } from '../HoverProvider'; const template = (source: string) => ({ source, relativePath: 'templates/index.liquid' }); @@ -11,10 +17,12 @@ const HEADING_SETTING = { label: 'Heading', info: 'Shown above the card', }; -const HEADING_MERCHANT_NOTE = - '**Merchant-facing setting** (`text`)\n- Label: Heading\n- Info: Shown above the card'; -const BUILT_IN_CONTENT_NOTE = - 'Built-in parameter. A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; +const HEADING_THEME_SETTING = '**Theme setting**\n\nHeading\n\nShown above the card'; +const CONTENT_PRECEDENCE_NOTE = + 'A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; +const SCHEMA_TRANSLATIONS: Translations = { + settings: { heading: { label: 'Translated heading', info: 'Translated info' } }, +}; describe('Module: BlockParameterHoverProvider', () => { let documentManager: DocumentManager; @@ -31,15 +39,63 @@ describe('Module: BlockParameterHoverProvider', () => { provider = createProvider(documentManager); }); - it('describes a schema-only argument as an optional merchant-facing setting', async () => { + it('describes a schema-only argument as an optional theme setting', async () => { openBlock(documentManager, blockSource([HEADING_SETTING])); await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (Optional): string', HEADING_MERCHANT_NOTE].join('\n\n'), + ['### `heading` (optional): string', HEADING_THEME_SETTING].join('\n\n'), + ); + }); + + it('resolves translated setting label and info', async () => { + openBlock( + documentManager, + blockSource([ + { + id: 'heading', + type: 'text', + label: 't:settings.heading.label', + info: 't:settings.heading.info', + }, + ]), + ); + + await expect(provider).to.hover( + template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), + [ + '### `heading` (optional): string', + '**Theme setting**\n\nTranslated heading\n\nTranslated info', + ].join('\n\n'), ); }); + it.each([ + ['a missing translation', async () => SCHEMA_TRANSLATIONS], + [ + 'a failed translation lookup', + async (): Promise => { + throw new Error('locale file unavailable'); + }, + ], + ])( + 'omits a translation key with %s', + async (_case, getSchemaTranslationsForURI: GetTranslationsForURI) => { + provider = createProvider(documentManager, [], getSchemaTranslationsForURI); + openBlock( + documentManager, + blockSource([ + { id: 'heading', type: 'text', label: 't:settings.missing.label', info: 'Literal info' }, + ]), + ); + + await expect(provider).to.hover( + template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), + ['### `heading` (optional): string', '**Theme setting**\n\nLiteral info'].join('\n\n'), + ); + }, + ); + it('merges a required LiquidDoc echo into one hover', async () => { openBlock( documentManager, @@ -48,7 +104,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading`: string', 'Card heading', HEADING_MERCHANT_NOTE].join('\n\n'), + ['### `heading`: string', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -60,7 +116,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (Optional): string', 'Card heading', HEADING_MERCHANT_NOTE].join('\n\n'), + ['### `heading` (optional): string', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -78,12 +134,12 @@ describe('Module: BlockParameterHoverProvider', () => { [ '### `featured`: product', 'The product to feature', - '**Merchant-facing setting** (`product`)\n- Label: Featured product', + '**Theme setting**\n\nFeatured product', ].join('\n\n'), ); }); - it('identifies a LiquidDoc-only parameter as developer-only', async () => { + it('shows only the LiquidDoc description for a LiquidDoc-only parameter', async () => { openBlock( documentManager, blockSource([], ['@param {string} tracking_id - Analytics identifier']), @@ -91,11 +147,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', track█ing_id: 'x' %}{% endblock %}`), - [ - '### `tracking_id`: string', - 'Analytics identifier', - 'Developer-only LiquidDoc parameter. Not merchant-facing.', - ].join('\n\n'), + ['### `tracking_id`: string', 'Analytics identifier'].join('\n\n'), ); }); @@ -104,7 +156,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), - ['### `content` (Optional): string', BUILT_IN_CONTENT_NOTE].join('\n\n'), + ['### `content` (optional): string', CONTENT_PRECEDENCE_NOTE].join('\n\n'), ); }); @@ -113,7 +165,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), - ['### `content`: string', 'Card body', BUILT_IN_CONTENT_NOTE].join('\n\n'), + ['### `content`: string', 'Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), ); }); @@ -123,9 +175,9 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), [ - '### `content` (Optional): string', - '**Merchant-facing setting** (`number`)\n- Label: Body', - BUILT_IN_CONTENT_NOTE, + '### `content` (optional): string', + '**Theme setting**\n\nBody', + CONTENT_PRECEDENCE_NOTE, ].join('\n\n'), ); }); @@ -164,7 +216,11 @@ const blockObject: ObjectEntry = { properties: [{ name: 'settings', return_type: [{ type: 'untyped', name: '' }] }], }; -function createProvider(documentManager: DocumentManager, objects: ObjectEntry[] = []) { +function createProvider( + documentManager: DocumentManager, + objects: ObjectEntry[] = [], + getSchemaTranslationsForURI: GetTranslationsForURI = async () => SCHEMA_TRANSLATIONS, +) { return new HoverProvider( documentManager, { @@ -188,6 +244,7 @@ function createProvider(documentManager: DocumentManager, objects: ObjectEntry[] if (block?.type !== SourceCodeType.LiquidHtml) return undefined; return block.getSchema(); }, + getSchemaTranslationsForURI, ); } diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts index 5d41a265a..e8e4946d8 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts @@ -1,7 +1,13 @@ import { NodeTypes } from '@shopify/liquid-html-parser'; import { LiquidHtmlNode } from '@shopify/theme-check-common'; import { Hover, HoverParams, MarkupKind } from 'vscode-languageserver'; -import { formatBlockParameter, GetBlockParametersForURI } from '../../utils/blockParameters'; +import { GetTranslationsForURI } from '../../translations'; +import { + formatBlockParameterDescription, + formatBlockParameterHeading, + getBlockParameterTranslations, + GetBlockParametersForURI, +} from '../../utils/blockParameters'; import { BaseHoverProvider } from '../BaseHoverProvider'; /** @@ -11,7 +17,10 @@ import { BaseHoverProvider } from '../BaseHoverProvider'; * @example {% block 'card', hea█ding: 'Sale' %} */ export class BlockParameterHoverProvider implements BaseHoverProvider { - constructor(private readonly getBlockParametersForURI: GetBlockParametersForURI) {} + constructor( + private readonly getBlockParametersForURI: GetBlockParametersForURI, + private readonly getSchemaTranslationsForURI: GetTranslationsForURI, + ) {} async hover( currentNode: LiquidHtmlNode, @@ -29,11 +38,18 @@ export class BlockParameterHoverProvider implements BaseHoverProvider { const parameter = parameters?.get(currentNode.name); if (!parameter) return null; - return { - contents: { - kind: MarkupKind.Markdown, - value: formatBlockParameter(parameter), - }, - }; + const translations = await getBlockParameterTranslations( + this.getSchemaTranslationsForURI, + params.textDocument.uri, + [parameter], + ); + const value = [ + formatBlockParameterHeading(parameter), + formatBlockParameterDescription(parameter, translations), + ] + .filter(Boolean) + .join('\n\n'); + + return { contents: { kind: MarkupKind.Markdown, value } }; } } diff --git a/packages/theme-language-server-common/src/server/startServer.ts b/packages/theme-language-server-common/src/server/startServer.ts index 419acdd3e..00026e797 100644 --- a/packages/theme-language-server-common/src/server/startServer.ts +++ b/packages/theme-language-server-common/src/server/startServer.ts @@ -334,6 +334,7 @@ export function startServer( documentManager, themeDocset, getTranslationsForURI, + getSchemaTranslationsForURI, getSnippetNamesForURI, getThemeSettingsSchemaForURI, log, @@ -353,6 +354,7 @@ export function startServer( getDocDefinitionForURI, getModeForURI, getThemeBlockSchema, + getSchemaTranslationsForURI, ); const executeCommandProvider = new ExecuteCommandProvider( diff --git a/packages/theme-language-server-common/src/utils/blockParameters.ts b/packages/theme-language-server-common/src/utils/blockParameters.ts index e7114f877..0a279dc57 100644 --- a/packages/theme-language-server-common/src/utils/blockParameters.ts +++ b/packages/theme-language-server-common/src/utils/blockParameters.ts @@ -6,9 +6,10 @@ import { isBlockSchema, makeGetBlockParameters, Setting, + Translations, } from '@shopify/theme-check-common'; import { GetThemeBlockSchema } from '../json/JSONContributions'; -import { formatLiquidDocParameter } from './liquidDoc'; +import { GetTranslationsForURI, renderTranslation, translationValue } from '../translations'; import { blockName } from './uri'; /** Resolves the parameters that a `block` tag in `uri` can pass to `blockName`. */ @@ -41,43 +42,91 @@ export function makeGetBlockParametersForURI( } /** - * Markdown documentation for a block parameter. Schema owns the type and the - * merchant-facing details; LiquidDoc owns the description and requiredness. + * Loads the default schema translations when a parameter's schema setting + * uses a `t:` label or info. Returns no translations when none is needed or + * the lookup fails, so one missing locale file cannot fail the request. */ -export function formatBlockParameter(parameter: BlockParameter): string { - const heading = formatLiquidDocParameter( - { - nodeType: 'param', - name: parameter.name, - type: parameter.type ?? null, - description: parameter.liquidDoc?.description ?? null, - required: parameter.required, - }, - true, - ); +export async function getBlockParameterTranslations( + getSchemaTranslationsForURI: GetTranslationsForURI, + uri: string, + parameters: BlockParameter[], +): Promise { + if (!parameters.some(hasTranslatedSchemaText)) return {}; + + try { + return await getSchemaTranslationsForURI(uri); + } catch { + return {}; + } +} - return [heading, ...sourceNotes(parameter)].join('\n\n'); +/** + * Markdown heading for a block parameter hover, such as + * `### \`heading\` (optional): string`. + */ +export function formatBlockParameterHeading({ name, required, type }: BlockParameter): string { + const optional = required ? '' : ' (optional)'; + const typeSuffix = type ? `: ${type}` : ''; + return `### \`${name}\`${optional}${typeSuffix}`; } -const BUILT_IN_CONTENT_NOTE = - 'Built-in parameter. A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; +/** + * Markdown description shared by block parameter completion and hover: the + * verbatim LiquidDoc description, the theme setting's label and info, and the + * precedence rule for `content`. Empty when no source describes the parameter. + */ +export function formatBlockParameterDescription( + parameter: BlockParameter, + translations: Translations, +): string { + const { name, liquidDoc, schemaSetting } = parameter; + + return [ + liquidDoc?.description ?? undefined, + schemaSetting ? formatThemeSetting(schemaSetting, translations) : undefined, + name === BLOCK_CONTENT_PARAMETER ? CONTENT_PRECEDENCE_NOTE : undefined, + ] + .filter(isPresent) + .join('\n\n'); +} -const DEVELOPER_ONLY_NOTE = 'Developer-only LiquidDoc parameter. Not merchant-facing.'; +const CONTENT_PRECEDENCE_NOTE = + 'A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; -function sourceNotes(parameter: BlockParameter): string[] { - const { name, schemaSetting } = parameter; - const notes: string[] = []; - if (schemaSetting) notes.push(merchantSettingNote(schemaSetting)); - if (name === BLOCK_CONTENT_PARAMETER) notes.push(BUILT_IN_CONTENT_NOTE); - if (!schemaSetting && name !== BLOCK_CONTENT_PARAMETER) notes.push(DEVELOPER_ONLY_NOTE); - return notes; +function formatThemeSetting(setting: Setting.InputSetting, translations: Translations): string { + return [ + '**Theme setting**', + resolveSchemaText(setting.label, translations), + resolveSchemaText(setting.info, translations), + ] + .filter(isPresent) + .join('\n\n'); } -function merchantSettingNote(setting: Setting.InputSetting): string { - const details = [ - setting.label ? `- Label: ${setting.label}` : undefined, - setting.info ? `- Info: ${setting.info}` : undefined, - ].filter((detail): detail is string => detail !== undefined); +/** + * Returns literal schema text unchanged and resolves a `t:` key against the + * default schema translations. Returns undefined for a missing translation + * rather than showing the raw key. + */ +function resolveSchemaText( + text: string | undefined, + translations: Translations, +): string | undefined { + if (!text) return undefined; + if (!isTranslationKey(text)) return text; + + const translation = translationValue(text.substring(2), translations); + return translation ? renderTranslation(translation) : undefined; +} + +function hasTranslatedSchemaText({ schemaSetting }: BlockParameter): boolean { + return [schemaSetting?.label, schemaSetting?.info].some(isTranslationKey); +} + +function isTranslationKey(text: string | undefined): text is string { + return text?.startsWith('t:') ?? false; +} - return [`**Merchant-facing setting** (\`${setting.type}\`)`, ...details].join('\n'); +function isPresent(text: string | undefined): text is string { + return !!text; } From 037db55fb2270bd6a4d2c885937841643197cb79 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 16:07:19 -0400 Subject: [PATCH 5/7] Match LiquidDoc parameter presentation --- .../BlockParameterCompletionProvider.spec.ts | 63 ++++++++++++------- .../BlockParameterCompletionProvider.ts | 16 ++--- .../BlockParameterHoverProvider.spec.ts | 12 ++-- .../providers/BlockParameterHoverProvider.ts | 17 +++-- .../src/utils/blockParameters.ts | 34 +++++----- .../src/utils/liquidDoc.ts | 8 ++- 6 files changed, 81 insertions(+), 69 deletions(-) diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts index 8d0a6be53..c0a781e08 100644 --- a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts @@ -64,24 +64,28 @@ describe('Module: BlockParameterCompletionProvider', () => { ]); }); - it('describes each parameter with label details and markdown documentation', async () => { + it('describes each parameter with markdown documentation', async () => { await expect(provider).to.complete( template(`{% block 'card', █ %}{% endblock %}`), expect.arrayContaining([ expect.objectContaining({ label: 'heading', - labelDetails: { detail: ' (optional)', description: 'string' }, kind: CompletionItemKind.Property, documentation: { kind: MarkupKind.Markdown, - value: '**Theme setting**\n\nHeading\n\nShown above the card', + value: [ + '### `heading` (Optional): string', + '**Theme setting**\n\nHeading\n\nShown above the card', + ].join('\n\n'), }, }), expect.objectContaining({ label: 'tracking_id', - labelDetails: { description: 'string' }, kind: CompletionItemKind.Property, - documentation: { kind: MarkupKind.Markdown, value: 'Analytics identifier' }, + documentation: { + kind: MarkupKind.Markdown, + value: '### `tracking_id`: string\n\nAnalytics identifier', + }, }), ]), ); @@ -99,12 +103,7 @@ describe('Module: BlockParameterCompletionProvider', () => { ); expect(completionItem.label).toBe('heading'); - expect(hover).toBe( - [ - '### `heading` (optional): string', - (completionItem.documentation as MarkupContent).value, - ].join('\n\n'), - ); + expect(hover).toBe((completionItem.documentation as MarkupContent).value); }); it('inserts a value template derived from the schema type', async () => { @@ -320,7 +319,9 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', val█ %}{% endblock %}`), [ expect.objectContaining({ label: 'value', - labelDetails: expect.objectContaining({ detail: ' (optional)' }), + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### `value` \(Optional\)/), + }), textEdit: expect.objectContaining({ newText: `value: ${value}` }), }), ]); @@ -343,14 +344,17 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'content' }), expect.objectContaining({ label: 'heading', - labelDetails: { description: 'string' }, documentation: expect.objectContaining({ - value: ['Card heading', '**Theme setting**\n\nHeading'].join('\n\n'), + value: ['### `heading`: string', 'Card heading', '**Theme setting**\n\nHeading'].join( + '\n\n', + ), }), }), expect.objectContaining({ label: 'subheading', - labelDetails: { detail: ' (optional)', description: 'string' }, + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### `subheading` \(Optional\): string/), + }), }), ]); }); @@ -368,9 +372,12 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', fe█ %}{% endblock %}`), [ expect.objectContaining({ label: 'featured', - labelDetails: { description: 'product' }, documentation: expect.objectContaining({ - value: ['The product to feature', '**Theme setting**\n\nFeatured product'].join('\n\n'), + value: [ + '### `featured`: product', + 'The product to feature', + '**Theme setting**\n\nFeatured product', + ].join('\n\n'), }), textEdit: expect.objectContaining({ newText: 'featured: ${1:}$0' }), }), @@ -383,8 +390,9 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ expect.objectContaining({ label: 'content', - labelDetails: { detail: ' (optional)', description: 'string' }, - documentation: expect.objectContaining({ value: CONTENT_PRECEDENCE_NOTE }), + documentation: expect.objectContaining({ + value: ['### `content` (Optional): string', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + }), textEdit: expect.objectContaining({ newText: "content: '$1'$0" }), }), ]); @@ -396,9 +404,8 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', █ %}{% endblock %}`), [ expect.objectContaining({ label: 'content', - labelDetails: { description: 'string' }, documentation: expect.objectContaining({ - value: ['Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + value: ['### `content`: string', 'Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), }), }), ]); @@ -419,7 +426,10 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'heading', documentation: expect.objectContaining({ - value: '**Theme setting**\n\nHeading\n\nUses t:settings syntax', + value: [ + '### `heading` (Optional): string', + '**Theme setting**\n\nHeading\n\nUses t:settings syntax', + ].join('\n\n'), }), }), ]); @@ -443,7 +453,10 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'heading', documentation: expect.objectContaining({ - value: '**Theme setting**\n\nTranslated heading\n\nTranslated info', + value: [ + '### `heading` (Optional): string', + '**Theme setting**\n\nTranslated heading\n\nTranslated info', + ].join('\n\n'), }), }), ]); @@ -466,7 +479,9 @@ describe('Module: BlockParameterCompletionProvider', () => { await expect(provider).to.complete(template(`{% block 'card', hea█ %}{% endblock %}`), [ expect.objectContaining({ label: 'heading', - documentation: expect.objectContaining({ value: '**Theme setting**' }), + documentation: expect.objectContaining({ + value: '### `heading` (Optional): string\n\n**Theme setting**', + }), }), ]); }); diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts index 11cd31fcc..32b3cb276 100644 --- a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts @@ -16,7 +16,7 @@ import { import { AugmentedLiquidSourceCode } from '../../documents'; import { GetTranslationsForURI } from '../../translations'; import { - formatBlockParameterDescription, + formatBlockParameter, getBlockParameterTranslations, GetBlockParametersForURI, } from '../../utils/blockParameters'; @@ -90,10 +90,6 @@ function isTypedOver(arg: BlockMarkup['args'][number], node: LiquidVariableLooku return node.name !== '' && arg.position.start === node.position.start; } -/** - * The label stays the bare parameter name for filtering and insertion. - * Requiredness and type appear as label details. - */ function toCompletionItem( parameter: BlockParameter, translations: Translations, @@ -101,16 +97,14 @@ function toCompletionItem( document: AugmentedLiquidSourceCode, ): CompletionItem { const { textEdit, insertTextFormat } = argumentNameEdit(parameter, node, document); - const description = formatBlockParameterDescription(parameter, translations); return { label: parameter.name, - labelDetails: { - detail: parameter.required ? undefined : ' (optional)', - description: parameter.type, - }, kind: CompletionItemKind.Property, - documentation: description ? { kind: MarkupKind.Markdown, value: description } : undefined, + documentation: { + kind: MarkupKind.Markdown, + value: formatBlockParameter(parameter, translations), + }, insertTextFormat, textEdit, }; diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts index 5d467db2a..5c6a2276c 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -44,7 +44,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (optional): string', HEADING_THEME_SETTING].join('\n\n'), + ['### `heading` (Optional): string', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -64,7 +64,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), [ - '### `heading` (optional): string', + '### `heading` (Optional): string', '**Theme setting**\n\nTranslated heading\n\nTranslated info', ].join('\n\n'), ); @@ -91,7 +91,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (optional): string', '**Theme setting**\n\nLiteral info'].join('\n\n'), + ['### `heading` (Optional): string', '**Theme setting**\n\nLiteral info'].join('\n\n'), ); }, ); @@ -116,7 +116,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (optional): string', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), + ['### `heading` (Optional): string', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -156,7 +156,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), - ['### `content` (optional): string', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + ['### `content` (Optional): string', CONTENT_PRECEDENCE_NOTE].join('\n\n'), ); }); @@ -175,7 +175,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), [ - '### `content` (optional): string', + '### `content` (Optional): string', '**Theme setting**\n\nBody', CONTENT_PRECEDENCE_NOTE, ].join('\n\n'), diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts index e8e4946d8..0065a3a40 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts @@ -3,8 +3,7 @@ import { LiquidHtmlNode } from '@shopify/theme-check-common'; import { Hover, HoverParams, MarkupKind } from 'vscode-languageserver'; import { GetTranslationsForURI } from '../../translations'; import { - formatBlockParameterDescription, - formatBlockParameterHeading, + formatBlockParameter, getBlockParameterTranslations, GetBlockParametersForURI, } from '../../utils/blockParameters'; @@ -43,13 +42,11 @@ export class BlockParameterHoverProvider implements BaseHoverProvider { params.textDocument.uri, [parameter], ); - const value = [ - formatBlockParameterHeading(parameter), - formatBlockParameterDescription(parameter, translations), - ] - .filter(Boolean) - .join('\n\n'); - - return { contents: { kind: MarkupKind.Markdown, value } }; + return { + contents: { + kind: MarkupKind.Markdown, + value: formatBlockParameter(parameter, translations), + }, + }; } } diff --git a/packages/theme-language-server-common/src/utils/blockParameters.ts b/packages/theme-language-server-common/src/utils/blockParameters.ts index 0a279dc57..91aecea14 100644 --- a/packages/theme-language-server-common/src/utils/blockParameters.ts +++ b/packages/theme-language-server-common/src/utils/blockParameters.ts @@ -10,6 +10,7 @@ import { } from '@shopify/theme-check-common'; import { GetThemeBlockSchema } from '../json/JSONContributions'; import { GetTranslationsForURI, renderTranslation, translationValue } from '../translations'; +import { formatLiquidDocParameter } from './liquidDoc'; import { blockName } from './uri'; /** Resolves the parameters that a `block` tag in `uri` can pass to `blockName`. */ @@ -61,28 +62,27 @@ export async function getBlockParameterTranslations( } /** - * Markdown heading for a block parameter hover, such as - * `### \`heading\` (optional): string`. + * Markdown shared by block parameter completion and hover. The heading follows + * the existing LiquidDoc parameter format, followed by resolved theme-setting + * copy and the precedence rule for `content`. */ -export function formatBlockParameterHeading({ name, required, type }: BlockParameter): string { - const optional = required ? '' : ' (optional)'; - const typeSuffix = type ? `: ${type}` : ''; - return `### \`${name}\`${optional}${typeSuffix}`; -} - -/** - * Markdown description shared by block parameter completion and hover: the - * verbatim LiquidDoc description, the theme setting's label and info, and the - * precedence rule for `content`. Empty when no source describes the parameter. - */ -export function formatBlockParameterDescription( +export function formatBlockParameter( parameter: BlockParameter, translations: Translations, ): string { - const { name, liquidDoc, schemaSetting } = parameter; + const { name, type, required, liquidDoc, schemaSetting } = parameter; + const heading = formatLiquidDocParameter( + { + name, + type: type ?? null, + description: liquidDoc?.description ?? null, + required, + }, + true, + ); return [ - liquidDoc?.description ?? undefined, + heading, schemaSetting ? formatThemeSetting(schemaSetting, translations) : undefined, name === BLOCK_CONTENT_PARAMETER ? CONTENT_PRECEDENCE_NOTE : undefined, ] @@ -123,7 +123,7 @@ function hasTranslatedSchemaText({ schemaSetting }: BlockParameter): boolean { return [schemaSetting?.label, schemaSetting?.info].some(isTranslationKey); } -function isTranslationKey(text: string | undefined): text is string { +function isTranslationKey(text: string | undefined): boolean { return text?.startsWith('t:') ?? false; } diff --git a/packages/theme-language-server-common/src/utils/liquidDoc.ts b/packages/theme-language-server-common/src/utils/liquidDoc.ts index 66c62b235..6ce5665cd 100644 --- a/packages/theme-language-server-common/src/utils/liquidDoc.ts +++ b/packages/theme-language-server-common/src/utils/liquidDoc.ts @@ -7,7 +7,13 @@ import { } from '@shopify/theme-check-common'; export function formatLiquidDocParameter( - { name, type, description, required }: LiquidDocParameter, + { + name, + type, + description, + required, + }: Pick & + Partial>, heading: boolean = false, ) { const nameStr = required ? `\`${name}\`` : `\`${name}\` (Optional)`; From 89c44695c51c6354dd135ed998953a6240a7211a Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 16:17:09 -0400 Subject: [PATCH 6/7] Align block parameter hover syntax --- .../BlockParameterCompletionProvider.spec.ts | 22 +++++++------- .../BlockParameterHoverProvider.spec.ts | 20 ++++++------- .../src/utils/blockParameters.ts | 30 +++++++++---------- .../src/utils/liquidDoc.ts | 8 +---- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts index c0a781e08..469810260 100644 --- a/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts @@ -74,7 +74,7 @@ describe('Module: BlockParameterCompletionProvider', () => { documentation: { kind: MarkupKind.Markdown, value: [ - '### `heading` (Optional): string', + '### heading (Optional): `string`', '**Theme setting**\n\nHeading\n\nShown above the card', ].join('\n\n'), }, @@ -84,7 +84,7 @@ describe('Module: BlockParameterCompletionProvider', () => { kind: CompletionItemKind.Property, documentation: { kind: MarkupKind.Markdown, - value: '### `tracking_id`: string\n\nAnalytics identifier', + value: '### tracking_id: `string`\n\nAnalytics identifier', }, }), ]), @@ -320,7 +320,7 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'value', documentation: expect.objectContaining({ - value: expect.stringMatching(/^### `value` \(Optional\)/), + value: expect.stringMatching(/^### value \(Optional\)/), }), textEdit: expect.objectContaining({ newText: `value: ${value}` }), }), @@ -345,7 +345,7 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'heading', documentation: expect.objectContaining({ - value: ['### `heading`: string', 'Card heading', '**Theme setting**\n\nHeading'].join( + value: ['### heading: `string`', 'Card heading', '**Theme setting**\n\nHeading'].join( '\n\n', ), }), @@ -353,7 +353,7 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'subheading', documentation: expect.objectContaining({ - value: expect.stringMatching(/^### `subheading` \(Optional\): string/), + value: expect.stringMatching(/^### subheading \(Optional\): `string`/), }), }), ]); @@ -374,7 +374,7 @@ describe('Module: BlockParameterCompletionProvider', () => { label: 'featured', documentation: expect.objectContaining({ value: [ - '### `featured`: product', + '### featured: `product`', 'The product to feature', '**Theme setting**\n\nFeatured product', ].join('\n\n'), @@ -391,7 +391,7 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'content', documentation: expect.objectContaining({ - value: ['### `content` (Optional): string', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + value: ['### content (Optional): `string`', CONTENT_PRECEDENCE_NOTE].join('\n\n'), }), textEdit: expect.objectContaining({ newText: "content: '$1'$0" }), }), @@ -405,7 +405,7 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'content', documentation: expect.objectContaining({ - value: ['### `content`: string', 'Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + value: ['### content: `string`', 'Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), }), }), ]); @@ -427,7 +427,7 @@ describe('Module: BlockParameterCompletionProvider', () => { label: 'heading', documentation: expect.objectContaining({ value: [ - '### `heading` (Optional): string', + '### heading (Optional): `string`', '**Theme setting**\n\nHeading\n\nUses t:settings syntax', ].join('\n\n'), }), @@ -454,7 +454,7 @@ describe('Module: BlockParameterCompletionProvider', () => { label: 'heading', documentation: expect.objectContaining({ value: [ - '### `heading` (Optional): string', + '### heading (Optional): `string`', '**Theme setting**\n\nTranslated heading\n\nTranslated info', ].join('\n\n'), }), @@ -480,7 +480,7 @@ describe('Module: BlockParameterCompletionProvider', () => { expect.objectContaining({ label: 'heading', documentation: expect.objectContaining({ - value: '### `heading` (Optional): string\n\n**Theme setting**', + value: '### heading (Optional): `string`\n\n**Theme setting**', }), }), ]); diff --git a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts index 5c6a2276c..b30376774 100644 --- a/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -44,7 +44,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (Optional): string', HEADING_THEME_SETTING].join('\n\n'), + ['### heading (Optional): `string`', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -64,7 +64,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), [ - '### `heading` (Optional): string', + '### heading (Optional): `string`', '**Theme setting**\n\nTranslated heading\n\nTranslated info', ].join('\n\n'), ); @@ -91,7 +91,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (Optional): string', '**Theme setting**\n\nLiteral info'].join('\n\n'), + ['### heading (Optional): `string`', '**Theme setting**\n\nLiteral info'].join('\n\n'), ); }, ); @@ -104,7 +104,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading`: string', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), + ['### heading: `string`', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -116,7 +116,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), - ['### `heading` (Optional): string', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), + ['### heading (Optional): `string`', 'Card heading', HEADING_THEME_SETTING].join('\n\n'), ); }); @@ -132,7 +132,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', feat█ured: product %}{% endblock %}`), [ - '### `featured`: product', + '### featured: `product`', 'The product to feature', '**Theme setting**\n\nFeatured product', ].join('\n\n'), @@ -147,7 +147,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', track█ing_id: 'x' %}{% endblock %}`), - ['### `tracking_id`: string', 'Analytics identifier'].join('\n\n'), + ['### tracking_id: `string`', 'Analytics identifier'].join('\n\n'), ); }); @@ -156,7 +156,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), - ['### `content` (Optional): string', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + ['### content (Optional): `string`', CONTENT_PRECEDENCE_NOTE].join('\n\n'), ); }); @@ -165,7 +165,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), - ['### `content`: string', 'Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), + ['### content: `string`', 'Card body', CONTENT_PRECEDENCE_NOTE].join('\n\n'), ); }); @@ -175,7 +175,7 @@ describe('Module: BlockParameterHoverProvider', () => { await expect(provider).to.hover( template(`{% block 'card', cont█ent: body %}{% endblock %}`), [ - '### `content` (Optional): string', + '### content (Optional): `string`', '**Theme setting**\n\nBody', CONTENT_PRECEDENCE_NOTE, ].join('\n\n'), diff --git a/packages/theme-language-server-common/src/utils/blockParameters.ts b/packages/theme-language-server-common/src/utils/blockParameters.ts index 91aecea14..7ee78b0c1 100644 --- a/packages/theme-language-server-common/src/utils/blockParameters.ts +++ b/packages/theme-language-server-common/src/utils/blockParameters.ts @@ -10,7 +10,6 @@ import { } from '@shopify/theme-check-common'; import { GetThemeBlockSchema } from '../json/JSONContributions'; import { GetTranslationsForURI, renderTranslation, translationValue } from '../translations'; -import { formatLiquidDocParameter } from './liquidDoc'; import { blockName } from './uri'; /** Resolves the parameters that a `block` tag in `uri` can pass to `blockName`. */ @@ -62,27 +61,18 @@ export async function getBlockParameterTranslations( } /** - * Markdown shared by block parameter completion and hover. The heading follows - * the existing LiquidDoc parameter format, followed by resolved theme-setting - * copy and the precedence rule for `content`. + * Markdown shared by block parameter completion and hover. The heading uses + * the language server's `name: type` presentation, followed by LiquidDoc text, + * resolved theme-setting copy, and the precedence rule for `content`. */ export function formatBlockParameter( parameter: BlockParameter, translations: Translations, ): string { const { name, type, required, liquidDoc, schemaSetting } = parameter; - const heading = formatLiquidDocParameter( - { - name, - type: type ?? null, - description: liquidDoc?.description ?? null, - required, - }, - true, - ); - return [ - heading, + formatBlockParameterHeading(name, type, required), + liquidDoc?.description ?? undefined, schemaSetting ? formatThemeSetting(schemaSetting, translations) : undefined, name === BLOCK_CONTENT_PARAMETER ? CONTENT_PRECEDENCE_NOTE : undefined, ] @@ -93,6 +83,16 @@ export function formatBlockParameter( const CONTENT_PRECEDENCE_NOTE = 'A non-empty block body supplies `content` and takes precedence over a `content:` argument.'; +function formatBlockParameterHeading( + name: string, + type: string | undefined, + required: boolean, +): string { + const optional = required ? '' : ' (Optional)'; + const typeSuffix = type ? `: \`${type}\`` : ''; + return `### ${name}${optional}${typeSuffix}`; +} + function formatThemeSetting(setting: Setting.InputSetting, translations: Translations): string { return [ '**Theme setting**', diff --git a/packages/theme-language-server-common/src/utils/liquidDoc.ts b/packages/theme-language-server-common/src/utils/liquidDoc.ts index 6ce5665cd..66c62b235 100644 --- a/packages/theme-language-server-common/src/utils/liquidDoc.ts +++ b/packages/theme-language-server-common/src/utils/liquidDoc.ts @@ -7,13 +7,7 @@ import { } from '@shopify/theme-check-common'; export function formatLiquidDocParameter( - { - name, - type, - description, - required, - }: Pick & - Partial>, + { name, type, description, required }: LiquidDocParameter, heading: boolean = false, ) { const nameStr = required ? `\`${name}\`` : `\`${name}\` (Optional)`; From 935c02023e12ae92d013c1238eb05b5ca7dc9f77 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 29 Sep 2026 16:44:33 -0400 Subject: [PATCH 7/7] Complete block settings as variables --- .../block-parameter-language-features.md | 2 + .../src/TypeSystem.spec.ts | 154 ++++++++++++++++++ .../src/TypeSystem.ts | 54 +++++- .../ObjectCompletionProvider.spec.ts | 92 +++++++++++ .../LiquidObjectHoverProvider.spec.ts | 94 +++++++++++ 5 files changed, 392 insertions(+), 4 deletions(-) diff --git a/.changeset/block-parameter-language-features.md b/.changeset/block-parameter-language-features.md index 27bd70643..7316d4e24 100644 --- a/.changeset/block-parameter-language-features.md +++ b/.changeset/block-parameter-language-features.md @@ -5,3 +5,5 @@ Complete and hover `block` tag arguments from the target block's schema settings, LiquidDoc parameters, and built-in `content`. Completion offers plain parameter names with schema-derived value templates and skips arguments the call already passes. Completion and hover show requiredness, the Liquid type, the LiquidDoc description, and the theme setting's label and info, resolving `t:` keys from the default schema locale. The `block` tag is offered only in `templates/**/*.liquid` and `layout/*.liquid` files. + +Inside `blocks/*.liquid`, each schema setting ID also completes and hovers as a plain variable with the same schema-derived type as `block.settings.`. A same-named LiquidDoc parameter describes that variable, and the schema sets its type. diff --git a/packages/theme-language-server-common/src/TypeSystem.spec.ts b/packages/theme-language-server-common/src/TypeSystem.spec.ts index f728d6d69..2e6528753 100644 --- a/packages/theme-language-server-common/src/TypeSystem.spec.ts +++ b/packages/theme-language-server-common/src/TypeSystem.spec.ts @@ -1,6 +1,8 @@ import { AssignMarkup, + LiquidHtmlNode, LiquidVariable, + LiquidVariableLookup, LiquidVariableOutput, NamedTags, NodeTypes, @@ -11,6 +13,8 @@ import { path as pathUtils, BasicParamTypes, ObjectEntry, + SourceCodeType, + visit, } from '@shopify/theme-check-common'; import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; import { URI } from 'vscode-uri'; @@ -453,6 +457,136 @@ describe('Module: TypeSystem', () => { expect(inferredType).toEqual(expectedType); }); + describe('when a theme block schema defines settings', () => { + const articleCardSource = ` + {% doc %} + @param {object} image - Card image + @param {string} heading - Card heading + {% enddoc %} +
+ {{ image }} + {{ block.settings.image }} + {{ heading }} + {{ block.settings.heading }} + {{ block.settings.background_color }} +
+ {% schema %} + { + "name": "Article card", + "settings": [ + { "type": "color_background", "id": "background_color", "label": "Background" }, + { "type": "image_picker", "id": "image", "label": "Image" }, + { "type": "text", "id": "heading", "label": "Heading" } + ] + } + {% endschema %} + `; + const blockUri = 'file:///blocks/article-card.liquid'; + + it.each([ + ['background_color', 'string'], + ['image', 'image'], + ['heading', 'string'], + ])( + 'infers the same schema type for %s and its block.settings alias', + async (settingId, expectedType) => { + const ast = toLiquidHtmlAST(articleCardSource); + + const bareType = await typeSystem.inferType(liquidVariable(ast, settingId), ast, blockUri); + const aliasType = await typeSystem.inferType( + liquidVariable(ast, `block.settings.${settingId}`), + ast, + blockUri, + ); + + expect(bareType).toEqual(expectedType); + expect(aliasType).toEqual(expectedType); + }, + ); + + it('uses the schema type over a same-named LiquidDoc parameter', async () => { + const ast = toLiquidHtmlAST(articleCardSource); + + const inferredType = await typeSystem.inferType(liquidVariable(ast, 'image'), ast, blockUri); + + expect(inferredType).toEqual('image'); + }); + + describe('when a schema setting ID is reassigned', () => { + const reassignedSource = ` + {% doc %} + @param {number} image - Card image + {% enddoc %} + {{ background_color }} + {{ image }} + {% assign background_color = 1 %} + {% assign image = 'hero.png' %} + {{ background_color }} + {{ image }} + {% schema %} + { + "name": "Article card", + "settings": [ + { "type": "color_background", "id": "background_color", "label": "Background" }, + { "type": "image_picker", "id": "image", "label": "Image" } + ] + } + {% endschema %} + `; + + it('infers the schema type before the assignment and the assigned type after it', async () => { + const ast = toLiquidHtmlAST(reassignedSource); + const [beforeAssign, afterAssign] = liquidVariables(ast, 'background_color'); + + const typeBeforeAssign = await typeSystem.inferType(beforeAssign, ast, blockUri); + const typeAfterAssign = await typeSystem.inferType(afterAssign, ast, blockUri); + + expect(typeBeforeAssign).toEqual('string'); + expect(typeAfterAssign).toEqual('number'); + }); + + it('infers the schema type over a same-named LiquidDoc parameter until the assignment', async () => { + const ast = toLiquidHtmlAST(reassignedSource); + const [beforeAssign, afterAssign] = liquidVariables(ast, 'image'); + + const typeBeforeAssign = await typeSystem.inferType(beforeAssign, ast, blockUri); + const typeAfterAssign = await typeSystem.inferType(afterAssign, ast, blockUri); + + expect(typeBeforeAssign).toEqual('image'); + expect(typeAfterAssign).toEqual('string'); + }); + }); + + it('makes each setting ID available once, and never the setting type value', async () => { + const ast = toLiquidHtmlAST(articleCardSource); + const lookup = variableLookup(ast, 'heading'); + + const variables = await typeSystem.availableVariables(ast, '', lookup, blockUri); + const namesAndTypes = variables.map(({ entry, type }) => [entry.name, type]); + + expect(namesAndTypes.filter(([name]) => name === 'image')).toEqual([['image', 'image']]); + expect(namesAndTypes.filter(([name]) => name === 'heading')).toEqual([['heading', 'string']]); + expect(namesAndTypes).toContainEqual(['background_color', 'string']); + expect(namesAndTypes.map(([name]) => name)).not.toContain('color_background'); + expect(namesAndTypes.map(([name]) => name)).not.toContain('image_picker'); + }); + + it.each(['sections/article-card.liquid', 'snippets/article-card.liquid'])( + 'does not expose schema setting IDs as variables in %s', + async (relativePath) => { + const ast = toLiquidHtmlAST(articleCardSource); + + const inferredType = await typeSystem.inferType( + liquidVariable(ast, 'background_color'), + ast, + `file:///${relativePath}`, + ); + + expect(inferredType).toEqual('unknown'); + }, + ); + }); + // TODO it.skip('should support narrowing the type of blocks', async () => { const sourceCode = ` @@ -673,3 +807,23 @@ describe('Module: TypeSystem', () => { }); }); }); + +function liquidVariable(ast: LiquidHtmlNode, expression: string): LiquidVariable { + const [variable] = liquidVariables(ast, expression); + assert(variable, `expected a {{ ${expression} }} output`); + return variable; +} + +function liquidVariables(ast: LiquidHtmlNode, expression: string): LiquidVariable[] { + return visit(ast, { + LiquidVariable: (node) => node, + }).filter( + (node) => node.source.slice(node.position.start, node.position.end).trim() === expression, + ); +} + +function variableLookup(ast: LiquidHtmlNode, expression: string): LiquidVariableLookup { + const { expression: lookup } = liquidVariable(ast, expression); + assert(lookup.type === NodeTypes.VariableLookup); + return lookup; +} diff --git a/packages/theme-language-server-common/src/TypeSystem.ts b/packages/theme-language-server-common/src/TypeSystem.ts index 5a657b9dc..cc3ce23f4 100644 --- a/packages/theme-language-server-common/src/TypeSystem.ts +++ b/packages/theme-language-server-common/src/TypeSystem.ts @@ -289,8 +289,17 @@ export class TypeSystem { }); private async symbolsTable(partialAst: LiquidHtmlNode, uri: string): Promise { - const seedSymbolsTable = await this.seedSymbolsTable(uri); - return buildSymbolsTable(partialAst, seedSymbolsTable, await this.themeDocset.liquidDrops()); + const schemaSettingTypes = blockSchemaSettingTypes(partialAst, uri); + const seedSymbolsTable = seedSchemaSettingVariables( + await this.seedSymbolsTable(uri), + schemaSettingTypes, + ); + return buildSymbolsTable( + partialAst, + seedSymbolsTable, + await this.themeDocset.liquidDrops(), + schemaSettingTypes, + ); } /** @@ -489,6 +498,7 @@ function buildSymbolsTable( partialAst: LiquidHtmlNode, seedSymbolsTable: SymbolsTable, liquidDrops: ObjectEntry[], + schemaSettingTypes: SchemaSettingTypes, ): SymbolsTable { const typeRanges = visit(partialAst, { // {% assign x = foo.x | filter %} @@ -503,10 +513,13 @@ function buildSymbolsTable( // {% doc %} // @param {string} name - your name // {% enddoc %} + // + // In a theme block, a schema setting with the same ID decides the type. LiquidDocParamNode(node) { + const identifier = node.paramName.value; return { - identifier: node.paramName.value, - type: inferLiquidDocParamType(node, liquidDrops), + identifier, + type: schemaSettingTypes.get(identifier) ?? inferLiquidDocParamType(node, liquidDrops), range: [node.position.end], }; }, @@ -569,6 +582,39 @@ function buildSymbolsTable( }, seedSymbolsTable); } +/** The type of each schema setting ID, by setting ID. */ +type SchemaSettingTypes = Map; + +/** + * A theme block's schema settings are also plain variables in the block file. + * Other files get no schema setting variables. + */ +function blockSchemaSettingTypes(partialAst: LiquidHtmlNode, uri: string): SchemaSettingTypes { + if (!BLOCK_FILE_REGEX.test(path.normalize(uri))) return new Map(); + + return new Map( + schemaSettingsAsProperties(partialAst).map((setting) => [ + setting.name, + objectEntryType(setting), + ]), + ); +} + +/** + * Schema setting variables start at the top of the file, like other seeded + * variables, so later assigns and captures change their type by position. + */ +function seedSchemaSettingVariables( + seedSymbolsTable: SymbolsTable, + schemaSettingTypes: SchemaSettingTypes, +): SymbolsTable { + for (const [identifier, type] of schemaSettingTypes) { + seedSymbolsTable[identifier] ??= []; + seedSymbolsTable[identifier].push({ identifier, type, range: [0] }); + } + return seedSymbolsTable; +} + /** * Given a TypeRange['type'] (which may be lazy), resolve its type recursively. * diff --git a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts index cdb49970d..a3095f079 100644 --- a/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.spec.ts @@ -369,6 +369,65 @@ describe('Module: ObjectCompletionProvider', async () => { await expect(provider).to.complete('{% assign x = "█" %}', []); }); + describe('when a theme block schema defines settings', () => { + beforeEach(() => { + provider = new CompletionsProvider({ + documentManager: new DocumentManager(), + themeDocset: { + filters: async () => [], + objects: async () => blockSettingsObjects, + liquidDrops: async () => blockSettingsObjects, + tags: async () => [], + systemTranslations: async () => ({}), + }, + getMetafieldDefinitions: async () => ({}) as MetafieldDefinitionMap, + }); + }); + + it('completes a setting ID as a plain variable with its schema type', async () => { + await expect(provider).to.complete(articleCard('{{ backgr█ }}'), [ + expect.objectContaining({ + label: 'background_color', + documentation: expect.objectContaining({ value: '### background_color: `string`' }), + }), + ]); + }); + + it('gives the plain variable and block.settings alias the same type', async () => { + const imageItem = expect.objectContaining({ + label: 'image', + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### image: `image`/), + }), + }); + + await expect(provider).to.complete(articleCard('{{ imag█ }}'), [imageItem]); + await expect(provider).to.complete(articleCard('{{ block.settings.imag█ }}'), [imageItem]); + }); + + it('offers one variable with the schema type for a same-named LiquidDoc parameter', async () => { + await expect(provider).to.complete(articleCard('{{ imag█ }}', '@param {object} image'), [ + expect.objectContaining({ + label: 'image', + documentation: expect.objectContaining({ + value: expect.stringMatching(/^### image: `image`/), + }), + }), + ]); + }); + + it('does not offer the setting type value as a variable', async () => { + await expect(provider).to.complete(articleCard('{{ color_█ }}'), []); + }); + + it.each(['sections/article-card.liquid', 'snippets/article-card.liquid'])( + 'does not offer setting IDs in %s', + async (relativePath) => { + await expect(provider).to.complete({ ...articleCard('{{ backgr█ }}'), relativePath }, []); + }, + ); + }); + it('should complete metafields defined by getMetafieldDefinitions', async () => { await expect(provider).to.complete('{% echo product.metafields.█ %}', ['custom']); await expect(provider).to.complete('{% echo product.metafields.custom.█ %}', ['color']); @@ -378,3 +437,36 @@ describe('Module: ObjectCompletionProvider', async () => { ]); }); }); + +const blockSettingsObjects: ObjectEntry[] = [ + { + name: 'block', + access: { global: false, parents: [], template: [] }, + return_type: [], + properties: [{ name: 'settings', return_type: [{ type: 'untyped', name: '' }] }], + }, + { + name: 'image', + access: { global: false, parents: [], template: [] }, + return_type: [], + }, +]; + +function articleCard(body: string, docParam?: string) { + return { + relativePath: 'blocks/article-card.liquid', + source: [ + docParam ? `{% doc %}\n ${docParam} - Card image\n{% enddoc %}` : '', + `
${body}
`, + '{% schema %}', + JSON.stringify({ + name: 'Article card', + settings: [ + { type: 'color_background', id: 'background_color', label: 'Background' }, + { type: 'image_picker', id: 'image', label: 'Image' }, + ], + }), + '{% endschema %}', + ].join('\n'), + }; +} diff --git a/packages/theme-language-server-common/src/hover/providers/LiquidObjectHoverProvider.spec.ts b/packages/theme-language-server-common/src/hover/providers/LiquidObjectHoverProvider.spec.ts index 72d4d610f..759fddc46 100644 --- a/packages/theme-language-server-common/src/hover/providers/LiquidObjectHoverProvider.spec.ts +++ b/packages/theme-language-server-common/src/hover/providers/LiquidObjectHoverProvider.spec.ts @@ -244,6 +244,57 @@ describe('Module: LiquidObjectHoverProvider', async () => { ); }); + describe('when a theme block schema defines settings', () => { + beforeEach(() => { + provider = new HoverProvider( + new DocumentManager(), + { + filters: async () => [], + objects: async () => blockSettingsObjects, + liquidDrops: async () => blockSettingsObjects, + tags: async () => [], + systemTranslations: async () => ({}), + }, + async () => ({}) as MetafieldDefinitionMap, + ); + }); + + it('hovers a setting ID as a plain variable with its schema type', async () => { + await expect(provider).to.hover( + articleCard('{{ backgr█ound_color }}'), + '### background_color: `string`', + ); + }); + + it('gives the plain variable and block.settings alias the same type', async () => { + const imageTitle = expect.stringMatching(/^### image: `image`(\n|$)/); + + await expect(provider).to.hover(articleCard('{{ ima█ge }}'), imageTitle); + await expect(provider).to.hover(articleCard('{{ block.settings.ima█ge }}'), imageTitle); + }); + + it('uses the schema type for a same-named LiquidDoc parameter', async () => { + await expect(provider).to.hover( + articleCard('{{ ima█ge }}', '@param {object} image'), + IMAGE_HOVER, + ); + }); + + it('does not hover the setting type value as a variable', async () => { + await expect(provider).to.hover(articleCard('{{ color_back█ground }}'), null); + }); + + it.each(['sections/article-card.liquid', 'snippets/article-card.liquid'])( + 'does not hover setting IDs as variables in %s', + async (relativePath) => { + await expect(provider).to.hover( + { ...articleCard('{{ backgr█ound_color }}'), relativePath }, + null, + ); + }, + ); + }); + it('should return null when hovering over an undefined variable', async () => { await expect(provider).to.hover(`{{ unknown█ }}`, null); }); @@ -272,3 +323,46 @@ describe('Module: LiquidObjectHoverProvider', async () => { ); }); }); + +const IMAGE_HOVER = [ + '### image: `image`', + 'image description', + '', + '---', + '', + '[Shopify Reference](https://shopify.dev/docs/api/liquid/objects/image)', +].join('\n'); + +const blockSettingsObjects: ObjectEntry[] = [ + { + name: 'block', + access: { global: false, parents: [], template: [] }, + return_type: [], + properties: [{ name: 'settings', return_type: [{ type: 'untyped', name: '' }] }], + }, + { + name: 'image', + description: 'image description', + access: { global: false, parents: [], template: [] }, + return_type: [], + }, +]; + +function articleCard(body: string, docParam?: string) { + return { + relativePath: 'blocks/article-card.liquid', + source: [ + docParam ? `{% doc %}\n ${docParam} - Card image\n{% enddoc %}` : '', + `
${body}
`, + '{% schema %}', + JSON.stringify({ + name: 'Article card', + settings: [ + { type: 'color_background', id: 'background_color', label: 'Background' }, + { type: 'image_picker', id: 'image', label: 'Image' }, + ], + }), + '{% endschema %}', + ].join('\n'), + }; +}