-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add filter parameter completion support
- Loading branch information
1 parent
8850fca
commit 6c38ea7
Showing
4 changed files
with
314 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
198 changes: 198 additions & 0 deletions
198
...ge-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, []); | ||
}); | ||
}); | ||
}); |
113 changes: 113 additions & 0 deletions
113
...anguage-server-common/src/completions/providers/FilterNamedParameterCompletionProvider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters