diff --git a/packages/theme-language-server-common/src/renamed/RenameHandler.ts b/packages/theme-language-server-common/src/renamed/RenameHandler.ts
index cfb38ab5..b5009743 100644
--- a/packages/theme-language-server-common/src/renamed/RenameHandler.ts
+++ b/packages/theme-language-server-common/src/renamed/RenameHandler.ts
@@ -3,6 +3,7 @@ import { Connection } from 'vscode-languageserver';
import { RenameFilesParams } from 'vscode-languageserver-protocol';
import { DocumentManager } from '../documents';
import { BaseRenameHandler } from './BaseRenameHandler';
+import { AssetRenameHandler } from './handlers/AssetRenameHandler';
import { SnippetRenameHandler } from './handlers/SnippetRenameHandler';
/**
@@ -20,7 +21,7 @@ export class RenameHandler {
private documentManager: DocumentManager,
private fileExists: FileExists,
) {
- this.handlers = [new SnippetRenameHandler(connection)];
+ this.handlers = [new SnippetRenameHandler(connection), new AssetRenameHandler(connection)];
}
async onDidRenameFiles(params: RenameFilesParams) {
diff --git a/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.spec.ts b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.spec.ts
new file mode 100644
index 00000000..fc27aa51
--- /dev/null
+++ b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.spec.ts
@@ -0,0 +1,184 @@
+import { makeFileExists } from '@shopify/theme-check-common';
+import { MockFileSystem } from '@shopify/theme-check-common/src/test';
+import { assert, beforeEach, describe, expect, it } from 'vitest';
+import { TextDocumentEdit } from 'vscode-json-languageservice';
+import { ApplyWorkspaceEditParams } from 'vscode-languageserver-protocol';
+import { TextDocument } from 'vscode-languageserver-textdocument';
+import { DocumentManager } from '../../documents';
+import { MockConnection, mockConnection } from '../../test/MockConnection';
+import { RenameHandler } from '../RenameHandler';
+
+describe('Module: AssetRenameHandler', () => {
+ let documentManager: DocumentManager;
+ let handler: RenameHandler;
+ let connection: MockConnection;
+ let fs: MockFileSystem;
+ beforeEach(() => {
+ connection = mockConnection();
+ connection.spies.sendRequest.mockReturnValue(Promise.resolve(true));
+ fs = new MockFileSystem(
+ {
+ 'assets/oldName.js': 'console.log("Hello, world!")',
+ 'sections/section.liquid': ``,
+ 'blocks/block.liquid': `{{ 'oldName.js' | asset_url | script_tag }} oldName.js`,
+ },
+ 'mock-fs:',
+ );
+ documentManager = new DocumentManager(fs);
+ handler = new RenameHandler(connection, documentManager, makeFileExists(fs));
+ });
+
+ it('returns a needConfirmation: false workspace edit for renaming an asset', async () => {
+ await handler.onDidRenameFiles({
+ files: [
+ {
+ oldUri: 'mock-fs:/assets/oldName.js',
+ newUri: 'mock-fs:/assets/newName.js',
+ },
+ ],
+ });
+
+ const expectedTextEdit = {
+ range: expect.any(Object),
+ newText: 'newName.js',
+ };
+
+ expect(connection.spies.sendRequest).toHaveBeenCalledWith('workspace/applyEdit', {
+ label: "Rename asset 'oldName.js' to 'newName.js'",
+ edit: {
+ changeAnnotations: {
+ renameAsset: {
+ label: `Rename asset 'oldName.js' to 'newName.js'`,
+ needsConfirmation: false,
+ },
+ },
+ documentChanges: [
+ {
+ textDocument: {
+ uri: 'mock-fs:/sections/section.liquid',
+ version: null,
+ },
+ edits: [expectedTextEdit],
+ annotationId: 'renameAsset',
+ },
+ {
+ textDocument: {
+ uri: 'mock-fs:/blocks/block.liquid',
+ version: null,
+ },
+ edits: [expectedTextEdit],
+ annotationId: 'renameAsset',
+ },
+ ],
+ },
+ });
+ });
+
+ it('replaces the correct text in the documents', async () => {
+ await handler.onDidRenameFiles({
+ files: [
+ {
+ oldUri: 'mock-fs:/assets/oldName.js',
+ newUri: 'mock-fs:/assets/newName.js',
+ },
+ ],
+ });
+
+ const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1];
+ const expectedFs = new MockFileSystem(
+ {
+ 'assets/oldName.js': 'console.log("Hello, world!")',
+ 'sections/section.liquid': ``,
+ 'blocks/block.liquid': `{{ 'newName.js' | asset_url | script_tag }} oldName.js`,
+ },
+ 'mock-fs:',
+ );
+
+ assert(params.edit);
+ assert(params.edit.documentChanges);
+ for (const docChange of params.edit.documentChanges) {
+ assert(TextDocumentEdit.is(docChange));
+ const uri = docChange.textDocument.uri;
+ const edits = docChange.edits;
+ const initialDoc = await fs.readFile(uri);
+ const expectedDoc = await expectedFs.readFile(uri);
+ const textDocument = TextDocument.create(uri, 'liquid', 0, initialDoc);
+ expect(TextDocument.applyEdits(textDocument, edits)).toBe(expectedDoc);
+ }
+ });
+
+ it('handles .js.liquid files', async (ext) => {
+ await handler.onDidRenameFiles({
+ files: [
+ {
+ oldUri: 'mock-fs:/assets/oldName.js',
+ newUri: `mock-fs:/assets/newName.js.liquid`,
+ },
+ ],
+ });
+
+ const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1];
+ const expectedFs = new MockFileSystem(
+ {
+ 'assets/oldName.js': 'console.log("Hello, world!")',
+ 'sections/section.liquid': ``,
+ 'blocks/block.liquid': `{{ 'newName.js' | asset_url | script_tag }} oldName.js`,
+ },
+ 'mock-fs:',
+ );
+
+ assert(params.edit);
+ assert(params.edit.documentChanges);
+ for (const docChange of params.edit.documentChanges) {
+ assert(TextDocumentEdit.is(docChange));
+ const uri = docChange.textDocument.uri;
+ const edits = docChange.edits;
+ const initialDoc = await fs.readFile(uri);
+ const expectedDoc = await expectedFs.readFile(uri);
+ const textDocument = TextDocument.create(uri, 'liquid', 0, initialDoc);
+ expect(TextDocument.applyEdits(textDocument, edits)).toBe(expectedDoc);
+ }
+ });
+
+ it('handles .css.liquid files', async () => {
+ fs = new MockFileSystem(
+ {
+ 'assets/oldName.css.liquid': 'body { color: red; }',
+ 'sections/section.liquid': `{% echo 'oldName.css' | asset_url | stylesheet_tag %}`,
+ },
+ 'mock-fs:',
+ );
+ documentManager = new DocumentManager(fs);
+ handler = new RenameHandler(connection, documentManager, makeFileExists(fs));
+
+ await handler.onDidRenameFiles({
+ files: [
+ {
+ oldUri: 'mock-fs:/assets/oldName.css.liquid',
+ newUri: `mock-fs:/assets/newName.css.liquid`,
+ },
+ ],
+ });
+
+ const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1];
+ const expectedFs = new MockFileSystem(
+ {
+ 'assets/oldName.css.liquid': 'body { color: red; }',
+ 'sections/section.liquid': `{% echo 'newName.css' | asset_url | stylesheet_tag %}`,
+ },
+ 'mock-fs:',
+ );
+
+ assert(params.edit);
+ assert(params.edit.documentChanges);
+ for (const docChange of params.edit.documentChanges) {
+ assert(TextDocumentEdit.is(docChange));
+ const uri = docChange.textDocument.uri;
+ const edits = docChange.edits;
+ const initialDoc = await fs.readFile(uri);
+ const expectedDoc = await expectedFs.readFile(uri);
+ const textDocument = TextDocument.create(uri, 'liquid', 0, initialDoc);
+ expect(TextDocument.applyEdits(textDocument, edits)).toBe(expectedDoc);
+ }
+ });
+});
diff --git a/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts
new file mode 100644
index 00000000..525b2ac5
--- /dev/null
+++ b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts
@@ -0,0 +1,99 @@
+import { LiquidVariable, NodeTypes } from '@shopify/liquid-html-parser';
+import { SourceCodeType } from '@shopify/theme-check-common';
+import { Connection } from 'vscode-languageserver';
+import {
+ ApplyWorkspaceEditRequest,
+ Range,
+ RenameFilesParams,
+ TextEdit,
+ WorkspaceEdit,
+} from 'vscode-languageserver-protocol';
+import { AugmentedLiquidSourceCode, AugmentedSourceCode } from '../../documents';
+import { assetName, isAsset } from '../../utils/uri';
+import { visit } from '../../visitor';
+import { BaseRenameHandler } from '../BaseRenameHandler';
+
+/**
+ * The AssetRenameHandler will handle asset renames.
+ *
+ * We'll change all the `| asset_url` that reference the old asset:
+ * {{ 'oldName.js' | asset_url }} -> {{ 'newName.js' | asset_url }}
+ *
+ * We'll do that for `.(css|js).liquid` files as well
+ *
+ * We'll do this by visiting all the liquid files in the theme and looking for
+ * string | asset_url Variable nodes that reference the old asset. We'll then create a
+ * WorkspaceEdit that changes the references to the new asset.
+ */
+export class AssetRenameHandler implements BaseRenameHandler {
+ constructor(private connection: Connection) {}
+
+ async onDidRenameFiles(params: RenameFilesParams, theme: AugmentedSourceCode[]): Promise {
+ const isLiquidSourceCode = (file: AugmentedSourceCode): file is AugmentedLiquidSourceCode =>
+ file.type === SourceCodeType.LiquidHtml;
+
+ const liquidSourceCodes = theme.filter(isLiquidSourceCode);
+ const relevantRenames = params.files.filter(
+ (file) => isAsset(file.oldUri) && isAsset(file.newUri),
+ );
+
+ const promises = relevantRenames.map(async (file) => {
+ const oldAssetName = assetName(file.oldUri);
+ const newAssetName = assetName(file.newUri);
+ const editLabel = `Rename asset '${oldAssetName}' to '${newAssetName}'`;
+ const annotationId = 'renameAsset';
+ const workspaceEdit: WorkspaceEdit = {
+ documentChanges: [],
+ changeAnnotations: {
+ [annotationId]: {
+ label: editLabel,
+ needsConfirmation: false,
+ },
+ },
+ };
+
+ for (const sourceCode of liquidSourceCodes) {
+ if (sourceCode.ast instanceof Error) continue;
+ const textDocument = sourceCode.textDocument;
+ const edits: TextEdit[] = visit(sourceCode.ast, {
+ LiquidVariable(node: LiquidVariable) {
+ if (node.filters.length === 0) return undefined;
+ if (node.expression.type !== NodeTypes.String) return undefined;
+ if (node.filters[0].name !== 'asset_url') return undefined;
+ const assetName = node.expression.value;
+ if (assetName !== oldAssetName) return undefined;
+ return {
+ newText: newAssetName,
+ range: Range.create(
+ textDocument.positionAt(node.expression.position.start + 1), // +1 to skip the opening quote
+ textDocument.positionAt(node.expression.position.end - 1), // -1 to skip the closing quote
+ ),
+ };
+ },
+ });
+
+ if (edits.length === 0) continue;
+ workspaceEdit.documentChanges!.push({
+ textDocument: {
+ uri: textDocument.uri,
+ version: sourceCode.version ?? null /* null means file from disk in this API */,
+ },
+ annotationId,
+ edits,
+ });
+ }
+
+ if (workspaceEdit.documentChanges!.length === 0) {
+ console.error('Nothing to do!');
+ return;
+ }
+
+ return this.connection.sendRequest(ApplyWorkspaceEditRequest.type, {
+ label: editLabel,
+ edit: workspaceEdit,
+ });
+ });
+
+ await Promise.all(promises);
+ }
+}
diff --git a/packages/theme-language-server-common/src/utils/uri.ts b/packages/theme-language-server-common/src/utils/uri.ts
index 25526eee..52a25cd3 100644
--- a/packages/theme-language-server-common/src/utils/uri.ts
+++ b/packages/theme-language-server-common/src/utils/uri.ts
@@ -2,3 +2,7 @@ import { path } from '@shopify/theme-check-common';
export const snippetName = (uri: string) => path.basename(uri, '.liquid');
export const isSnippet = (uri: string) => /\bsnippets(\\|\/)[^\\\/]*\.liquid/.test(uri);
+
+// asset urls have their `.liquid`` removed (if present) and require the other extension */
+export const assetName = (uri: string) => path.basename(uri, '.liquid');
+export const isAsset = (uri: string) => /\bassets(\\|\/)[^\\\/]/.test(uri);