Skip to content

Commit a38caf0

Browse files
authored
Add rename support for YAML anchors and aliases (#1149)
* Add rename support for YAML anchors and aliases * Add validation for anchor names and remove normalization * Also reject whitespace in anchor names
1 parent 1dc7e48 commit a38caf0

File tree

5 files changed

+373
-0
lines changed

5 files changed

+373
-0
lines changed

src/languageserver/handlers/languageHandlers.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
TextDocumentPositionParams,
1717
CodeLensParams,
1818
DefinitionParams,
19+
PrepareRenameParams,
20+
RenameParams,
1921
} from 'vscode-languageserver-protocol';
2022
import {
2123
CodeAction,
@@ -26,9 +28,11 @@ import {
2628
DocumentSymbol,
2729
Hover,
2830
FoldingRange,
31+
Range,
2932
SelectionRange,
3033
SymbolInformation,
3134
TextEdit,
35+
WorkspaceEdit,
3236
} from 'vscode-languageserver-types';
3337
import { isKubernetesAssociatedDocument } from '../../languageservice/parser/isKubernetes';
3438
import { LanguageService } from '../../languageservice/yamlLanguageService';
@@ -70,6 +74,8 @@ export class LanguageHandlers {
7074
this.connection.onCodeLens((params) => this.codeLensHandler(params));
7175
this.connection.onCodeLensResolve((params) => this.codeLensResolveHandler(params));
7276
this.connection.onDefinition((params) => this.definitionHandler(params));
77+
this.connection.onPrepareRename((params) => this.prepareRenameHandler(params));
78+
this.connection.onRenameRequest((params) => this.renameHandler(params));
7379

7480
this.yamlSettings.documents.onDidChangeContent((change) => this.cancelLimitExceededWarnings(change.document.uri));
7581
this.yamlSettings.documents.onDidClose((event) => this.cancelLimitExceededWarnings(event.document.uri));
@@ -250,6 +256,24 @@ export class LanguageHandlers {
250256
return this.languageService.doDefinition(textDocument, params);
251257
}
252258

259+
prepareRenameHandler(params: PrepareRenameParams): Range | null {
260+
const textDocument = this.yamlSettings.documents.get(params.textDocument.uri);
261+
if (!textDocument) {
262+
return null;
263+
}
264+
265+
return this.languageService.prepareRename(textDocument, params);
266+
}
267+
268+
renameHandler(params: RenameParams): WorkspaceEdit | null {
269+
const textDocument = this.yamlSettings.documents.get(params.textDocument.uri);
270+
if (!textDocument) {
271+
return null;
272+
}
273+
274+
return this.languageService.doRename(textDocument, params);
275+
}
276+
253277
// Adapted from:
254278
// https://github.com/microsoft/vscode/blob/94c9ea46838a9a619aeafb7e8afd1170c967bb55/extensions/json-language-features/server/src/jsonServer.ts#L172
255279
private cancelLimitExceededWarnings(uri: string): void {
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Red Hat, Inc. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { TextDocument } from 'vscode-languageserver-textdocument';
7+
import { Position, Range, WorkspaceEdit, TextEdit } from 'vscode-languageserver-types';
8+
import { yamlDocumentsCache } from '../parser/yaml-documents';
9+
import { matchOffsetToDocument } from '../utils/arrUtils';
10+
import { TextBuffer } from '../utils/textBuffer';
11+
import { Telemetry } from '../telemetry';
12+
import { CST, isAlias, isCollection, isScalar, visit, Node } from 'yaml';
13+
import { SourceToken, CollectionItem } from 'yaml/dist/parse/cst';
14+
import { SingleYAMLDocument } from '../parser/yamlParser07';
15+
import { isCollectionItem } from '../utils/astUtils';
16+
import { PrepareRenameParams, RenameParams, ResponseError, ErrorCodes } from 'vscode-languageserver-protocol';
17+
18+
interface RenameTarget {
19+
anchorNode: Node;
20+
token: SourceToken;
21+
yamlDoc: SingleYAMLDocument;
22+
}
23+
24+
export class YamlRename {
25+
constructor(private readonly telemetry?: Telemetry) {}
26+
27+
prepareRename(document: TextDocument, params: PrepareRenameParams): Range | null {
28+
try {
29+
const target = this.findTarget(document, params.position);
30+
if (!target) {
31+
return null;
32+
}
33+
if (!this.findAnchorToken(target.yamlDoc, target.anchorNode)) {
34+
return null;
35+
}
36+
return this.getNameRange(document, target.token);
37+
} catch (err) {
38+
this.telemetry?.sendError('yaml.prepareRename.error', err);
39+
return null;
40+
}
41+
}
42+
43+
doRename(document: TextDocument, params: RenameParams): WorkspaceEdit | null {
44+
try {
45+
const target = this.findTarget(document, params.position);
46+
if (!target) {
47+
return null;
48+
}
49+
50+
const anchorToken = this.findAnchorToken(target.yamlDoc, target.anchorNode);
51+
if (!anchorToken) {
52+
return null;
53+
}
54+
55+
const newName = params.newName;
56+
const invalidChar = this.findInvalidAnchorChar(newName);
57+
if (invalidChar !== null) {
58+
throw new ResponseError(ErrorCodes.InvalidParams, `Anchor name cannot contain '${invalidChar}'`);
59+
}
60+
61+
const edits: TextEdit[] = [];
62+
63+
edits.push(TextEdit.replace(this.getNameRange(document, anchorToken), newName));
64+
65+
visit(target.yamlDoc.internalDocument, (key, node) => {
66+
if (isAlias(node) && node.srcToken && node.resolve(target.yamlDoc.internalDocument) === target.anchorNode) {
67+
edits.push(TextEdit.replace(this.getNameRange(document, node.srcToken as SourceToken), newName));
68+
}
69+
});
70+
71+
return {
72+
changes: {
73+
[document.uri]: edits,
74+
},
75+
};
76+
} catch (err) {
77+
if (err instanceof ResponseError) {
78+
throw err;
79+
}
80+
this.telemetry?.sendError('yaml.rename.error', err);
81+
return null;
82+
}
83+
}
84+
85+
private findTarget(document: TextDocument, position: Position): RenameTarget | null {
86+
const yamlDocuments = yamlDocumentsCache.getYamlDocument(document);
87+
const offset = document.offsetAt(position);
88+
const yamlDoc = matchOffsetToDocument(offset, yamlDocuments);
89+
if (!yamlDoc) {
90+
return null;
91+
}
92+
93+
const [node] = yamlDoc.getNodeFromPosition(offset, new TextBuffer(document));
94+
if (!node) {
95+
return this.findByToken(yamlDoc, offset);
96+
}
97+
98+
if (isAlias(node) && node.srcToken && this.isOffsetInsideToken(node.srcToken as SourceToken, offset)) {
99+
const anchorNode = node.resolve(yamlDoc.internalDocument);
100+
if (!anchorNode) {
101+
return null;
102+
}
103+
return { anchorNode, token: node.srcToken as SourceToken, yamlDoc };
104+
}
105+
106+
if ((isCollection(node) || isScalar(node)) && node.anchor) {
107+
const anchorToken = this.findAnchorToken(yamlDoc, node);
108+
if (anchorToken && this.isOffsetInsideToken(anchorToken, offset)) {
109+
return { anchorNode: node, token: anchorToken, yamlDoc };
110+
}
111+
}
112+
113+
return this.findByToken(yamlDoc, offset);
114+
}
115+
116+
private findByToken(yamlDoc: SingleYAMLDocument, offset: number): RenameTarget | null {
117+
let target: RenameTarget;
118+
visit(yamlDoc.internalDocument, (key, node) => {
119+
if (isAlias(node) && node.srcToken && this.isOffsetInsideToken(node.srcToken as SourceToken, offset)) {
120+
const anchorNode = node.resolve(yamlDoc.internalDocument);
121+
if (anchorNode) {
122+
target = { anchorNode, token: node.srcToken as SourceToken, yamlDoc };
123+
return visit.BREAK;
124+
}
125+
}
126+
if ((isCollection(node) || isScalar(node)) && node.anchor) {
127+
const anchorToken = this.findAnchorToken(yamlDoc, node);
128+
if (anchorToken && this.isOffsetInsideToken(anchorToken, offset)) {
129+
target = { anchorNode: node, token: anchorToken, yamlDoc };
130+
return visit.BREAK;
131+
}
132+
}
133+
});
134+
135+
return target ?? null;
136+
}
137+
138+
private findAnchorToken(yamlDoc: SingleYAMLDocument, node: Node): SourceToken | undefined {
139+
const parent = yamlDoc.getParent(node);
140+
const candidates = [];
141+
if (parent && (parent as unknown as { srcToken?: SourceToken }).srcToken) {
142+
candidates.push((parent as unknown as { srcToken: SourceToken }).srcToken);
143+
}
144+
if ((node as unknown as { srcToken?: SourceToken }).srcToken) {
145+
candidates.push((node as unknown as { srcToken: SourceToken }).srcToken);
146+
}
147+
148+
for (const token of candidates) {
149+
const anchor = this.getAnchorFromToken(token, node);
150+
if (anchor) {
151+
return anchor;
152+
}
153+
}
154+
155+
return undefined;
156+
}
157+
158+
private getAnchorFromToken(token: SourceToken, node: Node): SourceToken | undefined {
159+
if (isCollectionItem(token)) {
160+
return this.getAnchorFromCollectionItem(token);
161+
} else if (CST.isCollection(token)) {
162+
const collection = token as unknown as { items?: CollectionItem[] };
163+
for (const item of collection.items ?? []) {
164+
if (item.value !== (node as unknown as { srcToken?: SourceToken }).srcToken) {
165+
continue;
166+
}
167+
const anchor = this.getAnchorFromCollectionItem(item);
168+
if (anchor) {
169+
return anchor;
170+
}
171+
}
172+
}
173+
return undefined;
174+
}
175+
176+
private getAnchorFromCollectionItem(token: CollectionItem): SourceToken | undefined {
177+
for (const t of token.start) {
178+
if (t.type === 'anchor') {
179+
return t;
180+
}
181+
}
182+
if (token.sep && Array.isArray(token.sep)) {
183+
for (const t of token.sep) {
184+
if (t.type === 'anchor') {
185+
return t;
186+
}
187+
}
188+
}
189+
return undefined;
190+
}
191+
192+
private getNameRange(document: TextDocument, token: SourceToken): Range {
193+
const startOffset = token.offset + 1;
194+
const endOffset = token.offset + token.source.length;
195+
return Range.create(document.positionAt(startOffset), document.positionAt(endOffset));
196+
}
197+
198+
private isOffsetInsideToken(token: SourceToken, offset: number): boolean {
199+
return offset >= token.offset && offset <= token.offset + token.source.length;
200+
}
201+
202+
private findInvalidAnchorChar(name: string): string | null {
203+
// YAML 1.2.2 spec: anchor names cannot contain flow indicators or whitespace
204+
// https://yaml.org/spec/1.2.2/#rule-ns-anchor-char
205+
const invalidChars = ['[', ']', '{', '}', ',', ' ', '\t'];
206+
for (const char of invalidChars) {
207+
if (name.includes(char)) {
208+
return char === ' ' ? 'space' : char === '\t' ? 'tab' : char;
209+
}
210+
}
211+
return null;
212+
}
213+
}

src/languageservice/yamlLanguageService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
CodeLens,
2626
DefinitionLink,
2727
SelectionRange,
28+
Range,
29+
WorkspaceEdit,
2830
} from 'vscode-languageserver-types';
2931
import { JSONSchema } from './jsonSchema';
3032
import { YAMLDocumentSymbols } from './services/documentSymbols';
@@ -39,6 +41,8 @@ import {
3941
Connection,
4042
DocumentOnTypeFormattingParams,
4143
DefinitionParams,
44+
PrepareRenameParams,
45+
RenameParams,
4246
} from 'vscode-languageserver';
4347
import { TextDocument } from 'vscode-languageserver-textdocument';
4448
import { getFoldingRanges } from './services/yamlFolding';
@@ -54,6 +58,7 @@ import { SettingsState } from '../yamlSettings';
5458
import { JSONSchemaSelection } from '../languageserver/handlers/schemaSelectionHandlers';
5559
import { YamlDefinition } from './services/yamlDefinition';
5660
import { getSelectionRanges } from './services/yamlSelectionRanges';
61+
import { YamlRename } from './services/yamlRename';
5762

5863
export enum SchemaPriority {
5964
SchemaStore = 1,
@@ -180,6 +185,8 @@ export interface LanguageService {
180185
getCodeAction: (document: TextDocument, params: CodeActionParams) => CodeAction[] | undefined;
181186
getCodeLens: (document: TextDocument) => PromiseLike<CodeLens[] | undefined> | CodeLens[] | undefined;
182187
resolveCodeLens: (param: CodeLens) => PromiseLike<CodeLens> | CodeLens;
188+
prepareRename: (document: TextDocument, params: PrepareRenameParams) => Range | null;
189+
doRename: (document: TextDocument, params: RenameParams) => WorkspaceEdit | null;
183190
}
184191

185192
export function getLanguageService(params: {
@@ -200,6 +207,7 @@ export function getLanguageService(params: {
200207
const yamlCodeLens = new YamlCodeLens(schemaService, params.telemetry);
201208
const yamlLinks = new YamlLinks(params.telemetry);
202209
const yamlDefinition = new YamlDefinition(params.telemetry);
210+
const yamlRename = new YamlRename(params.telemetry);
203211

204212
new JSONSchemaSelection(schemaService, params.yamlSettings, params.connection);
205213

@@ -266,5 +274,7 @@ export function getLanguageService(params: {
266274
return yamlCodeLens.getCodeLens(document);
267275
},
268276
resolveCodeLens: (param) => yamlCodeLens.resolveCodeLens(param),
277+
prepareRename: (document, params) => yamlRename.prepareRename(document, params),
278+
doRename: (document, params) => yamlRename.doRename(document, params),
269279
};
270280
}

src/yamlServerInit.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export class YAMLServerInit {
135135
},
136136
documentRangeFormattingProvider: false,
137137
definitionProvider: true,
138+
renameProvider: { prepareProvider: true },
138139
documentLinkProvider: {},
139140
foldingRangeProvider: true,
140141
selectionRangeProvider: true,

0 commit comments

Comments
 (0)