Skip to content

Commit

Permalink
Add filter parameter completion support
Browse files Browse the repository at this point in the history
  • Loading branch information
graygilmore committed Nov 13, 2024
1 parent 8850fca commit 6c38ea7
Show file tree
Hide file tree
Showing 4 changed files with 314 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Provider,
RenderSnippetCompletionProvider,
TranslationCompletionProvider,
FilterNamedParameterCompletionProvider,
} from './providers';
import { GetSnippetNamesForURI } from './providers/RenderSnippetCompletionProvider';

Expand Down Expand Up @@ -65,6 +66,7 @@ export class CompletionsProvider {
new FilterCompletionProvider(typeSystem),
new TranslationCompletionProvider(documentManager, getTranslationsForURI),
new RenderSnippetCompletionProvider(getSnippetNamesForURI),
new FilterNamedParameterCompletionProvider(themeDocset),
];
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
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: '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', '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('restricts the range to only the name of the parameter', async () => {
const context = `{{ product | image_url: cr█op: '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('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', 'width']);
});

it('does not replace the existing text', async () => {
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, // importantly the range should be 0
},
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 () => {
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, []);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { NodeTypes } from '@shopify/liquid-html-parser';
import {
CompletionItem,
CompletionItemKind,
InsertTextFormat,
Range,
TextEdit,
} from 'vscode-languageserver';
import { CURSOR, LiquidCompletionParams } from '../params';
import { Provider, createCompletionItem } from './common';
import { ThemeDocset } from '@shopify/theme-check-common';

export class FilterNamedParameterCompletionProvider implements Provider {
constructor(private readonly themeDocset: ThemeDocset) {}

async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> {
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: any,
document: any,
name: string,
type: string,
): {
textEdit: TextEdit;
format: InsertTextFormat;
} {
const remainingText = document.source.slice(node.position.end);
const offset = 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 we want to restrict
// the insert to just the name of the parameter.
// e.g. `{{ product | image_url: cr█op: 'center' }}`
if (node.name + remainingText.slice(0, offset) == name) {
newText = name;
format = InsertTextFormat.PlainText;
}

// 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' }}`
if (node.name === '█') {
end = start;
}

return {
textEdit: TextEdit.replace(
{
start,
end,
},
newText,
),
format,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit 6c38ea7

Please sign in to comment.