From 15cf74edd52c4427de91541ac9387c84b2c6d800 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Fri, 6 Dec 2024 11:37:28 -0500 Subject: [PATCH] --wip-- [skip ci] --- .../renamed/handlers/BlockRenameHandler.ts | 521 +++++++++++++----- 1 file changed, 370 insertions(+), 151 deletions(-) diff --git a/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts index ddeb8905c..fb54e1575 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts @@ -1,3 +1,4 @@ +import { NodeTypes } from '@shopify/liquid-html-parser'; import { isBlockSchema, isError, @@ -9,23 +10,34 @@ import { Preset, Section, Setting, + SourceCodeType, ThemeBlock, + visit, } from '@shopify/theme-check-common'; import { Connection } from 'vscode-languageserver'; import { ApplyWorkspaceEditRequest, Range, RenameFilesParams, - TextEdit, + TextDocumentEdit, + AnnotatedTextEdit as TextEdit, WorkspaceEdit, } from 'vscode-languageserver-protocol'; +import { TextDocument } from 'vscode-languageserver-textdocument'; import { ClientCapabilities } from '../../ClientCapabilities'; -import { DocumentManager, isJsonSourceCode, isLiquidSourceCode } from '../../documents'; +import { + AugmentedJsonSourceCode, + AugmentedLiquidSourceCode, + AugmentedSourceCode, + DocumentManager, + isJsonSourceCode, + isLiquidSourceCode, +} from '../../documents'; import { blockName, isBlock, isSection, isSectionGroup, isTemplate } from '../../utils/uri'; import { BaseRenameHandler } from '../BaseRenameHandler'; type Element = T extends Array ? U : never; -type DocumentChange = Element>; +type DocumentChange = TextDocumentEdit; export namespace Template { export interface Template { @@ -51,16 +63,39 @@ export namespace Template { 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 to it in all template files must be changed - * 2. references to it in section groups must be changed - * 3. references to it in files with a {% schema %} must be changed - * 4. references in content_for "block", type: "oldName" must be changed + * 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 shit is complicated enough as it is 😅. */ export class BlockRenameHandler implements BaseRenameHandler { constructor( @@ -82,9 +117,10 @@ export class BlockRenameHandler implements BaseRenameHandler { const rootUri = await this.findThemeRootURI(path.dirname(params.files[0].oldUri)); await this.documentManager.preload(rootUri); const theme = this.documentManager.theme(rootUri, true); - const sectionsAndBlocks = theme - .filter(isLiquidSourceCode) - .filter((file) => isBlock(file.uri) || isSection(file.uri)); + 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)); @@ -92,7 +128,6 @@ export class BlockRenameHandler implements BaseRenameHandler { const oldBlockName = blockName(file.oldUri); const newBlockName = blockName(file.newUri); const editLabel = `Rename block '${oldBlockName}' to '${newBlockName}'`; - const annotationId = 'renameBlock'; const workspaceEdit: WorkspaceEdit = { documentChanges: [], changeAnnotations: { @@ -103,33 +138,166 @@ export class BlockRenameHandler implements BaseRenameHandler { }, }; + const documentChanges = ( + sourceCode: AugmentedSourceCode, + edits: TextEdit[], + ): DocumentChange => ({ + textDocument: { + uri: sourceCode.uri, + version: sourceCode.version ?? null /* null means file from disk in this API */, + }, + edits, + }); + + // 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(async (sourceCode) => { - 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']); + sectionsAndBlocks.map( + this.getSchemaChanges( + sectionsWithLocalBlocks, + oldBlockName, + newBlockName, + documentChanges, + ), + ), + ); + + // 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, + documentChanges, + ), + ), + ), + Promise.all( + sectionGroups.map( + this.getSectionGroupChanges( + oldBlockName, + newBlockName, + sectionsWithLocalBlocks, + documentChanges, + ), + ), + ), + Promise.all( + liquidFiles.map(this.getContentForChanges(oldBlockName, newBlockName, documentChanges)), + ), + ]); + + 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; + } + + return this.connection.sendRequest(ApplyWorkspaceEditRequest.type, { + label: editLabel, + edit: workspaceEdit, + }); + }); + + await Promise.all(promises); + } + + private getSchemaChanges( + sectionsWithLocalBlocks: Set, + oldBlockName: string, + newBlockName: string, + documentChanges: (sourceCode: AugmentedSourceCode, edits: TextEdit[]) => DocumentChange, + ) { + 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) { + const hasAtTheme = validSchema.blocks.some((block) => block.type === '@theme'); + if (hasAtTheme && isPrivate(newBlockName) && isPublic(oldBlockName)) { + const arrayNode = nodeAtPath(ast, ['blocks']); + edits.push({ + annotationId, + newText: `, { "type": "${newBlockName}" }`, + range: Range.create( + textDocument.positionAt(offset + arrayNode!.loc.start.offset - 1), + textDocument.positionAt(offset + arrayNode!.loc.end.offset - 1), + ), + }); + } + + 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), @@ -137,138 +305,142 @@ export class BlockRenameHandler implements BaseRenameHandler { ), }); } - } - - 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({ - 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({ - newText: newBlockName, - range: Range.create( - textDocument.positionAt(offset + node!.loc!.start.offset + 1), - textDocument.positionAt(offset + node!.loc!.end.offset - 1), - ), - }); - } - return edits; + 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, + documentChanges: (sourceCode: AugmentedSourceCode, edits: TextEdit[]) => DocumentChange, + ) { + 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 (validSchema.presets) { - edits.push( - ...validSchema.presets.flatMap((preset, i) => presetEdits(preset, ['presets', i])), - ); - } + if (edits.length === 0) return null; - if (edits.length === 0) return null; - return { - textDocument: { - uri: textDocument.uri, - version: sourceCode.version ?? null /* null means file from disk in this API */, - }, - annotationId, - edits, - }; - }), - ); + return documentChanges(sourceCode, edits); + }; + } - const templateEdits: (DocumentChange | null)[] = await Promise.all( - templates.map(async (sourceCode) => { - // assuming that the JSON is valid... - const { textDocument, ast, source } = sourceCode; - const parsed = parseJSON(source); - if (!parsed || isError(parsed) || isError(ast)) return null; - function blocksEdits( - blocks: Record | undefined, - path: (string | number)[], - ): TextEdit[] { - if (!blocks) return []; - return Object.entries(blocks).flatMap(([key, block]) => { - const _edits = blocksEdits(block.blocks, [...path, key, 'blocks']); - if (block.type === oldBlockName) { - const node = nodeAtPath(ast as JSONNode, [...path, key, 'type'])!; - _edits.push({ - newText: newBlockName, - range: Range.create( - textDocument.positionAt(node.loc!.start.offset + 1), - textDocument.positionAt(node.loc!.end.offset - 1), - ), - }); - } - return _edits; - }); - } + private getSectionGroupChanges( + oldBlockName: string, + newBlockName: string, + sectionsWithLocalBlocks: Set, + documentChanges: (sourceCode: AugmentedSourceCode, edits: TextEdit[]) => DocumentChange, + ) { + 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 []; + } + }); - const edits: TextEdit[] = !isValidTemplate(parsed) - ? [] - : Object.entries(parsed.sections).flatMap(([key, section]) => { - if ( - 'blocks' in section && - !!section.blocks && - !sectionsWithLocalBlocks.has(section.type) - ) { - return blocksEdits(section.blocks, ['sections', key, 'blocks']); - } else { - return []; - } - }); + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } - if (edits.length === 0) return null; + private getContentForChanges( + oldBlockName: string, + newBlockName: string, + documentChanges: (sourceCode: AugmentedSourceCode, edits: TextEdit[]) => DocumentChange, + ) { + 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 { - textDocument: { - uri: sourceCode.uri, - version: sourceCode.version ?? null /* null means file from disk in this API */, - }, annotationId, - edits, + newText: newBlockName, + range: Range.create( + textDocument.positionAt(typeNode.value.position.start + 1), + textDocument.positionAt(typeNode.value.position.end - 1), + ), }; - }), - ); - - for (const docChange of [...sectionAndBlocksChanges, ...templateEdits]) { - if (docChange !== null) { - workspaceEdit.documentChanges!.push(docChange); - } - } - - 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); + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; } } @@ -286,3 +458,50 @@ function isValidTemplate(parsed: unknown): parsed is Template.Template { 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 isPrivate(blockName: string) { + return blockName.startsWith('_'); +} + +function isPublic(blockName: string) { + return !isPrivate(blockName); +}