diff --git a/packages/compiler-cli/src/ngtsc/testing/fake_common/index.ts b/packages/compiler-cli/src/ngtsc/testing/fake_common/index.ts index f2d0c5f257..5d49dd7a8a 100644 --- a/packages/compiler-cli/src/ngtsc/testing/fake_common/index.ts +++ b/packages/compiler-cli/src/ngtsc/testing/fake_common/index.ts @@ -11,6 +11,12 @@ import {NgIterable, TemplateRef, ɵɵDirectiveDefWithMeta, ɵɵNgModuleDefWithMe export interface NgForOfContext> { $implicit: T; ngForOf: U; + odd: boolean; + event: boolean; + first: boolean; + last: boolean; + count: number; + index: number; } export interface TrackByFunction { @@ -54,6 +60,19 @@ export declare class NgIf { ctx is NgIfContext>; } +export declare class NgTemplateOutlet { + ngTemplateOutlet: TemplateRef|null; + ngTemplateOutletContext: Object|null; + + static ɵdir: ɵɵDirectiveDefWithMeta < NgTemplateOutlet, '[ngTemplateOutlet]', never, { + 'ngTemplateOutlet': 'ngTemplateOutlet'; + 'ngTemplateOutletContext': 'ngTemplateOutletContext'; + } + , {}, never > ; + static ngTemplateContextGuard(dir: NgIf, ctx: any): + ctx is NgIfContext>; +} + 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; + static ɵmod: ɵɵNgModuleDefWithMeta< + CommonModule, [typeof NgForOf, typeof NgIf, typeof DatePipe, typeof NgTemplateOutlet], never, + [typeof NgForOf, typeof NgIf, typeof DatePipe, typeof NgTemplateOutlet]>; } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 366d059625..25ce5e7c5e 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -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. * diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 2e77230c88..ca5cb27478 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -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. diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts index 16ac8b874f..b7d56a00a5 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts @@ -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}; } diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index b5d4f1d08a..b1612cf80a 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -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, diff --git a/packages/language-service/ivy/references.ts b/packages/language-service/ivy/references.ts new file mode 100644 index 0000000000..2fc5170023 --- /dev/null +++ b/packages/language-service/ivy/references.ts @@ -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), + }; +} diff --git a/packages/language-service/ivy/template_target.ts b/packages/language-service/ivy/template_target.ts index 39d3edaaed..e39180919f 100644 --- a/packages/language-service/ivy/template_target.ts +++ b/packages/language-service/ivy/template_target.ts @@ -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; -} diff --git a/packages/language-service/ivy/test/env.ts b/packages/language-service/ivy/test/env.ts index 5351275162..aa7a1e011b 100644 --- a/packages/language-service/ivy/test/env.ts +++ b/packages/language-service/ivy/test/env.ts @@ -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 '¦'`); diff --git a/packages/language-service/ivy/test/references_spec.ts b/packages/language-service/ivy/test/references_spec.ts new file mode 100644 index 0000000000..249f305cb9 --- /dev/null +++ b/packages/language-service/ivy/test/references_spec.ts @@ -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: '
'}) + 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: '
'}) + 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 = `
`; + 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: '
' }) + 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 = `
`; + 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: ' {{ 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 = ` + bla + `; + 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 = '
{{ 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 = '
{{ 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 = '
{{ 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 = '
{{ 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: '
{{her¦o}}
'}) + 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: '
{{iR¦ef}}
'}) + 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 { + constructor(readonly $implicit: T, readonly identifier: string) {} + } + + @Directive({ selector: '[example]' }) + export class ExampleDirective { + @Input() set example(v: T) { } + static ngTemplateContextGuard(dir: ExampleDirective, ctx: unknown): + ctx is ExampleContext { + return true; + } + }`; + const fileWithCursor = ` + import {Component, NgModule} from '@angular/core'; + import {ExampleDirective} from './example-directive'; + + @Component({template: '
{{id}}
'}) + 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: '
{{hero.na¦me}}
'}) + 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: '
'}) + 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: '
'}) + 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: '
'}) + 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: '
'}) + 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: '
'}) + 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(); + @Output('alias') aliasedModelChange = new EventEmitter(); + }`; + + 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(`
`)); + 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(`
`)); + 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: '
'}) + 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, ['
', '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& + Pick { + 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(array: T[]): T { + return array[array.length - 1]; +} + +type Stringy = { + [P in keyof T]: string; +}; diff --git a/packages/language-service/ivy/test/test_utils.ts b/packages/language-service/ivy/test/test_utils.ts new file mode 100644 index 0000000000..9a96c2bad3 --- /dev/null +++ b/packages/language-service/ivy/test/test_utils.ts @@ -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); +} diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index 473842312d..8c28a2db35 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -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 { diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index d76d4d6ea0..2aa5d04290 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -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; +}