UNPKG

@shopify/theme-language-server-common

Version:

<h1 align="center" style="position: relative;" > <br> <img src="https://github.com/Shopify/theme-check-vscode/blob/main/images/shopify_glyph.png?raw=true" alt="logo" width="141" height="160"> <br> Theme Language Server </h1>

223 lines (198 loc) 8.15 kB
import { LiquidString } from '@shopify/liquid-html-parser'; import { isError, LiteralNode, nodeAtPath, parseJSON, path, SourceCodeType, visit, } from '@shopify/theme-check-common'; import { Connection } from 'vscode-languageserver'; import { AnnotatedTextEdit, ApplyWorkspaceEditRequest, Range, RenameFilesParams, TextDocumentEdit, AnnotatedTextEdit as TextEdit, WorkspaceEdit, } from 'vscode-languageserver-protocol'; import { ClientCapabilities } from '../../ClientCapabilities'; import { AugmentedJsonSourceCode, AugmentedLiquidSourceCode, AugmentedSourceCode, DocumentManager, isJsonSourceCode, isLiquidSourceCode, } from '../../documents'; import { isSection, isSectionGroup, isTemplate, sectionName } from '../../utils/uri'; import { BaseRenameHandler } from '../BaseRenameHandler'; import { isValidSectionGroup, isValidTemplate } from './utils'; type DocumentChange = TextDocumentEdit; const annotationId = 'renameSection'; /** * The SectionRenameHandler will handle section renames * * Whenever a section gets renamed, a lot of things need to happen: * 2. References in template files must be changed * 3. References in section groups must be changed * 4. References like {% section "oldName" %} must be changed */ export class SectionRenameHandler implements BaseRenameHandler { constructor( private documentManager: DocumentManager, private connection: Connection, private capabilities: ClientCapabilities, private findThemeRootURI: (uri: string) => Promise<string>, ) {} async onDidRenameFiles(params: RenameFilesParams): Promise<void> { if (!this.capabilities.hasApplyEditSupport) return; const relevantRenames = params.files.filter( (file) => isSection(file.oldUri) && isSection(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 templates = theme.filter(isJsonSourceCode).filter((file) => isTemplate(file.uri)); const sectionGroups = theme.filter(isJsonSourceCode).filter((file) => isSectionGroup(file.uri)); const oldSectionName = sectionName(rename.oldUri); const newSectionName = sectionName(rename.newUri); const editLabel = `Rename section '${oldSectionName}' to '${newSectionName}'`; const workspaceEdit: WorkspaceEdit = { documentChanges: [], changeAnnotations: { [annotationId]: { label: editLabel, needsConfirmation: false, }, }, }; // 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, sectionTagChanges] = await Promise.all([ Promise.all(templates.map(this.getTemplateChanges(oldSectionName, newSectionName))), Promise.all(sectionGroups.map(this.getSectionGroupChanges(oldSectionName, newSectionName))), Promise.all(liquidFiles.map(this.getSectionTagChanges(oldSectionName, newSectionName))), ]); for (const docChange of [...templateChanges, ...sectionGroupChanges]) { if (docChange !== null) { workspaceEdit.documentChanges!.push(docChange); } } // Because section tag changes 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 sectionTagChanges) { 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 getTemplateChanges(oldSectionName: string, newSectionName: string) { return async (sourceCode: AugmentedJsonSourceCode) => { const { textDocument, ast, source } = sourceCode; const parsed = parseJSON(source); if (!parsed || isError(parsed) || isError(ast)) return null; const edits: AnnotatedTextEdit[] = !isValidTemplate(parsed) ? [] : Object.entries(parsed.sections) .filter(([_key, section]) => section.type === oldSectionName) .map(([key]) => { const node = nodeAtPath(ast, ['sections', key, 'type']) as LiteralNode; return { annotationId, newText: newSectionName, range: Range.create( textDocument.positionAt(node.loc.start.offset + 1), textDocument.positionAt(node.loc.end.offset - 1), ), } as AnnotatedTextEdit; }); if (edits.length === 0) return null; return documentChanges(sourceCode, edits); }; } // Awfully similar except for the isValidSectionGroup check and the types of the objects. // Feels like a coincidence that the types are so similar. I'm not sure this should be DRY'd up. private getSectionGroupChanges(oldSectionName: string, newSectionName: string) { return async (sourceCode: AugmentedJsonSourceCode) => { const { textDocument, ast, source } = sourceCode; const parsed = parseJSON(source); if (!parsed || isError(parsed) || isError(ast)) return null; const edits: TextEdit[] = !isValidSectionGroup(parsed) ? [] : Object.entries(parsed.sections) .filter(([_key, section]) => section.type === oldSectionName) .map(([key]) => { const node = nodeAtPath(ast, ['sections', key, 'type']) as LiteralNode; return { annotationId, newText: newSectionName, range: Range.create( textDocument.positionAt(node.loc.start.offset + 1), textDocument.positionAt(node.loc.end.offset - 1), ), } as AnnotatedTextEdit; }); if (edits.length === 0) return null; return documentChanges(sourceCode, edits); }; } private getSectionTagChanges(oldSectionName: string, newSectionName: string) { return async (sourceCode: AugmentedLiquidSourceCode) => { const { textDocument, ast } = sourceCode; if (isError(ast)) return null; const edits = visit<SourceCodeType.LiquidHtml, TextEdit>(ast, { LiquidTag(node) { if (node.name !== 'section') return; if (typeof node.markup === 'string') return; // Note the type assertion to the LHS of the expression. // The type assertions above are enough for this to be true. // But I'm making the explicit annotation here to make it clear. const typeNode: LiquidString = node.markup; if (typeNode.value !== oldSectionName) return; return { annotationId, newText: newSectionName, range: Range.create( textDocument.positionAt(typeNode.position.start + 1), textDocument.positionAt(typeNode.position.end - 1), ), }; }, }); if (edits.length === 0) return null; return documentChanges(sourceCode, 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, }; }