1/* @internal */ 2namespace ts.textChanges { 3 4 /** 5 * Currently for simplicity we store recovered positions on the node itself. 6 * It can be changed to side-table later if we decide that current design is too invasive. 7 */ 8 function getPos(n: TextRange): number { 9 const result = (<any>n).__pos; 10 Debug.assert(typeof result === "number"); 11 return result; 12 } 13 14 function setPos(n: TextRange, pos: number): void { 15 Debug.assert(typeof pos === "number"); 16 (<any>n).__pos = pos; 17 } 18 19 function getEnd(n: TextRange): number { 20 const result = (<any>n).__end; 21 Debug.assert(typeof result === "number"); 22 return result; 23 } 24 25 function setEnd(n: TextRange, end: number): void { 26 Debug.assert(typeof end === "number"); 27 (<any>n).__end = end; 28 } 29 30 export interface ConfigurableStart { 31 leadingTriviaOption?: LeadingTriviaOption; 32 } 33 export interface ConfigurableEnd { 34 trailingTriviaOption?: TrailingTriviaOption; 35 } 36 37 export enum LeadingTriviaOption { 38 /** Exclude all leading trivia (use getStart()) */ 39 Exclude, 40 /** Include leading trivia and, 41 * if there are no line breaks between the node and the previous token, 42 * include all trivia between the node and the previous token 43 */ 44 IncludeAll, 45 /** 46 * Include attached JSDoc comments 47 */ 48 JSDoc, 49 /** 50 * Only delete trivia on the same line as getStart(). 51 * Used to avoid deleting leading comments 52 */ 53 StartLine, 54 } 55 56 export enum TrailingTriviaOption { 57 /** Exclude all trailing trivia (use getEnd()) */ 58 Exclude, 59 /** Doesn't include whitespace, but does strip comments */ 60 ExcludeWhitespace, 61 /** Include trailing trivia */ 62 Include, 63 } 64 65 function skipWhitespacesAndLineBreaks(text: string, start: number) { 66 return skipTrivia(text, start, /*stopAfterLineBreak*/ false, /*stopAtComments*/ true); 67 } 68 69 function hasCommentsBeforeLineBreak(text: string, start: number) { 70 let i = start; 71 while (i < text.length) { 72 const ch = text.charCodeAt(i); 73 if (isWhiteSpaceSingleLine(ch)) { 74 i++; 75 continue; 76 } 77 return ch === CharacterCodes.slash; 78 } 79 return false; 80 } 81 82 /** 83 * Usually node.pos points to a position immediately after the previous token. 84 * If this position is used as a beginning of the span to remove - it might lead to removing the trailing trivia of the previous node, i.e: 85 * const x; // this is x 86 * ^ - pos for the next variable declaration will point here 87 * const y; // this is y 88 * ^ - end for previous variable declaration 89 * Usually leading trivia of the variable declaration 'y' should not include trailing trivia (whitespace, comment 'this is x' and newline) from the preceding 90 * variable declaration and trailing trivia for 'y' should include (whitespace, comment 'this is y', newline). 91 * By default when removing nodes we adjust start and end positions to respect specification of the trivia above. 92 * If pos\end should be interpreted literally (that is, withouth including leading and trailing trivia), `leadingTriviaOption` should be set to `LeadingTriviaOption.Exclude` 93 * and `trailingTriviaOption` to `TrailingTriviaOption.Exclude`. 94 */ 95 export interface ConfigurableStartEnd extends ConfigurableStart, ConfigurableEnd {} 96 97 const useNonAdjustedPositions: ConfigurableStartEnd = { 98 leadingTriviaOption: LeadingTriviaOption.Exclude, 99 trailingTriviaOption: TrailingTriviaOption.Exclude, 100 }; 101 102 export interface InsertNodeOptions { 103 /** 104 * Text to be inserted before the new node 105 */ 106 prefix?: string; 107 /** 108 * Text to be inserted after the new node 109 */ 110 suffix?: string; 111 /** 112 * Text of inserted node will be formatted with this indentation, otherwise indentation will be inferred from the old node 113 */ 114 indentation?: number; 115 /** 116 * Text of inserted node will be formatted with this delta, otherwise delta will be inferred from the new node kind 117 */ 118 delta?: number; 119 /** 120 * Do not trim leading white spaces in the edit range 121 */ 122 preserveLeadingWhitespace?: boolean; 123 } 124 125 export interface ReplaceWithMultipleNodesOptions extends InsertNodeOptions { 126 readonly joiner?: string; 127 } 128 129 enum ChangeKind { 130 Remove, 131 ReplaceWithSingleNode, 132 ReplaceWithMultipleNodes, 133 Text, 134 } 135 136 type Change = ReplaceWithSingleNode | ReplaceWithMultipleNodes | RemoveNode | ChangeText; 137 138 interface BaseChange { 139 readonly sourceFile: SourceFile; 140 readonly range: TextRange; 141 } 142 143 export interface ChangeNodeOptions extends ConfigurableStartEnd, InsertNodeOptions {} 144 interface ReplaceWithSingleNode extends BaseChange { 145 readonly kind: ChangeKind.ReplaceWithSingleNode; 146 readonly node: Node; 147 readonly options?: InsertNodeOptions; 148 } 149 150 interface RemoveNode extends BaseChange { 151 readonly kind: ChangeKind.Remove; 152 readonly node?: never; 153 readonly options?: never; 154 } 155 156 interface ReplaceWithMultipleNodes extends BaseChange { 157 readonly kind: ChangeKind.ReplaceWithMultipleNodes; 158 readonly nodes: readonly Node[]; 159 readonly options?: ReplaceWithMultipleNodesOptions; 160 } 161 162 interface ChangeText extends BaseChange { 163 readonly kind: ChangeKind.Text; 164 readonly text: string; 165 } 166 167 function getAdjustedRange(sourceFile: SourceFile, startNode: Node, endNode: Node, options: ConfigurableStartEnd): TextRange { 168 return { pos: getAdjustedStartPosition(sourceFile, startNode, options), end: getAdjustedEndPosition(sourceFile, endNode, options) }; 169 } 170 171 function getAdjustedStartPosition(sourceFile: SourceFile, node: Node, options: ConfigurableStart) { 172 const { leadingTriviaOption } = options; 173 if (leadingTriviaOption === LeadingTriviaOption.Exclude) { 174 return node.getStart(sourceFile); 175 } 176 if (leadingTriviaOption === LeadingTriviaOption.StartLine) { 177 return getLineStartPositionForPosition(node.getStart(sourceFile), sourceFile); 178 } 179 if (leadingTriviaOption === LeadingTriviaOption.JSDoc) { 180 const JSDocComments = getJSDocCommentRanges(node, sourceFile.text); 181 if (JSDocComments?.length) { 182 return getLineStartPositionForPosition(JSDocComments[0].pos, sourceFile); 183 } 184 } 185 const fullStart = node.getFullStart(); 186 const start = node.getStart(sourceFile); 187 if (fullStart === start) { 188 return start; 189 } 190 const fullStartLine = getLineStartPositionForPosition(fullStart, sourceFile); 191 const startLine = getLineStartPositionForPosition(start, sourceFile); 192 if (startLine === fullStartLine) { 193 // full start and start of the node are on the same line 194 // a, b; 195 // ^ ^ 196 // | start 197 // fullstart 198 // when b is replaced - we usually want to keep the leading trvia 199 // when b is deleted - we delete it 200 return leadingTriviaOption === LeadingTriviaOption.IncludeAll ? fullStart : start; 201 } 202 // get start position of the line following the line that contains fullstart position 203 // (but only if the fullstart isn't the very beginning of the file) 204 const nextLineStart = fullStart > 0 ? 1 : 0; 205 let adjustedStartPosition = getStartPositionOfLine(getLineOfLocalPosition(sourceFile, fullStartLine) + nextLineStart, sourceFile); 206 // skip whitespaces/newlines 207 adjustedStartPosition = skipWhitespacesAndLineBreaks(sourceFile.text, adjustedStartPosition); 208 return getStartPositionOfLine(getLineOfLocalPosition(sourceFile, adjustedStartPosition), sourceFile); 209 } 210 211 function getAdjustedEndPosition(sourceFile: SourceFile, node: Node, options: ConfigurableEnd) { 212 const { end } = node; 213 const { trailingTriviaOption } = options; 214 if (trailingTriviaOption === TrailingTriviaOption.Exclude) { 215 return end; 216 } 217 if (trailingTriviaOption === TrailingTriviaOption.ExcludeWhitespace) { 218 const comments = concatenate(getTrailingCommentRanges(sourceFile.text, end), getLeadingCommentRanges(sourceFile.text, end)); 219 const realEnd = comments?.[comments.length - 1]?.end; 220 if (realEnd) { 221 return realEnd; 222 } 223 return end; 224 } 225 const newEnd = skipTrivia(sourceFile.text, end, /*stopAfterLineBreak*/ true); 226 227 return newEnd !== end && (trailingTriviaOption === TrailingTriviaOption.Include || isLineBreak(sourceFile.text.charCodeAt(newEnd - 1))) 228 ? newEnd 229 : end; 230 } 231 232 /** 233 * Checks if 'candidate' argument is a legal separator in the list that contains 'node' as an element 234 */ 235 function isSeparator(node: Node, candidate: Node | undefined): candidate is Token<SyntaxKind.CommaToken | SyntaxKind.SemicolonToken> { 236 return !!candidate && !!node.parent && (candidate.kind === SyntaxKind.CommaToken || (candidate.kind === SyntaxKind.SemicolonToken && node.parent.kind === SyntaxKind.ObjectLiteralExpression)); 237 } 238 239 function spaces(count: number) { 240 let s = ""; 241 for (let i = 0; i < count; i++) { 242 s += " "; 243 } 244 return s; 245 } 246 247 export interface TextChangesContext { 248 host: LanguageServiceHost; 249 formatContext: formatting.FormatContext; 250 preferences: UserPreferences; 251 } 252 253 export type TypeAnnotatable = SignatureDeclaration | VariableDeclaration | ParameterDeclaration | PropertyDeclaration | PropertySignature; 254 255 export type ThisTypeAnnotatable = FunctionDeclaration | FunctionExpression; 256 257 export function isThisTypeAnnotatable(containingFunction: SignatureDeclaration): containingFunction is ThisTypeAnnotatable { 258 return isFunctionExpression(containingFunction) || isFunctionDeclaration(containingFunction); 259 } 260 261 export class ChangeTracker { 262 private readonly changes: Change[] = []; 263 private readonly newFiles: { readonly oldFile: SourceFile | undefined, readonly fileName: string, readonly statements: readonly (Statement | SyntaxKind.NewLineTrivia)[] }[] = []; 264 private readonly classesWithNodesInsertedAtStart = new Map<string, { readonly node: ClassDeclaration | InterfaceDeclaration | ObjectLiteralExpression, readonly sourceFile: SourceFile }>(); // Set<ClassDeclaration> implemented as Map<node id, ClassDeclaration> 265 private readonly deletedNodes: { readonly sourceFile: SourceFile, readonly node: Node | NodeArray<TypeParameterDeclaration> }[] = []; 266 267 public static fromContext(context: TextChangesContext): ChangeTracker { 268 return new ChangeTracker(getNewLineOrDefaultFromHost(context.host, context.formatContext.options), context.formatContext); 269 } 270 271 public static with(context: TextChangesContext, cb: (tracker: ChangeTracker) => void): FileTextChanges[] { 272 const tracker = ChangeTracker.fromContext(context); 273 cb(tracker); 274 return tracker.getChanges(); 275 } 276 277 /** Public for tests only. Other callers should use `ChangeTracker.with`. */ 278 constructor(private readonly newLineCharacter: string, private readonly formatContext: formatting.FormatContext) {} 279 280 public pushRaw(sourceFile: SourceFile, change: FileTextChanges) { 281 Debug.assertEqual(sourceFile.fileName, change.fileName); 282 for (const c of change.textChanges) { 283 this.changes.push({ 284 kind: ChangeKind.Text, 285 sourceFile, 286 text: c.newText, 287 range: createTextRangeFromSpan(c.span), 288 }); 289 } 290 } 291 292 public deleteRange(sourceFile: SourceFile, range: TextRange): void { 293 this.changes.push({ kind: ChangeKind.Remove, sourceFile, range }); 294 } 295 296 delete(sourceFile: SourceFile, node: Node | NodeArray<TypeParameterDeclaration>): void { 297 this.deletedNodes.push({ sourceFile, node }); 298 } 299 300 public deleteNode(sourceFile: SourceFile, node: Node, options: ConfigurableStartEnd = { leadingTriviaOption: LeadingTriviaOption.IncludeAll }): void { 301 this.deleteRange(sourceFile, getAdjustedRange(sourceFile, node, node, options)); 302 } 303 304 public deleteModifier(sourceFile: SourceFile, modifier: Modifier): void { 305 this.deleteRange(sourceFile, { pos: modifier.getStart(sourceFile), end: skipTrivia(sourceFile.text, modifier.end, /*stopAfterLineBreak*/ true) }); 306 } 307 308 public deleteNodeRange(sourceFile: SourceFile, startNode: Node, endNode: Node, options: ConfigurableStartEnd = { leadingTriviaOption: LeadingTriviaOption.IncludeAll }): void { 309 const startPosition = getAdjustedStartPosition(sourceFile, startNode, options); 310 const endPosition = getAdjustedEndPosition(sourceFile, endNode, options); 311 this.deleteRange(sourceFile, { pos: startPosition, end: endPosition }); 312 } 313 314 public deleteNodeRangeExcludingEnd(sourceFile: SourceFile, startNode: Node, afterEndNode: Node | undefined, options: ConfigurableStartEnd = { leadingTriviaOption: LeadingTriviaOption.IncludeAll }): void { 315 const startPosition = getAdjustedStartPosition(sourceFile, startNode, options); 316 const endPosition = afterEndNode === undefined ? sourceFile.text.length : getAdjustedStartPosition(sourceFile, afterEndNode, options); 317 this.deleteRange(sourceFile, { pos: startPosition, end: endPosition }); 318 } 319 320 public replaceRange(sourceFile: SourceFile, range: TextRange, newNode: Node, options: InsertNodeOptions = {}): void { 321 this.changes.push({ kind: ChangeKind.ReplaceWithSingleNode, sourceFile, range, options, node: newNode }); 322 } 323 324 public replaceNode(sourceFile: SourceFile, oldNode: Node, newNode: Node, options: ChangeNodeOptions = useNonAdjustedPositions): void { 325 this.replaceRange(sourceFile, getAdjustedRange(sourceFile, oldNode, oldNode, options), newNode, options); 326 } 327 328 public replaceNodeRange(sourceFile: SourceFile, startNode: Node, endNode: Node, newNode: Node, options: ChangeNodeOptions = useNonAdjustedPositions): void { 329 this.replaceRange(sourceFile, getAdjustedRange(sourceFile, startNode, endNode, options), newNode, options); 330 } 331 332 private replaceRangeWithNodes(sourceFile: SourceFile, range: TextRange, newNodes: readonly Node[], options: ReplaceWithMultipleNodesOptions & ConfigurableStartEnd = {}): void { 333 this.changes.push({ kind: ChangeKind.ReplaceWithMultipleNodes, sourceFile, range, options, nodes: newNodes }); 334 } 335 336 public replaceNodeWithNodes(sourceFile: SourceFile, oldNode: Node, newNodes: readonly Node[], options: ChangeNodeOptions = useNonAdjustedPositions): void { 337 this.replaceRangeWithNodes(sourceFile, getAdjustedRange(sourceFile, oldNode, oldNode, options), newNodes, options); 338 } 339 340 public replaceNodeWithText(sourceFile: SourceFile, oldNode: Node, text: string): void { 341 this.replaceRangeWithText(sourceFile, getAdjustedRange(sourceFile, oldNode, oldNode, useNonAdjustedPositions), text); 342 } 343 344 public replaceNodeRangeWithNodes(sourceFile: SourceFile, startNode: Node, endNode: Node, newNodes: readonly Node[], options: ReplaceWithMultipleNodesOptions & ConfigurableStartEnd = useNonAdjustedPositions): void { 345 this.replaceRangeWithNodes(sourceFile, getAdjustedRange(sourceFile, startNode, endNode, options), newNodes, options); 346 } 347 348 private nextCommaToken(sourceFile: SourceFile, node: Node): Node | undefined { 349 const next = findNextToken(node, node.parent, sourceFile); 350 return next && next.kind === SyntaxKind.CommaToken ? next : undefined; 351 } 352 353 public replacePropertyAssignment(sourceFile: SourceFile, oldNode: PropertyAssignment, newNode: PropertyAssignment): void { 354 const suffix = this.nextCommaToken(sourceFile, oldNode) ? "" : ("," + this.newLineCharacter); 355 this.replaceNode(sourceFile, oldNode, newNode, { suffix }); 356 } 357 358 public insertNodeAt(sourceFile: SourceFile, pos: number, newNode: Node, options: InsertNodeOptions = {}): void { 359 this.replaceRange(sourceFile, createRange(pos), newNode, options); 360 } 361 362 private insertNodesAt(sourceFile: SourceFile, pos: number, newNodes: readonly Node[], options: ReplaceWithMultipleNodesOptions = {}): void { 363 this.replaceRangeWithNodes(sourceFile, createRange(pos), newNodes, options); 364 } 365 366 public insertNodeAtTopOfFile(sourceFile: SourceFile, newNode: Statement, blankLineBetween: boolean): void { 367 this.insertAtTopOfFile(sourceFile, newNode, blankLineBetween); 368 } 369 370 public insertNodesAtTopOfFile(sourceFile: SourceFile, newNodes: readonly Statement[], blankLineBetween: boolean): void { 371 this.insertAtTopOfFile(sourceFile, newNodes, blankLineBetween); 372 } 373 374 private insertAtTopOfFile(sourceFile: SourceFile, insert: Statement | readonly Statement[], blankLineBetween: boolean): void { 375 const pos = getInsertionPositionAtSourceFileTop(sourceFile); 376 const options = { 377 prefix: pos === 0 ? undefined : this.newLineCharacter, 378 suffix: (isLineBreak(sourceFile.text.charCodeAt(pos)) ? "" : this.newLineCharacter) + (blankLineBetween ? this.newLineCharacter : ""), 379 }; 380 if (isArray(insert)) { 381 this.insertNodesAt(sourceFile, pos, insert, options); 382 } 383 else { 384 this.insertNodeAt(sourceFile, pos, insert, options); 385 } 386 } 387 388 public insertFirstParameter(sourceFile: SourceFile, parameters: NodeArray<ParameterDeclaration>, newParam: ParameterDeclaration): void { 389 const p0 = firstOrUndefined(parameters); 390 if (p0) { 391 this.insertNodeBefore(sourceFile, p0, newParam); 392 } 393 else { 394 this.insertNodeAt(sourceFile, parameters.pos, newParam); 395 } 396 } 397 398 public insertNodeBefore(sourceFile: SourceFile, before: Node, newNode: Node, blankLineBetween = false, options: ConfigurableStartEnd = {}): void { 399 this.insertNodeAt(sourceFile, getAdjustedStartPosition(sourceFile, before, options), newNode, this.getOptionsForInsertNodeBefore(before, newNode, blankLineBetween)); 400 } 401 402 public insertModifierAt(sourceFile: SourceFile, pos: number, modifier: SyntaxKind, options: InsertNodeOptions = {}): void { 403 this.insertNodeAt(sourceFile, pos, factory.createToken(modifier), options); 404 } 405 406 public insertModifierBefore(sourceFile: SourceFile, modifier: SyntaxKind, before: Node): void { 407 return this.insertModifierAt(sourceFile, before.getStart(sourceFile), modifier, { suffix: " " }); 408 } 409 410 public insertCommentBeforeLine(sourceFile: SourceFile, lineNumber: number, position: number, commentText: string): void { 411 const lineStartPosition = getStartPositionOfLine(lineNumber, sourceFile); 412 const startPosition = getFirstNonSpaceCharacterPosition(sourceFile.text, lineStartPosition); 413 // First try to see if we can put the comment on the previous line. 414 // We need to make sure that we are not in the middle of a string literal or a comment. 415 // If so, we do not want to separate the node from its comment if we can. 416 // Otherwise, add an extra new line immediately before the error span. 417 const insertAtLineStart = isValidLocationToAddComment(sourceFile, startPosition); 418 const token = getTouchingToken(sourceFile, insertAtLineStart ? startPosition : position); 419 const indent = sourceFile.text.slice(lineStartPosition, startPosition); 420 const text = `${insertAtLineStart ? "" : this.newLineCharacter}//${commentText}${this.newLineCharacter}${indent}`; 421 this.insertText(sourceFile, token.getStart(sourceFile), text); 422 } 423 424 public insertJsdocCommentBefore(sourceFile: SourceFile, node: HasJSDoc, tag: JSDoc): void { 425 const fnStart = node.getStart(sourceFile); 426 if (node.jsDoc) { 427 for (const jsdoc of node.jsDoc) { 428 this.deleteRange(sourceFile, { 429 pos: getLineStartPositionForPosition(jsdoc.getStart(sourceFile), sourceFile), 430 end: getAdjustedEndPosition(sourceFile, jsdoc, /*options*/ {}) 431 }); 432 } 433 } 434 const startPosition = getPrecedingNonSpaceCharacterPosition(sourceFile.text, fnStart - 1); 435 const indent = sourceFile.text.slice(startPosition, fnStart); 436 this.insertNodeAt(sourceFile, fnStart, tag, { preserveLeadingWhitespace: false, suffix: this.newLineCharacter + indent }); 437 } 438 439 public replaceRangeWithText(sourceFile: SourceFile, range: TextRange, text: string): void { 440 this.changes.push({ kind: ChangeKind.Text, sourceFile, range, text }); 441 } 442 443 public insertText(sourceFile: SourceFile, pos: number, text: string): void { 444 this.replaceRangeWithText(sourceFile, createRange(pos), text); 445 } 446 447 /** Prefer this over replacing a node with another that has a type annotation, as it avoids reformatting the other parts of the node. */ 448 public tryInsertTypeAnnotation(sourceFile: SourceFile, node: TypeAnnotatable, type: TypeNode): boolean { 449 let endNode: Node | undefined; 450 if (isFunctionLike(node)) { 451 endNode = findChildOfKind(node, SyntaxKind.CloseParenToken, sourceFile); 452 if (!endNode) { 453 if (!isArrowFunction(node)) return false; // Function missing parentheses, give up 454 // If no `)`, is an arrow function `x => x`, so use the end of the first parameter 455 endNode = first(node.parameters); 456 } 457 } 458 else { 459 endNode = (node.kind === SyntaxKind.VariableDeclaration ? node.exclamationToken : node.questionToken) ?? node.name; 460 } 461 462 this.insertNodeAt(sourceFile, endNode.end, type, { prefix: ": " }); 463 return true; 464 } 465 466 public tryInsertThisTypeAnnotation(sourceFile: SourceFile, node: ThisTypeAnnotatable, type: TypeNode): void { 467 const start = findChildOfKind(node, SyntaxKind.OpenParenToken, sourceFile)!.getStart(sourceFile) + 1; 468 const suffix = node.parameters.length ? ", " : ""; 469 470 this.insertNodeAt(sourceFile, start, type, { prefix: "this: ", suffix }); 471 } 472 473 public insertTypeParameters(sourceFile: SourceFile, node: SignatureDeclaration, typeParameters: readonly TypeParameterDeclaration[]): void { 474 // If no `(`, is an arrow function `x => x`, so use the pos of the first parameter 475 const start = (findChildOfKind(node, SyntaxKind.OpenParenToken, sourceFile) || first(node.parameters)).getStart(sourceFile); 476 this.insertNodesAt(sourceFile, start, typeParameters, { prefix: "<", suffix: ">", joiner: ", " }); 477 } 478 479 private getOptionsForInsertNodeBefore(before: Node, inserted: Node, blankLineBetween: boolean): InsertNodeOptions { 480 if (isStatement(before) || isClassElement(before)) { 481 return { suffix: blankLineBetween ? this.newLineCharacter + this.newLineCharacter : this.newLineCharacter }; 482 } 483 else if (isVariableDeclaration(before)) { // insert `x = 1, ` into `const x = 1, y = 2; 484 return { suffix: ", " }; 485 } 486 else if (isParameter(before)) { 487 return isParameter(inserted) ? { suffix: ", " } : {}; 488 } 489 else if (isStringLiteral(before) && isImportDeclaration(before.parent) || isNamedImports(before)) { 490 return { suffix: ", " }; 491 } 492 else if (isImportSpecifier(before)) { 493 return { suffix: "," + (blankLineBetween ? this.newLineCharacter : " ") }; 494 } 495 return Debug.failBadSyntaxKind(before); // We haven't handled this kind of node yet -- add it 496 } 497 498 public insertNodeAtConstructorStart(sourceFile: SourceFile, ctr: ConstructorDeclaration, newStatement: Statement): void { 499 const firstStatement = firstOrUndefined(ctr.body!.statements); 500 if (!firstStatement || !ctr.body!.multiLine) { 501 this.replaceConstructorBody(sourceFile, ctr, [newStatement, ...ctr.body!.statements]); 502 } 503 else { 504 this.insertNodeBefore(sourceFile, firstStatement, newStatement); 505 } 506 } 507 508 public insertNodeAtConstructorEnd(sourceFile: SourceFile, ctr: ConstructorDeclaration, newStatement: Statement): void { 509 const lastStatement = lastOrUndefined(ctr.body!.statements); 510 if (!lastStatement || !ctr.body!.multiLine) { 511 this.replaceConstructorBody(sourceFile, ctr, [...ctr.body!.statements, newStatement]); 512 } 513 else { 514 this.insertNodeAfter(sourceFile, lastStatement, newStatement); 515 } 516 } 517 518 private replaceConstructorBody(sourceFile: SourceFile, ctr: ConstructorDeclaration, statements: readonly Statement[]): void { 519 this.replaceNode(sourceFile, ctr.body!, factory.createBlock(statements, /*multiLine*/ true)); 520 } 521 522 public insertNodeAtEndOfScope(sourceFile: SourceFile, scope: Node, newNode: Node): void { 523 const pos = getAdjustedStartPosition(sourceFile, scope.getLastToken()!, {}); 524 this.insertNodeAt(sourceFile, pos, newNode, { 525 prefix: isLineBreak(sourceFile.text.charCodeAt(scope.getLastToken()!.pos)) ? this.newLineCharacter : this.newLineCharacter + this.newLineCharacter, 526 suffix: this.newLineCharacter 527 }); 528 } 529 530 public insertNodeAtClassStart(sourceFile: SourceFile, cls: ClassLikeDeclaration | InterfaceDeclaration, newElement: ClassElement): void { 531 this.insertNodeAtStartWorker(sourceFile, cls, newElement); 532 } 533 534 public insertNodeAtObjectStart(sourceFile: SourceFile, obj: ObjectLiteralExpression, newElement: ObjectLiteralElementLike): void { 535 this.insertNodeAtStartWorker(sourceFile, obj, newElement); 536 } 537 538 private insertNodeAtStartWorker(sourceFile: SourceFile, cls: ClassLikeDeclaration | InterfaceDeclaration | ObjectLiteralExpression, newElement: ClassElement | ObjectLiteralElementLike): void { 539 const indentation = this.guessIndentationFromExistingMembers(sourceFile, cls) ?? this.computeIndentationForNewMember(sourceFile, cls); 540 this.insertNodeAt(sourceFile, getMembersOrProperties(cls).pos, newElement, this.getInsertNodeAtStartInsertOptions(sourceFile, cls, indentation)); 541 } 542 543 /** 544 * Tries to guess the indentation from the existing members of a class/interface/object. All members must be on 545 * new lines and must share the same indentation. 546 */ 547 private guessIndentationFromExistingMembers(sourceFile: SourceFile, cls: ClassLikeDeclaration | InterfaceDeclaration | ObjectLiteralExpression) { 548 let indentation: number | undefined; 549 let lastRange: TextRange = cls; 550 for (const member of getMembersOrProperties(cls)) { 551 if (rangeStartPositionsAreOnSameLine(lastRange, member, sourceFile)) { 552 // each indented member must be on a new line 553 return undefined; 554 } 555 const memberStart = member.getStart(sourceFile); 556 const memberIndentation = formatting.SmartIndenter.findFirstNonWhitespaceColumn(getLineStartPositionForPosition(memberStart, sourceFile), memberStart, sourceFile, this.formatContext.options); 557 if (indentation === undefined) { 558 indentation = memberIndentation; 559 } 560 else if (memberIndentation !== indentation) { 561 // indentation of multiple members is not consistent 562 return undefined; 563 } 564 lastRange = member; 565 } 566 return indentation; 567 } 568 569 private computeIndentationForNewMember(sourceFile: SourceFile, cls: ClassLikeDeclaration | InterfaceDeclaration | ObjectLiteralExpression) { 570 const clsStart = cls.getStart(sourceFile); 571 return formatting.SmartIndenter.findFirstNonWhitespaceColumn(getLineStartPositionForPosition(clsStart, sourceFile), clsStart, sourceFile, this.formatContext.options) 572 + (this.formatContext.options.indentSize ?? 4); 573 } 574 575 private getInsertNodeAtStartInsertOptions(sourceFile: SourceFile, cls: ClassLikeDeclaration | InterfaceDeclaration | ObjectLiteralExpression, indentation: number): InsertNodeOptions { 576 // Rules: 577 // - Always insert leading newline. 578 // - For object literals: 579 // - Add a trailing comma if there are existing members in the node, or the source file is not a JSON file 580 // (because trailing commas are generally illegal in a JSON file). 581 // - Add a leading comma if the source file is not a JSON file, there are existing insertions, 582 // and the node is empty (because we didn't add a trailing comma per the previous rule). 583 // - Only insert a trailing newline if body is single-line and there are no other insertions for the node. 584 // NOTE: This is handled in `finishClassesWithNodesInsertedAtStart`. 585 586 const members = getMembersOrProperties(cls); 587 const isEmpty = members.length === 0; 588 const isFirstInsertion = addToSeen(this.classesWithNodesInsertedAtStart, getNodeId(cls), { node: cls, sourceFile }); 589 const insertTrailingComma = isObjectLiteralExpression(cls) && (!isJsonSourceFile(sourceFile) || !isEmpty); 590 const insertLeadingComma = isObjectLiteralExpression(cls) && isJsonSourceFile(sourceFile) && isEmpty && !isFirstInsertion; 591 return { 592 indentation, 593 prefix: (insertLeadingComma ? "," : "") + this.newLineCharacter, 594 suffix: insertTrailingComma ? "," : "" 595 }; 596 } 597 598 public insertNodeAfterComma(sourceFile: SourceFile, after: Node, newNode: Node): void { 599 const endPosition = this.insertNodeAfterWorker(sourceFile, this.nextCommaToken(sourceFile, after) || after, newNode); 600 this.insertNodeAt(sourceFile, endPosition, newNode, this.getInsertNodeAfterOptions(sourceFile, after)); 601 } 602 603 public insertNodeAfter(sourceFile: SourceFile, after: Node, newNode: Node): void { 604 const endPosition = this.insertNodeAfterWorker(sourceFile, after, newNode); 605 this.insertNodeAt(sourceFile, endPosition, newNode, this.getInsertNodeAfterOptions(sourceFile, after)); 606 } 607 608 public insertNodeAtEndOfList(sourceFile: SourceFile, list: NodeArray<Node>, newNode: Node): void { 609 this.insertNodeAt(sourceFile, list.end, newNode, { prefix: ", " }); 610 } 611 612 public insertNodesAfter(sourceFile: SourceFile, after: Node, newNodes: readonly Node[]): void { 613 const endPosition = this.insertNodeAfterWorker(sourceFile, after, first(newNodes)); 614 this.insertNodesAt(sourceFile, endPosition, newNodes, this.getInsertNodeAfterOptions(sourceFile, after)); 615 } 616 617 private insertNodeAfterWorker(sourceFile: SourceFile, after: Node, newNode: Node): number { 618 if (needSemicolonBetween(after, newNode)) { 619 // check if previous statement ends with semicolon 620 // if not - insert semicolon to preserve the code from changing the meaning due to ASI 621 if (sourceFile.text.charCodeAt(after.end - 1) !== CharacterCodes.semicolon) { 622 this.replaceRange(sourceFile, createRange(after.end), factory.createToken(SyntaxKind.SemicolonToken)); 623 } 624 } 625 const endPosition = getAdjustedEndPosition(sourceFile, after, {}); 626 return endPosition; 627 } 628 629 private getInsertNodeAfterOptions(sourceFile: SourceFile, after: Node): InsertNodeOptions { 630 const options = this.getInsertNodeAfterOptionsWorker(after); 631 return { 632 ...options, 633 prefix: after.end === sourceFile.end && isStatement(after) ? (options.prefix ? `\n${options.prefix}` : "\n") : options.prefix, 634 }; 635 } 636 637 private getInsertNodeAfterOptionsWorker(node: Node): InsertNodeOptions { 638 switch (node.kind) { 639 case SyntaxKind.ClassDeclaration: 640 case SyntaxKind.StructDeclaration: 641 case SyntaxKind.ModuleDeclaration: 642 return { prefix: this.newLineCharacter, suffix: this.newLineCharacter }; 643 644 case SyntaxKind.VariableDeclaration: 645 case SyntaxKind.StringLiteral: 646 case SyntaxKind.Identifier: 647 return { prefix: ", " }; 648 649 case SyntaxKind.PropertyAssignment: 650 return { suffix: "," + this.newLineCharacter }; 651 652 case SyntaxKind.ExportKeyword: 653 return { prefix: " " }; 654 655 case SyntaxKind.Parameter: 656 return {}; 657 658 default: 659 Debug.assert(isStatement(node) || isClassOrTypeElement(node)); // Else we haven't handled this kind of node yet -- add it 660 return { suffix: this.newLineCharacter }; 661 } 662 } 663 664 public insertName(sourceFile: SourceFile, node: FunctionExpression | ClassExpression | ArrowFunction, name: string): void { 665 Debug.assert(!node.name); 666 if (node.kind === SyntaxKind.ArrowFunction) { 667 const arrow = findChildOfKind(node, SyntaxKind.EqualsGreaterThanToken, sourceFile)!; 668 const lparen = findChildOfKind(node, SyntaxKind.OpenParenToken, sourceFile); 669 if (lparen) { 670 // `() => {}` --> `function f() {}` 671 this.insertNodesAt(sourceFile, lparen.getStart(sourceFile), [factory.createToken(SyntaxKind.FunctionKeyword), factory.createIdentifier(name)], { joiner: " " }); 672 deleteNode(this, sourceFile, arrow); 673 } 674 else { 675 // `x => {}` -> `function f(x) {}` 676 this.insertText(sourceFile, first(node.parameters).getStart(sourceFile), `function ${name}(`); 677 // Replacing full range of arrow to get rid of the leading space -- replace ` =>` with `)` 678 this.replaceRange(sourceFile, arrow, factory.createToken(SyntaxKind.CloseParenToken)); 679 } 680 681 if (node.body.kind !== SyntaxKind.Block) { 682 // `() => 0` => `function f() { return 0; }` 683 this.insertNodesAt(sourceFile, node.body.getStart(sourceFile), [factory.createToken(SyntaxKind.OpenBraceToken), factory.createToken(SyntaxKind.ReturnKeyword)], { joiner: " ", suffix: " " }); 684 this.insertNodesAt(sourceFile, node.body.end, [factory.createToken(SyntaxKind.SemicolonToken), factory.createToken(SyntaxKind.CloseBraceToken)], { joiner: " " }); 685 } 686 } 687 else { 688 const pos = findChildOfKind(node, node.kind === SyntaxKind.FunctionExpression ? SyntaxKind.FunctionKeyword : SyntaxKind.ClassKeyword, sourceFile)!.end; 689 this.insertNodeAt(sourceFile, pos, factory.createIdentifier(name), { prefix: " " }); 690 } 691 } 692 693 public insertExportModifier(sourceFile: SourceFile, node: DeclarationStatement | VariableStatement): void { 694 this.insertText(sourceFile, node.getStart(sourceFile), "export "); 695 } 696 697 /** 698 * This function should be used to insert nodes in lists when nodes don't carry separators as the part of the node range, 699 * i.e. arguments in arguments lists, parameters in parameter lists etc. 700 * Note that separators are part of the node in statements and class elements. 701 */ 702 public insertNodeInListAfter(sourceFile: SourceFile, after: Node, newNode: Node, containingList = formatting.SmartIndenter.getContainingList(after, sourceFile)): void { 703 if (!containingList) { 704 Debug.fail("node is not a list element"); 705 return; 706 } 707 const index = indexOfNode(containingList, after); 708 if (index < 0) { 709 return; 710 } 711 const end = after.getEnd(); 712 if (index !== containingList.length - 1) { 713 // any element except the last one 714 // use next sibling as an anchor 715 const nextToken = getTokenAtPosition(sourceFile, after.end); 716 if (nextToken && isSeparator(after, nextToken)) { 717 // for list 718 // a, b, c 719 // create change for adding 'e' after 'a' as 720 // - find start of next element after a (it is b) 721 // - use this start as start and end position in final change 722 // - build text of change by formatting the text of node + separator + whitespace trivia of b 723 724 // in multiline case it will work as 725 // a, 726 // b, 727 // c, 728 // result - '*' denotes leading trivia that will be inserted after new text (displayed as '#') 729 // a,* 730 // ***insertedtext<separator># 731 // ###b, 732 // c, 733 // find line and character of the next element 734 const lineAndCharOfNextElement = getLineAndCharacterOfPosition(sourceFile, skipWhitespacesAndLineBreaks(sourceFile.text, containingList[index + 1].getFullStart())); 735 // find line and character of the token that precedes next element (usually it is separator) 736 const lineAndCharOfNextToken = getLineAndCharacterOfPosition(sourceFile, nextToken.end); 737 let prefix: string | undefined; 738 let startPos: number; 739 if (lineAndCharOfNextToken.line === lineAndCharOfNextElement.line) { 740 // next element is located on the same line with separator: 741 // a,$$$$b 742 // ^ ^ 743 // | |-next element 744 // |-separator 745 // where $$$ is some leading trivia 746 // for a newly inserted node we'll maintain the same relative position comparing to separator and replace leading trivia with spaces 747 // a, x,$$$$b 748 // ^ ^ ^ 749 // | | |-next element 750 // | |-new inserted node padded with spaces 751 // |-separator 752 startPos = nextToken.end; 753 prefix = spaces(lineAndCharOfNextElement.character - lineAndCharOfNextToken.character); 754 } 755 else { 756 // next element is located on different line that separator 757 // let insert position be the beginning of the line that contains next element 758 startPos = getStartPositionOfLine(lineAndCharOfNextElement.line, sourceFile); 759 } 760 761 // write separator and leading trivia of the next element as suffix 762 const suffix = `${tokenToString(nextToken.kind)}${sourceFile.text.substring(nextToken.end, containingList[index + 1].getStart(sourceFile))}`; 763 this.replaceRange(sourceFile, createRange(startPos, containingList[index + 1].getStart(sourceFile)), newNode, { prefix, suffix }); 764 } 765 } 766 else { 767 const afterStart = after.getStart(sourceFile); 768 const afterStartLinePosition = getLineStartPositionForPosition(afterStart, sourceFile); 769 770 let separator: SyntaxKind.CommaToken | SyntaxKind.SemicolonToken | undefined; 771 let multilineList = false; 772 773 // insert element after the last element in the list that has more than one item 774 // pick the element preceding the after element to: 775 // - pick the separator 776 // - determine if list is a multiline 777 if (containingList.length === 1) { 778 // if list has only one element then we'll format is as multiline if node has comment in trailing trivia, or as singleline otherwise 779 // i.e. var x = 1 // this is x 780 // | new element will be inserted at this position 781 separator = SyntaxKind.CommaToken; 782 } 783 else { 784 // element has more than one element, pick separator from the list 785 const tokenBeforeInsertPosition = findPrecedingToken(after.pos, sourceFile); 786 separator = isSeparator(after, tokenBeforeInsertPosition) ? tokenBeforeInsertPosition.kind : SyntaxKind.CommaToken; 787 // determine if list is multiline by checking lines of after element and element that precedes it. 788 const afterMinusOneStartLinePosition = getLineStartPositionForPosition(containingList[index - 1].getStart(sourceFile), sourceFile); 789 multilineList = afterMinusOneStartLinePosition !== afterStartLinePosition; 790 } 791 if (hasCommentsBeforeLineBreak(sourceFile.text, after.end)) { 792 // in this case we'll always treat containing list as multiline 793 multilineList = true; 794 } 795 if (multilineList) { 796 // insert separator immediately following the 'after' node to preserve comments in trailing trivia 797 this.replaceRange(sourceFile, createRange(end), factory.createToken(separator)); 798 // use the same indentation as 'after' item 799 const indentation = formatting.SmartIndenter.findFirstNonWhitespaceColumn(afterStartLinePosition, afterStart, sourceFile, this.formatContext.options); 800 // insert element before the line break on the line that contains 'after' element 801 let insertPos = skipTrivia(sourceFile.text, end, /*stopAfterLineBreak*/ true, /*stopAtComments*/ false); 802 // find position before "\n" or "\r\n" 803 while (insertPos !== end && isLineBreak(sourceFile.text.charCodeAt(insertPos - 1))) { 804 insertPos--; 805 } 806 this.replaceRange(sourceFile, createRange(insertPos), newNode, { indentation, prefix: this.newLineCharacter }); 807 } 808 else { 809 this.replaceRange(sourceFile, createRange(end), newNode, { prefix: `${tokenToString(separator)} ` }); 810 } 811 } 812 } 813 814 public parenthesizeExpression(sourceFile: SourceFile, expression: Expression) { 815 this.replaceRange(sourceFile, rangeOfNode(expression), factory.createParenthesizedExpression(expression)); 816 } 817 818 private finishClassesWithNodesInsertedAtStart(): void { 819 this.classesWithNodesInsertedAtStart.forEach(({ node, sourceFile }) => { 820 const [openBraceEnd, closeBraceEnd] = getClassOrObjectBraceEnds(node, sourceFile); 821 if (openBraceEnd !== undefined && closeBraceEnd !== undefined) { 822 const isEmpty = getMembersOrProperties(node).length === 0; 823 const isSingleLine = positionsAreOnSameLine(openBraceEnd, closeBraceEnd, sourceFile); 824 if (isEmpty && isSingleLine && openBraceEnd !== closeBraceEnd - 1) { 825 // For `class C { }` remove the whitespace inside the braces. 826 this.deleteRange(sourceFile, createRange(openBraceEnd, closeBraceEnd - 1)); 827 } 828 if (isSingleLine) { 829 this.insertText(sourceFile, closeBraceEnd - 1, this.newLineCharacter); 830 } 831 } 832 }); 833 } 834 835 private finishDeleteDeclarations(): void { 836 const deletedNodesInLists = new Set<Node>(); // Stores nodes in lists that we already deleted. Used to avoid deleting `, ` twice in `a, b`. 837 for (const { sourceFile, node } of this.deletedNodes) { 838 if (!this.deletedNodes.some(d => d.sourceFile === sourceFile && rangeContainsRangeExclusive(d.node, node))) { 839 if (isArray(node)) { 840 this.deleteRange(sourceFile, rangeOfTypeParameters(sourceFile, node)); 841 } 842 else { 843 deleteDeclaration.deleteDeclaration(this, deletedNodesInLists, sourceFile, node); 844 } 845 } 846 } 847 848 deletedNodesInLists.forEach(node => { 849 const sourceFile = node.getSourceFile(); 850 const list = formatting.SmartIndenter.getContainingList(node, sourceFile)!; 851 if (node !== last(list)) return; 852 853 const lastNonDeletedIndex = findLastIndex(list, n => !deletedNodesInLists.has(n), list.length - 2); 854 if (lastNonDeletedIndex !== -1) { 855 this.deleteRange(sourceFile, { pos: list[lastNonDeletedIndex].end, end: startPositionToDeleteNodeInList(sourceFile, list[lastNonDeletedIndex + 1]) }); 856 } 857 }); 858 } 859 860 /** 861 * Note: after calling this, the TextChanges object must be discarded! 862 * @param validate only for tests 863 * The reason we must validate as part of this method is that `getNonFormattedText` changes the node's positions, 864 * so we can only call this once and can't get the non-formatted text separately. 865 */ 866 public getChanges(validate?: ValidateNonFormattedText): FileTextChanges[] { 867 this.finishDeleteDeclarations(); 868 this.finishClassesWithNodesInsertedAtStart(); 869 const changes = changesToText.getTextChangesFromChanges(this.changes, this.newLineCharacter, this.formatContext, validate); 870 for (const { oldFile, fileName, statements } of this.newFiles) { 871 changes.push(changesToText.newFileChanges(oldFile, fileName, statements, this.newLineCharacter, this.formatContext)); 872 } 873 return changes; 874 } 875 876 public createNewFile(oldFile: SourceFile | undefined, fileName: string, statements: readonly (Statement | SyntaxKind.NewLineTrivia)[]): void { 877 this.newFiles.push({ oldFile, fileName, statements }); 878 } 879 } 880 881 // find first non-whitespace position in the leading trivia of the node 882 function startPositionToDeleteNodeInList(sourceFile: SourceFile, node: Node): number { 883 return skipTrivia(sourceFile.text, getAdjustedStartPosition(sourceFile, node, { leadingTriviaOption: LeadingTriviaOption.IncludeAll }), /*stopAfterLineBreak*/ false, /*stopAtComments*/ true); 884 } 885 886 function getClassOrObjectBraceEnds(cls: ClassLikeDeclaration | InterfaceDeclaration | ObjectLiteralExpression, sourceFile: SourceFile): [number | undefined, number | undefined] { 887 const open = findChildOfKind(cls, SyntaxKind.OpenBraceToken, sourceFile); 888 const close = findChildOfKind(cls, SyntaxKind.CloseBraceToken, sourceFile); 889 return [open?.end, close?.end]; 890 } 891 function getMembersOrProperties(cls: ClassLikeDeclaration | InterfaceDeclaration | ObjectLiteralExpression): NodeArray<Node> { 892 return isObjectLiteralExpression(cls) ? cls.properties : cls.members; 893 } 894 895 export type ValidateNonFormattedText = (node: Node, text: string) => void; 896 897 export function getNewFileText(statements: readonly Statement[], scriptKind: ScriptKind, newLineCharacter: string, formatContext: formatting.FormatContext): string { 898 return changesToText.newFileChangesWorker(/*oldFile*/ undefined, scriptKind, statements, newLineCharacter, formatContext); 899 } 900 901 namespace changesToText { 902 export function getTextChangesFromChanges(changes: readonly Change[], newLineCharacter: string, formatContext: formatting.FormatContext, validate: ValidateNonFormattedText | undefined): FileTextChanges[] { 903 return mapDefined(group(changes, c => c.sourceFile.path), changesInFile => { 904 const sourceFile = changesInFile[0].sourceFile; 905 // order changes by start position 906 // If the start position is the same, put the shorter range first, since an empty range (x, x) may precede (x, y) but not vice-versa. 907 const normalized = stableSort(changesInFile, (a, b) => (a.range.pos - b.range.pos) || (a.range.end - b.range.end)); 908 // verify that change intervals do not overlap, except possibly at end points. 909 for (let i = 0; i < normalized.length - 1; i++) { 910 Debug.assert(normalized[i].range.end <= normalized[i + 1].range.pos, "Changes overlap", () => 911 `${JSON.stringify(normalized[i].range)} and ${JSON.stringify(normalized[i + 1].range)}`); 912 } 913 914 const textChanges = mapDefined(normalized, c => { 915 const span = createTextSpanFromRange(c.range); 916 const newText = computeNewText(c, sourceFile, newLineCharacter, formatContext, validate); 917 918 // Filter out redundant changes. 919 if (span.length === newText.length && stringContainsAt(sourceFile.text, newText, span.start)) { 920 return undefined; 921 } 922 923 return createTextChange(span, newText); 924 }); 925 926 return textChanges.length > 0 ? { fileName: sourceFile.fileName, textChanges } : undefined; 927 }); 928 } 929 930 export function newFileChanges(oldFile: SourceFile | undefined, fileName: string, statements: readonly (Statement | SyntaxKind.NewLineTrivia)[], newLineCharacter: string, formatContext: formatting.FormatContext): FileTextChanges { 931 const text = newFileChangesWorker(oldFile, getScriptKindFromFileName(fileName), statements, newLineCharacter, formatContext); 932 return { fileName, textChanges: [createTextChange(createTextSpan(0, 0), text)], isNewFile: true }; 933 } 934 935 export function newFileChangesWorker(oldFile: SourceFile | undefined, scriptKind: ScriptKind, statements: readonly (Statement | SyntaxKind.NewLineTrivia)[], newLineCharacter: string, formatContext: formatting.FormatContext): string { 936 // TODO: this emits the file, parses it back, then formats it that -- may be a less roundabout way to do this 937 const nonFormattedText = statements.map(s => s === SyntaxKind.NewLineTrivia ? "" : getNonformattedText(s, oldFile, newLineCharacter).text).join(newLineCharacter); 938 const sourceFile = createSourceFile("any file name", nonFormattedText, ScriptTarget.ESNext, /*setParentNodes*/ true, scriptKind); 939 const changes = formatting.formatDocument(sourceFile, formatContext); 940 return applyChanges(nonFormattedText, changes) + newLineCharacter; 941 } 942 943 function computeNewText(change: Change, sourceFile: SourceFile, newLineCharacter: string, formatContext: formatting.FormatContext, validate: ValidateNonFormattedText | undefined): string { 944 if (change.kind === ChangeKind.Remove) { 945 return ""; 946 } 947 if (change.kind === ChangeKind.Text) { 948 return change.text; 949 } 950 951 const { options = {}, range: { pos } } = change; 952 const format = (n: Node) => getFormattedTextOfNode(n, sourceFile, pos, options, newLineCharacter, formatContext, validate); 953 const text = change.kind === ChangeKind.ReplaceWithMultipleNodes 954 ? change.nodes.map(n => removeSuffix(format(n), newLineCharacter)).join(change.options!.joiner || newLineCharacter) // TODO: GH#18217 955 : format(change.node); 956 // strip initial indentation (spaces or tabs) if text will be inserted in the middle of the line 957 const noIndent = (options.preserveLeadingWhitespace || options.indentation !== undefined || getLineStartPositionForPosition(pos, sourceFile) === pos) ? text : text.replace(/^\s+/, ""); 958 return (options.prefix || "") + noIndent 959 + ((!options.suffix || endsWith(noIndent, options.suffix)) 960 ? "" : options.suffix); 961 } 962 963 function getFormatCodeSettingsForWriting({ options }: formatting.FormatContext, sourceFile: SourceFile): FormatCodeSettings { 964 const shouldAutoDetectSemicolonPreference = !options.semicolons || options.semicolons === SemicolonPreference.Ignore; 965 const shouldRemoveSemicolons = options.semicolons === SemicolonPreference.Remove || shouldAutoDetectSemicolonPreference && !probablyUsesSemicolons(sourceFile); 966 return { 967 ...options, 968 semicolons: shouldRemoveSemicolons ? SemicolonPreference.Remove : SemicolonPreference.Ignore, 969 }; 970 } 971 972 /** Note: this may mutate `nodeIn`. */ 973 function getFormattedTextOfNode(nodeIn: Node, sourceFile: SourceFile, pos: number, { indentation, prefix, delta }: InsertNodeOptions, newLineCharacter: string, formatContext: formatting.FormatContext, validate: ValidateNonFormattedText | undefined): string { 974 const { node, text } = getNonformattedText(nodeIn, sourceFile, newLineCharacter); 975 if (validate) validate(node, text); 976 const formatOptions = getFormatCodeSettingsForWriting(formatContext, sourceFile); 977 const initialIndentation = 978 indentation !== undefined 979 ? indentation 980 : formatting.SmartIndenter.getIndentation(pos, sourceFile, formatOptions, prefix === newLineCharacter || getLineStartPositionForPosition(pos, sourceFile) === pos); 981 if (delta === undefined) { 982 delta = formatting.SmartIndenter.shouldIndentChildNode(formatOptions, nodeIn) ? (formatOptions.indentSize || 0) : 0; 983 } 984 985 const file: SourceFileLike = { text, getLineAndCharacterOfPosition(pos) { return getLineAndCharacterOfPosition(this, pos); } }; 986 const changes = formatting.formatNodeGivenIndentation(node, file, sourceFile.languageVariant, initialIndentation, delta, { ...formatContext, options: formatOptions }); 987 return applyChanges(text, changes); 988 } 989 990 /** Note: output node may be mutated input node. */ 991 export function getNonformattedText(node: Node, sourceFile: SourceFile | undefined, newLineCharacter: string): { text: string, node: Node } { 992 const writer = createWriter(newLineCharacter); 993 const newLine = newLineCharacter === "\n" ? NewLineKind.LineFeed : NewLineKind.CarriageReturnLineFeed; 994 createPrinter({ 995 newLine, 996 neverAsciiEscape: true, 997 preserveSourceNewlines: true, 998 terminateUnterminatedLiterals: true 999 }, writer).writeNode(EmitHint.Unspecified, node, sourceFile, writer); 1000 return { text: writer.getText(), node: assignPositionsToNode(node) }; 1001 } 1002 } 1003 1004 export function applyChanges(text: string, changes: readonly TextChange[]): string { 1005 for (let i = changes.length - 1; i >= 0; i--) { 1006 const { span, newText } = changes[i]; 1007 text = `${text.substring(0, span.start)}${newText}${text.substring(textSpanEnd(span))}`; 1008 } 1009 return text; 1010 } 1011 1012 function isTrivia(s: string) { 1013 return skipTrivia(s, 0) === s.length; 1014 } 1015 1016 function assignPositionsToNode(node: Node): Node { 1017 const visited = visitEachChild(node, assignPositionsToNode, nullTransformationContext, assignPositionsToNodeArray, assignPositionsToNode)!; // TODO: GH#18217 1018 // create proxy node for non synthesized nodes 1019 const newNode = nodeIsSynthesized(visited) ? visited : Object.create(visited) as Node; 1020 setTextRangePosEnd(newNode, getPos(node), getEnd(node)); 1021 return newNode; 1022 } 1023 1024 function assignPositionsToNodeArray(nodes: NodeArray<any>, visitor: Visitor, test?: (node: Node) => boolean, start?: number, count?: number) { 1025 const visited = visitNodes(nodes, visitor, test, start, count); 1026 if (!visited) { 1027 return visited; 1028 } 1029 // clone nodearray if necessary 1030 const nodeArray = visited === nodes ? factory.createNodeArray(visited.slice(0)) : visited; 1031 setTextRangePosEnd(nodeArray, getPos(nodes), getEnd(nodes)); 1032 return nodeArray; 1033 } 1034 1035 interface TextChangesWriter extends EmitTextWriter, PrintHandlers {} 1036 1037 function createWriter(newLine: string): TextChangesWriter { 1038 let lastNonTriviaPosition = 0; 1039 1040 const writer = createTextWriter(newLine); 1041 const onEmitNode: PrintHandlers["onEmitNode"] = (hint, node, printCallback) => { 1042 if (node) { 1043 setPos(node, lastNonTriviaPosition); 1044 } 1045 printCallback(hint, node); 1046 if (node) { 1047 setEnd(node, lastNonTriviaPosition); 1048 } 1049 }; 1050 const onBeforeEmitNodeArray: PrintHandlers["onBeforeEmitNodeArray"] = nodes => { 1051 if (nodes) { 1052 setPos(nodes, lastNonTriviaPosition); 1053 } 1054 }; 1055 const onAfterEmitNodeArray: PrintHandlers["onAfterEmitNodeArray"] = nodes => { 1056 if (nodes) { 1057 setEnd(nodes, lastNonTriviaPosition); 1058 } 1059 }; 1060 const onBeforeEmitToken: PrintHandlers["onBeforeEmitToken"] = node => { 1061 if (node) { 1062 setPos(node, lastNonTriviaPosition); 1063 } 1064 }; 1065 const onAfterEmitToken: PrintHandlers["onAfterEmitToken"] = node => { 1066 if (node) { 1067 setEnd(node, lastNonTriviaPosition); 1068 } 1069 }; 1070 1071 function setLastNonTriviaPosition(s: string, force: boolean) { 1072 if (force || !isTrivia(s)) { 1073 lastNonTriviaPosition = writer.getTextPos(); 1074 let i = 0; 1075 while (isWhiteSpaceLike(s.charCodeAt(s.length - i - 1))) { 1076 i++; 1077 } 1078 // trim trailing whitespaces 1079 lastNonTriviaPosition -= i; 1080 } 1081 } 1082 1083 function write(s: string): void { 1084 writer.write(s); 1085 setLastNonTriviaPosition(s, /*force*/ false); 1086 } 1087 function writeComment(s: string): void { 1088 writer.writeComment(s); 1089 } 1090 function writeKeyword(s: string): void { 1091 writer.writeKeyword(s); 1092 setLastNonTriviaPosition(s, /*force*/ false); 1093 } 1094 function writeOperator(s: string): void { 1095 writer.writeOperator(s); 1096 setLastNonTriviaPosition(s, /*force*/ false); 1097 } 1098 function writePunctuation(s: string): void { 1099 writer.writePunctuation(s); 1100 setLastNonTriviaPosition(s, /*force*/ false); 1101 } 1102 function writeTrailingSemicolon(s: string): void { 1103 writer.writeTrailingSemicolon(s); 1104 setLastNonTriviaPosition(s, /*force*/ false); 1105 } 1106 function writeParameter(s: string): void { 1107 writer.writeParameter(s); 1108 setLastNonTriviaPosition(s, /*force*/ false); 1109 } 1110 function writeProperty(s: string): void { 1111 writer.writeProperty(s); 1112 setLastNonTriviaPosition(s, /*force*/ false); 1113 } 1114 function writeSpace(s: string): void { 1115 writer.writeSpace(s); 1116 setLastNonTriviaPosition(s, /*force*/ false); 1117 } 1118 function writeStringLiteral(s: string): void { 1119 writer.writeStringLiteral(s); 1120 setLastNonTriviaPosition(s, /*force*/ false); 1121 } 1122 function writeSymbol(s: string, sym: Symbol): void { 1123 writer.writeSymbol(s, sym); 1124 setLastNonTriviaPosition(s, /*force*/ false); 1125 } 1126 function writeLine(force?: boolean): void { 1127 writer.writeLine(force); 1128 } 1129 function increaseIndent(): void { 1130 writer.increaseIndent(); 1131 } 1132 function decreaseIndent(): void { 1133 writer.decreaseIndent(); 1134 } 1135 function getText(): string { 1136 return writer.getText(); 1137 } 1138 function rawWrite(s: string): void { 1139 writer.rawWrite(s); 1140 setLastNonTriviaPosition(s, /*force*/ false); 1141 } 1142 function writeLiteral(s: string): void { 1143 writer.writeLiteral(s); 1144 setLastNonTriviaPosition(s, /*force*/ true); 1145 } 1146 function getTextPos(): number { 1147 return writer.getTextPos(); 1148 } 1149 function getLine(): number { 1150 return writer.getLine(); 1151 } 1152 function getColumn(): number { 1153 return writer.getColumn(); 1154 } 1155 function getIndent(): number { 1156 return writer.getIndent(); 1157 } 1158 function isAtStartOfLine(): boolean { 1159 return writer.isAtStartOfLine(); 1160 } 1161 function clear(): void { 1162 writer.clear(); 1163 lastNonTriviaPosition = 0; 1164 } 1165 1166 return { 1167 onEmitNode, 1168 onBeforeEmitNodeArray, 1169 onAfterEmitNodeArray, 1170 onBeforeEmitToken, 1171 onAfterEmitToken, 1172 write, 1173 writeComment, 1174 writeKeyword, 1175 writeOperator, 1176 writePunctuation, 1177 writeTrailingSemicolon, 1178 writeParameter, 1179 writeProperty, 1180 writeSpace, 1181 writeStringLiteral, 1182 writeSymbol, 1183 writeLine, 1184 increaseIndent, 1185 decreaseIndent, 1186 getText, 1187 rawWrite, 1188 writeLiteral, 1189 getTextPos, 1190 getLine, 1191 getColumn, 1192 getIndent, 1193 isAtStartOfLine, 1194 hasTrailingComment: () => writer.hasTrailingComment(), 1195 hasTrailingWhitespace: () => writer.hasTrailingWhitespace(), 1196 clear 1197 }; 1198 } 1199 1200 function getInsertionPositionAtSourceFileTop(sourceFile: SourceFile): number { 1201 let lastPrologue: PrologueDirective | undefined; 1202 for (const node of sourceFile.statements) { 1203 if (isPrologueDirective(node)) { 1204 lastPrologue = node; 1205 } 1206 else { 1207 break; 1208 } 1209 } 1210 1211 let position = 0; 1212 const text = sourceFile.text; 1213 if (lastPrologue) { 1214 position = lastPrologue.end; 1215 advancePastLineBreak(); 1216 return position; 1217 } 1218 1219 const shebang = getShebang(text); 1220 if (shebang !== undefined) { 1221 position = shebang.length; 1222 advancePastLineBreak(); 1223 } 1224 1225 const ranges = getLeadingCommentRanges(text, position); 1226 if (!ranges) return position; 1227 1228 // Find the first attached comment to the first node and add before it 1229 let lastComment: { range: CommentRange; pinnedOrTripleSlash: boolean; } | undefined; 1230 let firstNodeLine: number | undefined; 1231 for (const range of ranges) { 1232 if (range.kind === SyntaxKind.MultiLineCommentTrivia) { 1233 if (isPinnedComment(text, range.pos)) { 1234 lastComment = { range, pinnedOrTripleSlash: true }; 1235 continue; 1236 } 1237 } 1238 else if (isRecognizedTripleSlashComment(text, range.pos, range.end)) { 1239 lastComment = { range, pinnedOrTripleSlash: true }; 1240 continue; 1241 } 1242 1243 if (lastComment) { 1244 // Always insert after pinned or triple slash comments 1245 if (lastComment.pinnedOrTripleSlash) break; 1246 1247 // There was a blank line between the last comment and this comment. 1248 // This comment is not part of the copyright comments 1249 const commentLine = sourceFile.getLineAndCharacterOfPosition(range.pos).line; 1250 const lastCommentEndLine = sourceFile.getLineAndCharacterOfPosition(lastComment.range.end).line; 1251 if (commentLine >= lastCommentEndLine + 2) break; 1252 } 1253 1254 if (sourceFile.statements.length) { 1255 if (firstNodeLine === undefined) firstNodeLine = sourceFile.getLineAndCharacterOfPosition(sourceFile.statements[0].getStart()).line; 1256 const commentEndLine = sourceFile.getLineAndCharacterOfPosition(range.end).line; 1257 if (firstNodeLine < commentEndLine + 2) break; 1258 } 1259 lastComment = { range, pinnedOrTripleSlash: false }; 1260 } 1261 1262 if (lastComment) { 1263 position = lastComment.range.end; 1264 advancePastLineBreak(); 1265 } 1266 return position; 1267 1268 function advancePastLineBreak() { 1269 if (position < text.length) { 1270 const charCode = text.charCodeAt(position); 1271 if (isLineBreak(charCode)) { 1272 position++; 1273 1274 if (position < text.length && charCode === CharacterCodes.carriageReturn && text.charCodeAt(position) === CharacterCodes.lineFeed) { 1275 position++; 1276 } 1277 } 1278 } 1279 } 1280 } 1281 1282 export function isValidLocationToAddComment(sourceFile: SourceFile, position: number) { 1283 return !isInComment(sourceFile, position) && !isInString(sourceFile, position) && !isInTemplateString(sourceFile, position) && !isInJSXText(sourceFile, position); 1284 } 1285 1286 function needSemicolonBetween(a: Node, b: Node): boolean { 1287 return (isPropertySignature(a) || isPropertyDeclaration(a)) && isClassOrTypeElement(b) && b.name!.kind === SyntaxKind.ComputedPropertyName 1288 || isStatementButNotDeclaration(a) && isStatementButNotDeclaration(b); // TODO: only if b would start with a `(` or `[` 1289 } 1290 1291 namespace deleteDeclaration { 1292 export function deleteDeclaration(changes: ChangeTracker, deletedNodesInLists: Set<Node>, sourceFile: SourceFile, node: Node): void { 1293 switch (node.kind) { 1294 case SyntaxKind.Parameter: { 1295 const oldFunction = node.parent; 1296 if (isArrowFunction(oldFunction) && 1297 oldFunction.parameters.length === 1 && 1298 !findChildOfKind(oldFunction, SyntaxKind.OpenParenToken, sourceFile)) { 1299 // Lambdas with exactly one parameter are special because, after removal, there 1300 // must be an empty parameter list (i.e. `()`) and this won't necessarily be the 1301 // case if the parameter is simply removed (e.g. in `x => 1`). 1302 changes.replaceNodeWithText(sourceFile, node, "()"); 1303 } 1304 else { 1305 deleteNodeInList(changes, deletedNodesInLists, sourceFile, node); 1306 } 1307 break; 1308 } 1309 1310 case SyntaxKind.ImportDeclaration: 1311 case SyntaxKind.ImportEqualsDeclaration: 1312 const isFirstImport = sourceFile.imports.length && node === first(sourceFile.imports).parent || node === find(sourceFile.statements, isAnyImportSyntax); 1313 // For first import, leave header comment in place, otherwise only delete JSDoc comments 1314 deleteNode(changes, sourceFile, node, 1315 { leadingTriviaOption: isFirstImport ? LeadingTriviaOption.Exclude : hasJSDocNodes(node) ? LeadingTriviaOption.JSDoc : LeadingTriviaOption.StartLine }); 1316 break; 1317 1318 case SyntaxKind.BindingElement: 1319 const pattern = (node as BindingElement).parent; 1320 const preserveComma = pattern.kind === SyntaxKind.ArrayBindingPattern && node !== last(pattern.elements); 1321 if (preserveComma) { 1322 deleteNode(changes, sourceFile, node); 1323 } 1324 else { 1325 deleteNodeInList(changes, deletedNodesInLists, sourceFile, node); 1326 } 1327 break; 1328 1329 case SyntaxKind.VariableDeclaration: 1330 deleteVariableDeclaration(changes, deletedNodesInLists, sourceFile, node as VariableDeclaration); 1331 break; 1332 1333 case SyntaxKind.TypeParameter: 1334 deleteNodeInList(changes, deletedNodesInLists, sourceFile, node); 1335 break; 1336 1337 case SyntaxKind.ImportSpecifier: 1338 const namedImports = (node as ImportSpecifier).parent; 1339 if (namedImports.elements.length === 1) { 1340 deleteImportBinding(changes, sourceFile, namedImports); 1341 } 1342 else { 1343 deleteNodeInList(changes, deletedNodesInLists, sourceFile, node); 1344 } 1345 break; 1346 1347 case SyntaxKind.NamespaceImport: 1348 deleteImportBinding(changes, sourceFile, node as NamespaceImport); 1349 break; 1350 1351 case SyntaxKind.SemicolonToken: 1352 deleteNode(changes, sourceFile, node, { trailingTriviaOption: TrailingTriviaOption.Exclude }); 1353 break; 1354 1355 case SyntaxKind.FunctionKeyword: 1356 deleteNode(changes, sourceFile, node, { leadingTriviaOption: LeadingTriviaOption.Exclude }); 1357 break; 1358 1359 case SyntaxKind.ClassDeclaration: 1360 case SyntaxKind.StructDeclaration: 1361 case SyntaxKind.FunctionDeclaration: 1362 deleteNode(changes, sourceFile, node, { leadingTriviaOption: hasJSDocNodes(node) ? LeadingTriviaOption.JSDoc : LeadingTriviaOption.StartLine }); 1363 break; 1364 1365 default: 1366 if (isImportClause(node.parent) && node.parent.name === node) { 1367 deleteDefaultImport(changes, sourceFile, node.parent); 1368 } 1369 else if (isCallExpression(node.parent) && contains(node.parent.arguments, node)) { 1370 deleteNodeInList(changes, deletedNodesInLists, sourceFile, node); 1371 } 1372 else { 1373 deleteNode(changes, sourceFile, node); 1374 } 1375 } 1376 } 1377 1378 function deleteDefaultImport(changes: ChangeTracker, sourceFile: SourceFile, importClause: ImportClause): void { 1379 if (!importClause.namedBindings) { 1380 // Delete the whole import 1381 deleteNode(changes, sourceFile, importClause.parent); 1382 } 1383 else { 1384 // import |d,| * as ns from './file' 1385 const start = importClause.name!.getStart(sourceFile); 1386 const nextToken = getTokenAtPosition(sourceFile, importClause.name!.end); 1387 if (nextToken && nextToken.kind === SyntaxKind.CommaToken) { 1388 // shift first non-whitespace position after comma to the start position of the node 1389 const end = skipTrivia(sourceFile.text, nextToken.end, /*stopAfterLineBreaks*/ false, /*stopAtComments*/ true); 1390 changes.deleteRange(sourceFile, { pos: start, end }); 1391 } 1392 else { 1393 deleteNode(changes, sourceFile, importClause.name!); 1394 } 1395 } 1396 } 1397 1398 function deleteImportBinding(changes: ChangeTracker, sourceFile: SourceFile, node: NamedImportBindings): void { 1399 if (node.parent.name) { 1400 // Delete named imports while preserving the default import 1401 // import d|, * as ns| from './file' 1402 // import d|, { a }| from './file' 1403 const previousToken = Debug.checkDefined(getTokenAtPosition(sourceFile, node.pos - 1)); 1404 changes.deleteRange(sourceFile, { pos: previousToken.getStart(sourceFile), end: node.end }); 1405 } 1406 else { 1407 // Delete the entire import declaration 1408 // |import * as ns from './file'| 1409 // |import { a } from './file'| 1410 const importDecl = getAncestor(node, SyntaxKind.ImportDeclaration)!; 1411 deleteNode(changes, sourceFile, importDecl); 1412 } 1413 } 1414 1415 function deleteVariableDeclaration(changes: ChangeTracker, deletedNodesInLists: Set<Node>, sourceFile: SourceFile, node: VariableDeclaration): void { 1416 const { parent } = node; 1417 1418 if (parent.kind === SyntaxKind.CatchClause) { 1419 // TODO: There's currently no unused diagnostic for this, could be a suggestion 1420 changes.deleteNodeRange(sourceFile, findChildOfKind(parent, SyntaxKind.OpenParenToken, sourceFile)!, findChildOfKind(parent, SyntaxKind.CloseParenToken, sourceFile)!); 1421 return; 1422 } 1423 1424 if (parent.declarations.length !== 1) { 1425 deleteNodeInList(changes, deletedNodesInLists, sourceFile, node); 1426 return; 1427 } 1428 1429 const gp = parent.parent; 1430 switch (gp.kind) { 1431 case SyntaxKind.ForOfStatement: 1432 case SyntaxKind.ForInStatement: 1433 changes.replaceNode(sourceFile, node, factory.createObjectLiteralExpression()); 1434 break; 1435 1436 case SyntaxKind.ForStatement: 1437 deleteNode(changes, sourceFile, parent); 1438 break; 1439 1440 case SyntaxKind.VariableStatement: 1441 deleteNode(changes, sourceFile, gp, { leadingTriviaOption: hasJSDocNodes(gp) ? LeadingTriviaOption.JSDoc : LeadingTriviaOption.StartLine }); 1442 break; 1443 1444 default: 1445 Debug.assertNever(gp); 1446 } 1447 } 1448 } 1449 1450 /** Warning: This deletes comments too. See `copyComments` in `convertFunctionToEs6Class`. */ 1451 // Exported for tests only! (TODO: improve tests to not need this) 1452 export function deleteNode(changes: ChangeTracker, sourceFile: SourceFile, node: Node, options: ConfigurableStartEnd = { leadingTriviaOption: LeadingTriviaOption.IncludeAll }): void { 1453 const startPosition = getAdjustedStartPosition(sourceFile, node, options); 1454 const endPosition = getAdjustedEndPosition(sourceFile, node, options); 1455 changes.deleteRange(sourceFile, { pos: startPosition, end: endPosition }); 1456 } 1457 1458 function deleteNodeInList(changes: ChangeTracker, deletedNodesInLists: Set<Node>, sourceFile: SourceFile, node: Node): void { 1459 const containingList = Debug.checkDefined(formatting.SmartIndenter.getContainingList(node, sourceFile)); 1460 const index = indexOfNode(containingList, node); 1461 Debug.assert(index !== -1); 1462 if (containingList.length === 1) { 1463 deleteNode(changes, sourceFile, node); 1464 return; 1465 } 1466 1467 // Note: We will only delete a comma *after* a node. This will leave a trailing comma if we delete the last node. 1468 // That's handled in the end by `finishTrailingCommaAfterDeletingNodesInList`. 1469 Debug.assert(!deletedNodesInLists.has(node), "Deleting a node twice"); 1470 deletedNodesInLists.add(node); 1471 changes.deleteRange(sourceFile, { 1472 pos: startPositionToDeleteNodeInList(sourceFile, node), 1473 end: index === containingList.length - 1 ? getAdjustedEndPosition(sourceFile, node, {}) : startPositionToDeleteNodeInList(sourceFile, containingList[index + 1]), 1474 }); 1475 } 1476} 1477