From 4e8198d60f421ce120e3a6b57afe60a9332d2692 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 1 Dec 2020 14:55:57 -0800 Subject: [PATCH] feat(language-service): Implement `getRenameInfo` (#40439) The `getRenameInfo` action is used by consumers to 1. Determine if a location is a candidate for renames 2. Determine what text to use as the starting point for the rename PR Close #40439 --- .../language-service/ivy/language_service.ts | 17 +++- packages/language-service/ivy/references.ts | 88 ++++++++++++++----- .../ivy/test/references_spec.ts | 75 ++++++++++++++++ packages/language-service/ivy/ts_plugin.ts | 7 ++ packages/language-service/ivy/utils.ts | 5 +- 5 files changed, 167 insertions(+), 25 deletions(-) diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index 1557a1ac8d..fc8f886822 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -8,7 +8,7 @@ import {AST, TmplAstBoundEvent, TmplAstNode} from '@angular/compiler'; import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli'; -import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript/lib/tsserverlibrary'; @@ -113,6 +113,21 @@ export class LanguageService { return results; } + getRenameInfo(fileName: string, position: number): ts.RenameInfo { + const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName); + const renameInfo = new ReferencesAndRenameBuilder(this.strategy, this.tsLS, compiler) + .getRenameInfo(absoluteFrom(fileName), position); + if (!renameInfo.canRename) { + return renameInfo; + } + + const quickInfo = this.getQuickInfoAtPosition(fileName, position) ?? + this.tsLS.getQuickInfoAtPosition(fileName, position); + const kind = quickInfo?.kind ?? ts.ScriptElementKind.unknown; + const kindModifiers = quickInfo?.kindModifiers ?? ts.ScriptElementKind.unknown; + return {...renameInfo, kind, kindModifiers}; + } + findRenameLocations(fileName: string, position: number): readonly ts.RenameLocation[]|undefined { const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName); const results = new ReferencesAndRenameBuilder(this.strategy, this.tsLS, compiler) diff --git a/packages/language-service/ivy/references.ts b/packages/language-service/ivy/references.ts index dfd76da857..4e71360764 100644 --- a/packages/language-service/ivy/references.ts +++ b/packages/language-service/ivy/references.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {AST, BindingPipe, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstNode, TmplAstReference, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; +import {AbsoluteSourceSpan, AST, BindingPipe, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstNode, TmplAstReference, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {DirectiveSymbol, ShimLocation, SymbolKind, TemplateTypeChecker, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; @@ -14,7 +14,7 @@ import * as ts from 'typescript'; import {getTargetAtPosition, TargetNodeKind} from './template_target'; import {findTightestNode} from './ts_utils'; -import {getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, isTemplateNode, isWithin, TemplateInfo, toTextSpan} from './utils'; +import {getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, isWithin, TemplateInfo, toTextSpan} from './utils'; interface FilePosition { fileName: string; @@ -64,10 +64,37 @@ export class ReferencesAndRenameBuilder { private readonly strategy: TypeCheckingProgramStrategy, private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} + getRenameInfo(filePath: string, position: number): + Omit|ts.RenameInfoFailure { + const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); + // We could not get a template at position so we assume the request came from outside the + // template. + if (templateInfo === undefined) { + return this.tsLS.getRenameInfo(filePath, position); + } + + const allTargetDetails = this.getTargetDetailsAtTemplatePosition(templateInfo, position); + if (allTargetDetails === null) { + return {canRename: false, localizedErrorMessage: 'Could not find template node at position.'}; + } + const {templateTarget} = allTargetDetails[0]; + const templateTextAndSpan = getRenameTextAndSpanAtPosition(templateTarget, position); + if (templateTextAndSpan === null) { + return {canRename: false, localizedErrorMessage: 'Could not determine template node text.'}; + } + const {text, span} = templateTextAndSpan; + return { + canRename: true, + displayName: text, + fullDisplayName: text, + triggerSpan: toTextSpan(span), + }; + } + findRenameLocations(filePath: string, position: number): readonly ts.RenameLocation[]|undefined { this.ttc.generateAllTypeCheckBlocks(); const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); - // We could not get a template at position so we assume the request is came from outside the + // We could not get a template at position so we assume the request came from outside the // template. if (templateInfo === undefined) { const requestNode = this.getTsNodeAtPosition(filePath, position); @@ -126,11 +153,11 @@ export class ReferencesAndRenameBuilder { originalNodeText = requestOrigin.requestNode.getText(); } else { const templateNodeText = - getTemplateNodeRenameTextAtPosition(requestOrigin.requestNode, requestOrigin.position); + getRenameTextAndSpanAtPosition(requestOrigin.requestNode, requestOrigin.position); if (templateNodeText === null) { return undefined; } - originalNodeText = templateNodeText; + originalNodeText = templateNodeText.text; } const locations = this.tsLS.findRenameLocations( @@ -207,11 +234,11 @@ export class ReferencesAndRenameBuilder { for (const node of nodes) { // Get the information about the TCB at the template position. const symbol = this.ttc.getSymbolOfNode(node, component); - const templateTarget = node; - if (symbol === null) { continue; } + + const templateTarget = node; switch (symbol.kind) { case SymbolKind.Directive: case SymbolKind.Template: @@ -233,13 +260,17 @@ export class ReferencesAndRenameBuilder { } const directives = getDirectiveMatchesForAttribute( node.name, symbol.host.templateNode, symbol.host.directives); - details.push( - {typescriptLocations: this.getPositionsForDirectives(directives), templateTarget}); + details.push({ + typescriptLocations: this.getPositionsForDirectives(directives), + templateTarget, + }); break; } case SymbolKind.Reference: { - details.push( - {typescriptLocations: [toFilePosition(symbol.referenceVarLocation)], templateTarget}); + details.push({ + typescriptLocations: [toFilePosition(symbol.referenceVarLocation)], + templateTarget, + }); break; } case SymbolKind.Variable: { @@ -253,14 +284,18 @@ export class ReferencesAndRenameBuilder { }); } else if (isWithin(position, templateTarget.keySpan)) { // In the keySpan of the variable, we want to get the reference of the local variable. - details.push( - {typescriptLocations: [toFilePosition(symbol.localVarLocation)], templateTarget}); + details.push({ + typescriptLocations: [toFilePosition(symbol.localVarLocation)], + templateTarget, + }); } } else { // If the templateNode is not the `TmplAstVariable`, it must be a usage of the // variable somewhere in the template. - details.push( - {typescriptLocations: [toFilePosition(symbol.localVarLocation)], templateTarget}); + details.push({ + typescriptLocations: [toFilePosition(symbol.localVarLocation)], + templateTarget, + }); } break; } @@ -374,15 +409,19 @@ export class ReferencesAndRenameBuilder { } } -function getTemplateNodeRenameTextAtPosition(node: TmplAstNode|AST, position: number): string|null { +function getRenameTextAndSpanAtPosition(node: TmplAstNode|AST, position: number): + {text: string, span: ParseSourceSpan|AbsoluteSourceSpan}|null { if (node instanceof TmplAstBoundAttribute || node instanceof TmplAstTextAttribute || node instanceof TmplAstBoundEvent) { - return node.name; + if (node.keySpan === undefined) { + return null; + } + return {text: node.name, span: node.keySpan}; } else if (node instanceof TmplAstVariable || node instanceof TmplAstReference) { if (isWithin(position, node.keySpan)) { - return node.keySpan.toString(); + return {text: node.keySpan.toString(), span: node.keySpan}; } else if (node.valueSpan && isWithin(position, node.valueSpan)) { - return node.valueSpan.toString(); + return {text: node.valueSpan.toString(), span: node.valueSpan}; } } @@ -392,9 +431,16 @@ function getTemplateNodeRenameTextAtPosition(node: TmplAstNode|AST, position: nu } if (node instanceof PropertyRead || node instanceof MethodCall || node instanceof PropertyWrite || node instanceof SafePropertyRead || node instanceof SafeMethodCall) { - return node.name; + return {text: node.name, span: node.nameSpan}; } else if (node instanceof LiteralPrimitive) { - return node.value; + const span = node.span; + const text = node.value; + if (typeof text === 'string') { + // The span of a string literal includes the quotes but they should be removed for renaming. + span.start += 1; + span.end -= 1; + } + return {text, span}; } return null; diff --git a/packages/language-service/ivy/test/references_spec.ts b/packages/language-service/ivy/test/references_spec.ts index 715f27087c..39cb45be21 100644 --- a/packages/language-service/ivy/test/references_spec.ts +++ b/packages/language-service/ivy/test/references_spec.ts @@ -1369,6 +1369,81 @@ describe('find references and rename locations', () => { }); }); + describe('get rename info', () => { + it('indicates inability to rename when cursor is outside template and in a string literal', + () => { + const {cursor, text} = extractCursorInfo(` + import {Component} from '@angular/core'; + + @Component({selector: 'my-comp', template: ''}) + export class MyComp { + myProp = 'cannot rena¦me me'; + }`); + env = createModuleWithDeclarations([{name: _('/my-comp.ts'), contents: text}]); + env.expectNoSourceDiagnostics(); + const result = env.ngLS.getRenameInfo(_('/my-comp.ts'), cursor); + expect(result.canRename).toEqual(false); + }); + + it('gets rename info when cursor is outside template', () => { + const {cursor, text} = extractCursorInfo(` + import {Component, Input} from '@angular/core'; + + @Component({name: 'my-comp', template: ''}) + export class MyComp { + @Input() m¦yProp!: string; + }`); + env = createModuleWithDeclarations([{name: _('/my-comp.ts'), contents: text}]); + env.expectNoSourceDiagnostics(); + const result = env.ngLS.getRenameInfo(_('/my-comp.ts'), cursor) as ts.RenameInfoSuccess; + expect(result.canRename).toEqual(true); + expect(result.displayName).toEqual('myProp'); + expect(result.kind).toEqual('property'); + }); + + it('gets rename info on keyed read', () => { + const {cursor, text} = extractCursorInfo(` + import {Component} from '@angular/core'; + + @Component({name: 'my-comp', template: '{{ myObj["my¦Prop"] }}'}) + export class MyComp { + readonly myObj = {'myProp': 'hello world'}; + }`); + env = createModuleWithDeclarations([{name: _('/my-comp.ts'), contents: text}]); + env.expectNoSourceDiagnostics(); + const result = env.ngLS.getRenameInfo(_('/my-comp.ts'), cursor) as ts.RenameInfoSuccess; + expect(result.canRename).toEqual(true); + expect(result.displayName).toEqual('myProp'); + expect(result.kind).toEqual('property'); + expect(result.triggerSpan.length).toEqual('myProp'.length); + }); + + it('gets rename info when cursor is on a directive input in a template', () => { + const dirFile = { + name: _('/dir.ts'), + contents: ` + import {Directive, Input} from '@angular/core'; + @Directive({selector: '[dir]'}) + export class MyDir { + @Input() dir!: any; + }` + }; + const {cursor, text} = extractCursorInfo(` + import {Component, Input} from '@angular/core'; + + @Component({name: 'my-comp', template: '
'}) + export class MyComp { + @Input() myProp!: string; + }`); + env = createModuleWithDeclarations([{name: _('/my-comp.ts'), contents: text}, dirFile]); + env.expectNoSourceDiagnostics(); + const result = env.ngLS.getRenameInfo(_('/my-comp.ts'), cursor) as ts.RenameInfoSuccess; + expect(result.canRename).toEqual(true); + expect(result.displayName).toEqual('dir'); + expect(result.kind).toEqual('property'); + }); + }); + function getReferencesAtPosition(fileName: string, position: number) { env.expectNoSourceDiagnostics(); const result = env.ngLS.getReferencesAtPosition(fileName, position); diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index 64a3b654b9..d39c0946fc 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -71,6 +71,12 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { return ngLS.findRenameLocations(fileName, position); } + function getRenameInfo(fileName: string, position: number): ts.RenameInfo { + // See the comment in `findRenameLocations` explaining why we don't check the `angularOnly` + // flag. + return ngLS.getRenameInfo(fileName, position); + } + function getCompletionsAtPosition( fileName: string, position: number, options: ts.GetCompletionsAtPositionOptions): ts.WithMetadata|undefined { @@ -118,6 +124,7 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService { getDefinitionAndBoundSpan, getReferencesAtPosition, findRenameLocations, + getRenameInfo, getCompletionsAtPosition, getCompletionEntryDetails, getCompletionEntrySymbol, diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index 57c80704f2..07c4c15c28 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -10,7 +10,6 @@ import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {isExternalResource} from '@angular/compiler-cli/src/ngtsc/metadata'; import {DeclarationNode} from '@angular/compiler-cli/src/ngtsc/reflection'; import {DirectiveSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; -import {Diagnostic as ngDiagnostic, isNgDiagnostic} from '@angular/compiler-cli/src/transformers/api'; import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expression AST import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST import * as ts from 'typescript'; @@ -33,9 +32,9 @@ export function getTextSpanOfNode(node: t.Node|e.AST): ts.TextSpan { } } -export function toTextSpan(span: AbsoluteSourceSpan|ParseSourceSpan): ts.TextSpan { +export function toTextSpan(span: AbsoluteSourceSpan|ParseSourceSpan|e.ParseSpan): ts.TextSpan { let start: number, end: number; - if (span instanceof AbsoluteSourceSpan) { + if (span instanceof AbsoluteSourceSpan || span instanceof e.ParseSpan) { start = span.start; end = span.end; } else {