From 80f74f1d02a96bda3d283477f40b2cac6a7ddbf9 Mon Sep 17 00:00:00 2001 From: Enno Richter Date: Sun, 30 Nov 2025 10:55:34 +0100 Subject: [PATCH] Add rename support for YAML anchors and aliases --- .../handlers/languageHandlers.ts | 24 +++ src/languageservice/services/yamlRename.ts | 201 ++++++++++++++++++ src/languageservice/yamlLanguageService.ts | 10 + src/yamlServerInit.ts | 1 + test/yamlRename.test.ts | 92 ++++++++ 5 files changed, 328 insertions(+) create mode 100644 src/languageservice/services/yamlRename.ts create mode 100644 test/yamlRename.test.ts diff --git a/src/languageserver/handlers/languageHandlers.ts b/src/languageserver/handlers/languageHandlers.ts index 7f971167b..c0bb5552b 100644 --- a/src/languageserver/handlers/languageHandlers.ts +++ b/src/languageserver/handlers/languageHandlers.ts @@ -16,6 +16,8 @@ import { TextDocumentPositionParams, CodeLensParams, DefinitionParams, + PrepareRenameParams, + RenameParams, } from 'vscode-languageserver-protocol'; import { CodeAction, @@ -26,9 +28,11 @@ import { DocumentSymbol, Hover, FoldingRange, + Range, SelectionRange, SymbolInformation, TextEdit, + WorkspaceEdit, } from 'vscode-languageserver-types'; import { isKubernetesAssociatedDocument } from '../../languageservice/parser/isKubernetes'; import { LanguageService } from '../../languageservice/yamlLanguageService'; @@ -70,6 +74,8 @@ export class LanguageHandlers { this.connection.onCodeLens((params) => this.codeLensHandler(params)); this.connection.onCodeLensResolve((params) => this.codeLensResolveHandler(params)); this.connection.onDefinition((params) => this.definitionHandler(params)); + this.connection.onPrepareRename((params) => this.prepareRenameHandler(params)); + this.connection.onRenameRequest((params) => this.renameHandler(params)); this.yamlSettings.documents.onDidChangeContent((change) => this.cancelLimitExceededWarnings(change.document.uri)); this.yamlSettings.documents.onDidClose((event) => this.cancelLimitExceededWarnings(event.document.uri)); @@ -250,6 +256,24 @@ export class LanguageHandlers { return this.languageService.doDefinition(textDocument, params); } + prepareRenameHandler(params: PrepareRenameParams): Range | null { + const textDocument = this.yamlSettings.documents.get(params.textDocument.uri); + if (!textDocument) { + return null; + } + + return this.languageService.prepareRename(textDocument, params); + } + + renameHandler(params: RenameParams): WorkspaceEdit | null { + const textDocument = this.yamlSettings.documents.get(params.textDocument.uri); + if (!textDocument) { + return null; + } + + return this.languageService.doRename(textDocument, params); + } + // Adapted from: // https://github.com/microsoft/vscode/blob/94c9ea46838a9a619aeafb7e8afd1170c967bb55/extensions/json-language-features/server/src/jsonServer.ts#L172 private cancelLimitExceededWarnings(uri: string): void { diff --git a/src/languageservice/services/yamlRename.ts b/src/languageservice/services/yamlRename.ts new file mode 100644 index 000000000..749ae9447 --- /dev/null +++ b/src/languageservice/services/yamlRename.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { Position, Range, WorkspaceEdit, TextEdit } from 'vscode-languageserver-types'; +import { yamlDocumentsCache } from '../parser/yaml-documents'; +import { matchOffsetToDocument } from '../utils/arrUtils'; +import { TextBuffer } from '../utils/textBuffer'; +import { Telemetry } from '../telemetry'; +import { CST, isAlias, isCollection, isScalar, visit, Node } from 'yaml'; +import { SourceToken, CollectionItem } from 'yaml/dist/parse/cst'; +import { SingleYAMLDocument } from '../parser/yamlParser07'; +import { isCollectionItem } from '../utils/astUtils'; +import { PrepareRenameParams, RenameParams } from 'vscode-languageserver-protocol'; + +interface RenameTarget { + anchorNode: Node; + token: SourceToken; + yamlDoc: SingleYAMLDocument; +} + +export class YamlRename { + constructor(private readonly telemetry?: Telemetry) {} + + prepareRename(document: TextDocument, params: PrepareRenameParams): Range | null { + try { + const target = this.findTarget(document, params.position); + if (!target) { + return null; + } + if (!this.findAnchorToken(target.yamlDoc, target.anchorNode)) { + return null; + } + return this.getNameRange(document, target.token); + } catch (err) { + this.telemetry?.sendError('yaml.prepareRename.error', err); + return null; + } + } + + doRename(document: TextDocument, params: RenameParams): WorkspaceEdit | null { + try { + const target = this.findTarget(document, params.position); + if (!target) { + return null; + } + + const anchorToken = this.findAnchorToken(target.yamlDoc, target.anchorNode); + if (!anchorToken) { + return null; + } + + const normalizedNewName = this.normalizeName(params.newName); + const edits: TextEdit[] = []; + + edits.push(TextEdit.replace(this.getNameRange(document, anchorToken), normalizedNewName)); + + visit(target.yamlDoc.internalDocument, (key, node) => { + if (isAlias(node) && node.srcToken && node.resolve(target.yamlDoc.internalDocument) === target.anchorNode) { + edits.push(TextEdit.replace(this.getNameRange(document, node.srcToken as SourceToken), normalizedNewName)); + } + }); + + if (edits.length === 0) { + return null; + } + + return { + changes: { + [document.uri]: edits, + }, + }; + } catch (err) { + this.telemetry?.sendError('yaml.rename.error', err); + return null; + } + } + + private findTarget(document: TextDocument, position: Position): RenameTarget | null { + const yamlDocuments = yamlDocumentsCache.getYamlDocument(document); + const offset = document.offsetAt(position); + const yamlDoc = matchOffsetToDocument(offset, yamlDocuments); + if (!yamlDoc) { + return null; + } + + const [node] = yamlDoc.getNodeFromPosition(offset, new TextBuffer(document)); + if (!node) { + return this.findByToken(yamlDoc, offset); + } + + if (isAlias(node) && node.srcToken && this.isOffsetInsideToken(node.srcToken as SourceToken, offset)) { + const anchorNode = node.resolve(yamlDoc.internalDocument); + if (!anchorNode) { + return null; + } + return { anchorNode, token: node.srcToken as SourceToken, yamlDoc }; + } + + if ((isCollection(node) || isScalar(node)) && node.anchor) { + const anchorToken = this.findAnchorToken(yamlDoc, node); + if (anchorToken && this.isOffsetInsideToken(anchorToken, offset)) { + return { anchorNode: node, token: anchorToken, yamlDoc }; + } + } + + return this.findByToken(yamlDoc, offset); + } + + private findByToken(yamlDoc: SingleYAMLDocument, offset: number): RenameTarget | null { + let target: RenameTarget; + visit(yamlDoc.internalDocument, (key, node) => { + if (isAlias(node) && node.srcToken && this.isOffsetInsideToken(node.srcToken as SourceToken, offset)) { + const anchorNode = node.resolve(yamlDoc.internalDocument); + if (anchorNode) { + target = { anchorNode, token: node.srcToken as SourceToken, yamlDoc }; + return visit.BREAK; + } + } + if ((isCollection(node) || isScalar(node)) && node.anchor) { + const anchorToken = this.findAnchorToken(yamlDoc, node); + if (anchorToken && this.isOffsetInsideToken(anchorToken, offset)) { + target = { anchorNode: node, token: anchorToken, yamlDoc }; + return visit.BREAK; + } + } + }); + + return target ?? null; + } + + private findAnchorToken(yamlDoc: SingleYAMLDocument, node: Node): SourceToken | undefined { + const parent = yamlDoc.getParent(node); + const candidates = []; + if (parent && (parent as unknown as { srcToken?: SourceToken }).srcToken) { + candidates.push((parent as unknown as { srcToken: SourceToken }).srcToken); + } + if ((node as unknown as { srcToken?: SourceToken }).srcToken) { + candidates.push((node as unknown as { srcToken: SourceToken }).srcToken); + } + + for (const token of candidates) { + const anchor = this.getAnchorFromToken(token, node); + if (anchor) { + return anchor; + } + } + + return undefined; + } + + private getAnchorFromToken(token: SourceToken, node: Node): SourceToken | undefined { + if (isCollectionItem(token)) { + return this.getAnchorFromCollectionItem(token); + } else if (CST.isCollection(token)) { + const collection = token as unknown as { items?: CollectionItem[] }; + for (const item of collection.items ?? []) { + if (item.value !== (node as unknown as { srcToken?: SourceToken }).srcToken) { + continue; + } + const anchor = this.getAnchorFromCollectionItem(item); + if (anchor) { + return anchor; + } + } + } + return undefined; + } + + private getAnchorFromCollectionItem(token: CollectionItem): SourceToken | undefined { + for (const t of token.start) { + if (t.type === 'anchor') { + return t; + } + } + if (token.sep && Array.isArray(token.sep)) { + for (const t of token.sep) { + if (t.type === 'anchor') { + return t; + } + } + } + return undefined; + } + + private getNameRange(document: TextDocument, token: SourceToken): Range { + const startOffset = token.offset + 1; + const endOffset = token.offset + token.source.length; + return Range.create(document.positionAt(startOffset), document.positionAt(endOffset)); + } + + private isOffsetInsideToken(token: SourceToken, offset: number): boolean { + return offset >= token.offset && offset <= token.offset + token.source.length; + } + + private normalizeName(name: string): string { + return name.replace(/^([*&])/, ''); + } +} diff --git a/src/languageservice/yamlLanguageService.ts b/src/languageservice/yamlLanguageService.ts index fd414a319..6c5353966 100644 --- a/src/languageservice/yamlLanguageService.ts +++ b/src/languageservice/yamlLanguageService.ts @@ -25,6 +25,8 @@ import { CodeLens, DefinitionLink, SelectionRange, + Range, + WorkspaceEdit, } from 'vscode-languageserver-types'; import { JSONSchema } from './jsonSchema'; import { YAMLDocumentSymbols } from './services/documentSymbols'; @@ -39,6 +41,8 @@ import { Connection, DocumentOnTypeFormattingParams, DefinitionParams, + PrepareRenameParams, + RenameParams, } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { getFoldingRanges } from './services/yamlFolding'; @@ -54,6 +58,7 @@ import { SettingsState } from '../yamlSettings'; import { JSONSchemaSelection } from '../languageserver/handlers/schemaSelectionHandlers'; import { YamlDefinition } from './services/yamlDefinition'; import { getSelectionRanges } from './services/yamlSelectionRanges'; +import { YamlRename } from './services/yamlRename'; export enum SchemaPriority { SchemaStore = 1, @@ -180,6 +185,8 @@ export interface LanguageService { getCodeAction: (document: TextDocument, params: CodeActionParams) => CodeAction[] | undefined; getCodeLens: (document: TextDocument) => PromiseLike | CodeLens[] | undefined; resolveCodeLens: (param: CodeLens) => PromiseLike | CodeLens; + prepareRename: (document: TextDocument, params: PrepareRenameParams) => Range | null; + doRename: (document: TextDocument, params: RenameParams) => WorkspaceEdit | null; } export function getLanguageService(params: { @@ -200,6 +207,7 @@ export function getLanguageService(params: { const yamlCodeLens = new YamlCodeLens(schemaService, params.telemetry); const yamlLinks = new YamlLinks(params.telemetry); const yamlDefinition = new YamlDefinition(params.telemetry); + const yamlRename = new YamlRename(params.telemetry); new JSONSchemaSelection(schemaService, params.yamlSettings, params.connection); @@ -266,5 +274,7 @@ export function getLanguageService(params: { return yamlCodeLens.getCodeLens(document); }, resolveCodeLens: (param) => yamlCodeLens.resolveCodeLens(param), + prepareRename: (document, params) => yamlRename.prepareRename(document, params), + doRename: (document, params) => yamlRename.doRename(document, params), }; } diff --git a/src/yamlServerInit.ts b/src/yamlServerInit.ts index 77767b182..2437f10e2 100644 --- a/src/yamlServerInit.ts +++ b/src/yamlServerInit.ts @@ -131,6 +131,7 @@ export class YAMLServerInit { }, documentRangeFormattingProvider: false, definitionProvider: true, + renameProvider: { prepareProvider: true }, documentLinkProvider: {}, foldingRangeProvider: true, selectionRangeProvider: true, diff --git a/test/yamlRename.test.ts b/test/yamlRename.test.ts new file mode 100644 index 000000000..5bcb90663 --- /dev/null +++ b/test/yamlRename.test.ts @@ -0,0 +1,92 @@ +import { expect } from 'chai'; +import { Position, TextEdit } from 'vscode-languageserver-types'; +import { setupLanguageService, setupTextDocument, TEST_URI } from './utils/testHelper'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +function applyEdits(document: TextDocument, edits: TextEdit[]): string { + const sorted = [...edits].sort((a, b) => document.offsetAt(b.range.start) - document.offsetAt(a.range.start)); + let content = document.getText(); + for (const edit of sorted) { + const start = document.offsetAt(edit.range.start); + const end = document.offsetAt(edit.range.end); + content = content.slice(0, start) + edit.newText + content.slice(end); + } + return content; +} + +describe('YAML Rename', () => { + it('renames anchor and aliases when invoked on alias', () => { + const { languageService } = setupLanguageService({}); + const document = setupTextDocument('foo: &a value\nbar: *a\nbaz: *a\n'); + + const result = languageService.doRename(document, { + position: Position.create(1, 6), + textDocument: { uri: TEST_URI }, + newName: 'renamed', + }); + + expect(result).to.not.equal(null); + const edits = result?.changes?.[TEST_URI]; + expect(edits).to.have.length(3); + const updated = applyEdits(document, edits); + expect(updated).to.equal('foo: &renamed value\nbar: *renamed\nbaz: *renamed\n'); + }); + + it('renames when cursor is on anchor token', () => { + const { languageService } = setupLanguageService({}); + const document = setupTextDocument('foo: &bar value\nbar: *bar\n'); + + const result = languageService.doRename(document, { + position: Position.create(0, 6), + textDocument: { uri: TEST_URI }, + newName: '*newName', + }); + + expect(result).to.not.equal(null); + const edits = result?.changes?.[TEST_URI]; + expect(edits).to.have.length(2); + const updated = applyEdits(document, edits); + expect(updated).to.equal('foo: &newName value\nbar: *newName\n'); + }); + + it('limits rename to current YAML document', () => { + const { languageService } = setupLanguageService({}); + const document = setupTextDocument('---\nfoo: &a 1\nbar: *a\n---\nfoo: &b 1\nbar: *b\n'); + + const result = languageService.doRename(document, { + position: Position.create(5, 6), + textDocument: { uri: TEST_URI }, + newName: 'c', + }); + + expect(result).to.not.equal(null); + const edits = result?.changes?.[TEST_URI]; + const updated = applyEdits(document, edits); + expect(updated).to.equal('---\nfoo: &a 1\nbar: *a\n---\nfoo: &c 1\nbar: *c\n'); + }); + + it('returns null for unresolved alias', () => { + const { languageService } = setupLanguageService({}); + const document = setupTextDocument('*missing\n'); + + const result = languageService.doRename(document, { + position: Position.create(0, 1), + textDocument: { uri: TEST_URI }, + newName: 'new', + }); + + expect(result).to.equal(null); + }); + + it('prepareRename rejects non-alias/anchor positions', () => { + const { languageService } = setupLanguageService({}); + const document = setupTextDocument('foo: bar\n'); + + const range = languageService.prepareRename(document, { + position: Position.create(0, 1), + textDocument: { uri: TEST_URI }, + }); + + expect(range).to.equal(null); + }); +});