|
| 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 | +} |
0 commit comments