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 e954d67f5..31511e139 100644 --- a/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts +++ b/packages/theme-language-server-common/src/renamed/handlers/AssetRenameHandler.ts @@ -9,12 +9,7 @@ import { WorkspaceEdit, } from 'vscode-languageserver-protocol'; import { ClientCapabilities } from '../../ClientCapabilities'; -import { - AugmentedLiquidSourceCode, - AugmentedSourceCode, - DocumentManager, - isLiquidSourceCode, -} from '../../documents'; +import { DocumentManager, isLiquidSourceCode } from '../../documents'; import { assetName, isAsset } from '../../utils/uri'; import { BaseRenameHandler } from '../BaseRenameHandler'; 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..9e11dc9e9 --- /dev/null +++ b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.spec.ts @@ -0,0 +1,368 @@ +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/oldName.liquid': ` + {% schema %} + { + "name": "Old Block", + "blocks": [ + { + "type": "other-block" + } + ] + } + {% endschema %}`, + + 'sections/section.liquid': ` +
{% content_for "block", id: "oldName", type: "oldName" %}
+ {% schema %} + { + "name": "Section", + "blocks": [ + { + "type": "oldName" + } + ], + "presets": [ + { + "name": "Default", + "blocks": [ + { + "type": "oldName", + "blocks": [ + { + "type": "other-block" + } + ] + } + ] + } + ] + } + {% endschema %}`, + + 'templates/index.json': `{ + "sections": { + "custom-section": { + "type": "custom-section", + "blocks": { + "text_tqQTNE": { + "type": "oldName" + } + }, + "block_order": ["text_tqQTNE"] + } + }, + "order": ["custom-section"] + }`, + }, + 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/oldName.liquid', + newUri: 'mock-fs:/blocks/newName.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/oldName.liquid', + newUri: 'mock-fs:/blocks/newName.liquid', + }, + ], + }); + + const templateTextEdit = { + annotationId: 'renameBlock', + newText: 'newName', + range: { + start: { line: 6, character: 29 }, + end: { line: 6, character: 36 }, + }, + }; + + const schemaTextEdits = [ + { + annotationId: 'renameBlock', + newText: 'newName', + range: { + start: { line: 7, character: 27 }, + end: { line: 7, character: 34 }, + }, + }, + { + annotationId: 'renameBlock', + newText: 'newName', + range: { + start: { line: 15, character: 31 }, + end: { line: 15, character: 38 }, + }, + }, + { + annotationId: 'renameBlock', + newText: 'newName', + range: { + start: { line: 1, character: 63 }, + end: { line: 1, character: 70 }, + }, + }, + ]; + + expect(connection.spies.sendRequest).toHaveBeenCalledWith('workspace/applyEdit', { + label: "Rename block 'oldName' to 'newName'", + edit: { + changeAnnotations: { + renameBlock: { + label: `Rename block 'oldName' to 'newName'`, + needsConfirmation: false, + }, + }, + documentChanges: [ + { + textDocument: { + uri: 'mock-fs:/sections/section.liquid', + version: null, + }, + edits: schemaTextEdits, + }, + { + textDocument: { + uri: 'mock-fs:/templates/index.json', + version: null, + }, + edits: [templateTextEdit], + }, + ], + }, + }); + }); + + it('replaces the correct text in the documents', async () => { + await handler.onDidRenameFiles({ + files: [ + { + oldUri: 'mock-fs:/blocks/oldName.liquid', + newUri: 'mock-fs:/blocks/newName.liquid', + }, + ], + }); + + const params: ApplyWorkspaceEditParams = connection.spies.sendRequest.mock.calls[0][1]; + const expectedFs = new MockFileSystem( + { + 'blocks/newName.liquid': ` + {% schema %} + { + "name": "Old Block", + "blocks": [ + { + "type": "other-block" + } + ] + } + {% endschema %}`, + + 'sections/section.liquid': ` +
{% content_for "block", id: "oldName", type: "newName" %}
+ {% schema %} + { + "name": "Section", + "blocks": [ + { + "type": "newName" + } + ], + "presets": [ + { + "name": "Default", + "blocks": [ + { + "type": "newName", + "blocks": [ + { + "type": "other-block" + } + ] + } + ] + } + ] + } + {% endschema %}`, + + 'templates/index.json': `{ + "sections": { + "custom-section": { + "type": "custom-section", + "blocks": { + "text_tqQTNE": { + "type": "newName" + } + }, + "block_order": ["text_tqQTNE"] + } + }, + "order": ["custom-section"] + }`, + }, + 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..e5cf17827 --- /dev/null +++ b/packages/theme-language-server-common/src/renamed/handlers/BlockRenameHandler.ts @@ -0,0 +1,485 @@ +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 shit 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 + if (relevantRenames.length === 0) return; + 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 promises = relevantRenames.map(async (file) => { + const oldBlockName = blockName(file.oldUri); + const newBlockName = blockName(file.newUri); + const editLabel = `Rename block '${oldBlockName}' to '${newBlockName}'`; + const workspaceEdit: WorkspaceEdit = { + documentChanges: [], + changeAnnotations: { + [annotationId]: { + label: editLabel, + needsConfirmation: false, + }, + }, + }; + + 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( + 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) { + 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, + 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 (edits.length === 0) return null; + + return documentChanges(sourceCode, 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 []; + } + }); + + if (edits.length === 0) return null; + + return documentChanges(sourceCode, edits); + }; + } + + 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 { + 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; + }); + }; +} 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);