/** * @license * Copyright Google LLC All Rights Reserved. * * 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 {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 {PerfPhase} from '@angular/compiler-cli/src/ngtsc/perf'; import {ProgramDriver} from '@angular/compiler-cli/src/ngtsc/program_driver'; import {DirectiveSymbol, ShimLocation, SymbolKind, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import {ExpressionIdentifier, hasExpressionIdentifier} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments'; import * as ts from 'typescript'; import {getTargetAtPosition, TargetNodeKind} from './template_target'; import {findTightestNode} from './ts_utils'; import {getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTemplateLocationFromShimLocation, isWithin, TemplateInfo, toTextSpan} from './utils'; interface FilePosition { fileName: string; position: number; } function toFilePosition(shimLocation: ShimLocation): FilePosition { return {fileName: shimLocation.shimPath, position: shimLocation.positionInShimFile}; } enum RequestKind { Template, TypeScript, } interface TemplateRequest { kind: RequestKind.Template; requestNode: TmplAstNode|AST; position: number; } interface TypeScriptRequest { kind: RequestKind.TypeScript; requestNode: ts.Node; } type RequestOrigin = TemplateRequest|TypeScriptRequest; interface TemplateLocationDetails { /** * A target node in a template. */ templateTarget: TmplAstNode|AST; /** * TypeScript locations which the template node maps to. A given template node might map to * several TS nodes. For example, a template node for an attribute might resolve to several * directives or a directive and one of its inputs. */ typescriptLocations: FilePosition[]; } export class ReferencesAndRenameBuilder { private readonly ttc = this.compiler.getTemplateTypeChecker(); constructor( private readonly driver: ProgramDriver, private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} getRenameInfo(filePath: string, position: number): Omit|ts.RenameInfoFailure { return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { 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: span, }; }); } findRenameLocations(filePath: string, position: number): readonly ts.RenameLocation[]|undefined { this.ttc.generateAllTypeCheckBlocks(); return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { 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) { const requestNode = this.getTsNodeAtPosition(filePath, position); if (requestNode === null) { return undefined; } const requestOrigin: TypeScriptRequest = {kind: RequestKind.TypeScript, requestNode}; return this.findRenameLocationsAtTypescriptPosition(filePath, position, requestOrigin); } return this.findRenameLocationsAtTemplatePosition(templateInfo, position); }); } private findRenameLocationsAtTemplatePosition(templateInfo: TemplateInfo, position: number): readonly ts.RenameLocation[]|undefined { const allTargetDetails = this.getTargetDetailsAtTemplatePosition(templateInfo, position); if (allTargetDetails === null) { return undefined; } const allRenameLocations: ts.RenameLocation[] = []; for (const targetDetails of allTargetDetails) { const requestOrigin: TemplateRequest = { kind: RequestKind.Template, requestNode: targetDetails.templateTarget, position, }; for (const location of targetDetails.typescriptLocations) { const locations = this.findRenameLocationsAtTypescriptPosition( location.fileName, location.position, requestOrigin); // If we couldn't find rename locations for _any_ result, we should not allow renaming to // proceed instead of having a partially complete rename. if (locations === undefined) { return undefined; } allRenameLocations.push(...locations); } } return allRenameLocations.length > 0 ? allRenameLocations : undefined; } private getTsNodeAtPosition(filePath: string, position: number): ts.Node|null { const sf = this.driver.getProgram().getSourceFile(filePath); if (!sf) { return null; } return findTightestNode(sf, position) ?? null; } findRenameLocationsAtTypescriptPosition( filePath: string, position: number, requestOrigin: RequestOrigin): readonly ts.RenameLocation[]|undefined { return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { let originalNodeText: string; if (requestOrigin.kind === RequestKind.TypeScript) { originalNodeText = requestOrigin.requestNode.getText(); } else { const templateNodeText = getRenameTextAndSpanAtPosition(requestOrigin.requestNode, requestOrigin.position); if (templateNodeText === null) { return undefined; } originalNodeText = templateNodeText.text; } const locations = this.tsLS.findRenameLocations( filePath, position, /*findInStrings*/ false, /*findInComments*/ false); if (locations === undefined) { return undefined; } const entries: Map = new Map(); for (const location of locations) { // TODO(atscott): Determine if a file is a shim file in a more robust way and make the API // available in an appropriate location. if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(location.fileName))) { const entry = this.convertToTemplateDocumentSpan(location, this.ttc, originalNodeText); // There is no template node whose text matches the original rename request. Bail on // renaming completely rather than providing incomplete results. if (entry === null) { return undefined; } entries.set(createLocationKey(entry), entry); } else { // Ensure we only allow renaming a TS result with matching text const refNode = this.getTsNodeAtPosition(location.fileName, location.textSpan.start); if (refNode === null || refNode.getText() !== originalNodeText) { return undefined; } entries.set(createLocationKey(location), location); } } return Array.from(entries.values()); }); } getReferencesAtPosition(filePath: string, position: number): ts.ReferenceEntry[]|undefined { this.ttc.generateAllTypeCheckBlocks(); return this.compiler.perfRecorder.inPhase(PerfPhase.LsReferencesAndRenames, () => { const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler); if (templateInfo === undefined) { return this.getReferencesAtTypescriptPosition(filePath, position); } return this.getReferencesAtTemplatePosition(templateInfo, position); }); } private getReferencesAtTemplatePosition(templateInfo: TemplateInfo, position: number): ts.ReferenceEntry[]|undefined { const allTargetDetails = this.getTargetDetailsAtTemplatePosition(templateInfo, position); if (allTargetDetails === null) { return undefined; } const allReferences: ts.ReferenceEntry[] = []; for (const targetDetails of allTargetDetails) { for (const location of targetDetails.typescriptLocations) { const refs = this.getReferencesAtTypescriptPosition(location.fileName, location.position); if (refs !== undefined) { allReferences.push(...refs); } } } return allReferences.length > 0 ? allReferences : undefined; } private getTargetDetailsAtTemplatePosition({template, component}: TemplateInfo, position: number): TemplateLocationDetails[]|null { // Find the AST node in the template at the position. const positionDetails = getTargetAtPosition(template, position); if (positionDetails === null) { return null; } const nodes = positionDetails.context.kind === TargetNodeKind.TwoWayBindingContext ? positionDetails.context.nodes : [positionDetails.context.node]; const details: TemplateLocationDetails[] = []; for (const node of nodes) { // Get the information about the TCB at the template position. const symbol = this.ttc.getSymbolOfNode(node, component); if (symbol === null) { continue; } const templateTarget = node; switch (symbol.kind) { case SymbolKind.Directive: case SymbolKind.Template: // References to elements, templates, and directives will be through template references // (#ref). They shouldn't be used directly for a Language Service reference request. break; case SymbolKind.Element: { const matches = getDirectiveMatchesForElementTag(symbol.templateNode, symbol.directives); details.push( {typescriptLocations: this.getPositionsForDirectives(matches), templateTarget}); break; } case SymbolKind.DomBinding: { // Dom bindings aren't currently type-checked (see `checkTypeOfDomBindings`) so they don't // have a shim location. This means we can't match dom bindings to their lib.dom // reference, but we can still see if they match to a directive. if (!(node instanceof TmplAstTextAttribute) && !(node instanceof TmplAstBoundAttribute)) { return null; } const directives = getDirectiveMatchesForAttribute( node.name, symbol.host.templateNode, symbol.host.directives); details.push({ typescriptLocations: this.getPositionsForDirectives(directives), templateTarget, }); break; } case SymbolKind.Reference: { details.push({ typescriptLocations: [toFilePosition(symbol.referenceVarLocation)], templateTarget, }); break; } case SymbolKind.Variable: { if ((templateTarget instanceof TmplAstVariable)) { if (templateTarget.valueSpan !== undefined && isWithin(position, templateTarget.valueSpan)) { // In the valueSpan of the variable, we want to get the reference of the initializer. details.push({ typescriptLocations: [toFilePosition(symbol.initializerLocation)], templateTarget, }); } 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, }); } } 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, }); } break; } case SymbolKind.Input: case SymbolKind.Output: { details.push({ typescriptLocations: symbol.bindings.map(binding => toFilePosition(binding.shimLocation)), templateTarget, }); break; } case SymbolKind.Pipe: case SymbolKind.Expression: { details.push( {typescriptLocations: [toFilePosition(symbol.shimLocation)], templateTarget}); break; } } } return details.length > 0 ? details : null; } private getPositionsForDirectives(directives: Set): FilePosition[] { const allDirectives: FilePosition[] = []; for (const dir of directives.values()) { const dirClass = dir.tsSymbol.valueDeclaration; if (dirClass === undefined || !ts.isClassDeclaration(dirClass) || dirClass.name === undefined) { continue; } const {fileName} = dirClass.getSourceFile(); const position = dirClass.name.getStart(); allDirectives.push({fileName, position}); } return allDirectives; } private getReferencesAtTypescriptPosition(fileName: string, position: number): ts.ReferenceEntry[]|undefined { const refs = this.tsLS.getReferencesAtPosition(fileName, position); if (refs === undefined) { return undefined; } const entries: Map = new Map(); for (const ref of refs) { if (this.ttc.isTrackedTypeCheckFile(absoluteFrom(ref.fileName))) { const entry = this.convertToTemplateDocumentSpan(ref, this.ttc); if (entry !== null) { entries.set(createLocationKey(entry), entry); } } else { // TODO(atscott): uncomment when VSCode deduplicates results on their end // https://github.com/microsoft/vscode/issues/117095 // entries.set(createLocationKey(ref), ref); } } return Array.from(entries.values()); } private convertToTemplateDocumentSpan( shimDocumentSpan: T, templateTypeChecker: TemplateTypeChecker, requiredNodeText?: string): T |null { const sf = this.driver.getProgram().getSourceFile(shimDocumentSpan.fileName); if (sf === undefined) { return null; } const tcbNode = findTightestNode(sf, shimDocumentSpan.textSpan.start); if (tcbNode === undefined || hasExpressionIdentifier(sf, tcbNode, ExpressionIdentifier.EVENT_PARAMETER)) { // If the reference result is the $event parameter in the subscribe/addEventListener // function in the TCB, we want to filter this result out of the references. We really only // want to return references to the parameter in the template itself. return null; } // TODO(atscott): Determine how to consistently resolve paths. i.e. with the project // serverHost or LSParseConfigHost in the adapter. We should have a better defined way to // normalize paths. const mapping = getTemplateLocationFromShimLocation( templateTypeChecker, absoluteFrom(shimDocumentSpan.fileName), shimDocumentSpan.textSpan.start); if (mapping === null) { return null; } const {span, templateUrl} = mapping; if (requiredNodeText !== undefined && span.toString() !== requiredNodeText) { return null; } return { ...shimDocumentSpan, fileName: templateUrl, textSpan: toTextSpan(span), // Specifically clear other text span values because we do not have enough knowledge to // convert these to spans in the template. contextSpan: undefined, originalContextSpan: undefined, originalTextSpan: undefined, }; } } function getRenameTextAndSpanAtPosition( node: TmplAstNode|AST, position: number): {text: string, span: ts.TextSpan}|null { if (node instanceof TmplAstBoundAttribute || node instanceof TmplAstTextAttribute || node instanceof TmplAstBoundEvent) { if (node.keySpan === undefined) { return null; } return {text: node.name, span: toTextSpan(node.keySpan)}; } else if (node instanceof TmplAstVariable || node instanceof TmplAstReference) { if (isWithin(position, node.keySpan)) { return {text: node.keySpan.toString(), span: toTextSpan(node.keySpan)}; } else if (node.valueSpan && isWithin(position, node.valueSpan)) { return {text: node.valueSpan.toString(), span: toTextSpan(node.valueSpan)}; } } if (node instanceof BindingPipe) { // TODO(atscott): Add support for renaming pipes return null; } if (node instanceof PropertyRead || node instanceof MethodCall || node instanceof PropertyWrite || node instanceof SafePropertyRead || node instanceof SafeMethodCall) { return {text: node.name, span: toTextSpan(node.nameSpan)}; } else if (node instanceof LiteralPrimitive) { const span = toTextSpan(node.sourceSpan); 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.length -= 2; } return {text, span}; } return null; } /** * Creates a "key" for a rename/reference location by concatenating file name, span start, and span * length. This allows us to de-duplicate template results when an item may appear several times * in the TCB but map back to the same template location. */ function createLocationKey(ds: ts.DocumentSpan) { return ds.fileName + ds.textSpan.start + ds.textSpan.length; }