diff --git a/.changeset/block-parameter-language-features.md b/.changeset/block-parameter-language-features.md new file mode 100644 index 000000000..7316d4e24 --- /dev/null +++ b/.changeset/block-parameter-language-features.md @@ -0,0 +1,9 @@ +--- +'@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. 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/CompletionsProvider.ts b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts index 64704916d..328861f94 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, @@ -36,12 +40,15 @@ export interface CompletionProviderDependencies { documentManager: DocumentManager; themeDocset: ThemeDocset; getTranslationsForURI?: GetTranslationsForURI; + getSchemaTranslationsForURI?: GetTranslationsForURI; getSnippetNamesForURI?: GetSnippetNamesForURI; getThemeSettingsSchemaForURI?: GetThemeSettingsSchemaForURI; getMetafieldDefinitions: (rootUri: string) => Promise; getDocDefinitionForURI?: GetDocDefinitionForURI; getThemeBlockNames?: (rootUri: string, includePrivate: boolean) => Promise; + getThemeBlockSchema?: GetThemeBlockSchema; getModeForURI?: (uri: string) => Promise; + findThemeRootURI?: FindThemeRootURI; log?: (message: string) => void; } @@ -56,11 +63,14 @@ export class CompletionsProvider { themeDocset, getMetafieldDefinitions, getTranslationsForURI = async () => ({}), + getSchemaTranslationsForURI = async () => ({}), getSnippetNamesForURI = async () => [], 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 +87,14 @@ export class CompletionsProvider { new ContentForCompletionProvider(), new ContentForBlockTypeCompletionProvider(getThemeBlockNames), new ContentForParameterCompletionProvider(getDocDefinitionForURI), + new BlockParameterCompletionProvider( + makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), + getSchemaTranslationsForURI, + ), 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..469810260 --- /dev/null +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.spec.ts @@ -0,0 +1,653 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +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; + + 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 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`', + '**Theme setting**\n\nHeading\n\nShown above the card', + ].join('\n\n'), + }, + }), + expect.objectContaining({ + label: 'tracking_id', + kind: CompletionItemKind.Property, + documentation: { + kind: MarkupKind.Markdown, + value: '### tracking_id: `string`\n\nAnalytics 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((completionItem.documentation as MarkupContent).value); + }); + + 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', '**Theme setting**\n\nHeading'].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', + '**Theme setting**\n\nFeatured 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`', CONTENT_PRECEDENCE_NOTE].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: ['### content: `string`', '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: [ + '### heading (Optional): `string`', + '**Theme setting**\n\nHeading\n\nUses t:settings syntax', + ].join('\n\n'), + }), + }), + ]); + }); + + 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: [ + '### heading (Optional): `string`', + '**Theme setting**\n\nTranslated heading\n\nTranslated info', + ].join('\n\n'), + }), + }), + ]); + }); + + 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: '### heading (Optional): `string`\n\n**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 %}`), []); + }); + }); + + 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, + getSchemaTranslationsForURI: async () => SCHEMA_TRANSLATIONS, + 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 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); +} + +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..32b3cb276 --- /dev/null +++ b/packages/theme-language-server-common/src/completions/providers/BlockParameterCompletionProvider.ts @@ -0,0 +1,160 @@ +import { + BlockMarkup, + LiquidHtmlNode, + LiquidVariableLookup, + NodeTypes, +} from '@shopify/liquid-html-parser'; +import { BLOCK_CONTENT_PARAMETER, BlockParameter, Translations } from '@shopify/theme-check-common'; +import { + CompletionItem, + CompletionItemKind, + InsertTextFormat, + MarkupKind, + Range, + TextEdit, +} from 'vscode-languageserver'; +import { AugmentedLiquidSourceCode } from '../../documents'; +import { GetTranslationsForURI } from '../../translations'; +import { + formatBlockParameter, + getBlockParameterTranslations, + 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, + private readonly getSchemaTranslationsForURI: GetTranslationsForURI, + ) {} + + 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)); + + 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), + ); + } +} + +/** + * 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, + translations: Translations, + 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, translations), + }, + 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.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/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..ca6501e10 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,8 @@ export class HoverProvider { readonly getSettingsSchemaForURI: GetThemeSettingsSchemaForURI = async () => [], 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, @@ -49,6 +54,10 @@ export class HoverProvider { this.providers = [ new ContentForArgumentHoverProvider(getDocDefinitionForURI), new ContentForTypeHoverProvider(getDocDefinitionForURI), + new BlockParameterHoverProvider( + makeGetBlockParametersForURI(getThemeBlockSchema, getDocDefinitionForURI), + getSchemaTranslationsForURI, + ), 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..b30376774 --- /dev/null +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.spec.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +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' }); + +const HEADING_SETTING = { + id: 'heading', + type: 'text', + label: 'Heading', + info: 'Shown above the card', +}; +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; + 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 theme setting', async () => { + openBlock(documentManager, blockSource([HEADING_SETTING])); + + await expect(provider).to.hover( + template(`{% block 'card', hea█ding: 'Sale' %}{% endblock %}`), + ['### 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, + 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_THEME_SETTING].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_THEME_SETTING].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', + '**Theme setting**\n\nFeatured product', + ].join('\n\n'), + ); + }); + + it('shows only the LiquidDoc description for a LiquidDoc-only parameter', 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'].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`', CONTENT_PRECEDENCE_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', CONTENT_PRECEDENCE_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`', + '**Theme setting**\n\nBody', + CONTENT_PRECEDENCE_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[] = [], + getSchemaTranslationsForURI: GetTranslationsForURI = async () => SCHEMA_TRANSLATIONS, +) { + 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(); + }, + getSchemaTranslationsForURI, + ); +} + +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..0065a3a40 --- /dev/null +++ b/packages/theme-language-server-common/src/hover/providers/BlockParameterHoverProvider.ts @@ -0,0 +1,52 @@ +import { NodeTypes } from '@shopify/liquid-html-parser'; +import { LiquidHtmlNode } from '@shopify/theme-check-common'; +import { Hover, HoverParams, MarkupKind } from 'vscode-languageserver'; +import { GetTranslationsForURI } from '../../translations'; +import { + formatBlockParameter, + getBlockParameterTranslations, + 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, + private readonly getSchemaTranslationsForURI: GetTranslationsForURI, + ) {} + + 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; + + const translations = await getBlockParameterTranslations( + this.getSchemaTranslationsForURI, + params.textDocument.uri, + [parameter], + ); + return { + contents: { + kind: MarkupKind.Markdown, + value: formatBlockParameter(parameter, translations), + }, + }; + } +} 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'), + }; +} 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..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, @@ -341,6 +342,8 @@ export function startServer( getMetafieldDefinitions, getDocDefinitionForURI, getModeForURI, + getThemeBlockSchema, + findThemeRootURI, }); const hoverProvider = new HoverProvider( documentManager, @@ -350,6 +353,8 @@ export function startServer( getThemeSettingsSchemaForURI, 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 new file mode 100644 index 000000000..7ee78b0c1 --- /dev/null +++ b/packages/theme-language-server-common/src/utils/blockParameters.ts @@ -0,0 +1,132 @@ +import { + BLOCK_CONTENT_PARAMETER, + BlockParameter, + BlockParameters, + GetDocDefinitionForURI, + isBlockSchema, + makeGetBlockParameters, + Setting, + Translations, +} from '@shopify/theme-check-common'; +import { GetThemeBlockSchema } from '../json/JSONContributions'; +import { GetTranslationsForURI, renderTranslation, translationValue } from '../translations'; +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); + }; +} + +/** + * 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 async function getBlockParameterTranslations( + getSchemaTranslationsForURI: GetTranslationsForURI, + uri: string, + parameters: BlockParameter[], +): Promise { + if (!parameters.some(hasTranslatedSchemaText)) return {}; + + try { + return await getSchemaTranslationsForURI(uri); + } catch { + return {}; + } +} + +/** + * 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; + return [ + formatBlockParameterHeading(name, type, required), + liquidDoc?.description ?? undefined, + schemaSetting ? formatThemeSetting(schemaSetting, translations) : undefined, + name === BLOCK_CONTENT_PARAMETER ? CONTENT_PRECEDENCE_NOTE : undefined, + ] + .filter(isPresent) + .join('\n\n'); +} + +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**', + resolveSchemaText(setting.label, translations), + resolveSchemaText(setting.info, translations), + ] + .filter(isPresent) + .join('\n\n'); +} + +/** + * 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): boolean { + return text?.startsWith('t:') ?? false; +} + +function isPresent(text: string | undefined): text is string { + return !!text; +}