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);