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
This commit is contained in:
Andrew Scott 2020-12-01 14:55:57 -08:00 committed by Andrew Kushnir
parent 40e0bfdc0d
commit 4e8198d60f
5 changed files with 167 additions and 25 deletions

View File

@ -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)

View File

@ -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.RenameInfoSuccess, 'kind'|'kindModifiers'>|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;

View File

@ -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: '<div di¦r="something"></div>'})
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);

View File

@ -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<ts.CompletionInfo>|undefined {
@ -118,6 +124,7 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
getDefinitionAndBoundSpan,
getReferencesAtPosition,
findRenameLocations,
getRenameInfo,
getCompletionsAtPosition,
getCompletionEntryDetails,
getCompletionEntrySymbol,

View File

@ -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 {