diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index 77bb60084b..da40fb7cb8 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -98,4 +98,17 @@ class LanguageServiceImpl implements ng.LanguageService { const declarations = this.host.getDeclarations(fileName); return getTsHover(position, declarations, analyzedModules); } + + getReferencesAtPosition(fileName: string, position: number): tss.ReferenceEntry[]|undefined { + const defAndSpan = this.getDefinitionAndBoundSpan(fileName, position); + if (!defAndSpan?.definitions) { + return; + } + const {definitions} = defAndSpan; + const tsDef = definitions.find(def => def.fileName.endsWith('.ts')); + if (!tsDef) { + return; + } + return this.host.tsLS.getReferencesAtPosition(tsDef.fileName, tsDef.textSpan.start); + } } diff --git a/packages/language-service/src/types.ts b/packages/language-service/src/types.ts index e69c8efc93..d024c2530d 100644 --- a/packages/language-service/src/types.ts +++ b/packages/language-service/src/types.ts @@ -254,7 +254,7 @@ export interface Diagnostic { export type LanguageService = Pick< ts.LanguageService, 'getCompletionsAtPosition'|'getDefinitionAndBoundSpan'|'getQuickInfoAtPosition'| - 'getSemanticDiagnostics'>; + 'getSemanticDiagnostics'|'getReferencesAtPosition'>; /** Information about an Angular template AST. */ export interface AstResult { diff --git a/packages/language-service/src/typescript_host.ts b/packages/language-service/src/typescript_host.ts index 948971f817..cf4bf437b0 100644 --- a/packages/language-service/src/typescript_host.ts +++ b/packages/language-service/src/typescript_host.ts @@ -72,8 +72,7 @@ export class TypeScriptServiceHost implements LanguageServiceHost { ngModules: [], }; - constructor( - readonly tsLsHost: tss.LanguageServiceHost, private readonly tsLS: tss.LanguageService) { + constructor(readonly tsLsHost: tss.LanguageServiceHost, readonly tsLS: tss.LanguageService) { this.summaryResolver = new AotSummaryResolver( { loadSummary(_filePath: string) { diff --git a/packages/language-service/test/BUILD.bazel b/packages/language-service/test/BUILD.bazel index f2f0a5959c..553002ddfe 100644 --- a/packages/language-service/test/BUILD.bazel +++ b/packages/language-service/test/BUILD.bazel @@ -29,6 +29,7 @@ ts_library( "definitions_spec.ts", "diagnostics_spec.ts", "hover_spec.ts", + "references_spec.ts", ], data = [":project"], deps = [ diff --git a/packages/language-service/test/definitions_spec.ts b/packages/language-service/test/definitions_spec.ts index f560ad348c..0a3bed10e8 100644 --- a/packages/language-service/test/definitions_spec.ts +++ b/packages/language-service/test/definitions_spec.ts @@ -71,7 +71,7 @@ describe('definitions', () => { const fileContent = mockHost.readFile(def.fileName); expect(fileContent!.substring(def.textSpan.start, def.textSpan.start + def.textSpan.length)) - .toEqual(`title = 'Some title';`); + .toEqual(`title = 'Tour of Heroes';`); }); it('should be able to find a method from a call', () => { diff --git a/packages/language-service/test/project/app/app.component.ts b/packages/language-service/test/project/app/app.component.ts index 768b8d11e7..e1ac8cd409 100644 --- a/packages/language-service/test/project/app/app.component.ts +++ b/packages/language-service/test/project/app/app.component.ts @@ -24,4 +24,7 @@ export class AppComponent { title = 'Tour of Heroes'; hero: Hero = {id: 1, name: 'Windstorm'}; private internal: string = 'internal'; + setTitle(newTitle: string) { + this.title = newTitle; + } } diff --git a/packages/language-service/test/project/app/parsing-cases.ts b/packages/language-service/test/project/app/parsing-cases.ts index 703c1b71ef..0d478db136 100644 --- a/packages/language-service/test/project/app/parsing-cases.ts +++ b/packages/language-service/test/project/app/parsing-cases.ts @@ -102,7 +102,7 @@ export class TemplateReference { /** * This is the title of the `TemplateReference` Component. */ - title = 'Some title'; + title = 'Tour of Heroes'; hero: Hero = {id: 1, name: 'Windstorm'}; heroP = Promise.resolve(this.hero); heroes: Hero[] = [this.hero]; @@ -121,4 +121,7 @@ export class TemplateReference { constNames = [{name: 'name'}] as const; private myField = 'My Field'; strOrNumber: string|number = ''; + setTitle(newTitle: string) { + this.title = newTitle; + } } diff --git a/packages/language-service/test/references_spec.ts b/packages/language-service/test/references_spec.ts new file mode 100644 index 0000000000..0d9ab36100 --- /dev/null +++ b/packages/language-service/test/references_spec.ts @@ -0,0 +1,78 @@ +/** + * @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 * as ts from 'typescript'; + +import {createLanguageService} from '../src/language_service'; +import {TypeScriptServiceHost} from '../src/typescript_host'; + +import {MockTypescriptHost} from './test_utils'; + +const APP_COMPONENT = '/app/app.component.ts'; +const TEST_TEMPLATE = '/app/test.ng'; + +describe('references', () => { + const mockHost = new MockTypescriptHost(['/app/main.ts']); + const tsLS = ts.createLanguageService(mockHost); + const ngHost = new TypeScriptServiceHost(mockHost, tsLS); + const ngLS = createLanguageService(ngHost); + + beforeEach(() => { + mockHost.reset(); + }); + + for (const templateStrategy of ['inline', 'external'] as const) { + describe(`template: ${templateStrategy}`, () => { + describe('component members', () => { + it('should get TS references for a member in template', () => { + const fileName = overrideTemplate('{{«title»}}'); + const marker = mockHost.getReferenceMarkerFor(fileName, 'title'); + const references = ngLS.getReferencesAtPosition(fileName, marker.start)!; + + expect(references).toBeDefined(); + expect(references.length).toBe(2); + + for (let i = 0; i < references.length; ++i) { + // The first reference is declared as a class member. + // The second is in `setTitle`. + const ref = references[i]; + expect(getSource(ref)).toBe('title'); + if (i == 0) { + // The first reference is the member declaration, so it should + // have a context span pointing to the whole declaration. + expect(getSource(ref, 'contextSpan')).toBe('title = \'Tour of Heroes\';'); + } + } + }); + }); + }); + + // TODO: override parsing-cases#TemplateReference for inline templates. + const overrideTemplate = (template: string): string => { + if (templateStrategy === 'inline') { + mockHost.overrideInlineTemplate(APP_COMPONENT, template); + return APP_COMPONENT; + } else { + mockHost.override(TEST_TEMPLATE, template); + return TEST_TEMPLATE; + } + }; + } + + /** + * Gets the source code of a reference entry. By default the reference + * `textSpan` is checked, but this can be overridden by specifying `spanKind`. + */ + function getSource( + reference: ts.ReferenceEntry, spanKind: 'textSpan'|'contextSpan' = 'textSpan'): string { + const span = reference[spanKind]!; + const fileName = reference.fileName; + const content = mockHost.readFile(fileName)!; + return content.substring(span.start, span.start + span.length); + } +});