Skip to content

Commit 22dd956

Browse files
authoredMar 6, 2025
Renaming liquid doc params refactors render tag params (#843)
1 parent 91e5930 commit 22dd956

File tree

6 files changed

+243
-12
lines changed

6 files changed

+243
-12
lines changed
 

‎.changeset/witty-lizards-collect.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/theme-language-server-common': minor
3+
---
4+
5+
Renaming liquid doc params refactors render tag params

‎packages/theme-language-server-common/src/rename/RenameProvider.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { findCurrentNode } from '@shopify/theme-check-common';
1212
import { BaseRenameProvider } from './BaseRenameProvider';
1313
import { HtmlTagNameRenameProvider } from './providers/HtmlTagNameRenameProvider';
1414
import { LiquidVariableRenameProvider } from './providers/LiquidVariableRenameProvider';
15+
import { Connection } from 'vscode-languageserver';
16+
import { ClientCapabilities } from '../ClientCapabilities';
1517

1618
/**
1719
* RenameProvider is responsible for providing rename support for the theme language server.
@@ -21,10 +23,20 @@ import { LiquidVariableRenameProvider } from './providers/LiquidVariableRenamePr
2123
export class RenameProvider {
2224
private providers: BaseRenameProvider[];
2325

24-
constructor(private documentManager: DocumentManager) {
26+
constructor(
27+
connection: Connection,
28+
clientCapabilities: ClientCapabilities,
29+
private documentManager: DocumentManager,
30+
findThemeRootURI: (uri: string) => Promise<string>,
31+
) {
2532
this.providers = [
2633
new HtmlTagNameRenameProvider(documentManager),
27-
new LiquidVariableRenameProvider(documentManager),
34+
new LiquidVariableRenameProvider(
35+
connection,
36+
clientCapabilities,
37+
documentManager,
38+
findThemeRootURI,
39+
),
2840
];
2941
}
3042

‎packages/theme-language-server-common/src/rename/providers/HtmlTagNameRenameProvider.spec.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@ import { assert, beforeEach, describe, expect, it } from 'vitest';
22
import { Position, TextDocumentEdit } from 'vscode-languageserver-protocol';
33
import { DocumentManager } from '../../documents';
44
import { RenameProvider } from '../RenameProvider';
5+
import { ClientCapabilities } from '../../ClientCapabilities';
6+
import { mockConnection } from '../../test/MockConnection';
7+
8+
const mockRoot = 'file:';
59

610
describe('HtmlTagNameRenameProvider', () => {
711
let documentManager: DocumentManager;
812
let provider: RenameProvider;
913

1014
beforeEach(() => {
1115
documentManager = new DocumentManager();
12-
provider = new RenameProvider(documentManager);
16+
provider = new RenameProvider(
17+
mockConnection(mockRoot),
18+
new ClientCapabilities(),
19+
documentManager,
20+
async () => mockRoot,
21+
);
1322
});
1423

1524
it('returns null when the cursor is not over an HTML tag name', async () => {

‎packages/theme-language-server-common/src/rename/providers/LiquidVariableRenameProvider.spec.ts

+118-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,23 @@ import { Position, TextDocumentEdit } from 'vscode-languageserver-protocol';
33
import { TextDocument } from 'vscode-languageserver-textdocument';
44
import { DocumentManager } from '../../documents';
55
import { RenameProvider } from '../RenameProvider';
6+
import { mockConnection, MockConnection } from '../../test/MockConnection';
7+
import { ClientCapabilities } from '../../ClientCapabilities';
8+
9+
const mockRoot = 'file:';
610

711
describe('LiquidVariableRenameProvider', () => {
8-
const textDocumentUri = 'file:///path/to/document.liquid';
12+
const textDocumentUri = `${mockRoot}///snippets/example-snippet.liquid`;
13+
const findThemeRootURI = async () => mockRoot;
14+
15+
let capabilities: ClientCapabilities;
16+
let connection: MockConnection;
17+
18+
beforeEach(() => {
19+
capabilities = new ClientCapabilities();
20+
connection = mockConnection(mockRoot);
21+
connection.spies.sendRequest.mockReturnValue(Promise.resolve(true));
22+
});
923

1024
describe('unscoped variable', async () => {
1125
let documentManager: DocumentManager;
@@ -25,7 +39,7 @@ describe('LiquidVariableRenameProvider', () => {
2539

2640
beforeEach(() => {
2741
documentManager = new DocumentManager();
28-
provider = new RenameProvider(documentManager);
42+
provider = new RenameProvider(connection, capabilities, documentManager, findThemeRootURI);
2943

3044
textDocument = TextDocument.create(textDocumentUri, 'liquid', 1, documentSource);
3145
documentManager.open(textDocument.uri, documentSource, 1);
@@ -184,7 +198,7 @@ describe('LiquidVariableRenameProvider', () => {
184198

185199
beforeEach(() => {
186200
documentManager = new DocumentManager();
187-
provider = new RenameProvider(documentManager);
201+
provider = new RenameProvider(connection, capabilities, documentManager, findThemeRootURI);
188202
});
189203

190204
['for', 'tablerow'].forEach((tag) => {
@@ -334,4 +348,105 @@ describe('LiquidVariableRenameProvider', () => {
334348
});
335349
});
336350
});
351+
352+
describe('updates across files', async () => {
353+
let documentManager: DocumentManager;
354+
let provider: RenameProvider;
355+
let textDocument: TextDocument;
356+
357+
beforeEach(() => {
358+
capabilities.setup({
359+
workspace: {
360+
applyEdit: true,
361+
},
362+
});
363+
textDocument = TextDocument.create(
364+
textDocumentUri,
365+
'liquid',
366+
1,
367+
`{% doc %}
368+
@param [name] - the name
369+
@param [age] - the age
370+
{% enddoc %}`,
371+
);
372+
373+
documentManager = new DocumentManager();
374+
provider = new RenameProvider(connection, capabilities, documentManager, findThemeRootURI);
375+
376+
documentManager.open(textDocumentUri, textDocument.getText(), 1);
377+
});
378+
379+
it("updates render tag's named parameter when exists", async () => {
380+
createSectionWithSource(
381+
documentManager,
382+
'section1',
383+
`<div>{% render 'example-snippet', name: 'Bob' %}</div>`,
384+
);
385+
createSectionWithSource(
386+
documentManager,
387+
'section2',
388+
`<div>{% render 'example-snippet', age: 60 %}</div>`,
389+
);
390+
391+
const params = {
392+
textDocument,
393+
position: Position.create(1, 11),
394+
newName: 'first_name',
395+
};
396+
const result = await provider.rename(params);
397+
assert(result);
398+
assert(result.documentChanges);
399+
400+
expect(connection.spies.sendRequest).toHaveBeenCalledOnce();
401+
expect(connection.spies.sendRequest).toHaveBeenCalledWith('workspace/applyEdit', {
402+
label: `Rename snippet parameter 'name' to 'first_name'`,
403+
edit: {
404+
changeAnnotations: {
405+
renameSnippetParameter: {
406+
label: `Rename snippet parameter 'name' to 'first_name'`,
407+
needsConfirmation: false,
408+
},
409+
},
410+
documentChanges: [
411+
{
412+
textDocument: {
413+
uri: getSectionUri('section1'),
414+
version: 1,
415+
},
416+
edits: [
417+
{
418+
newText: 'first_name: ',
419+
range: {
420+
end: {
421+
character: 40,
422+
line: 0,
423+
},
424+
start: {
425+
character: 34,
426+
line: 0,
427+
},
428+
},
429+
},
430+
],
431+
annotationId: 'renameSnippetParameter',
432+
},
433+
],
434+
},
435+
});
436+
});
437+
});
337438
});
439+
440+
function createSectionWithSource(
441+
documentManager: DocumentManager,
442+
sectionName: string,
443+
source: string,
444+
) {
445+
const sectionUri = getSectionUri(sectionName);
446+
const sectionTextDocument = TextDocument.create(sectionUri, 'liquid', 1, source);
447+
documentManager.open(sectionUri, sectionTextDocument.getText(), 1);
448+
}
449+
450+
function getSectionUri(sectionName: string) {
451+
return `${mockRoot}///sections/${sectionName}.liquid`;
452+
}

‎packages/theme-language-server-common/src/rename/providers/LiquidVariableRenameProvider.ts

+90-5
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
11
import { BaseRenameProvider } from '../BaseRenameProvider';
2-
import { DocumentManager } from '../../documents';
2+
import { AugmentedLiquidSourceCode, DocumentManager, isLiquidSourceCode } from '../../documents';
33
import {
44
LiquidHtmlNode,
55
LiquidTagFor,
66
LiquidTagTablerow,
77
NamedTags,
88
NodeTypes,
99
Position,
10+
RenderMarkup,
1011
AssignMarkup,
1112
TextNode,
1213
LiquidVariableLookup,
1314
ForMarkup,
1415
} from '@shopify/liquid-html-parser';
15-
import { Range } from 'vscode-languageserver';
16+
import { Connection, Range } from 'vscode-languageserver';
1617
import {
18+
ApplyWorkspaceEditRequest,
1719
PrepareRenameParams,
1820
PrepareRenameResult,
1921
RenameParams,
2022
TextDocumentEdit,
2123
TextEdit,
2224
WorkspaceEdit,
2325
} from 'vscode-languageserver-protocol';
24-
import { visit } from '@shopify/theme-check-common';
2526
import { TextDocument } from 'vscode-languageserver-textdocument';
26-
import { JSONNode } from '@shopify/theme-check-common';
27+
import { JSONNode, SourceCodeType, visit } from '@shopify/theme-check-common';
28+
import { snippetName } from '../../utils/uri';
29+
import { ClientCapabilities } from '../../ClientCapabilities';
2730

2831
export class LiquidVariableRenameProvider implements BaseRenameProvider {
29-
constructor(private documentManager: DocumentManager) {}
32+
constructor(
33+
private connection: Connection,
34+
private clientCapabilities: ClientCapabilities,
35+
private documentManager: DocumentManager,
36+
private findThemeRootURI: (uri: string) => Promise<string>,
37+
) {}
3038

3139
async prepare(
3240
node: LiquidHtmlNode,
@@ -60,6 +68,7 @@ export class LiquidVariableRenameProvider implements BaseRenameProvider {
6068
params: RenameParams,
6169
): Promise<null | WorkspaceEdit> {
6270
const document = this.documentManager.get(params.textDocument.uri);
71+
const rootUri = await this.findThemeRootURI(params.textDocument.uri);
6372
const textDocument = document?.textDocument;
6473

6574
if (!textDocument || !node || !ancestors) return null;
@@ -70,17 +79,29 @@ export class LiquidVariableRenameProvider implements BaseRenameProvider {
7079
const scope = variableNameBlockScope(oldName, ancestors);
7180
const replaceRange = textReplaceRange(oldName, textDocument, scope);
7281

82+
let liquidDocParamUpdated = false;
83+
7384
const ranges: Range[] = visit(document.ast, {
7485
VariableLookup: replaceRange,
7586
AssignMarkup: replaceRange,
7687
ForMarkup: replaceRange,
7788
TextNode: (node: LiquidHtmlNode, ancestors: (LiquidHtmlNode | JSONNode)[]) => {
7889
if (ancestors.at(-1)?.type !== NodeTypes.LiquidDocParamNode) return;
7990

91+
liquidDocParamUpdated = true;
92+
8093
return replaceRange(node, ancestors);
8194
},
8295
});
8396

97+
if (this.clientCapabilities.hasApplyEditSupport && liquidDocParamUpdated) {
98+
const themeFiles = this.documentManager.theme(rootUri, true);
99+
const liquidSourceCodes = themeFiles.filter(isLiquidSourceCode);
100+
const name = snippetName(params.textDocument.uri);
101+
102+
updateRenderTags(this.connection, liquidSourceCodes, name, oldName, params.newName);
103+
}
104+
84105
const textDocumentEdit = TextDocumentEdit.create(
85106
{ uri: textDocument.uri, version: textDocument.version },
86107
ranges.map((range) => TextEdit.replace(range, params.newName)),
@@ -184,3 +205,67 @@ function textReplaceRange(
184205
);
185206
};
186207
}
208+
209+
async function updateRenderTags(
210+
connection: Connection,
211+
liquidSourceCodes: AugmentedLiquidSourceCode[],
212+
snippetName: string,
213+
oldParamName: string,
214+
newParamName: string,
215+
) {
216+
const editLabel = `Rename snippet parameter '${oldParamName}' to '${newParamName}'`;
217+
const annotationId = 'renameSnippetParameter';
218+
const workspaceEdit: WorkspaceEdit = {
219+
documentChanges: [],
220+
changeAnnotations: {
221+
[annotationId]: {
222+
label: editLabel,
223+
needsConfirmation: false,
224+
},
225+
},
226+
};
227+
228+
for (const sourceCode of liquidSourceCodes) {
229+
if (sourceCode.ast instanceof Error) continue;
230+
const textDocument = sourceCode.textDocument;
231+
const edits: TextEdit[] = visit<SourceCodeType.LiquidHtml, TextEdit>(sourceCode.ast, {
232+
RenderMarkup(node: RenderMarkup) {
233+
if (node.snippet.type !== NodeTypes.String || node.snippet.value !== snippetName) {
234+
return;
235+
}
236+
237+
const renamedNameParamNode = node.args.find((arg) => arg.name === oldParamName);
238+
239+
if (renamedNameParamNode) {
240+
return {
241+
newText: `${newParamName}: `,
242+
range: Range.create(
243+
textDocument.positionAt(renamedNameParamNode.position.start),
244+
textDocument.positionAt(renamedNameParamNode.value.position.start),
245+
),
246+
};
247+
}
248+
},
249+
});
250+
251+
if (edits.length === 0) continue;
252+
workspaceEdit.documentChanges!.push({
253+
textDocument: {
254+
uri: textDocument.uri,
255+
version: sourceCode.version ?? null /* null means file from disk in this API */,
256+
},
257+
annotationId,
258+
edits,
259+
});
260+
}
261+
262+
if (workspaceEdit.documentChanges!.length === 0) {
263+
console.error('Nothing to do!');
264+
return;
265+
}
266+
267+
await connection.sendRequest(ApplyWorkspaceEditRequest.type, {
268+
label: editLabel,
269+
edit: workspaceEdit,
270+
});
271+
}

‎packages/theme-language-server-common/src/server/startServer.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,12 @@ export function startServer(
117117
);
118118
const linkedEditingRangesProvider = new LinkedEditingRangesProvider(documentManager);
119119
const documentHighlightProvider = new DocumentHighlightsProvider(documentManager);
120-
const renameProvider = new RenameProvider(documentManager);
120+
const renameProvider = new RenameProvider(
121+
connection,
122+
clientCapabilities,
123+
documentManager,
124+
findThemeRootURI,
125+
);
121126
const renameHandler = new RenameHandler(
122127
connection,
123128
clientCapabilities,

0 commit comments

Comments
 (0)
Failed to load comments.