From b31e0f85881a37b3774106fc24e5a9bafbe818e5 Mon Sep 17 00:00:00 2001 From: CP Clermont Date: Thu, 9 Jan 2025 09:21:39 -0500 Subject: [PATCH] Add block rename handler (replace old->new everywhere!) (#655) * Add `offset` property on Schema objects Saves you some work to get position info * Move isLiquidSourceCode helper for reuse * Add BlockRenameHandler Co-authored-by: Navdeep Singh Whenever a block gets renamed, the following will now happen: 1. References in files with a {% schema %} will be changed 2. References in template files will be changed 3. References in section groups will be changed 4. References in {% content_for "block", type: "oldName" %} will be changed * Add changesets * Remove bugged support for subfolder renames * Clean up function that can be hoisted --- .changeset/green-mice-wait.md | 5 + .changeset/wild-beers-study.md | 12 + .../checks/valid-schema-name/index.spec.ts | 1 + packages/theme-check-common/src/to-schema.ts | 7 +- .../src/types/theme-schemas.ts | 3 + .../src/documents/types.ts | 6 + .../src/renamed/RenameHandler.ts | 3 +- .../renamed/handlers/AssetRenameHandler.ts | 109 ++--- .../handlers/BlockRenameHandler.spec.ts | 399 +++++++++++++++ .../renamed/handlers/BlockRenameHandler.ts | 453 ++++++++++++++++++ .../renamed/handlers/SnippetRenameHandler.ts | 119 +++-- .../src/utils/uri.ts | 14 + 12 files changed, 1008 insertions(+), 123 deletions(-) create mode 100644 .changeset/green-mice-wait.md create mode 100644 .changeset/wild-beers-study.md create mode 100644 packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.spec.ts create mode 100644 packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts diff --git a/.changeset/green-mice-wait.md b/.changeset/green-mice-wait.md new file mode 100644 index 000000000..dfa2f1fbb --- /dev/null +++ b/.changeset/green-mice-wait.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme-check-common': minor +--- + +[internal] Add `offset` information on Schema objects diff --git a/.changeset/wild-beers-study.md b/.changeset/wild-beers-study.md new file mode 100644 index 000000000..0d592dc16 --- /dev/null +++ b/.changeset/wild-beers-study.md @@ -0,0 +1,12 @@ +--- +'@shopify/theme-language-server-common': minor +'theme-check-vscode': minor +--- + +Add "On theme block rename" handling + +Whenever a theme block gets renamed, the following will now happen: + 1. References in files with a `{% schema %}` will be updated automatically + 2. References in template files will be updated automatically + 3. References in section groups will be updated automatically + 4. References in `{% content_for "block", type: "oldName" %}` will be updated automatically diff --git a/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts b/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts index c62c0927b..4cd61ba77 100644 --- a/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts +++ b/packages/theme-check-common/src/checks/valid-schema-name/index.spec.ts @@ -22,6 +22,7 @@ describe('Module: ValidSchemaName', () => { validSchema: new Error('Invalid schema'), name: 'file', type: ThemeSchemaType.Section, + offset: 0, }), }); diff --git a/packages/theme-check-common/src/to-schema.ts b/packages/theme-check-common/src/to-schema.ts index 54a5b7308..8d7feef14 100644 --- a/packages/theme-check-common/src/to-schema.ts +++ b/packages/theme-check-common/src/to-schema.ts @@ -46,13 +46,13 @@ export function isSection(uri: UriString) { } export function isBlockSchema( - schema: SectionSchema | ThemeBlockSchema | undefined, + schema: AppBlockSchema | SectionSchema | ThemeBlockSchema | undefined, ): schema is ThemeBlockSchema { return schema?.type === ThemeSchemaType.Block; } export function isSectionSchema( - schema: SectionSchema | ThemeBlockSchema | undefined, + schema: AppBlockSchema | SectionSchema | ThemeBlockSchema | undefined, ): schema is SectionSchema { return schema?.type === ThemeSchemaType.Section; } @@ -84,6 +84,7 @@ export async function toBlockSchema( return { type: ThemeSchemaType.Block, validSchema: await toValidSchema(uri, schemaNode, parsed, isValidSchema), + offset: schemaNode instanceof Error ? 0 : schemaNode.blockStartPosition.end, name, parsed, ast, @@ -105,6 +106,7 @@ export async function toSectionSchema( return { type: ThemeSchemaType.Section, validSchema: await toValidSchema(uri, schemaNode, parsed, isValidSchema), + offset: schemaNode instanceof Error ? 0 : schemaNode.blockStartPosition.end, name, parsed, ast, @@ -122,6 +124,7 @@ export async function toAppBlockSchema( const ast = toAst(schemaNode); return { type: ThemeSchemaType.AppBlock, + offset: schemaNode instanceof Error ? 0 : schemaNode.blockStartPosition.end, name, parsed, ast, diff --git a/packages/theme-check-common/src/types/theme-schemas.ts b/packages/theme-check-common/src/types/theme-schemas.ts index ed6b25b05..b59bb857f 100644 --- a/packages/theme-check-common/src/types/theme-schemas.ts +++ b/packages/theme-check-common/src/types/theme-schemas.ts @@ -29,6 +29,9 @@ export interface ThemeSchema { /** Parsed as a JavaScript object or an Error */ parsed: any | Error; + + /** 0-based index of the start of JSON object in the document */ + offset: number; } /** See {@link ThemeSchema} */ diff --git a/packages/theme-language-server-common/src/documents/types.ts b/packages/theme-language-server-common/src/documents/types.ts index 1c7421e80..2251c3697 100644 --- a/packages/theme-language-server-common/src/documents/types.ts +++ b/packages/theme-language-server-common/src/documents/types.ts @@ -39,3 +39,9 @@ export type AugmentedSourceCode = { [SourceCodeType.JSON]: AugmentedJsonSourceCode; [SourceCodeType.LiquidHtml]: AugmentedLiquidSourceCode; }[SCT]; + +export const isLiquidSourceCode = (file: AugmentedSourceCode): file is AugmentedLiquidSourceCode => + file.type === SourceCodeType.LiquidHtml; + +export const isJsonSourceCode = (file: AugmentedSourceCode): file is AugmentedJsonSourceCode => + file.type === SourceCodeType.JSON; diff --git a/packages/theme-language-server-common/src/renamed/RenameHandler.ts b/packages/theme-language-server-common/src/renamed/RenameHandler.ts index 907e2b0d1..a0a8dd23e 100644 --- a/packages/theme-language-server-common/src/renamed/RenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/RenameHandler.ts @@ -1,10 +1,10 @@ -import { path } from '@shopify/theme-check-common'; import { Connection } from 'vscode-languageserver'; import { RenameFilesParams } from 'vscode-languageserver-protocol'; import { ClientCapabilities } from '../ClientCapabilities'; import { DocumentManager } from '../documents'; import { BaseRenameHandler } from './BaseRenameHandler'; import { AssetRenameHandler } from './handlers/AssetRenameHandler'; +import { BlockRenameHandler } from './handlers/BlockRenameHandler'; import { SnippetRenameHandler } from './handlers/SnippetRenameHandler'; /** @@ -26,6 +26,7 @@ export class RenameHandler { this.handlers = [ new SnippetRenameHandler(documentManager, connection, capabilities, findThemeRootURI), new AssetRenameHandler(documentManager, connection, capabilities, findThemeRootURI), + new BlockRenameHandler(documentManager, connection, capabilities, findThemeRootURI), ]; } diff --git a/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts index 5c83f00bb..dfdf19f24 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts @@ -9,7 +9,7 @@ import { WorkspaceEdit, } from 'vscode-languageserver-protocol'; import { ClientCapabilities } from '../../ClientCapabilities'; -import { AugmentedLiquidSourceCode, AugmentedSourceCode, DocumentManager } from '../../documents'; +import { DocumentManager, isLiquidSourceCode } from '../../documents'; import { assetName, isAsset } from '../../utils/uri'; import { BaseRenameHandler } from '../BaseRenameHandler'; @@ -35,77 +35,72 @@ export class AssetRenameHandler implements BaseRenameHandler { async onDidRenameFiles(params: RenameFilesParams): Promise { if (!this.capabilities.hasApplyEditSupport) return; - const isLiquidSourceCode = (file: AugmentedSourceCode): file is AugmentedLiquidSourceCode => - file.type === SourceCodeType.LiquidHtml; const relevantRenames = params.files.filter( (file) => isAsset(file.oldUri) && isAsset(file.newUri), ); - // Only preload if you have something to do - if (relevantRenames.length === 0) return; + // Only preload if you have something to do (folder renames are not supported) + if (relevantRenames.length !== 1) return; + const rename = relevantRenames[0]; const rootUri = await this.findThemeRootURI(path.dirname(params.files[0].oldUri)); await this.documentManager.preload(rootUri); const theme = this.documentManager.theme(rootUri, true); const liquidSourceCodes = theme.filter(isLiquidSourceCode); - 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, - }, + const oldAssetName = assetName(rename.oldUri); + const newAssetName = assetName(rename.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; - if (node.expression.type !== NodeTypes.String) return; - if (node.filters[0].name !== 'asset_url') return; - const assetName = node.expression.value; - if (assetName !== oldAssetName) return; - 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 - ), - }; - }, - }); + 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; + if (node.expression.type !== NodeTypes.String) return; + if (node.filters[0].name !== 'asset_url') return; + const assetName = node.expression.value; + if (assetName !== oldAssetName) return; + 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 (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; - } + if (workspaceEdit.documentChanges!.length === 0) { + console.error('Nothing to do!'); + return; + } - return this.connection.sendRequest(ApplyWorkspaceEditRequest.type, { - label: editLabel, - edit: workspaceEdit, - }); + await this.connection.sendRequest(ApplyWorkspaceEditRequest.type, { + label: editLabel, + edit: workspaceEdit, }); - - await Promise.all(promises); } } diff --git a/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.spec.ts b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.spec.ts new file mode 100644 index 000000000..32f9bd6b2 --- /dev/null +++ b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.spec.ts @@ -0,0 +1,399 @@ +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 { ClientCapabilities } from '../../ClientCapabilities'; +import { DocumentManager } from '../../documents'; +import { MockConnection, mockConnection } from '../../test/MockConnection'; +import { RenameHandler } from '../RenameHandler'; + +describe('Module: BlockRenameHandler', () => { + const mockRoot = 'mock-fs:'; + const findThemeRootURI = async () => mockRoot; + let capabilities: ClientCapabilities; + let documentManager: DocumentManager; + let handler: RenameHandler; + let connection: MockConnection; + let fs: MockFileSystem; + + beforeEach(() => { + connection = mockConnection(mockRoot); + connection.spies.sendRequest.mockReturnValue(Promise.resolve(true)); + capabilities = new ClientCapabilities(); + fs = new MockFileSystem( + { + 'blocks/other-block.liquid': ``, + 'blocks/old-name.liquid': ` + {% schema %} + { + "name": "Old Block", + "blocks": [{ "type": "other-block" }], + "presets": [{ + "name": "Default", + "blocks": [{ + "type": "other-block" + }] + }] + } + {% endschema %}`, + + 'sections/section.liquid': ` +
{% content_for "block", id: "old-name-id", type: "old-name" %}
+ {% schema %} + { + "name": "Section", + "blocks": [{ "type": "old-name" }], + "presets": [ + { + "name": "Default", + "blocks": [ + { + "type": "old-name", + "blocks": [{ "type": "other-block" }] + } + ] + } + ] + } + {% endschema %}`, + + 'sections/header.json': ` + { + "type": "header", + "name": "Header", + "sections": { + "section-id": { + "type": "section", + "blocks": { + "old-name-id": { + "type": "old-name" + } + }, + "block_order": ["old-name-id"] + } + }, + "order": ["section-id"] + }`, + + 'templates/index.json': ` + { + "sections": { + "section-id": { + "type": "section", + "blocks": { + "old-name-id": { + "type": "old-name" + } + }, + "block_order": ["old-name-id"] + } + }, + "order": ["section-id"] + }`, + }, + mockRoot, + ); + documentManager = new DocumentManager( + fs, // filesystem + undefined, // optional mode getter + undefined, // optional mode map + async () => 'theme', // getModeForURI + async () => true, // isValidSchema - assume all schemas are valid in tests + ); + handler = new RenameHandler(connection, capabilities, documentManager, findThemeRootURI); + }); + + describe('when the client does not support workspace/applyEdit', () => { + beforeEach(() => { + capabilities.setup({ + workspace: { + applyEdit: false, + }, + }); + }); + + it('does nothing', async () => { + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/blocks/old-name.liquid', + newUri: 'mock-fs:/blocks/new-name.liquid', + }, + ], + }); + expect(connection.spies.sendRequest).not.toHaveBeenCalled(); + }); + }); + + describe('when the client supports workspace/applyEdit', () => { + beforeEach(() => { + capabilities.setup({ + workspace: { + applyEdit: true, + }, + }); + }); + + it('returns a needConfirmation: false workspace edit for renaming a block', async () => { + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/blocks/old-name.liquid', + newUri: 'mock-fs:/blocks/new-name.liquid', + }, + ], + }); + + const replaceWithNewNameTextEditAtAnyLocation = { + annotationId: 'renameBlock', + newText: 'new-name', + range: { + start: expect.any(Object), + end: expect.any(Object), + }, + }; + + const templateTextEdits = [ + // Block type is updated in the section blocks array + replaceWithNewNameTextEditAtAnyLocation, + ]; + + const sectionGroupTextEdits = [ + // Block type is updated in the section blocks array, + replaceWithNewNameTextEditAtAnyLocation, + ]; + + const sectionFileTextEdits = [ + // In the content_for + replaceWithNewNameTextEditAtAnyLocation, + // In the blocks definition + replaceWithNewNameTextEditAtAnyLocation, + // In the presets + replaceWithNewNameTextEditAtAnyLocation, + ]; + + expect(connection.spies.sendRequest).toHaveBeenCalledWith('workspace/applyEdit', { + label: "Rename block 'old-name' to 'new-name'", + edit: { + changeAnnotations: { + renameBlock: { + label: `Rename block 'old-name' to 'new-name'`, + needsConfirmation: false, + }, + }, + documentChanges: expect.arrayContaining([ + { + textDocument: { + uri: 'mock-fs:/sections/section.liquid', + version: null, + }, + edits: sectionFileTextEdits, + }, + { + textDocument: { + uri: 'mock-fs:/templates/index.json', + version: null, + }, + edits: templateTextEdits, + }, + { + textDocument: { + uri: 'mock-fs:/sections/header.json', + version: null, + }, + edits: sectionGroupTextEdits, + }, + ]), + }, + }); + }); + + it('replaces the correct text in the documents', async () => { + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/blocks/old-name.liquid', + newUri: 'mock-fs:/blocks/new-name.liquid', + }, + ], + }); + + const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1]; + const expectedFs = new MockFileSystem( + { + 'blocks/new-name.liquid': ` + {% schema %} + { + "name": "Old Block", + "blocks": [{ "type": "other-block" }], + "presets": [{ + "name": "Default", + "blocks": [{ + "type": "other-block" + }] + }] + } + {% endschema %}`, + + // The old-name block type is updated, but not the id. + 'sections/section.liquid': ` +
{% content_for "block", id: "old-name-id", type: "new-name" %}
+ {% schema %} + { + "name": "Section", + "blocks": [{ "type": "new-name" }], + "presets": [ + { + "name": "Default", + "blocks": [ + { + "type": "new-name", + "blocks": [{ "type": "other-block" }] + } + ] + } + ] + } + {% endschema %}`, + + 'sections/header.json': ` + { + "type": "header", + "name": "Header", + "sections": { + "section-id": { + "type": "section", + "blocks": { + "old-name-id": { + "type": "new-name" + } + }, + "block_order": ["old-name-id"] + } + }, + "order": ["section-id"] + }`, + + // The old-name-id's block type is updated, but not the id. + // This isn't a regex search and replace. + 'templates/index.json': ` + { + "sections": { + "section-id": { + "type": "section", + "blocks": { + "old-name-id": { + "type": "new-name" + } + }, + "block_order": ["old-name-id"] + } + }, + "order": ["section-id"] + }`, + }, + mockRoot, + ); + + 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); + expect(edits).to.applyEdits(initialDoc, expectedDoc); + } + }); + + it('preserves local block definitions', async () => { + fs = new MockFileSystem( + { + // a "theme" text block exists + 'blocks/text.liquid': ` + {% schema %} + { "name": "text block" } + {% endschema %}`, + + // this section uses a local block of type "text" + 'sections/local.liquid': ` + {% schema %} + { + "name": "Section with local blocks", + "blocks": [ + { + "type": "text", + "name": "Local text block" + } + ], + "presets": [ + { + "name": "Because text is a local block definition, this preset won't get a rename", + "blocks": [ + { "type": "text" } + ] + } + ] + } + {% endschema %}`, + + // This section group uses the local section that uses the local block, no rename needed + 'sections/header.json': JSON.stringify({ + type: 'header', + name: 'Header', + sections: { + local_id: { + type: 'local', + blocks: { + text_tqQTNE: { + type: 'text', + }, + }, + block_order: ['text_tqQTNE'], + }, + }, + order: ['local_id'], + }), + + // This template uses the section that uses the local block, no rename needed + 'templates/index.json': JSON.stringify({ + sections: { + local: { + type: 'local', + blocks: { + text_tqQTNE: { + type: 'text', + }, + }, + block_order: ['text_tqQTNE'], + }, + }, + }), + }, + mockRoot, + ); + documentManager = new DocumentManager( + fs, + undefined, + undefined, + async () => 'theme', + async () => true, + ); + handler = new RenameHandler(connection, capabilities, documentManager, findThemeRootURI); + + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/blocks/text.liquid', + newUri: 'mock-fs:/blocks/poetry.liquid', + }, + ], + }); + + // Check if sendRequest was called at all + expect(connection.spies.sendRequest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts new file mode 100644 index 000000000..3f9effedd --- /dev/null +++ b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts @@ -0,0 +1,453 @@ +import { NodeTypes } from '@shopify/liquid-html-parser'; +import { + isBlockSchema, + isError, + isSectionSchema, + JSONNode, + nodeAtPath, + parseJSON, + path, + Preset, + Section, + Setting, + SourceCodeType, + ThemeBlock, + visit, +} from '@shopify/theme-check-common'; +import { Connection } from 'vscode-languageserver'; +import { + ApplyWorkspaceEditRequest, + Range, + RenameFilesParams, + TextDocumentEdit, + AnnotatedTextEdit as TextEdit, + WorkspaceEdit, +} from 'vscode-languageserver-protocol'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { ClientCapabilities } from '../../ClientCapabilities'; +import { + AugmentedJsonSourceCode, + AugmentedLiquidSourceCode, + AugmentedSourceCode, + DocumentManager, + isJsonSourceCode, + isLiquidSourceCode, +} from '../../documents'; +import { blockName, isBlock, isSection, isSectionGroup, isTemplate } from '../../utils/uri'; +import { BaseRenameHandler } from '../BaseRenameHandler'; + +type DocumentChange = TextDocumentEdit; + +export namespace Template { + export interface Template { + layout?: string | false; + wrapper?: string; + sections: Record; + order: string[]; + } + + export interface Section { + type: string; + settings?: Setting.Values; + disabled?: boolean; + blocks?: Record; + block_order?: string[]; + } + + export interface Block { + type: string; + settings?: Setting.Values; + disabled?: boolean; + blocks?: Record; + block_order?: string[]; + static?: boolean; + } + + export interface SectionGroup { + type: string; + name: string; + sections: Record; + order: string[]; + } +} + +const annotationId = 'renameBlock'; + +/** + * The BlockRenameHandler will handle block renames. + * + * Whenever a block gets renamed, a lot of things need to happen: + * 1. References in files with a {% schema %} must be changed + * 2. References in template files must be changed + * 3. References in section groups must be changed + * 4. References in {% content_for "block", type: "oldName" %} must be changed + * + * Things we're not doing: + * 5. If isPublic(oldName) && isPrivate(newName) && "schema.blocks" accepts "@theme", + * Then the block should be added to the "blocks" array + * + * Reasoning: this is more noisy than useful. a now-private block + * could be used by a preset, template or section group. Doing a + * toil-free rename would require visiting all preset, templates and + * section groups to see if a parent that uses the new block name + * was supporting "@theme" blocks. It's a lot. It's O(S*(S+T+SG)) where + * S is the number of sections, T is the number of templates and SG is the + * number of section groups. It's not worth it. + * + * This stuff is complicated enough as it is 😅. + */ +export class BlockRenameHandler implements BaseRenameHandler { + constructor( + private documentManager: DocumentManager, + private connection: Connection, + private capabilities: ClientCapabilities, + private findThemeRootURI: (uri: string) => Promise, + ) {} + + async onDidRenameFiles(params: RenameFilesParams): Promise { + if (!this.capabilities.hasApplyEditSupport) return; + + const relevantRenames = params.files.filter( + (file) => isBlock(file.oldUri) && isBlock(file.newUri), + ); + + // Only preload if you have something to do (folder renames not supported yet). + if (relevantRenames.length !== 1) return; + const rename = relevantRenames[0]; + const rootUri = await this.findThemeRootURI(path.dirname(params.files[0].oldUri)); + await this.documentManager.preload(rootUri); + const theme = this.documentManager.theme(rootUri, true); + const liquidFiles = theme.filter(isLiquidSourceCode); + const sectionsAndBlocks = liquidFiles.filter( + (file) => isBlock(file.uri) || isSection(file.uri), + ); + const templates = theme.filter(isJsonSourceCode).filter((file) => isTemplate(file.uri)); + const sectionGroups = theme.filter(isJsonSourceCode).filter((file) => isSectionGroup(file.uri)); + const oldBlockName = blockName(rename.oldUri); + const newBlockName = blockName(rename.newUri); + const editLabel = `Rename block '${oldBlockName}' to '${newBlockName}'`; + const workspaceEdit: WorkspaceEdit = { + documentChanges: [], + changeAnnotations: { + [annotationId]: { + label: editLabel, + needsConfirmation: false, + }, + }, + }; + + // We need to keep track of sections that have local blocks, because we + // shouldn't rename those. Only uses of "@theme" or specifically named blocks + // should be renamed when the blocks/*.liquid file is renamed. + const sectionsWithLocalBlocks = new Set(); + const sectionAndBlocksChanges: (DocumentChange | null)[] = await Promise.all( + sectionsAndBlocks.map( + this.getSchemaChanges(sectionsWithLocalBlocks, oldBlockName, newBlockName), + ), + ); + + // All the templates/*.json files need to be updated with the new block name + // when the old block name wasn't a local block. + const [templateChanges, sectionGroupChanges, contentForChanges] = await Promise.all([ + Promise.all( + templates.map(this.getTemplateChanges(oldBlockName, newBlockName, sectionsWithLocalBlocks)), + ), + Promise.all( + sectionGroups.map( + this.getSectionGroupChanges(oldBlockName, newBlockName, sectionsWithLocalBlocks), + ), + ), + Promise.all(liquidFiles.map(this.getContentForChanges(oldBlockName, newBlockName))), + ]); + + for (const docChange of [ + ...sectionAndBlocksChanges, + ...templateChanges, + ...sectionGroupChanges, + ]) { + if (docChange !== null) { + workspaceEdit.documentChanges!.push(docChange); + } + } + + // Because contentForChanges could make a change to an existing document, we need + // to group the edits together by document. Or else we might have index + // drifting issues. + for (const docChange of contentForChanges) { + if (docChange !== null) { + const existingDocChange = (workspaceEdit.documentChanges as DocumentChange[]).find( + (dc) => dc.textDocument.uri === docChange?.textDocument.uri, + ); + if (existingDocChange) { + existingDocChange.edits.push(...docChange.edits); + } else { + workspaceEdit.documentChanges!.push(docChange); + } + } + } + + if (workspaceEdit.documentChanges!.length === 0) { + console.error('Nothing to do!'); + return; + } + + await this.connection.sendRequest(ApplyWorkspaceEditRequest.type, { + label: editLabel, + edit: workspaceEdit, + }); + } + + private getSchemaChanges( + sectionsWithLocalBlocks: Set, + oldBlockName: string, + newBlockName: string, + ) { + return async (sourceCode: AugmentedLiquidSourceCode) => { + if (sourceCode.ast instanceof Error) return null; + const textDocument = sourceCode.textDocument; + const schema = await sourceCode.getSchema(); + if (!isBlockSchema(schema) && !isSectionSchema(schema)) return null; + if (isError(schema.validSchema) || isError(schema.ast)) return null; + const { validSchema, ast, offset } = schema; + + const edits: TextEdit[] = []; + if (validSchema.blocks) { + for (let i = 0; i < validSchema.blocks.length; i++) { + const blockDef = validSchema.blocks[i]; + if (isLocalBlock(blockDef)) { + // If the section has a local blocks, we shouldn't rename + // anything in this file. + if (isSectionSchema(schema)) { + sectionsWithLocalBlocks.add(schema.name); + } + return null; + } + + if (blockDef.type !== oldBlockName) continue; + const node = nodeAtPath(ast, ['blocks', i, 'type']); + edits.push({ + annotationId, + newText: newBlockName, + range: Range.create( + textDocument.positionAt(offset + node!.loc!.start.offset + 1), + textDocument.positionAt(offset + node!.loc!.end.offset - 1), + ), + }); + } + } + + const presetEdits = ( + presetBlock: Preset.Preset | Preset.Block | undefined, + path: (string | number)[], + ): TextEdit[] => { + if (!presetBlock || !('blocks' in presetBlock)) return []; + if (Array.isArray(presetBlock.blocks)) { + return presetBlock.blocks.flatMap((block, index) => { + const edits = presetEdits(block, [...path, 'blocks', index]); + if (block.type === oldBlockName) { + const node = nodeAtPath(ast, [...path, 'blocks', index, 'type']); + edits.push({ + annotationId, + newText: newBlockName, + range: Range.create( + textDocument.positionAt(offset + node!.loc!.start.offset + 1), + textDocument.positionAt(offset + node!.loc!.end.offset - 1), + ), + }); + } + return edits; + }); + } else if (typeof presetBlock.blocks === 'object') { + return Object.entries(presetBlock.blocks).flatMap(([key, block]) => { + const edits = presetEdits(block, [...path, 'blocks', key]); + if (block.type === oldBlockName) { + const node = nodeAtPath(ast, [...path, 'blocks', key, 'type']); + edits.push({ + annotationId, + newText: newBlockName, + range: Range.create( + textDocument.positionAt(offset + node!.loc!.start.offset + 1), + textDocument.positionAt(offset + node!.loc!.end.offset - 1), + ), + }); + } + return edits; + }); + } else { + return []; + } + }; + + if (validSchema.presets) { + edits.push( + ...validSchema.presets.flatMap((preset, i) => presetEdits(preset, ['presets', i])), + ); + } + + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } + + private getTemplateChanges( + oldBlockName: string, + newBlockName: string, + sectionsWithLocalBlocks: Set, + ) { + return async (sourceCode: AugmentedJsonSourceCode) => { + // assuming that the JSON is valid... + const { textDocument, ast, source } = sourceCode; + const parsed = parseJSON(source); + if (!parsed || isError(parsed) || isError(ast)) return null; + const getBlocksEdits = getBlocksEditsFactory(oldBlockName, newBlockName, textDocument, ast); + const edits: TextEdit[] = !isValidTemplate(parsed) + ? [] + : Object.entries(parsed.sections).flatMap(([key, section]) => { + if ( + 'blocks' in section && + !!section.blocks && + !sectionsWithLocalBlocks.has(section.type) // don't rename local blocks + ) { + return getBlocksEdits(section.blocks, ['sections', key, 'blocks']); + } else { + return []; + } + }); + + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } + + private getSectionGroupChanges( + oldBlockName: string, + newBlockName: string, + sectionsWithLocalBlocks: Set, + ) { + return async (sourceCode: AugmentedJsonSourceCode) => { + const { textDocument, ast, source } = sourceCode; + const parsed = parseJSON(source); + if (!parsed || isError(parsed) || isError(ast)) return null; + const getBlocksEdits = getBlocksEditsFactory(oldBlockName, newBlockName, textDocument, ast); + const edits: TextEdit[] = !isValidSectionGroup(parsed) + ? [] + : Object.entries(parsed.sections).flatMap(([key, section]) => { + if ( + 'blocks' in section && + !!section.blocks && + !sectionsWithLocalBlocks.has(section.type) // don't rename local blocks + ) { + return getBlocksEdits(section.blocks, ['sections', key, 'blocks']); + } else { + return []; + } + }); + + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } + + private getContentForChanges(oldBlockName: string, newBlockName: string) { + return async (sourceCode: AugmentedLiquidSourceCode) => { + const { textDocument, ast } = sourceCode; + if (isError(ast)) return null; + + const edits = visit(ast, { + LiquidTag(node) { + if (node.name !== 'content_for') return; + if (typeof node.markup === 'string') return; + if (node.markup.contentForType.value !== 'block') return; + const typeNode = node.markup.args.find((arg) => arg.name === 'type'); + if ( + !typeNode || + typeNode.value.type !== NodeTypes.String || + typeNode.value.value !== oldBlockName + ) { + return; + } + + return { + annotationId, + newText: newBlockName, + range: Range.create( + textDocument.positionAt(typeNode.value.position.start + 1), + textDocument.positionAt(typeNode.value.position.end - 1), + ), + }; + }, + }); + + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } +} + +function isLocalBlock(blockDef: ThemeBlock.Block | Section.Block): blockDef is Section.LocalBlock { + return 'name' in blockDef && typeof blockDef.name === 'string'; +} + +// this is very very optimistic... +function isValidTemplate(parsed: unknown): parsed is Template.Template { + return ( + typeof parsed === 'object' && + parsed !== null && + 'sections' in parsed && + 'order' in parsed && + Array.isArray((parsed as Template.Template).order) + ); +} + +function isValidSectionGroup(parsed: unknown): parsed is Template.SectionGroup { + return ( + typeof parsed === 'object' && + parsed !== null && + 'sections' in parsed && + 'order' in parsed && + Array.isArray((parsed as Template.SectionGroup).order) + ); +} + +function getBlocksEditsFactory( + oldBlockName: string, + newBlockName: string, + textDocument: TextDocument, + ast: JSONNode, +) { + return function getBlocksEdits( + blocks: Record | undefined, + path: (string | number)[], + ): TextEdit[] { + if (!blocks) return []; + return Object.entries(blocks).flatMap(([key, block]) => { + const edits = getBlocksEdits(block.blocks, [...path, key, 'blocks']); + if (block.type === oldBlockName) { + const node = nodeAtPath(ast, [...path, key, 'type'])!; + edits.push({ + annotationId, + newText: newBlockName, + range: Range.create( + textDocument.positionAt(node.loc!.start.offset + 1), + textDocument.positionAt(node.loc!.end.offset - 1), + ), + }); + } + return edits; + }); + }; +} + +function documentChanges(sourceCode: AugmentedSourceCode, edits: TextEdit[]): DocumentChange { + return { + textDocument: { + uri: sourceCode.uri, + version: sourceCode.version ?? null /* null means file from disk in this API */, + }, + edits, + }; +} diff --git a/packages/theme-language-server-common/src/renamed/handlers/SnippetRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/SnippetRenameHandler.ts index 5a9e0431f..4b67cb858 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/SnippetRenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/SnippetRenameHandler.ts @@ -9,7 +9,7 @@ import { WorkspaceEdit, } from 'vscode-languageserver-protocol'; import { ClientCapabilities } from '../../ClientCapabilities'; -import { AugmentedLiquidSourceCode, AugmentedSourceCode, DocumentManager } from '../../documents'; +import { DocumentManager, isLiquidSourceCode } from '../../documents'; import { isSnippet, snippetName } from '../../utils/uri'; import { BaseRenameHandler } from '../BaseRenameHandler'; @@ -35,81 +35,74 @@ export class SnippetRenameHandler implements BaseRenameHandler { async onDidRenameFiles(params: RenameFilesParams): Promise { if (!this.capabilities.hasApplyEditSupport) return; - const isLiquidSourceCode = (file: AugmentedSourceCode): file is AugmentedLiquidSourceCode => - file.type === SourceCodeType.LiquidHtml; - const relevantRenames = params.files.filter( (file) => isSnippet(file.oldUri) && isSnippet(file.newUri), ); - // Only preload if you have something to do - if (relevantRenames.length === 0) return; + // Only preload if you have something to do (folder renames are not supported) + if (relevantRenames.length !== 1) return; + const rename = relevantRenames[0]; const rootUri = await this.findThemeRootURI(path.dirname(params.files[0].oldUri)); await this.documentManager.preload(rootUri); const theme = this.documentManager.theme(rootUri, true); const liquidSourceCodes = theme.filter(isLiquidSourceCode); - - const promises = relevantRenames.map(async (file) => { - const oldSnippetName = snippetName(file.oldUri); - const newSnippetName = snippetName(file.newUri); - const editLabel = `Rename snippet '${oldSnippetName}' to '${newSnippetName}'`; - const annotationId = 'renameSnippet'; - const workspaceEdit: WorkspaceEdit = { - documentChanges: [], - changeAnnotations: { - [annotationId]: { - label: editLabel, - needsConfirmation: false, - }, + const oldSnippetName = snippetName(rename.oldUri); + const newSnippetName = snippetName(rename.newUri); + const editLabel = `Rename snippet '${oldSnippetName}' to '${newSnippetName}'`; + const annotationId = 'renameSnippet'; + 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, { - LiquidTag(node: LiquidTag) { - if (node.name !== NamedTags.render && node.name !== NamedTags.include) { - return; - } - if (typeof node.markup === 'string') { - return; - } - const snippet = node.markup.snippet; - if (snippet.type === NodeTypes.String && snippet.value === oldSnippetName) { - return { - newText: `${newSnippetName}`, - range: Range.create( - textDocument.positionAt(snippet.position.start + 1), // +1 to skip the opening quote - textDocument.positionAt(snippet.position.end - 1), // -1 to skip the closing quote - ), - }; - } - }, - }); + for (const sourceCode of liquidSourceCodes) { + if (sourceCode.ast instanceof Error) continue; + const textDocument = sourceCode.textDocument; + const edits: TextEdit[] = visit(sourceCode.ast, { + LiquidTag(node: LiquidTag) { + if (node.name !== NamedTags.render && node.name !== NamedTags.include) { + return; + } + if (typeof node.markup === 'string') { + return; + } + const snippet = node.markup.snippet; + if (snippet.type === NodeTypes.String && snippet.value === oldSnippetName) { + return { + newText: `${newSnippetName}`, + range: Range.create( + textDocument.positionAt(snippet.position.start + 1), // +1 to skip the opening quote + textDocument.positionAt(snippet.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 (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; - } + if (workspaceEdit.documentChanges!.length === 0) { + console.error('Nothing to do!'); + return; + } - return this.connection.sendRequest(ApplyWorkspaceEditRequest.type, { - label: editLabel, - edit: workspaceEdit, - }); + await 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 52a25cd34..bd9334a32 100644 --- a/packages/theme-language-server-common/src/utils/uri.ts +++ b/packages/theme-language-server-common/src/utils/uri.ts @@ -6,3 +6,17 @@ export const isSnippet = (uri: string) => /\bsnippets(\\|\/)[^\\\/]*\.liquid/.te // 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); + +export const blockName = (uri: string) => path.basename(uri, '.liquid'); +export const isBlock = (uri: string) => /\bblocks(\\|\/)[^\\\/]/.test(uri); + +export const sectionName = (uri: string) => path.basename(uri, '.liquid'); +export const isSection = (uri: string) => + /\bsections(\\|\/)[^\\\/]/.test(uri) && /.liquid$/.test(uri); + +export const sectionGroupName = (uri: string) => path.basename(uri, '.json'); +export const isSectionGroup = (uri: string) => + /\bsections(\\|\/)[^\\\/]/.test(uri) && /.json$/.test(uri); + +export const templateName = (uri: string) => path.basename(uri, '.json'); +export const isTemplate = (uri: string) => /\btemplates(\\|\/)[^\\\/]/.test(uri);