feat(language-service): Add "find references" capability to Ivy integrated LS (#39768)

This commit adds "find references" functionality to the Ivy integrated
language service. The basic approach is as follows:

1. Generate shims for all files to ensure we find references in shims
throughout the entire program
2. Determine if the position for the reference request is within a
template.
  * Yes, it is in a template: Find which node in the template AST the
  position refers to. Then find the position in the shim file for that
  template node. Pass the shim file and position in the shim file along
  to step 3.
  * No, the request for references was made outside a template: Forward
  the file and position to step 3.
3. (`getReferencesAtTypescriptPosition`): Call the native TypeScript LS
`getReferencesAtPosition`. For each reference that is in a shim file, map those
back to a template location, otherwise return it as-is.

PR Close #39768
This commit is contained in:
Andrew Scott 2020-11-19 13:31:34 -08:00 committed by Misko Hevery
parent c69e67c9cb
commit 06a782a2e3
12 changed files with 997 additions and 28 deletions

View File

@ -11,6 +11,12 @@ import {NgIterable, TemplateRef, ɵɵDirectiveDefWithMeta, ɵɵNgModuleDefWithMe
export interface NgForOfContext<T, U extends NgIterable<T>> {
$implicit: T;
ngForOf: U;
odd: boolean;
event: boolean;
first: boolean;
last: boolean;
count: number;
index: number;
}
export interface TrackByFunction<T> {
@ -54,6 +60,19 @@ export declare class NgIf<T = unknown> {
ctx is NgIfContext<Exclude<T, false|0|''|null|undefined>>;
}
export declare class NgTemplateOutlet {
ngTemplateOutlet: TemplateRef<any>|null;
ngTemplateOutletContext: Object|null;
static ɵdir: ɵɵDirectiveDefWithMeta < NgTemplateOutlet, '[ngTemplateOutlet]', never, {
'ngTemplateOutlet': 'ngTemplateOutlet';
'ngTemplateOutletContext': 'ngTemplateOutletContext';
}
, {}, never > ;
static ngTemplateContextGuard<T>(dir: NgIf<T>, ctx: any):
ctx is NgIfContext<Exclude<T, false|0|''|null|undefined>>;
}
export declare class DatePipe {
transform(value: Date|string|number, format?: string, timezone?: string, locale?: string): string
|null;
@ -65,8 +84,7 @@ export declare class DatePipe {
}
export declare class CommonModule {
static ɵmod:
ɵɵNgModuleDefWithMeta<CommonModule, [typeof NgForOf, typeof NgIf, typeof DatePipe], never, [
typeof NgForOf, typeof NgIf, typeof DatePipe
]>;
static ɵmod: ɵɵNgModuleDefWithMeta<
CommonModule, [typeof NgForOf, typeof NgIf, typeof DatePipe, typeof NgTemplateOutlet], never,
[typeof NgForOf, typeof NgIf, typeof DatePipe, typeof NgTemplateOutlet]>;
}

View File

@ -80,6 +80,13 @@ export interface TemplateTypeChecker {
*/
getDiagnosticsForComponent(component: ts.ClassDeclaration): ts.Diagnostic[];
/**
* Ensures shims for the whole program are generated. This type of operation would be required by
* operations like "find references" and "refactor/rename" because references may appear in type
* check blocks generated from templates anywhere in the program.
*/
generateAllTypeCheckBlocks(): void;
/**
* Retrieve the top-level node representing the TCB for the given component.
*

View File

@ -197,6 +197,10 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
return getTemplateMapping(shimSf, positionInShimFile, fileRecord.sourceManager);
}
generateAllTypeCheckBlocks() {
this.ensureAllShimsForAllFiles();
}
/**
* Retrieve type-checking diagnostics from the given `ts.SourceFile` using the most recent
* type-checking program.

View File

@ -67,6 +67,8 @@ export function getTemplateMapping(
if (span === null) {
return null;
}
// TODO(atscott): Consider adding a context span by walking up from `node` until we get a
// different span.
return {sourceLocation, templateSourceMapping: mapping, span};
}

View File

@ -10,6 +10,7 @@ import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/co
import {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 {ReferenceBuilder} from '@angular/language-service/ivy/references';
import * as ts from 'typescript/lib/tsserverlibrary';
import {LanguageServiceAdapter, LSParseConfigHost} from './adapters';
@ -80,7 +81,6 @@ export class LanguageService {
}
getQuickInfoAtPosition(fileName: string, position: number): ts.QuickInfo|undefined {
const program = this.strategy.getProgram();
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const templateInfo = getTemplateInfoAtPosition(fileName, position, compiler);
if (templateInfo === undefined) {
@ -97,6 +97,14 @@ export class LanguageService {
return results;
}
getReferencesAtPosition(fileName: string, position: number): ts.ReferenceEntry[]|undefined {
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
const results =
new ReferenceBuilder(this.strategy, this.tsLS, compiler).get(fileName, position);
this.compilerFactory.registerLastKnownProgram();
return results;
}
private watchConfigFile(project: ts.server.Project) {
// TODO: Check the case when the project is disposed. An InferredProject
// could be disposed when a tsconfig.json is added to the workspace,

View File

@ -0,0 +1,152 @@
/**
* @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 {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 {SymbolKind, TemplateTypeChecker, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript';
import {getTargetAtPosition} from './template_target';
import {getTemplateInfoAtPosition, isWithin, TemplateInfo, toTextSpan} from './utils';
export class ReferenceBuilder {
private readonly ttc = this.compiler.getTemplateTypeChecker();
constructor(
private readonly strategy: TypeCheckingProgramStrategy,
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {}
get(filePath: string, position: number): ts.ReferenceEntry[]|undefined {
this.ttc.generateAllTypeCheckBlocks();
const templateInfo = getTemplateInfoAtPosition(filePath, position, this.compiler);
return templateInfo !== undefined ?
this.getReferencesAtTemplatePosition(templateInfo, position) :
this.getReferencesAtTypescriptPosition(filePath, position);
}
private getReferencesAtTemplatePosition({template, component}: TemplateInfo, position: number):
ts.ReferenceEntry[]|undefined {
// Find the AST node in the template at the position.
const positionDetails = getTargetAtPosition(template, position);
if (positionDetails === null) {
return undefined;
}
// Get the information about the TCB at the template position.
const symbol = this.ttc.getSymbolOfNode(positionDetails.node, component);
if (symbol === null) {
return undefined;
}
switch (symbol.kind) {
case SymbolKind.Element:
case SymbolKind.Directive:
case SymbolKind.Template:
case SymbolKind.DomBinding:
// References to elements, templates, and directives will be through template references
// (#ref). They shouldn't be used directly for a Language Service reference request.
//
// Dom bindings aren't currently type-checked (see `checkTypeOfDomBindings`) so they don't
// have a shim location and so we cannot find references for them.
//
// TODO(atscott): Consider finding references for elements that are components as well as
// when the position is on an element attribute that directly maps to a directive.
return undefined;
case SymbolKind.Reference: {
const {shimPath, positionInShimFile} = symbol.referenceVarLocation;
return this.getReferencesAtTypescriptPosition(shimPath, positionInShimFile);
}
case SymbolKind.Variable: {
const {positionInShimFile: initializerPosition, shimPath} = symbol.initializerLocation;
const localVarPosition = symbol.localVarLocation.positionInShimFile;
const templateNode = positionDetails.node;
if ((templateNode instanceof TmplAstVariable)) {
if (templateNode.valueSpan !== undefined && isWithin(position, templateNode.valueSpan)) {
// In the valueSpan of the variable, we want to get the reference of the initializer.
return this.getReferencesAtTypescriptPosition(shimPath, initializerPosition);
} else if (isWithin(position, templateNode.keySpan)) {
// In the keySpan of the variable, we want to get the reference of the local variable.
return this.getReferencesAtTypescriptPosition(shimPath, localVarPosition);
} else {
return undefined;
}
}
// If the templateNode is not the `TmplAstVariable`, it must be a usage of the variable
// somewhere in the template.
return this.getReferencesAtTypescriptPosition(shimPath, localVarPosition);
}
case SymbolKind.Input:
case SymbolKind.Output: {
// TODO(atscott): Determine how to handle when the binding maps to several inputs/outputs
const {shimPath, positionInShimFile} = symbol.bindings[0].shimLocation;
return this.getReferencesAtTypescriptPosition(shimPath, positionInShimFile);
}
case SymbolKind.Expression: {
const {shimPath, positionInShimFile} = symbol.shimLocation;
return this.getReferencesAtTypescriptPosition(shimPath, positionInShimFile);
}
}
}
private getReferencesAtTypescriptPosition(fileName: string, position: number):
ts.ReferenceEntry[]|undefined {
const refs = this.tsLS.getReferencesAtPosition(fileName, position);
if (refs === undefined) {
return undefined;
}
const entries: ts.ReferenceEntry[] = [];
for (const ref of refs) {
// 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 (ref.fileName.endsWith('ngtypecheck.ts')) {
const entry = convertToTemplateReferenceEntry(ref, this.ttc);
if (entry !== null) {
entries.push(entry);
}
} else {
entries.push(ref);
}
}
return entries;
}
}
function convertToTemplateReferenceEntry(
shimReferenceEntry: ts.ReferenceEntry,
templateTypeChecker: TemplateTypeChecker): ts.ReferenceEntry|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 = templateTypeChecker.getTemplateMappingAtShimLocation({
shimPath: absoluteFrom(shimReferenceEntry.fileName),
positionInShimFile: shimReferenceEntry.textSpan.start,
});
if (mapping === null) {
return null;
}
const {templateSourceMapping, span} = mapping;
let templateUrl: AbsoluteFsPath;
if (templateSourceMapping.type === 'direct') {
templateUrl = absoluteFromSourceFile(templateSourceMapping.node.getSourceFile());
} else if (templateSourceMapping.type === 'external') {
templateUrl = absoluteFrom(templateSourceMapping.templateUrl);
} else {
// This includes indirect mappings, which are difficult to map directly to the code location.
// Diagnostics similarly return a synthetic template string for this case rather than a real
// location.
return null;
}
return {
...shimReferenceEntry,
fileName: templateUrl,
textSpan: toTextSpan(span),
};
}

View File

@ -6,11 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AbsoluteSourceSpan, ParseSourceSpan} from '@angular/compiler';
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 {isTemplateNode, isTemplateNodeWithKeyAndValue} from './utils';
import {isTemplateNode, isTemplateNodeWithKeyAndValue, isWithin} from './utils';
/**
* Contextual information for a target position within the template.
@ -238,17 +237,3 @@ function getSpanIncludingEndTag(ast: t.Node) {
}
return result;
}
function isWithin(position: number, span: AbsoluteSourceSpan|ParseSourceSpan): boolean {
let start: number, end: number;
if (span instanceof ParseSourceSpan) {
start = span.start.offset;
end = span.end.offset;
} else {
start = span.start;
end = span.end;
}
// Note both start and end are inclusive because we want to match conditions
// like ¦start and end¦ where ¦ is the cursor.
return start <= position && position <= end;
}

View File

@ -46,8 +46,17 @@ function writeTsconfig(
export type TestableOptions = StrictTemplateOptions;
export interface TemplateOverwriteResult {
cursor: number;
nodes: TmplAstNode[];
component: ts.ClassDeclaration;
text: string;
}
export class LanguageServiceTestEnvironment {
private constructor(private tsLS: ts.LanguageService, readonly ngLS: LanguageService) {}
private constructor(
private tsLS: ts.LanguageService, readonly ngLS: LanguageService,
readonly host: MockServerHost) {}
static setup(files: TestFile[], options: TestableOptions = {}): LanguageServiceTestEnvironment {
const fs = getFileSystem();
@ -97,7 +106,7 @@ export class LanguageServiceTestEnvironment {
const tsLS = project.getLanguageService();
const ngLS = new LanguageService(project, tsLS);
return new LanguageServiceTestEnvironment(tsLS, ngLS);
return new LanguageServiceTestEnvironment(tsLS, ngLS, host);
}
getClass(fileName: AbsoluteFsPath, className: string): ts.ClassDeclaration {
@ -110,7 +119,7 @@ export class LanguageServiceTestEnvironment {
}
overrideTemplateWithCursor(fileName: AbsoluteFsPath, className: string, contents: string):
{cursor: number, nodes: TmplAstNode[], component: ts.ClassDeclaration, text: string} {
TemplateOverwriteResult {
const program = this.tsLS.getProgram();
if (program === undefined) {
throw new Error(`Expected to get a ts.Program`);
@ -206,7 +215,7 @@ function getClassOrError(sf: ts.SourceFile, name: string): ts.ClassDeclaration {
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
}
function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} {
export function extractCursorInfo(textWithCursor: string): {cursor: number, text: string} {
const cursor = textWithCursor.indexOf('¦');
if (cursor === -1) {
throw new Error(`Expected to find cursor symbol '¦'`);

View File

@ -0,0 +1,758 @@
/**
* @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 {absoluteFrom, absoluteFrom as _} from '@angular/compiler-cli/src/ngtsc/file_system';
import {initMockFileSystem, TestFile} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import * as ts from 'typescript/lib/tsserverlibrary';
import {extractCursorInfo, LanguageServiceTestEnvironment} from './env';
import {getText} from './test_utils';
describe('find references', () => {
let env: LanguageServiceTestEnvironment;
beforeEach(() => {
initMockFileSystem('Native');
});
it('gets component member references from TS file', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({templateUrl: './app.html'})
export class AppCmp {
myP¦rop!: string;
}`);
const appFile = {name: _('/app.ts'), contents: text};
const templateFile = {name: _('/app.html'), contents: '{{myProp}}'};
createModuleWithDeclarations([appFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.html', 'app.ts']);
assertTextSpans(refs, ['myProp']);
});
it('gets component member references from TS file and inline template', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '{{myProp}}'})
export class AppCmp {
myP¦rop!: string;
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['myProp']);
});
it('gets component member references from template', () => {
const appFile = {
name: _('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({templateUrl: './app.html'})
export class AppCmp {
myProp = '';
}`,
};
const {text, cursor} = extractCursorInfo('{{myP¦rop}}');
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.html', 'app.ts']);
assertTextSpans(refs, ['myProp']);
});
it('should work for method calls', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div (click)="set¦Title(2)"></div>'})
export class AppCmp {
setTitle(s: number) {}
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['setTitle']);
});
it('should work for method call arguments', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div (click)="setTitle(ti¦tle)"></div>'})
export class AppCmp {
title = '';
setTitle(s: string) {}
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['title']);
});
it('should work for property writes', () => {
const appFile = {
name: _('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({templateUrl: './app.html' })
export class AppCmp {
title = '';
}`,
};
const templateFileWithCursor = `<div (click)="ti¦tle = 'newtitle'"></div>`;
const {text, cursor} = extractCursorInfo(templateFileWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts', 'app.html']);
assertTextSpans(refs, ['title']);
});
it('should work for RHS of property writes', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div (click)="title = otherT¦itle"></div>' })
export class AppCmp {
title = '';
otherTitle = '';
}`);
const appFile = {
name: _('/app.ts'),
contents: text,
};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['otherTitle']);
});
it('should work for keyed reads', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '{{hero["na¦me"]}}' })
export class AppCmp {
hero: {name: string} = {name: 'Superman'};
}`);
const appFile = {
name: _('/app.ts'),
contents: text,
};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
// 3 references: the type definition, the value assignment, and the read in the template
expect(refs.length).toBe(3);
assertFileNames(refs, ['app.ts']);
// TODO(atscott): investigate if we can make the template keyed read be just the 'name' part.
// The TypeScript implementation specifically adjusts the span to accommodate string literals:
// https://sourcegraph.com/github.com/microsoft/TypeScript@d5779c75d3dd19565b60b9e2960b8aac36d4d635/-/blob/src/services/findAllReferences.ts#L508-512
// One possible solution would be to extend `FullTemplateMapping` to include the matched TCB
// node and then do the same thing that TS does: if the node is a string, adjust the span.
assertTextSpans(refs, ['name', '"name"']);
});
it('should work for keyed writes', () => {
const appFile = {
name: _('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({templateUrl: './app.html' })
export class AppCmp {
hero: {name: string} = {name: 'Superman'};
batman = 'batman';
}`,
};
const templateFileWithCursor = `<div (click)="hero['name'] = bat¦man"></div>`;
const {text, cursor} = extractCursorInfo(templateFileWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts', 'app.html']);
assertTextSpans(refs, ['batman']);
});
describe('references', () => {
it('should work for element references', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<input #myInput /> {{ myIn¦put.value }}'})
export class AppCmp {
title = '';
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['myInput']);
const originalRefs = env.ngLS.getReferencesAtPosition(_('/app.ts'), cursor)!;
// Get the declaration by finding the reference that appears first in the template
originalRefs.sort((a, b) => a.textSpan.start - b.textSpan.start);
expect(originalRefs[0].isDefinition).toBe(true);
});
it('should work for template references', () => {
const templateWithCursor = `
<ng-template #myTemplate >bla</ng-template>
<ng-container [ngTemplateOutlet]="myTem¦plate"></ng-container>`;
const appFile = {
name: _('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({templateUrl: './app.html'})
export class AppCmp {
title = '';
}`,
};
const {text, cursor} = extractCursorInfo(templateWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertTextSpans(refs, ['myTemplate']);
assertFileNames(refs, ['app.html']);
const originalRefs = env.ngLS.getReferencesAtPosition(_('/app.html'), cursor)!;
// Get the declaration by finding the reference that appears first in the template
originalRefs.sort((a, b) => a.textSpan.start - b.textSpan.start);
expect(originalRefs[0].isDefinition).toBe(true);
});
describe('directive references', () => {
let appFile: TestFile;
let dirFile: TestFile;
beforeEach(() => {
const dirFileContents = `
import {Directive} from '@angular/core';
@Directive({selector: '[dir]', exportAs: 'myDir'})
export class Dir {
dirValue!: string;
doSomething() {}
}`;
const appFileContents = `
import {Component} from '@angular/core';
@Component({templateUrl: './app.html'})
export class AppCmp {}`;
appFile = {name: _('/app.ts'), contents: appFileContents};
dirFile = {name: _('/dir.ts'), contents: dirFileContents};
});
it('should work for usage of reference in template', () => {
const templateWithCursor = '<div [dir] #dirRef="myDir"></div> {{ dirR¦ef }}';
const {text, cursor} = extractCursorInfo(templateWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile, dirFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.html']);
assertTextSpans(refs, ['dirRef']);
});
it('should work for prop reads of directive references', () => {
const fileWithCursor = '<div [dir] #dirRef="myDir"></div> {{ dirRef.dirV¦alue }}';
const {text, cursor} = extractCursorInfo(fileWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile, dirFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['dir.ts', 'app.html']);
assertTextSpans(refs, ['dirValue']);
});
it('should work for safe prop reads', () => {
const fileWithCursor = '<div [dir] #dirRef="myDir"></div> {{ dirRef?.dirV¦alue }}';
const {text, cursor} = extractCursorInfo(fileWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile, dirFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['dir.ts', 'app.html']);
assertTextSpans(refs, ['dirValue']);
});
it('should work for safe method calls', () => {
const fileWithCursor = '<div [dir] #dirRef="myDir"></div> {{ dirRef?.doSometh¦ing() }}';
const {text, cursor} = extractCursorInfo(fileWithCursor);
const templateFile = {name: _('/app.html'), contents: text};
createModuleWithDeclarations([appFile, dirFile], [templateFile]);
const refs = getReferencesAtPosition(_('/app.html'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['dir.ts', 'app.html']);
assertTextSpans(refs, ['doSomething']);
});
});
});
describe('variables', () => {
it('should work for variable initialized implicitly', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div *ngFor="let hero of heroes">{{her¦o}}</div>'})
export class AppCmp {
heroes: string[] = [];
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['hero']);
const originalRefs = env.ngLS.getReferencesAtPosition(_('/app.ts'), cursor)!;
// Get the declaration by finding the reference that appears first in the template
originalRefs.sort((a, b) => a.textSpan.start - b.textSpan.start);
expect(originalRefs[0].isDefinition).toBe(true);
});
it('should work for renamed variables', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div *ngFor="let hero of heroes; let iRef = index">{{iR¦ef}}</div>'})
export class AppCmp {
heroes: string[] = [];
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['iRef']);
const originalRefs = env.ngLS.getReferencesAtPosition(_('/app.ts'), cursor)!;
// Get the declaration by finding the reference that appears first in the template
originalRefs.sort((a, b) => a.textSpan.start - b.textSpan.start);
expect(originalRefs[0].isDefinition).toBe(true);
});
it('should work for initializer of variable', () => {
const dirFile = `
import {Directive, Input} from '@angular/core';
export class ExampleContext<T> {
constructor(readonly $implicit: T, readonly identifier: string) {}
}
@Directive({ selector: '[example]' })
export class ExampleDirective<T> {
@Input() set example(v: T) { }
static ngTemplateContextGuard<T>(dir: ExampleDirective<T>, ctx: unknown):
ctx is ExampleContext<T> {
return true;
}
}`;
const fileWithCursor = `
import {Component, NgModule} from '@angular/core';
import {ExampleDirective} from './example-directive';
@Component({template: '<div *example="state; let id = identif¦ier">{{id}}</div>'})
export class AppCmp {
state = {};
}
@NgModule({declarations: [AppCmp, ExampleDirective]})
export class AppModule {}`;
const {text, cursor} = extractCursorInfo(fileWithCursor);
env = LanguageServiceTestEnvironment.setup([
{name: _('/app.ts'), contents: text, isRoot: true},
{name: _('/example-directive.ts'), contents: dirFile},
]);
env.expectNoSourceDiagnostics();
env.expectNoTemplateDiagnostics(absoluteFrom('/app.ts'), 'AppCmp');
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts', 'example-directive.ts']);
assertTextSpans(refs, ['identifier']);
});
it('should work for prop reads of variables', () => {
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div *ngFor="let hero of heroes">{{hero.na¦me}}</div>'})
export class AppCmp {
heroes: Array<{name: string}> = [];
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['name']);
});
});
describe('pipes', () => {
let prefixPipeFile: TestFile;
beforeEach(() => {
const prefixPipe = `
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({ name: 'prefixPipe' })
export class PrefixPipe implements PipeTransform {
transform(value: string, prefix: string): string;
transform(value: number, prefix: number): number;
transform(value: string|number, prefix: string|number): string|number {
return '';
}
}`;
prefixPipeFile = {name: _('/prefix-pipe.ts'), contents: prefixPipe};
});
it('should work for pipe names', () => {
const appContentsWithCursor = `
import {Component} from '@angular/core';
@Component({template: '{{birthday | prefi¦xPipe: "MM/dd/yy"}}'})
export class AppCmp {
birthday = '';
}
`;
const {text, cursor} = extractCursorInfo(appContentsWithCursor);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile, prefixPipeFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(5);
assertFileNames(refs, ['index.d.ts', 'prefix-pipe.ts', 'app.ts']);
assertTextSpans(refs, ['transform', 'prefixPipe']);
});
it('should work for pipe arguments', () => {
const appContentsWithCursor = `
import {Component} from '@angular/core';
@Component({template: '{{birthday | prefixPipe: pr¦efix}}'})
export class AppCmp {
birthday = '';
prefix = '';
}
`;
const {text, cursor} = extractCursorInfo(appContentsWithCursor);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile, prefixPipeFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toBe(2);
assertFileNames(refs, ['app.ts']);
assertTextSpans(refs, ['prefix']);
});
});
describe('inputs', () => {
const dirFileContents = `
import {Directive, Input} from '@angular/core';
@Directive({selector: '[string-model]'})
export class StringModel {
@Input() model!: string;
@Input('alias') aliasedModel!: string;
}`;
it('should work from the template', () => {
const stringModelTestFile = {name: _('/string-model.ts'), contents: dirFileContents};
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div string-model [mod¦el]="title"></div>'})
export class AppCmp {
title = 'title';
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile, stringModelTestFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toEqual(2);
assertFileNames(refs, ['string-model.ts', 'app.ts']);
assertTextSpans(refs, ['model']);
});
it('should work for text attributes', () => {
const stringModelTestFile = {name: _('/string-model.ts'), contents: dirFileContents};
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div string-model mod¦el="title"></div>'})
export class AppCmp {
title = 'title';
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile, stringModelTestFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toEqual(2);
assertFileNames(refs, ['string-model.ts', 'app.ts']);
assertTextSpans(refs, ['model']);
});
it('should work from the TS input declaration', () => {
const dirFileWithCursor = `
import {Directive, Input} from '@angular/core';
@Directive({selector: '[string-model]'})
export class StringModel {
@Input() mod¦el!: string;
}`;
const {text, cursor} = extractCursorInfo(dirFileWithCursor);
const stringModelTestFile = {name: _('/string-model.ts'), contents: text};
const appFile = {
name: _('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({template: '<div string-model model="title"></div>'})
export class AppCmp {
title = 'title';
}`,
};
createModuleWithDeclarations([appFile, stringModelTestFile]);
const refs = getReferencesAtPosition(_('/string-model.ts'), cursor)!;
expect(refs.length).toEqual(2);
assertFileNames(refs, ['app.ts', 'string-model.ts']);
assertTextSpans(refs, ['model']);
});
it('should work for inputs referenced from some other place', () => {
const otherDirContents = `
import {Directive, Input} from '@angular/core';
import {StringModel} from './string-model';
@Directive({selector: '[other-dir]'})
export class OtherDir {
@Input() stringModelRef!: StringModel;
doSomething() {
console.log(this.stringModelRef.mod¦el);
}
}`;
const {text, cursor} = extractCursorInfo(otherDirContents);
const otherDirFile = {name: _('/other-dir.ts'), contents: text};
const stringModelTestFile = {
name: _('/string-model.ts'),
contents: `
import {Directive, Input} from '@angular/core';
@Directive({selector: '[string-model]'})
export class StringModel {
@Input() model!: string;
}`,
};
const appFile = {
name: _('/app.ts'),
contents: `
import {Component} from '@angular/core';
@Component({template: '<div string-model other-dir model="title"></div>'})
export class AppCmp {
title = 'title';
}`,
};
createModuleWithDeclarations([appFile, stringModelTestFile, otherDirFile]);
const refs = getReferencesAtPosition(_('/other-dir.ts'), cursor)!;
expect(refs.length).toEqual(3);
assertFileNames(refs, ['app.ts', 'string-model.ts', 'other-dir.ts']);
assertTextSpans(refs, ['model']);
});
it('should work with aliases', () => {
const stringModelTestFile = {name: _('/string-model.ts'), contents: dirFileContents};
const {text, cursor} = extractCursorInfo(`
import {Component} from '@angular/core';
@Component({template: '<div string-model [al¦ias]="title"></div>'})
export class AppCmp {
title = 'title';
}`);
const appFile = {name: _('/app.ts'), contents: text};
createModuleWithDeclarations([appFile, stringModelTestFile]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toEqual(2);
assertFileNames(refs, ['string-model.ts', 'app.ts']);
assertTextSpans(refs, ['aliasedModel', 'alias']);
});
});
describe('outputs', () => {
const dirFile = `
import {Directive, Output, EventEmitter} from '@angular/core';
@Directive({selector: '[string-model]'})
export class StringModel {
@Output() modelChange = new EventEmitter<string>();
@Output('alias') aliasedModelChange = new EventEmitter<string>();
}`;
function generateAppFile(template: string) {
return `
import {Component, NgModule} from '@angular/core';
import {StringModel} from './string-model';
@Component({template: '${template}'})
export class AppCmp {
setTitle(s: string) {}
}
@NgModule({declarations: [AppCmp, StringModel]})
export class AppModule {}`;
}
it('should work', () => {
const {text, cursor} = extractCursorInfo(
generateAppFile(`<div string-model (mod¦elChange)="setTitle($event)"></div>`));
env = LanguageServiceTestEnvironment.setup([
{name: _('/app.ts'), contents: text, isRoot: true},
{name: _('/string-model.ts'), contents: dirFile},
]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toEqual(2);
assertTextSpans(refs, ['modelChange']);
});
it('should work with aliases', () => {
const {text, cursor} = extractCursorInfo(
generateAppFile(`<div string-model (a¦lias)="setTitle($event)"></div>`));
env = LanguageServiceTestEnvironment.setup([
{name: _('/app.ts'), contents: text, isRoot: true},
{name: _('/string-model.ts'), contents: dirFile},
]);
const refs = getReferencesAtPosition(_('/app.ts'), cursor)!;
expect(refs.length).toEqual(2);
assertTextSpans(refs, ['aliasedModelChange', 'alias']);
});
});
describe('directives', () => {
it('works for directive classes', () => {
const {text, cursor} = extractCursorInfo(`
import {Directive} from '@angular/core';
@Directive({selector: '[dir]'})
export class Di¦r {}`);
const appFile = `
import {Component, NgModule} from '@angular/core';
import {Dir} from './dir';
@Component({template: '<div dir></div>'})
export class AppCmp {
}
@NgModule({declarations: [AppCmp, Dir]})
export class AppModule {}
`;
env = LanguageServiceTestEnvironment.setup([
{name: _('/app.ts'), contents: appFile, isRoot: true},
{name: _('/dir.ts'), contents: text},
]);
const refs = getReferencesAtPosition(_('/dir.ts'), cursor)!;
// 4 references are: class declaration, template usage, app import and use in declarations
// list.
expect(refs.length).toBe(4);
assertTextSpans(refs, ['<div dir>', 'Dir']);
assertFileNames(refs, ['app.ts', 'dir.ts']);
});
});
function getReferencesAtPosition(fileName: string, position: number) {
env.expectNoSourceDiagnostics();
const result = env.ngLS.getReferencesAtPosition(fileName, position);
return result?.map(humanizeReferenceEntry);
}
function humanizeReferenceEntry(entry: ts.ReferenceEntry): Stringy<ts.DocumentSpan>&
Pick<ts.ReferenceEntry, 'isWriteAccess'|'isDefinition'|'isInString'> {
const fileContents = env.host.readFile(entry.fileName);
if (!fileContents) {
throw new Error('Could not read file ${entry.fileName}');
}
return {
...entry,
textSpan: getText(fileContents, entry.textSpan),
contextSpan: entry.contextSpan ? getText(fileContents, entry.contextSpan) : undefined,
originalTextSpan: entry.originalTextSpan ? getText(fileContents, entry.originalTextSpan) :
undefined,
originalContextSpan:
entry.originalContextSpan ? getText(fileContents, entry.originalContextSpan) : undefined,
};
}
function getFirstClassDeclaration(declaration: string) {
const matches = declaration.match(/(?:export class )(\w+)(?:\s|\{)/);
if (matches === null || matches.length !== 2) {
throw new Error(`Did not find exactly one exported class in: ${declaration}`);
}
return matches[1].trim();
}
function createModuleWithDeclarations(
filesWithClassDeclarations: TestFile[], externalResourceFiles: TestFile[] = []): void {
const externalClasses =
filesWithClassDeclarations.map(file => getFirstClassDeclaration(file.contents));
const externalImports = filesWithClassDeclarations.map(file => {
const className = getFirstClassDeclaration(file.contents);
const fileName = last(file.name.split('/')).replace('.ts', '');
return `import {${className}} from './${fileName}';`;
});
const contents = `
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
${externalImports.join('\n')}
@NgModule({
declarations: [${externalClasses.join(',')}],
imports: [CommonModule],
})
export class AppModule {}
`;
const moduleFile = {name: _('/app-module.ts'), contents, isRoot: true};
env = LanguageServiceTestEnvironment.setup(
[moduleFile, ...filesWithClassDeclarations, ...externalResourceFiles]);
}
});
function assertFileNames(refs: Array<{fileName: string}>, expectedFileNames: string[]) {
const actualPaths = refs.map(r => r.fileName);
const actualFileNames = actualPaths.map(p => last(p.split('/')));
expect(new Set(actualFileNames)).toEqual(new Set(expectedFileNames));
}
function assertTextSpans(refs: Array<{textSpan: string}>, expectedTextSpans: string[]) {
const actualSpans = refs.map(ref => ref.textSpan);
expect(new Set(actualSpans)).toEqual(new Set(expectedTextSpans));
}
function last<T>(array: T[]): T {
return array[array.length - 1];
}
type Stringy<T> = {
[P in keyof T]: string;
};

View File

@ -0,0 +1,12 @@
/**
* @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
*/
export function getText(contents: string, textSpan: ts.TextSpan) {
return contents.substr(textSpan.start, textSpan.length);
}

View File

@ -56,9 +56,9 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
}
}
function getReferencesAtPosition(fileName: string, position: number) {
// TODO(atscott): implement references
return undefined;
function getReferencesAtPosition(fileName: string, position: number): ts.ReferenceEntry[]|
undefined {
return ngLS.getReferencesAtPosition(fileName, position);
}
return {

View File

@ -306,3 +306,17 @@ export function isTypeScriptFile(fileName: string): boolean {
export function isExternalTemplate(fileName: string): boolean {
return !isTypeScriptFile(fileName);
}
export function isWithin(position: number, span: AbsoluteSourceSpan|ParseSourceSpan): boolean {
let start: number, end: number;
if (span instanceof ParseSourceSpan) {
start = span.start.offset;
end = span.end.offset;
} else {
start = span.start;
end = span.end;
}
// Note both start and end are inclusive because we want to match conditions
// like ¦start and end¦ where ¦ is the cursor.
return start <= position && position <= end;
}