-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- {{ '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
1 parent
bbd0a3a
commit 48f9255
Showing
4 changed files
with
289 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
}); | ||
}); |
99 changes: 99 additions & 0 deletions
99
packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters