Skip to content

Commit

Permalink
Add AssetRenameHandler
Browse files Browse the repository at this point in the history
- {{ 'oldName.js' | asset_url }} -> {{ 'newName.js' | asset_url }}
- {{ 'oldName.css' | asset_url }} -> {{ 'newName.css' | asset_url }}
- {% echo 'oldName.css' | asset_url %} -> {% echo 'newName.css' | asset_url %}
- etc.
  • Loading branch information
charlespwd committed Oct 29, 2024
1 parent bbd0a3a commit 48f9255
Show file tree
Hide file tree
Showing 4 changed files with 289 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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': `<script src="{{ 'oldName.js' | asset_url }}" defer></script>`,
'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': `<script src="{{ 'newName.js' | asset_url }}" defer></script>`,
'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': `<script src="{{ 'newName.js' | asset_url }}" defer></script>`,
'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);
}
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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<SourceCodeType.LiquidHtml, TextEdit>(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);
}
}
4 changes: 4 additions & 0 deletions packages/theme-language-server-common/src/utils/uri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

0 comments on commit 48f9255

Please sign in to comment.