diff --git a/.changeset/dull-plants-shake.md b/.changeset/dull-plants-shake.md new file mode 100644 index 00000000..e44c9e33 --- /dev/null +++ b/.changeset/dull-plants-shake.md @@ -0,0 +1,7 @@ +--- +'@shopify/theme-language-server-common': patch +--- + +Add filter completion support + +We'll now provide completions for Liquid filter parameters when requested. diff --git a/.changeset/metal-seahorses-breathe.md b/.changeset/metal-seahorses-breathe.md new file mode 100644 index 00000000..3ec376fe --- /dev/null +++ b/.changeset/metal-seahorses-breathe.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-language-server-common': patch +--- + +Fix a bug where filter completions wouldn't replace existing text diff --git a/.changeset/new-peas-attack.md b/.changeset/new-peas-attack.md new file mode 100644 index 00000000..f0e9cee5 --- /dev/null +++ b/.changeset/new-peas-attack.md @@ -0,0 +1,10 @@ +--- +'@shopify/theme-language-server-common': patch +--- + +Add required parameters when autocompleting filters + +When autocompleting a Liquid filter that has required parameters we'll +automatically add them with tab support. For example, if you using the +completion feature to add the `image_url` filter we'll automatically add the +`width` and `height` parameters for it. diff --git a/.changeset/pretty-maps-fix.md b/.changeset/pretty-maps-fix.md new file mode 100644 index 00000000..f93df61b --- /dev/null +++ b/.changeset/pretty-maps-fix.md @@ -0,0 +1,15 @@ +--- +'@shopify/liquid-html-parser': patch +--- + +Add support for incomplete filter statements + +Adds the ability to continue to parse liquid code that would otherwise be +considered invalid: + +```liquid +{{ product | image_url: width: 100, c█ }} +``` + +The above liquid statement will now successfully parse in all placeholder modes +so that we can offer completion options for filter parameters. diff --git a/.changeset/unlucky-carpets-talk.md b/.changeset/unlucky-carpets-talk.md new file mode 100644 index 00000000..7fbf6ac9 --- /dev/null +++ b/.changeset/unlucky-carpets-talk.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': patch +--- + +Add positional attribute to exported Parameter type diff --git a/packages/liquid-html-parser/grammar/liquid-html.ohm b/packages/liquid-html-parser/grammar/liquid-html.ohm index a431a97b..d35441ee 100644 --- a/packages/liquid-html-parser/grammar/liquid-html.ohm +++ b/packages/liquid-html-parser/grammar/liquid-html.ohm @@ -264,6 +264,11 @@ Liquid <: Helpers { positionalArgument = liquidExpression ~(space* ":") namedArgument = variableSegment space* ":" space* liquidExpression tagArguments = listOf, argumentSeparatorOptionalComma> + filterArguments = + | complexArguments + | simpleArgument + complexArguments = arguments (space* "," space* simpleArgument)? + simpleArgument = liquidVariableLookup variableSegment = (letter | "_") (~endOfTagName identifierCharacter)* variableSegmentAsLookup = variableSegment @@ -495,18 +500,21 @@ StrictLiquidHTML <: LiquidHTML { } WithPlaceholderLiquid <: Liquid { + liquidFilter := space* "|" space* identifier (space* ":" space* filterArguments (space* ",")?)? liquidTagName := (letter | "█") (alnum | "_")* - variableSegment := (letter | "_" | "█") identifierCharacter* + variableSegment := (letter | "_" | "█") (identifierCharacter | "█")* } WithPlaceholderLiquidStatement <: LiquidStatement { + liquidFilter := space* "|" space* identifier (space* ":" space* filterArguments (space* ",")?)? liquidTagName := (letter | "█") (alnum | "_")* - variableSegment := (letter | "_" | "█") identifierCharacter* + variableSegment := (letter | "_" | "█") (identifierCharacter | "█")* } WithPlaceholderLiquidHTML <: LiquidHTML { + liquidFilter := space* "|" space* identifier (space* ":" space* filterArguments (space* ",")?)? liquidTagName := (letter | "█") (alnum | "_")* - variableSegment := (letter | "_" | "█") identifierCharacter* + variableSegment := (letter | "_" | "█") (identifierCharacter | "█")* leadingTagNameTextNode := (letter | "█") (alnum | "-" | ":" | "█")* trailingTagNameTextNode := (alnum | "-" | ":" | "█")+ } diff --git a/packages/liquid-html-parser/src/stage-1-cst.spec.ts b/packages/liquid-html-parser/src/stage-1-cst.spec.ts index 147916b2..fff2867c 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.spec.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.spec.ts @@ -1383,6 +1383,16 @@ describe('Unit: Stage 1 (CST)', () => { expectPath(cst, '0.markup.filters.0.args.1.value.type').to.eql('VariableLookup'); expectPath(cst, '0.markup.filters.0.args.1.value.name').to.eql('█'); }); + + it('should parse incomplete parameters for filters', () => { + const toCST = (source: string) => toLiquidHtmlCST(source, { mode: 'completion' }); + + cst = toCST(`{{ a[1].foo | image_url: 200, width: 100, h█ }}`); + + expectPath(cst, '0.markup.filters.0.args.0.type').to.equal('Number'); + expectPath(cst, '0.markup.filters.0.args.1.type').to.equal('NamedArgument'); + expectPath(cst, '0.markup.filters.0.args.2.type').to.equal('VariableLookup'); + }); }); function makeExpectPath(message: string) { diff --git a/packages/liquid-html-parser/src/stage-1-cst.ts b/packages/liquid-html-parser/src/stage-1-cst.ts index 36c2ab85..b3a1d391 100644 --- a/packages/liquid-html-parser/src/stage-1-cst.ts +++ b/packages/liquid-html-parser/src/stage-1-cst.ts @@ -860,7 +860,18 @@ function toCST( } }, }, + filterArguments: 0, arguments: 0, + complexArguments: function (completeParams, _space1, _comma, _space2, incompleteParam) { + const self = this as any; + + return completeParams + .toAST(self.args.mapping) + .concat( + incompleteParam.sourceString === '' ? [] : incompleteParam.toAST(self.args.mapping), + ); + }, + simpleArgument: 0, tagArguments: 0, positionalArgument: 0, namedArgument: { diff --git a/packages/theme-check-common/src/types/theme-liquid-docs.ts b/packages/theme-check-common/src/types/theme-liquid-docs.ts index b2561238..90ea7d61 100644 --- a/packages/theme-check-common/src/types/theme-liquid-docs.ts +++ b/packages/theme-check-common/src/types/theme-liquid-docs.ts @@ -131,6 +131,7 @@ export interface Parent { export interface Parameter { description: string; name: string; + positional: boolean; required: boolean; types: string[]; } diff --git a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts index a2b224bf..04237597 100644 --- a/packages/theme-language-server-common/src/completions/CompletionsProvider.ts +++ b/packages/theme-language-server-common/src/completions/CompletionsProvider.ts @@ -17,6 +17,7 @@ import { Provider, RenderSnippetCompletionProvider, TranslationCompletionProvider, + FilterNamedParameterCompletionProvider, } from './providers'; import { GetSnippetNamesForURI } from './providers/RenderSnippetCompletionProvider'; @@ -65,6 +66,7 @@ export class CompletionsProvider { new FilterCompletionProvider(typeSystem), new TranslationCompletionProvider(documentManager, getTranslationsForURI), new RenderSnippetCompletionProvider(getSnippetNamesForURI), + new FilterNamedParameterCompletionProvider(themeDocset), ]; } diff --git a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts index f03a8b3f..b9bc53e6 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.spec.ts @@ -43,6 +43,41 @@ const filters: FilterEntry[] = [ name: 'default', syntax: 'variable | default: variable', return_type: [{ type: 'untyped', name: '' }], + parameters: [ + { + description: 'Whether to use false values instead of the default.', + name: 'allow_false', + positional: true, + required: false, + types: ['boolean'], + }, + ], + }, + { + syntax: 'string | highlight: string', + name: 'highlight', + parameters: [ + { + description: 'The string that you want to highlight.', + name: 'highlighted_term', + positional: true, + required: true, + types: ['string'], + }, + ], + }, + { + syntax: 'string | preload_tag: as: string', + name: 'preload_tag', + parameters: [ + { + description: 'The type of element or resource to preload.', + name: 'as', + positional: false, + required: true, + types: ['string'], + }, + ], }, { name: 'missing_syntax', @@ -133,6 +168,161 @@ describe('Module: FilterCompletionProvider', async () => { // As in, the anyFilters are at the _end_ and not shown at the top. await expect(provider).to.complete('{{ string | █ }}', stringFilters.concat(anyFilters)); }); + + describe('when there are no required parameters', () => { + it('should not include parameters in the insertText of the completion', async () => { + // char 12 ⌄ ⌄ char 16 + const liquid = '{{ string | defa█ }}'; + + await expect(provider).to.complete(liquid, [ + expect.objectContaining({ + label: 'default', + insertTextFormat: 1, + textEdit: expect.objectContaining({ + newText: 'default', + range: { + end: { + line: 0, + character: 16, + }, + start: { + line: 0, + character: 12, + }, + }, + }), + }), + ]); + }); + }); + + describe('when there are required positional parameters', () => { + it('should include parameters in the insertText of the completion', async () => { + // char 12 ⌄ ⌄ char 15 + const liquid = '{{ string | hig█ }}'; + + await expect(provider).to.complete(liquid, [ + expect.objectContaining({ + label: 'highlight', + insertTextFormat: 2, + textEdit: expect.objectContaining({ + newText: "highlight: '${1:highlighted_term}'", + range: { + end: { + line: 0, + character: 15, + }, + start: { + line: 0, + character: 12, + }, + }, + }), + }), + ]); + }); + }); + + describe('when there are required named parameters', () => { + it('should include parameters in the insertText of the completion', async () => { + // char 12 ⌄ ⌄ char 15 + const liquid = '{{ string | pre█ }}'; + + await expect(provider).to.complete(liquid, [ + expect.objectContaining({ + label: 'preload_tag', + insertTextFormat: 2, + textEdit: expect.objectContaining({ + newText: "preload_tag: as: '$1'", + range: { + end: { + line: 0, + character: 15, + }, + start: { + line: 0, + character: 12, + }, + }, + }), + }), + ]); + }); + }); + + describe('when the cursor is in the middle of the filter', () => { + describe('and the filter can only be completed to the same name', () => { + it('sets the range to only the existing name', async () => { + // char 12 ⌄ ⌄ char 23 + const liquid = '{{ string | prel█oad_tag: as: "p" }}'; + + await expect(provider).to.complete(liquid, [ + expect.objectContaining({ + label: 'preload_tag', + insertTextFormat: 1, + textEdit: expect.objectContaining({ + newText: 'preload_tag', + range: { + end: { + line: 0, + character: 23, + }, + start: { + line: 0, + character: 12, + }, + }, + }), + }), + ]); + }); + }); + + describe('and the filter to be replaced has parameters', () => { + it('sets the range to include the parameters if replacing with a different filter', async () => { + // char 12 ⌄ ⌄ char 25 + const liquid = '{{ string | d█efault: true }}'; + // ⌃ char 19 + + await expect(provider).to.complete(liquid, [ + expect.objectContaining({ + label: 'downcase', + insertTextFormat: 1, + textEdit: expect.objectContaining({ + newText: 'downcase', + range: { + end: { + line: 0, + character: 25, + }, + start: { + line: 0, + character: 12, + }, + }, + }), + }), + expect.objectContaining({ + label: 'default', + insertTextFormat: 1, + textEdit: expect.objectContaining({ + newText: 'default', + range: { + end: { + line: 0, + character: 19, + }, + start: { + line: 0, + character: 12, + }, + }, + }), + }), + ]); + }); + }); + }); }); function filtersNamesOfInputType(inputType: string): string[] { diff --git a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts index 85dada0d..0f762107 100644 --- a/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts +++ b/packages/theme-language-server-common/src/completions/providers/FilterCompletionProvider.ts @@ -1,8 +1,14 @@ -import { NodeTypes } from '@shopify/liquid-html-parser'; -import { FilterEntry } from '@shopify/theme-check-common'; -import { CompletionItem, CompletionItemKind } from 'vscode-languageserver'; +import { LiquidFilter, NodeTypes } from '@shopify/liquid-html-parser'; +import { FilterEntry, Parameter } from '@shopify/theme-check-common'; +import { + CompletionItem, + CompletionItemKind, + InsertTextFormat, + TextEdit, +} from 'vscode-languageserver'; import { PseudoType, TypeSystem, isArrayType } from '../../TypeSystem'; import { memoize } from '../../utils'; +import { AugmentedLiquidSourceCode } from '../../documents'; import { CURSOR, LiquidCompletionParams } from '../params'; import { Provider, createCompletionItem, sortByName } from './common'; @@ -42,7 +48,82 @@ export class FilterCompletionProvider implements Provider { ); const partial = node.name.replace(CURSOR, ''); const options = await this.options(isArrayType(inputType) ? 'array' : inputType); - return completionItems(options, partial); + + return options + .filter(({ name }) => name.startsWith(partial)) + .map((entry) => { + const { textEdit, format } = this.textEdit(node, params.document, entry); + + return createCompletionItem( + entry, + { + kind: CompletionItemKind.Function, + insertTextFormat: format, + textEdit, + }, + 'filter', + ); + }); + } + + textEdit( + node: LiquidFilter, + document: AugmentedLiquidSourceCode, + entry: MaybeDeprioritisedFilterEntry, + ): { + textEdit: TextEdit; + format: InsertTextFormat; + } { + const remainingText = document.source.slice(node.position.end); + + // Match all the way up to the termination of the filter which could be + // another filter (`|`), or the end of a liquid statement. + const matchEndOfFilter = remainingText.match(/^(.*?)\s*(?=\||-?\}\}|-?\%\})|^(.*)$/); + const endOffset = matchEndOfFilter ? matchEndOfFilter[1].length : remainingText.length; + + // The start position for a LiquidFilter node includes the `|`. We need to + // ignore the pipe and any spaces for our starting position. + const pipeRegex = new RegExp(`(\\s*\\|\\s*)(?:${node.name}\\}\\})`); + const matchFilterPipe = node.source.match(pipeRegex); + const startOffet = matchFilterPipe ? matchFilterPipe[1].length : 0; + + let start = document.textDocument.positionAt(node.position.start + startOffet); + let end = document.textDocument.positionAt(node.position.end + endOffset); + + const { insertText, insertStyle } = appendRequiredParemeters(entry); + + let newText = insertText; + let format = insertStyle; + + // If the cursor is inside the filter or at the end and it's the same + // value as the one we're offering a completion for then we want to restrict + // the insert to just the name of the filter. + // e.g. `{{ product | imag█e_url: crop: 'center' }}` and we're offering `imag█e_url` + const existingFilterOffset = remainingText.match(/[^a-zA-Z_]/)?.index ?? remainingText.length; + if (node.name + remainingText.slice(0, existingFilterOffset) === entry.name) { + newText = entry.name; + format = InsertTextFormat.PlainText; + end = document.textDocument.positionAt(node.position.end + existingFilterOffset); + } + + // If the cursor is at the beginning of the string we can consider all + // options and should not replace any text. + // e.g. `{{ product | █image_url: crop: 'center' }}` + // e.g. `{{ product | █ }}` + if (node.name === '█') { + end = start; + } + + return { + textEdit: TextEdit.replace( + { + start, + end, + }, + newText, + ), + format, + }; } options: (inputType: PseudoType) => Promise = memoize( @@ -80,16 +161,52 @@ function deprioritized(entry: FilterEntry): MaybeDeprioritisedFilterEntry { return { ...entry, deprioritized: true }; } -function completionItems(options: MaybeDeprioritisedFilterEntry[], partial: string) { - return options.filter(({ name }) => name.startsWith(partial)).map(toPropertyCompletionItem); +function appendRequiredParemeters(entry: MaybeDeprioritisedFilterEntry): { + insertText: string; + insertStyle: InsertTextFormat; +} { + let insertText = entry.name; + let insertStyle: InsertTextFormat = InsertTextFormat.PlainText; + + if (!entry?.parameters?.length) { + return { insertText, insertStyle }; + } + + const requiredPositionalParams = entry.parameters + .filter((p) => p.required && p.positional) + .map(formatParameter); + const requiredNamedParams = entry.parameters + .filter((p) => p.required && !p.positional) + .map(formatParameter); + + if (requiredPositionalParams.length) { + insertText += `: ${requiredPositionalParams.join(', ')}`; + insertStyle = InsertTextFormat.Snippet; + } + + if (requiredNamedParams.length) { + insertText += `: ${requiredNamedParams.join(', ')}`; + insertStyle = InsertTextFormat.Snippet; + } + + return { + insertText, + insertStyle, + }; } -function toPropertyCompletionItem(entry: MaybeDeprioritisedFilterEntry) { - return createCompletionItem( - entry, - { - kind: CompletionItemKind.Function, - }, - 'filter', - ); +function formatParameter(parameter: Parameter, index: number) { + let cursorLocation = ''; + + if (parameter.positional) { + cursorLocation = `$\{${index + 1}:${parameter.name}\}`; + } else { + cursorLocation = `$${index + 1}`; + } + + if (parameter.types[0] === 'string') { + cursorLocation = `'${cursorLocation}'`; + } + + return parameter.positional ? cursorLocation : `${parameter.name}: ${cursorLocation}`; } diff --git a/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts new file mode 100644 index 00000000..2eb2e2e6 --- /dev/null +++ b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts @@ -0,0 +1,228 @@ +import { describe, beforeEach, it, expect } from 'vitest'; +import { InsertTextFormat } from 'vscode-languageserver'; +import { MetafieldDefinitionMap } from '@shopify/theme-check-common'; + +import { DocumentManager } from '../../documents'; +import { CompletionsProvider } from '../CompletionsProvider'; + +describe('Module: ObjectCompletionProvider', async () => { + let provider: CompletionsProvider; + + beforeEach(async () => { + provider = new CompletionsProvider({ + documentManager: new DocumentManager(), + themeDocset: { + filters: async () => [ + { + parameters: [ + { + description: '', + name: 'crop', + positional: false, + required: false, + types: ['string'], + }, + { + description: '', + name: 'weight', + positional: false, + required: false, + types: ['string'], + }, + { + description: '', + name: 'width', + positional: false, + required: false, + types: ['number'], + }, + ], + name: 'image_url', + }, + ], + objects: async () => [], + tags: async () => [], + systemTranslations: async () => ({}), + }, + getMetafieldDefinitions: async (_rootUri: string) => ({} as MetafieldDefinitionMap), + }); + }); + + it('should complete filter parameter lookups', async () => { + const contexts = [ + `{{ product | image_url: █`, + `{{ product | image_url: width: 100, █`, + `{{ product | image_url: 1, string, width: 100, █`, + `{{ product | image_url: width: 100 | image_url: █`, + ]; + await Promise.all( + contexts.map((context) => + expect(provider, context).to.complete(context, ['crop', 'weight', 'width']), + ), + ); + }); + + describe('when the user has already begun typing a filter parameter', () => { + it('should filter options based on the text', async () => { + const contexts = [ + `{{ product | image_url: c█`, + `{{ product | image_url: width: 100, c█`, + `{{ product | image_url: 1, string, width: 100, c█`, + `{{ product | image_url: width: 100 | image_url: c█`, + ]; + await Promise.all( + contexts.map((context) => expect(provider, context).to.complete(context, ['crop'])), + ); + }); + }); + + describe('when the user has already typed out the parameter name', () => { + describe('and the cursor is in the middle of the parameter', () => { + it('changes the range depending on the completion item', async () => { + // char 24 ⌄ ⌄ char 34 + const context = `{{ product | image_url: w█idth: 100, height: 200 | image_tag }}`; + // ⌃ char 29 + + await expect(provider).to.complete(context, [ + expect.objectContaining({ + label: 'weight', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: expect.objectContaining({ + newText: "weight: '$1'", + range: { + end: { + line: 0, + character: 34, + }, + start: { + line: 0, + character: 24, + }, + }, + }), + }), + expect.objectContaining({ + label: 'width', + insertTextFormat: InsertTextFormat.PlainText, + textEdit: expect.objectContaining({ + newText: 'width', + range: { + end: { + line: 0, + character: 29, + }, + start: { + line: 0, + character: 24, + }, + }, + }), + }), + ]); + }); + }); + + describe('and the cursor is at the beginning of the parameter', () => { + it('offers a full list of completion items', async () => { + const context = `{{ product | image_url: █crop: 'center' }}`; + + await expect(provider).to.complete(context, ['crop', 'weight', 'width']); + }); + + it('does not replace the existing text', async () => { + // char 24 ⌄ + const context = `{{ product | image_url: █crop: 'center' }}`; + + await expect(provider).to.complete( + context, + expect.arrayContaining([ + expect.objectContaining({ + label: 'crop', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: expect.objectContaining({ + newText: "crop: '$1'", + range: { + end: { + line: 0, + character: 24, + }, + start: { + line: 0, + character: 24, + }, + }, + }), + }), + ]), + ); + }); + }); + + describe('and the cursor is at the end of the parameter', () => { + it('restricts the range to only the name of the parameter', async () => { + // char 24 ⌄ ⌄ char 28 + const context = `{{ product | image_url: crop█: 'center' }}`; + + await expect(provider).to.complete(context, [ + expect.objectContaining({ + label: 'crop', + insertTextFormat: InsertTextFormat.PlainText, + textEdit: expect.objectContaining({ + newText: 'crop', + range: { + end: { + line: 0, + character: 28, + }, + start: { + line: 0, + character: 24, + }, + }, + }), + }), + ]); + }); + }); + }); + + describe('when the parameter is a string type', () => { + it('includes quotes in the insertText', async () => { + const context = `{{ product | image_url: cr█`; + + await expect(provider).to.complete(context, [ + expect.objectContaining({ + label: 'crop', + insertTextFormat: InsertTextFormat.Snippet, + textEdit: expect.objectContaining({ + newText: "crop: '$1'", + }), + }), + ]); + }); + }); + + describe('when the parameter is not a string type', () => { + it('does not include a tab stop position', async () => { + const context = `{{ product | image_url: wid█`; + + await expect(provider).to.complete(context, [ + expect.objectContaining({ + label: 'width', + insertTextFormat: InsertTextFormat.PlainText, + textEdit: expect.objectContaining({ + newText: 'width: ', + }), + }), + ]); + }); + }); + + describe('when the cursor is inside of a quotes', () => { + it('does not return any completion options', async () => { + const context = `{{ product | image_url: width: 100, crop: '█'`; + + await expect(provider).to.complete(context, []); + }); + }); +}); diff --git a/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts new file mode 100644 index 00000000..778c5ec1 --- /dev/null +++ b/packages/theme-language-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts @@ -0,0 +1,121 @@ +import { LiquidVariableLookup, NodeTypes } from '@shopify/liquid-html-parser'; +import { ThemeDocset } from '@shopify/theme-check-common'; +import { + CompletionItem, + CompletionItemKind, + InsertTextFormat, + TextEdit, +} from 'vscode-languageserver'; +import { CURSOR, LiquidCompletionParams } from '../params'; +import { Provider, createCompletionItem } from './common'; +import { AugmentedLiquidSourceCode } from '../../documents'; + +export class FilterNamedParameterCompletionProvider implements Provider { + constructor(private readonly themeDocset: ThemeDocset) {} + + async completions(params: LiquidCompletionParams): Promise { + if (!params.completionContext) return []; + + const { node } = params.completionContext; + + if (!node || node.type !== NodeTypes.VariableLookup) { + return []; + } + + if (!node.name || node.lookups.length > 0) { + // We only do top level in this one. + return []; + } + + const partial = node.name.replace(CURSOR, ''); + const currentContext = params.completionContext.ancestors.at(-1); + + if (!currentContext || currentContext?.type !== NodeTypes.LiquidFilter) { + return []; + } + + const filters = await this.themeDocset.filters(); + const foundFilter = filters.find((f) => f.name === currentContext.name); + + if (!foundFilter?.parameters) { + return []; + } + + const filteredOptions = foundFilter.parameters.filter( + (p) => !p.positional && p.name.startsWith(partial), + ); + + return filteredOptions.map(({ description, name, types }) => { + const { textEdit, format } = this.textEdit(node, params.document, name, types[0]); + + return createCompletionItem( + { + name, + description, + }, + { + kind: CompletionItemKind.TypeParameter, + insertTextFormat: format, + // We want to force these options to appear first in the list given + // the context that they are being requested in. + sortText: `1${name}`, + textEdit, + }, + 'filter', + Array.isArray(types) ? types[0] : 'unknown', + ); + }); + } + + textEdit( + node: LiquidVariableLookup, + document: AugmentedLiquidSourceCode, + name: string, + type: string, + ): { + textEdit: TextEdit; + format: InsertTextFormat; + } { + const remainingText = document.source.slice(node.position.end); + + // Match all the way up to the termination of the parameter which could be + // another parameter (`,`), filter (`|`), or the end of a liquid statement. + const match = remainingText.match(/^(.*?)\s*(?=,|\||-?\}\}|-?\%\})|^(.*)$/); + const offset = match ? match[0].length : remainingText.length; + const existingParameterOffset = remainingText.match(/[^a-zA-Z]/)?.index ?? remainingText.length; + + let start = document.textDocument.positionAt(node.position.start); + let end = document.textDocument.positionAt(node.position.end + offset); + let newText = type === 'string' ? `${name}: '$1'` : `${name}: `; + let format = type === 'string' ? InsertTextFormat.Snippet : InsertTextFormat.PlainText; + + // If the cursor is inside the parameter or at the end and it's the same + // value as the one we're offering a completion for then we want to restrict + // the insert to just the name of the parameter. + // e.g. `{{ product | image_url: cr█op: 'center' }}` and we're offering `crop` + if (node.name + remainingText.slice(0, existingParameterOffset) == name) { + newText = name; + format = InsertTextFormat.PlainText; + end = document.textDocument.positionAt(node.position.end + existingParameterOffset); + } + + // If the cursor is at the beginning of the string we can consider all + // options and should not replace any text. + // e.g. `{{ product | image_url: █crop: 'center' }}` + // e.g. `{{ product | image_url: █ }}` + if (node.name === '█') { + end = start; + } + + return { + textEdit: TextEdit.replace( + { + start, + end, + }, + newText, + ), + format, + }; + } +} 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 ea7c3006..0901b90c 100644 --- a/packages/theme-language-server-common/src/completions/providers/index.ts +++ b/packages/theme-language-server-common/src/completions/providers/index.ts @@ -3,6 +3,7 @@ export { HtmlTagCompletionProvider } from './HtmlTagCompletionProvider'; export { HtmlAttributeCompletionProvider } from './HtmlAttributeCompletionProvider'; export { HtmlAttributeValueCompletionProvider } from './HtmlAttributeValueCompletionProvider'; export { FilterCompletionProvider } from './FilterCompletionProvider'; +export { FilterNamedParameterCompletionProvider } from './FilterNamedParameterCompletionProvider'; export { LiquidTagsCompletionProvider } from './LiquidTagsCompletionProvider'; export { ObjectAttributeCompletionProvider } from './ObjectAttributeCompletionProvider'; export { ObjectCompletionProvider } from './ObjectCompletionProvider';