Skip to content

Commit d9dbc26

Browse files
authoredFeb 11, 2025··
Add completion support for content_for parameters (#745)
* Add support for parsing incomplete content_for tags * Add completion support for content_for parameters

12 files changed

+449
-3
lines changed
 

‎.changeset/fresh-suns-provide.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@shopify/liquid-html-parser': minor
3+
'@shopify/theme-language-server-common': minor
4+
---
5+
6+
- Support parsing incomplete content_for tags in completion context
7+
- Support content_for param completion

‎packages/liquid-html-parser/grammar/liquid-html.ohm

+7
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Liquid <: Helpers {
133133
contentForType (argumentSeparatorOptionalComma contentForTagArgument) (space* ",")? space*
134134

135135
contentForTagArgument = listOf<contentForNamedArgument<delimTag>, argumentSeparatorOptionalComma>
136+
completionModeContentForTagArgument = listOf<contentForNamedArgument<delimTag>, argumentSeparatorOptionalComma> (argumentSeparator? (liquidVariableLookup<delimTag>))?
136137
contentForNamedArgument<delim> = (variableSegment ("." variableSegment)*) space* ":" space* (liquidExpression<delim>)
137138

138139
contentForType = liquidString<delimTag>
@@ -551,18 +552,24 @@ StrictLiquidHTML <: LiquidHTML {
551552

552553
WithPlaceholderLiquid <: Liquid {
553554
liquidFilter<delim> := space* "|" space* identifier (space* ":" space* filterArguments<delim> (space* ",")?)?
555+
liquidTagContentForMarkup :=
556+
contentForType (argumentSeparatorOptionalComma completionModeContentForTagArgument) (space* ",")? space*
554557
liquidTagName := (letter | "█") (alnum | "_")*
555558
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
556559
}
557560

558561
WithPlaceholderLiquidStatement <: LiquidStatement {
559562
liquidFilter<delim> := space* "|" space* identifier (space* ":" space* filterArguments<delim> (space* ",")?)?
563+
liquidTagContentForMarkup :=
564+
contentForType (argumentSeparatorOptionalComma completionModeContentForTagArgument) (space* ",")? space*
560565
liquidTagName := (letter | "█") (alnum | "_")*
561566
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
562567
}
563568

564569
WithPlaceholderLiquidHTML <: LiquidHTML {
565570
liquidFilter<delim> := space* "|" space* identifier (space* ":" space* filterArguments<delim> (space* ",")?)?
571+
liquidTagContentForMarkup :=
572+
contentForType (argumentSeparatorOptionalComma completionModeContentForTagArgument) (space* ",")? space*
566573
liquidTagName := (letter | "█") (alnum | "_")*
567574
variableSegment := (letter | "_" | "█") (identifierCharacter | "█")*
568575
leadingTagNameTextNode := (letter | "█") (alnum | "-" | ":" | "█")*

‎packages/liquid-html-parser/src/stage-1-cst.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -1776,6 +1776,16 @@ describe('Unit: Stage 1 (CST)', () => {
17761776
expectPath(cst, '0.markup.filters.0.args.1.type').to.equal('NamedArgument');
17771777
expectPath(cst, '0.markup.filters.0.args.2.type').to.equal('VariableLookup');
17781778
});
1779+
1780+
it('should parse incomplete parameters for content_for tags', () => {
1781+
const toCST = (source: string) => toLiquidHtmlCST(source, { mode: 'completion' });
1782+
1783+
cst = toCST(`{% content_for "blocks", id: 1, cl█ %}`);
1784+
1785+
expectPath(cst, '0.markup.type').to.equal('ContentForMarkup');
1786+
expectPath(cst, '0.markup.args.0.type').to.equal('NamedArgument');
1787+
expectPath(cst, '0.markup.args.1.type').to.equal('VariableLookup');
1788+
});
17791789
});
17801790

17811791
function makeExpectPath(message: string) {

‎packages/liquid-html-parser/src/stage-1-cst.ts

+7
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,13 @@ function toCST<T>(
937937
simpleArgument: 0,
938938
tagArguments: 0,
939939
contentForTagArgument: 0,
940+
completionModeContentForTagArgument: function (namedArguments, _separator, variableLookup) {
941+
const self = this as any;
942+
943+
return namedArguments
944+
.toAST(self.args.mapping)
945+
.concat(variableLookup.sourceString === '' ? [] : variableLookup.toAST(self.args.mapping));
946+
},
940947
positionalArgument: 0,
941948
namedArgument: {
942949
type: ConcreteNodeTypes.NamedArgument,

‎packages/liquid-html-parser/src/stage-2-ast.spec.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1546,6 +1546,15 @@ describe('Unit: Stage 2 (AST)', () => {
15461546
expectPath(ast, 'children.0.name.0.value').to.equal('h█');
15471547
});
15481548

1549+
it('should not freak out when parsing incomplete named arguments for content_for tags', () => {
1550+
ast = toAST(`{% content_for "blocks", id: 1, cl█ %}`);
1551+
1552+
expectPath(ast, 'children.0.type').to.equal('LiquidTag');
1553+
expectPath(ast, 'children.0.markup.args.0.type').to.equal('NamedArgument');
1554+
expectPath(ast, 'children.0.markup.args.1.type').to.equal('VariableLookup');
1555+
expectPath(ast, 'children.0.markup.args').to.have.lengthOf(2);
1556+
});
1557+
15491558
it('should not freak out when parsing dangling liquid tags', () => {
15501559
ast = toAST(`<h {% if cond %}attr{% end█ %}>`);
15511560
expectPath(ast, 'children.0.type').to.equal('HtmlElement');

‎packages/liquid-html-parser/src/stage-2-ast.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,13 @@ export interface LiquidTagLiquid extends LiquidTagNode<NamedTags.liquid, LiquidS
391391
export interface ContentForMarkup extends ASTNode<NodeTypes.ContentForMarkup> {
392392
/** {% content_for 'contentForType' %} */
393393
contentForType: LiquidString;
394-
/** {% content_for 'contentForType', arg1: value1, arg2: value2 %} */
394+
/**
395+
* WARNING: `args` could contain LiquidVariableLookup when we are in a completion context
396+
* because the NamedArgument isn't fully typed out.
397+
* E.g. {% content_for 'contentForType', arg1: value1, arg2█ %}
398+
*
399+
* @example {% content_for 'contentForType', arg1: value1, arg2: value2 %}
400+
*/
395401
args: LiquidNamedArgument[];
396402
}
397403

@@ -1777,7 +1783,15 @@ function toContentForMarkup(node: ConcreteLiquidTagContentForMarkup): ContentFor
17771783
return {
17781784
type: NodeTypes.ContentForMarkup,
17791785
contentForType: toExpression(node.contentForType) as LiquidString,
1780-
args: node.args.map(toNamedArgument),
1786+
/**
1787+
* When we're in completion mode we won't necessarily have valid named
1788+
* arguments so we need to call toLiquidArgument instead of toNamedArgument.
1789+
* We cast using `as` so that this doesn't affect the type system used in
1790+
* other areas (like theme check) for a scenario that only occurs in
1791+
* completion mode. This means that our types are *wrong* in completion mode
1792+
* but this is the compromise we're making to get completions to work.
1793+
*/
1794+
args: node.args.map(toLiquidArgument) as LiquidNamedArgument[],
17811795
position: position(node),
17821796
source: node.source,
17831797
};

‎packages/theme-language-server-common/src/completions/CompletionsProvider.ts

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
RenderSnippetCompletionProvider,
2020
TranslationCompletionProvider,
2121
FilterNamedParameterCompletionProvider,
22+
ContentForParameterCompletionProvider,
2223
} from './providers';
2324
import { GetSnippetNamesForURI } from './providers/RenderSnippetCompletionProvider';
2425

@@ -61,6 +62,7 @@ export class CompletionsProvider {
6162
this.providers = [
6263
new ContentForCompletionProvider(),
6364
new ContentForBlockTypeCompletionProvider(getThemeBlockNames),
65+
new ContentForParameterCompletionProvider(),
6466
new HtmlTagCompletionProvider(),
6567
new HtmlAttributeCompletionProvider(documentManager),
6668
new HtmlAttributeValueCompletionProvider(),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { describe, afterEach, beforeEach, it, expect, vi } from 'vitest';
2+
import { CompletionsProvider } from '../CompletionsProvider';
3+
import { DocumentManager } from '../../documents';
4+
import { MetafieldDefinitionMap } from '@shopify/theme-check-common';
5+
import { TextEdit, InsertTextFormat } from 'vscode-languageserver-protocol';
6+
import { TextDocument } from 'vscode-languageserver-textdocument';
7+
import { CURSOR } from '../params';
8+
9+
vi.mock('./data/contentForParameterCompletionOptions', async () => {
10+
const actual = (await vi.importActual(
11+
'./data/contentForParameterCompletionOptions',
12+
)) as typeof import('./data/contentForParameterCompletionOptions');
13+
return {
14+
DEFAULT_COMPLETION_OPTIONS: {
15+
...actual.DEFAULT_COMPLETION_OPTIONS,
16+
// Add another option here so we can properly test some scenarios that
17+
// we wouldn't be able to otherwise.
18+
testOption: '',
19+
},
20+
};
21+
});
22+
23+
describe('Module: ContentForBlockParameterCompletionProvider', async () => {
24+
let provider: CompletionsProvider;
25+
26+
beforeEach(async () => {
27+
provider = new CompletionsProvider({
28+
documentManager: new DocumentManager(),
29+
themeDocset: {
30+
filters: async () => [],
31+
objects: async () => [],
32+
tags: async () => [],
33+
systemTranslations: async () => ({}),
34+
},
35+
getMetafieldDefinitions: async (_rootUri: string) => ({} as MetafieldDefinitionMap),
36+
});
37+
});
38+
39+
afterEach(() => {
40+
vi.restoreAllMocks();
41+
});
42+
43+
it('offers a full list of completion items', async () => {
44+
await expect(provider).to.complete('{% content_for "block", █ %}', [
45+
'type',
46+
'id',
47+
'closest',
48+
'testOption',
49+
]);
50+
});
51+
52+
it('uses text edits to insert the completion item', async () => {
53+
// char 24 ⌄
54+
const context = `{% content_for "block", █ %}`;
55+
56+
const textEdit: TextEdit = {
57+
newText: "type: '$1'",
58+
range: {
59+
end: { line: 0, character: 24 },
60+
start: { line: 0, character: 24 },
61+
},
62+
};
63+
64+
await expect(provider).to.complete(
65+
context,
66+
expect.arrayContaining([
67+
expect.objectContaining({
68+
label: 'type',
69+
insertTextFormat: InsertTextFormat.Snippet,
70+
textEdit,
71+
}),
72+
]),
73+
);
74+
75+
const textDocument = TextDocument.create('', 'liquid', 0, context.replace(CURSOR, ''));
76+
77+
expect(TextDocument.applyEdits(textDocument, [textEdit])).toBe(
78+
`{% content_for "block", type: '$1' %}`,
79+
);
80+
});
81+
82+
it('provides a different style of completion for "closest"', async () => {
83+
// char 24 ⌄
84+
const context = `{% content_for "block", █ %}`;
85+
86+
const textEdit: TextEdit = {
87+
newText: 'closest.',
88+
range: {
89+
end: { line: 0, character: 24 },
90+
start: { line: 0, character: 24 },
91+
},
92+
};
93+
94+
await expect(provider).to.complete(
95+
context,
96+
expect.arrayContaining([
97+
expect.objectContaining({
98+
label: 'closest',
99+
insertTextFormat: InsertTextFormat.PlainText,
100+
textEdit,
101+
}),
102+
]),
103+
);
104+
105+
const textDocument = TextDocument.create('', 'liquid', 0, context.replace(CURSOR, ''));
106+
107+
expect(TextDocument.applyEdits(textDocument, [textEdit])).toBe(
108+
`{% content_for "block", closest. %}`,
109+
);
110+
});
111+
112+
describe("when we're completing for blocks we only allow `closest`", () => {
113+
it('does something', async () => {
114+
await expect(provider).to.complete('{% content_for "blocks", █ %}', ['closest']);
115+
});
116+
});
117+
118+
describe('when the user has already started typing the name of the parameter', () => {
119+
it('filters the completion options to only include ones that match', async () => {
120+
await expect(provider).to.complete('{% content_for "block", t█ %}', ['type', 'testOption']);
121+
});
122+
});
123+
124+
describe('when the user has already typed out a parameter name', () => {
125+
describe('and the cursor is in the middle of the parameter', () => {
126+
it('changes the range depending on the completion item', async () => {
127+
// char 24 ⌄ ⌄ char 38
128+
const context = `{% content_for "block", t█ype: "button" %}`;
129+
// ⌃ char 28
130+
131+
const typeTextEdit: TextEdit = {
132+
newText: 'type',
133+
range: {
134+
end: { line: 0, character: 28 },
135+
start: { line: 0, character: 24 },
136+
},
137+
};
138+
139+
const testTextEdit: TextEdit = {
140+
newText: "testOption: '$1'",
141+
range: {
142+
end: { line: 0, character: 38 },
143+
start: { line: 0, character: 24 },
144+
},
145+
};
146+
147+
await expect(provider).to.complete(context, [
148+
expect.objectContaining({
149+
label: 'type',
150+
insertTextFormat: InsertTextFormat.PlainText,
151+
textEdit: expect.objectContaining(typeTextEdit),
152+
}),
153+
expect.objectContaining({
154+
label: 'testOption',
155+
insertTextFormat: InsertTextFormat.Snippet,
156+
textEdit: expect.objectContaining(testTextEdit),
157+
}),
158+
]);
159+
160+
const textDocument = TextDocument.create('', 'liquid', 0, context.replace(CURSOR, ''));
161+
162+
expect(TextDocument.applyEdits(textDocument, [testTextEdit])).toBe(
163+
`{% content_for "block", testOption: '$1' %}`,
164+
);
165+
166+
expect(TextDocument.applyEdits(textDocument, [typeTextEdit])).toBe(
167+
`{% content_for "block", type: "button" %}`,
168+
);
169+
});
170+
});
171+
172+
describe('and the cursor is at the beginning of the parameter', () => {
173+
it('offers a full list of completion items', async () => {
174+
const context = `{% content_for "block", █type: "button" %}`;
175+
176+
await expect(provider).to.complete(context, ['type', 'id', 'closest', 'testOption']);
177+
});
178+
179+
it('does not replace the existing text', async () => {
180+
// char 24 ⌄
181+
const context = `{% content_for "block", █type: "button" %}`;
182+
183+
const textEdit: TextEdit = {
184+
newText: "testOption: '$1', ",
185+
range: {
186+
end: { line: 0, character: 24 },
187+
start: { line: 0, character: 24 },
188+
},
189+
};
190+
191+
await expect(provider).to.complete(
192+
context,
193+
expect.arrayContaining([
194+
expect.objectContaining({
195+
label: 'testOption',
196+
insertTextFormat: InsertTextFormat.Snippet,
197+
textEdit,
198+
}),
199+
]),
200+
);
201+
202+
const textDocument = TextDocument.create('', 'liquid', 0, context.replace(CURSOR, ''));
203+
204+
expect(TextDocument.applyEdits(textDocument, [textEdit])).toBe(
205+
`{% content_for "block", testOption: '$1', type: "button" %}`,
206+
);
207+
});
208+
});
209+
210+
describe('and the cursor is at the end of the parameter', () => {
211+
it('offers only the same completion item', async () => {
212+
const context = `{% content_for "block", type█: "button" %}`;
213+
214+
await expect(provider).to.complete(context, ['type']);
215+
});
216+
217+
it('only replaces the parameter name', async () => {
218+
// char 24 ⌄ ⌄ char 28
219+
const context = `{% content_for "block", type█: "button" %}`;
220+
221+
const textEdit: TextEdit = {
222+
newText: 'type',
223+
range: {
224+
end: { line: 0, character: 28 },
225+
start: { line: 0, character: 24 },
226+
},
227+
};
228+
229+
await expect(provider).to.complete(
230+
context,
231+
expect.arrayContaining([
232+
expect.objectContaining({
233+
label: 'type',
234+
insertTextFormat: InsertTextFormat.PlainText,
235+
textEdit,
236+
}),
237+
]),
238+
);
239+
240+
const textDocument = TextDocument.create('', 'liquid', 0, context.replace(CURSOR, ''));
241+
242+
expect(TextDocument.applyEdits(textDocument, [textEdit])).toBe(
243+
`{% content_for "block", type: "button" %}`,
244+
);
245+
});
246+
});
247+
});
248+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { LiquidVariableLookup, NodeTypes } from '@shopify/liquid-html-parser';
2+
import {
3+
CompletionItem,
4+
CompletionItemKind,
5+
InsertTextFormat,
6+
TextEdit,
7+
} from 'vscode-languageserver';
8+
import { CURSOR, LiquidCompletionParams } from '../params';
9+
import { Provider } from './common';
10+
import { AugmentedLiquidSourceCode } from '../../documents';
11+
import { DEFAULT_COMPLETION_OPTIONS } from './data/contentForParameterCompletionOptions';
12+
13+
/**
14+
* Offers completions for parameters for the `content_for` tag after a user has
15+
* specificied the type.
16+
*
17+
* @example {% content_for "block", █ %}
18+
*/
19+
export class ContentForParameterCompletionProvider implements Provider {
20+
constructor() {}
21+
22+
async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> {
23+
if (!params.completionContext) return [];
24+
25+
const { node, ancestors } = params.completionContext;
26+
27+
const parentNode = ancestors.at(-1);
28+
29+
const parentIsContentFor = parentNode?.type == NodeTypes.ContentForMarkup;
30+
const nodeIsVariableLookup = node?.type == NodeTypes.VariableLookup;
31+
32+
if (!parentIsContentFor || !nodeIsVariableLookup) {
33+
return [];
34+
}
35+
36+
if (!node.name || node.lookups.length > 0) {
37+
return [];
38+
}
39+
40+
let options = DEFAULT_COMPLETION_OPTIONS;
41+
42+
const partial = node.name.replace(CURSOR, '');
43+
44+
if (parentNode.contentForType.value == 'blocks') {
45+
options = {
46+
closest: DEFAULT_COMPLETION_OPTIONS.closest,
47+
};
48+
}
49+
50+
return Object.entries(options)
51+
.filter(([keyword, _description]) => keyword.startsWith(partial))
52+
.map(([keyword, description]): CompletionItem => {
53+
const { textEdit, format } = this.textEdit(node, params.document, keyword);
54+
55+
return {
56+
label: keyword,
57+
kind: CompletionItemKind.Keyword,
58+
documentation: {
59+
kind: 'markdown',
60+
value: description,
61+
},
62+
insertTextFormat: format,
63+
// We want to force these options to appear first in the list given
64+
// the context that they are being requested in.
65+
sortText: `1${keyword}`,
66+
textEdit,
67+
};
68+
});
69+
}
70+
71+
textEdit(
72+
node: LiquidVariableLookup,
73+
document: AugmentedLiquidSourceCode,
74+
name: string,
75+
): {
76+
textEdit: TextEdit;
77+
format: InsertTextFormat;
78+
} {
79+
const remainingText = document.source.slice(node.position.end);
80+
81+
// Match all the way up to the termination of the parameter which could be
82+
// another parameter (`,`), filter (`|`), or the end of a liquid statement.
83+
const match = remainingText.match(/^(.*?)\s*(?=,|\||-?\}\}|-?\%\})|^(.*)$/);
84+
const offset = match ? match[0].trimEnd().length : remainingText.length;
85+
const existingParameterOffset = remainingText.match(/[^a-zA-Z]/)?.index ?? remainingText.length;
86+
87+
let start = document.textDocument.positionAt(node.position.start);
88+
let end = document.textDocument.positionAt(node.position.end + offset);
89+
let newText = name === 'closest' ? `${name}.` : `${name}: '$1'`;
90+
let format = name === 'closest' ? InsertTextFormat.PlainText : InsertTextFormat.Snippet;
91+
92+
// If the cursor is inside the parameter or at the end and it's the same
93+
// value as the one we're offering a completion for then we want to restrict
94+
// the insert to just the name of the parameter.
95+
// e.g. `{% content_for "block", t█ype: "button" %}` and we're offering `type`
96+
if (node.name + remainingText.slice(0, existingParameterOffset) == name) {
97+
newText = name;
98+
format = InsertTextFormat.PlainText;
99+
end = document.textDocument.positionAt(node.position.end + existingParameterOffset);
100+
}
101+
102+
// If the cursor is at the beginning of the string we can consider all
103+
// options and should not replace any text.
104+
// e.g. `{% content_for "block", █type: "button" %}`
105+
// e.g. `{% content_for "block", █ %}`
106+
if (node.name === CURSOR) {
107+
end = start;
108+
109+
// If we're inserting text in front of an existing parameter then we need
110+
// to add a comma to separate them.
111+
if (existingParameterOffset > 0) {
112+
newText += ', ';
113+
}
114+
}
115+
116+
return {
117+
textEdit: TextEdit.replace(
118+
{
119+
start,
120+
end,
121+
},
122+
newText,
123+
),
124+
format,
125+
};
126+
}
127+
}

‎packages/theme-language-server-common/src/completions/providers/ObjectCompletionProvider.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class ObjectCompletionProvider implements Provider {
1010
async completions(params: LiquidCompletionParams): Promise<CompletionItem[]> {
1111
if (!params.completionContext) return [];
1212

13-
const { partialAst, node } = params.completionContext;
13+
const { partialAst, node, ancestors } = params.completionContext;
1414
if (!node || node.type !== NodeTypes.VariableLookup) {
1515
return [];
1616
}
@@ -20,6 +20,11 @@ export class ObjectCompletionProvider implements Provider {
2020
return [];
2121
}
2222

23+
// ContentFor uses VariableLookup to support completion of NamedParams.
24+
if (ancestors.at(-1)?.type === NodeTypes.ContentForMarkup) {
25+
return [];
26+
}
27+
2328
const partial = node.name.replace(CURSOR, '');
2429
const options = await this.typeSystem.availableVariables(
2530
partialAst,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* These definitions are not currently available in the generated Liquid Docs
3+
* so we're hardcoding them here in the interim.
4+
*/
5+
export const DEFAULT_COMPLETION_OPTIONS: { [key: string]: string } = {
6+
type: "The type (name) of an existing theme block in your theme’s /blocks folder. Only applicable when `content_type` is 'block'.",
7+
id: "A unique identifier and literal string within the section or block that contains the static blocks. Only applicable when `content_type` is 'block'.",
8+
closest: 'A path that provides a way to access the closest resource of a given type.',
9+
};

‎packages/theme-language-server-common/src/completions/providers/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { ContentForCompletionProvider } from './ContentForCompletionProvider';
22
export { ContentForBlockTypeCompletionProvider } from './ContentForBlockTypeCompletionProvider';
3+
export { ContentForParameterCompletionProvider } from './ContentForParameterCompletionProvider';
34
export { HtmlTagCompletionProvider } from './HtmlTagCompletionProvider';
45
export { HtmlAttributeCompletionProvider } from './HtmlAttributeCompletionProvider';
56
export { HtmlAttributeValueCompletionProvider } from './HtmlAttributeValueCompletionProvider';

0 commit comments

Comments
 (0)
Please sign in to comment.