diff --git a/packages/language-service/api.ts b/packages/language-service/api.ts index 1ca4661afae..043a93f4b94 100644 --- a/packages/language-service/api.ts +++ b/packages/language-service/api.ts @@ -36,6 +36,12 @@ export interface PluginConfig { * Version of `@angular/core` that was detected in the user's workspace. */ angularCoreVersion?: string; + + // TODO(crisbeto): type this as `false` when the syntax is enabled by default. + /** + * If false, disables parsing of `@let` declarations in the compiler. + */ + enableLetSyntax?: boolean; } export type GetTcbResponse = { diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index cfe14e5927f..aab82333e9d 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -81,6 +81,8 @@ type ElementAnimationCompletionBuilder = CompletionBuilder< type BlockCompletionBuilder = CompletionBuilder; +type LetCompletionBuilder = CompletionBuilder; + export enum CompletionNodeContext { None, ElementTag, @@ -154,11 +156,17 @@ export class CompletionBuilder { return this.getLiteralCompletions(options); } else if (this.isBlockCompletion()) { return this.getBlockCompletions(options); + } else if (this.isLetCompletion()) { + return this.getGlobalPropertyExpressionCompletion(options); } else { return undefined; } } + private isLetCompletion(): this is LetCompletionBuilder { + return this.node instanceof TmplAstLetDeclaration; + } + private isBlockCompletion(): this is BlockCompletionBuilder { return this.node instanceof UnknownBlock; } @@ -484,7 +492,7 @@ export class CompletionBuilder { * Get completions for a property expression in a global context (e.g. `{{y|}}`). */ private getGlobalPropertyExpressionCompletion( - this: PropertyExpressionCompletionBuilder, + this: PropertyExpressionCompletionBuilder | LetCompletionBuilder, options: ts.GetCompletionsAtPositionOptions | undefined, ): ts.WithMetadata | undefined { const completions = this.templateTypeChecker.getGlobalCompletions( @@ -544,16 +552,22 @@ export class CompletionBuilder { } for (const [name, entity] of templateContext) { + let displayInfo: DisplayInfoKind; + + if (entity.kind === CompletionKind.Reference) { + displayInfo = DisplayInfoKind.REFERENCE; + } else if (entity.kind === CompletionKind.LetDeclaration) { + displayInfo = DisplayInfoKind.LET; + } else { + displayInfo = DisplayInfoKind.VARIABLE; + } + entries.push({ name, sortText: name, replacementSpan, kindModifiers: ts.ScriptElementKindModifier.none, - kind: unsafeCastDisplayInfoKindToScriptElementKind( - entity.kind === CompletionKind.Reference - ? DisplayInfoKind.REFERENCE - : DisplayInfoKind.VARIABLE, - ), + kind: unsafeCastDisplayInfoKindToScriptElementKind(displayInfo), }); } @@ -1185,9 +1199,15 @@ function makeReplacementSpanFromAst( | BindingPipe | EmptyExpr | LiteralPrimitive - | BoundEvent, + | BoundEvent + | TmplAstLetDeclaration, ): ts.TextSpan | undefined { - if (node instanceof EmptyExpr || node instanceof LiteralPrimitive || node instanceof BoundEvent) { + if ( + node instanceof EmptyExpr || + node instanceof LiteralPrimitive || + node instanceof BoundEvent || + node instanceof TmplAstLetDeclaration + ) { // empty nodes do not replace any existing text return undefined; } diff --git a/packages/language-service/src/definitions.ts b/packages/language-service/src/definitions.ts index 3acc96bdbb5..fd3984a0249 100644 --- a/packages/language-service/src/definitions.ts +++ b/packages/language-service/src/definitions.ts @@ -111,9 +111,6 @@ export class DefinitionBuilder { component, }: DefinitionMeta & TemplateInfo): readonly ts.DefinitionInfo[] | undefined { switch (symbol.kind) { - case SymbolKind.LetDeclaration: - // TODO(crisbeto): will integrate let declarations in a future commit. - return undefined; case SymbolKind.Directive: case SymbolKind.Element: case SymbolKind.Template: @@ -141,14 +138,15 @@ export class DefinitionBuilder { const directiveDefs = this.getDirectiveTypeDefsForBindingNode(node, parent, component); return [...bindingDefs, ...directiveDefs]; } + case SymbolKind.LetDeclaration: case SymbolKind.Variable: case SymbolKind.Reference: { const definitions: ts.DefinitionInfo[] = []; if (symbol.declaration !== node) { const tcbLocation = - symbol.kind === SymbolKind.Variable - ? symbol.localVarLocation - : symbol.referenceVarLocation; + symbol.kind === SymbolKind.Reference + ? symbol.referenceVarLocation + : symbol.localVarLocation; const mapping = getTemplateLocationFromTcbLocation( this.compiler.getTemplateTypeChecker(), tcbLocation.tcbPath, @@ -167,7 +165,7 @@ export class DefinitionBuilder { }); } } - if (symbol.kind === SymbolKind.Variable) { + if (symbol.kind === SymbolKind.Variable || symbol.kind === SymbolKind.LetDeclaration) { definitions.push( ...this.getDefinitionsForSymbols({tcbLocation: symbol.initializerLocation}), ); @@ -271,7 +269,8 @@ export class DefinitionBuilder { case SymbolKind.Expression: definitions.push(...this.getTypeDefinitionsForSymbols(symbol)); break; - case SymbolKind.Variable: { + case SymbolKind.Variable: + case SymbolKind.LetDeclaration: { definitions.push( ...this.getTypeDefinitionsForSymbols({tcbLocation: symbol.initializerLocation}), ); diff --git a/packages/language-service/src/display_parts.ts b/packages/language-service/src/display_parts.ts index 21b06f6f4c8..f5a940c2896 100644 --- a/packages/language-service/src/display_parts.ts +++ b/packages/language-service/src/display_parts.ts @@ -8,6 +8,7 @@ import {isNamedClassDeclaration} from '@angular/compiler-cli/src/ngtsc/reflection'; import { + LetDeclarationSymbol, PotentialDirective, ReferenceSymbol, Symbol, @@ -42,6 +43,7 @@ export enum DisplayInfoKind { METHOD = 'method', TEMPLATE = 'template', KEYWORD = 'keyword', + LET = 'let', } export interface DisplayInfo { @@ -54,13 +56,15 @@ export interface DisplayInfo { export function getSymbolDisplayInfo( tsLS: ts.LanguageService, typeChecker: ts.TypeChecker, - symbol: ReferenceSymbol | VariableSymbol, + symbol: ReferenceSymbol | VariableSymbol | LetDeclarationSymbol, ): DisplayInfo { let kind: DisplayInfoKind; if (symbol.kind === SymbolKind.Reference) { kind = DisplayInfoKind.REFERENCE; } else if (symbol.kind === SymbolKind.Variable) { kind = DisplayInfoKind.VARIABLE; + } else if (symbol.kind === SymbolKind.LetDeclaration) { + kind = DisplayInfoKind.LET; } else { throw new Error( `AssertionError: unexpected symbol kind ${SymbolKind[(symbol as Symbol).kind]}`, diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index 5b496cc21e0..5dfd28b63be 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -642,6 +642,10 @@ function parseNgCompilerOptions( if (config['enableBlockSyntax'] === false) { options['_enableBlockSyntax'] = false; } + // TODO(crisbeto): only allow `false` when the syntax is enabled by default. + if (config['enableLetSyntax'] != null) { + options['_enableLetSyntax'] = config['enableLetSyntax']; + } options['_angularCoreVersion'] = config['angularCoreVersion']; diff --git a/packages/language-service/src/quick_info.ts b/packages/language-service/src/quick_info.ts index 9226020814d..988e2147e78 100644 --- a/packages/language-service/src/quick_info.ts +++ b/packages/language-service/src/quick_info.ts @@ -19,6 +19,7 @@ import { DomBindingSymbol, ElementSymbol, InputBindingSymbol, + LetDeclarationSymbol, OutputBindingSymbol, PipeSymbol, ReferenceSymbol, @@ -84,9 +85,6 @@ export class QuickInfoBuilder { private getQuickInfoForSymbol(symbol: Symbol): ts.QuickInfo | undefined { switch (symbol.kind) { - case SymbolKind.LetDeclaration: - // TODO(crisbeto): will integrate let declarations in a future commit. - return undefined; case SymbolKind.Input: case SymbolKind.Output: return this.getQuickInfoForBindingSymbol(symbol); @@ -96,6 +94,8 @@ export class QuickInfoBuilder { return this.getQuickInfoForElementSymbol(symbol); case SymbolKind.Variable: return this.getQuickInfoForVariableSymbol(symbol); + case SymbolKind.LetDeclaration: + return this.getQuickInfoForLetDeclarationSymbol(symbol); case SymbolKind.Reference: return this.getQuickInfoForReferenceSymbol(symbol); case SymbolKind.DomBinding: @@ -151,6 +151,18 @@ export class QuickInfoBuilder { ); } + private getQuickInfoForLetDeclarationSymbol(symbol: LetDeclarationSymbol): ts.QuickInfo { + const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.initializerLocation); + return createQuickInfo( + symbol.declaration.name, + DisplayInfoKind.LET, + getTextSpanOfNode(this.node), + undefined /* containerName */, + this.typeChecker.typeToString(symbol.tsType), + documentation, + ); + } + private getQuickInfoForReferenceSymbol(symbol: ReferenceSymbol): ts.QuickInfo { const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.targetLocation); return createQuickInfo( diff --git a/packages/language-service/src/references_and_rename_utils.ts b/packages/language-service/src/references_and_rename_utils.ts index 946d9db3b7c..6fdaa3c9a18 100644 --- a/packages/language-service/src/references_and_rename_utils.ts +++ b/packages/language-service/src/references_and_rename_utils.ts @@ -14,6 +14,7 @@ import { SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, + TmplAstLetDeclaration, TmplAstNode, TmplAstReference, TmplAstTextAttribute, @@ -180,6 +181,20 @@ export function getTargetDetailsAtTemplatePosition( } break; } + case SymbolKind.LetDeclaration: + // If the templateNode isn't on a let declaration, it has to be on a usage of it + // somewhere in the template. Otherwise only pick up when it's within the name. + if ( + !(templateTarget instanceof TmplAstLetDeclaration) || + isWithin(position, templateTarget.nameSpan) + ) { + details.push({ + typescriptLocations: [toFilePosition(symbol.localVarLocation)], + templateTarget, + symbol, + }); + } + break; case SymbolKind.Input: case SymbolKind.Output: { details.push({ @@ -317,10 +332,9 @@ export function getRenameTextAndSpanAtPosition( node instanceof TmplAstTextAttribute || node instanceof TmplAstBoundEvent ) { - if (node.keySpan === undefined) { - return null; - } - return {text: node.name, span: toTextSpan(node.keySpan)}; + return node.keySpan === undefined ? null : {text: node.name, span: toTextSpan(node.keySpan)}; + } else if (node instanceof TmplAstLetDeclaration && isWithin(position, node.nameSpan)) { + return {text: node.nameSpan.toString(), span: toTextSpan(node.nameSpan)}; } else if (node instanceof TmplAstVariable || node instanceof TmplAstReference) { if (isWithin(position, node.keySpan)) { return {text: node.keySpan.toString(), span: toTextSpan(node.keySpan)}; diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 5157c07f0fe..838f0fc16a9 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -1952,6 +1952,65 @@ describe('completions', () => { expectReplacementText(completions, templateFile.contents, 'mat-'); }); }); + + describe('let declarations', () => { + it('should complete a let declaration', () => { + const {templateFile} = setup( + ` + @let message = 'hello'; + {{mess}} + `, + '', + ); + templateFile.moveCursorToText('{{mess¦}}'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, DisplayInfoKind.LET, ['message']); + }); + + it('should complete an empty let declaration with a terminating character', () => { + const {templateFile} = setup('@let foo = ;', `title!: string; hero!52: number;`); + templateFile.moveCursorToText('@let foo = ¦;'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']); + }); + + it('should complete a single let declaration without a terminating character', () => { + const {templateFile} = setup('@let foo = ', `title!: string; hero!52: number;`); + templateFile.moveCursorToText('@let foo = ¦'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']); + }); + + it('should complete a let declaration property in the global scope', () => { + const {templateFile} = setup( + ` + @let hobbit = {name: 'Frodo', age: 53}; + {{hobbit.}} + `, + '', + ); + templateFile.moveCursorToText('{{hobbit.¦}}'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['age', 'name']); + }); + + it('should complete a shadowed let declaration property', () => { + const {templateFile} = setup( + ` + @let hobbit = {name: 'Frodo', age: 53}; + + @if (true) { + @let hobbit = {hasRing: true, size: 'small'}; + {{hobbit.}} + } + `, + '', + ); + templateFile.moveCursorToText('{{hobbit.¦}}'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['hasRing', 'size']); + }); + }); }); function expectContainInsertText( @@ -2061,8 +2120,10 @@ function setup( const otherDirectiveClassDecls = Object.values(otherDeclarations).join('\n\n'); const env = LanguageServiceTestEnv.setup(); - const project = env.addProject('test', { - 'test.ts': ` + const project = env.addProject( + 'test', + { + 'test.ts': ` import {Component, input, output, @@ -2093,8 +2154,14 @@ function setup( }) export class AppModule {} `, - 'test.html': template, - }); + 'test.html': template, + }, + // Note: this object is cast to `any`, because for some reason the typing + // changes to the `TestableOption` type aren't being picked up in tests. + { + _enableLetSyntax: true, + } as any, + ); return {templateFile: project.openFile('test.html')}; } diff --git a/packages/language-service/test/definitions_spec.ts b/packages/language-service/test/definitions_spec.ts index 6cb433746bd..0866bb94743 100644 --- a/packages/language-service/test/definitions_spec.ts +++ b/packages/language-service/test/definitions_spec.ts @@ -7,6 +7,7 @@ */ import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import ts from 'typescript'; import { assertFileNames, @@ -381,6 +382,36 @@ describe('definitions', () => { assertFileNames(Array.from(definitions!), ['app.html']); }); + it('gets definition for a let declaration', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'app.ts': ` + import {Component} from '@angular/core'; + + @Component({templateUrl: '/app.html'}) + export class AppCmp {} + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createModuleAndProjectWithDeclarations(env, 'test', files, { + // Note: this object is cast to `any`, because for some reason the typing + // changes to the `TestableOption` type aren't being picked up in tests. + _enableLetSyntax: true, + } as any); + const template = project.openFile('app.html'); + template.contents = '@let foo = {value: 123}; {{foo.value}}'; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText('{{fo¦o.value}}'); + const {definitions} = getDefinitionsAndAssertBoundSpan(env, template); + expect(definitions[0].name).toEqual('foo'); + expect(definitions[0].kind).toBe(ts.ScriptElementKind.variableElement); + expect(definitions[0].textSpan).toBe('@let foo = {value: 123}'); + assertFileNames(Array.from(definitions), ['app.html']); + }); + function getDefinitionsAndAssertBoundSpan(env: LanguageServiceTestEnv, file: OpenBuffer) { env.expectNoSourceDiagnostics(); const definitionAndBoundSpan = file.getDefinitionAndBoundSpan(); diff --git a/packages/language-service/test/legacy/definitions_spec.ts b/packages/language-service/test/legacy/definitions_spec.ts index b350bb52047..c90c2c53c01 100644 --- a/packages/language-service/test/legacy/definitions_spec.ts +++ b/packages/language-service/test/legacy/definitions_spec.ts @@ -18,7 +18,9 @@ describe('definitions', () => { beforeAll(() => { const {project, service: _service, tsLS} = setup(); service = _service; - ngLS = new LanguageService(project, tsLS, {}); + ngLS = new LanguageService(project, tsLS, { + enableLetSyntax: true, + }); }); beforeEach(() => { @@ -287,6 +289,19 @@ describe('definitions', () => { }); }); + describe('@let declarations', () => { + it('should work for a @let declaration', () => { + const definitions = getDefinitionsAndAssertBoundSpan({ + templateOverride: `@let value = 42; {{val¦ue}}`, + expectedSpanText: 'value', + }); + + expect(definitions.length).toBe(1); + const [def] = definitions; + expect(def.textSpan).toBe('@let value = 42'); + }); + }); + describe('pipes', () => { it('should work for pipes', () => { const templateOverride = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; diff --git a/packages/language-service/test/legacy/language_service_spec.ts b/packages/language-service/test/legacy/language_service_spec.ts index ecc1f076fca..df6b6a16cd0 100644 --- a/packages/language-service/test/legacy/language_service_spec.ts +++ b/packages/language-service/test/legacy/language_service_spec.ts @@ -105,6 +105,19 @@ describe('language service adapter', () => { ); }); + it('should always disable let declarations if enableLetSyntax is false', () => { + const {project, tsLS} = setup(); + const ngLS = new LanguageService(project, tsLS, { + enableLetSyntax: false, + }); + + expect(ngLS.getCompilerOptions()).toEqual( + jasmine.objectContaining({ + '_enableLetSyntax': false, + }), + ); + }); + it('should pass the @angular/core version along to the compiler', () => { const {project, tsLS} = setup(); const ngLS = new LanguageService(project, tsLS, { diff --git a/packages/language-service/test/legacy/template_target_spec.ts b/packages/language-service/test/legacy/template_target_spec.ts index 5ba5c8411f7..9ba1ea9e208 100644 --- a/packages/language-service/test/legacy/template_target_spec.ts +++ b/packages/language-service/test/legacy/template_target_spec.ts @@ -41,6 +41,7 @@ function parse(template: string): ParseResult { leadingTriviaChars: [], preserveWhitespaces: true, alwaysAttemptHtmlToR3AstConversion: true, + enableLetSyntax: true, }), position, }; @@ -333,6 +334,15 @@ describe('getTargetAtPosition for template AST', () => { expect(node).toBeInstanceOf(t.Variable); }); + it('should locate a @let name', () => { + const {errors, nodes, position} = parse(`@let va¦lue = 1337;`); + expect(errors).toBe(null); + const {context} = getTargetAtPosition(nodes, position)!; + const {node} = context as SingleNodeTarget; + expect(isTemplateNode(node!)).toBe(true); + expect(node).toBeInstanceOf(t.LetDeclaration); + }); + it('should locate template children', () => { const {errors, nodes, position} = parse(``); expect(errors).toBe(null); @@ -661,6 +671,16 @@ describe('getTargetAtPosition for expression AST', () => { expect(node).toBeInstanceOf(e.Conditional); }); + it('should locate a @let value', () => { + const {errors, nodes, position} = parse(`@let value = 13¦37;`); + expect(errors).toBe(null); + const {context} = getTargetAtPosition(nodes, position)!; + const {node} = context as SingleNodeTarget; + expect(isExpressionNode(node!)).toBe(true); + expect(node).toBeInstanceOf(e.LiteralPrimitive); + expect((node as e.LiteralPrimitive).value).toBe(1337); + }); + describe('object literal shorthand', () => { it('should locate on literal with one shorthand property', () => { const {errors, nodes, position} = parse(`{{ {va¦l1} }}`); diff --git a/packages/language-service/test/legacy/type_definitions_spec.ts b/packages/language-service/test/legacy/type_definitions_spec.ts index f161c0521ed..d343841c314 100644 --- a/packages/language-service/test/legacy/type_definitions_spec.ts +++ b/packages/language-service/test/legacy/type_definitions_spec.ts @@ -18,7 +18,7 @@ describe('type definitions', () => { beforeAll(() => { const {project, service: _service, tsLS} = setup(); service = _service; - ngLS = new LanguageService(project, tsLS, {}); + ngLS = new LanguageService(project, tsLS, {enableLetSyntax: true}); }); const possibleArrayDefFiles: readonly string[] = [ @@ -231,6 +231,18 @@ describe('type definitions', () => { }); }); + describe('@let declarations', () => { + it('should work for a let declaration', () => { + const definitions = getTypeDefinitions({ + templateOverride: `@let address = hero.address; {{addr¦ess}}`, + }); + + expect(definitions.length).toEqual(1); + expect(definitions[0].textSpan).toBe('Address'); + expect(definitions[0].contextSpan).toContain('export interface Address'); + }); + }); + describe('pipes', () => { it('should work for pipes', () => { const templateOverride = `

The hero's birthday is {{birthday | da¦te: "MM/dd/yy"}}

`; diff --git a/packages/language-service/test/quick_info_spec.ts b/packages/language-service/test/quick_info_spec.ts index 36251a3f6dd..0449117c5ce 100644 --- a/packages/language-service/test/quick_info_spec.ts +++ b/packages/language-service/test/quick_info_spec.ts @@ -110,7 +110,11 @@ describe('quick info', () => { beforeEach(() => { initMockFileSystem('Native'); env = LanguageServiceTestEnv.setup(); - project = env.addProject('test', quickInfoSkeleton()); + project = env.addProject('test', quickInfoSkeleton(), { + // Note: this object is cast to `any`, because for some reason the typing + // changes to the `TestableOption` type aren't being picked up in tests. + _enableLetSyntax: true, + } as any); }); describe('elements', () => { @@ -705,6 +709,16 @@ describe('quick info', () => { }); }); + describe('let declarations', () => { + it('should get quick info for a let declaration', () => { + expectQuickInfo({ + templateOverride: `@let na¦me = 'Frodo';`, + expectedSpanText: `@let name = 'Frodo'`, + expectedDisplayString: `(let) name: "Frodo"`, + }); + }); + }); + it('should work for object literal with shorthand property declarations', () => { initMockFileSystem('Native'); env = LanguageServiceTestEnv.setup(); diff --git a/packages/language-service/test/references_and_rename_spec.ts b/packages/language-service/test/references_and_rename_spec.ts index 6ad42964231..a7951972b55 100644 --- a/packages/language-service/test/references_and_rename_spec.ts +++ b/packages/language-service/test/references_and_rename_spec.ts @@ -1262,6 +1262,90 @@ describe('find references and rename locations', () => { }); }); + describe('let declarations', () => { + // Note: this object is cast to `any`, because for some reason the typing + // changes to the `TestableOption` type aren't being picked up in tests. + const parseConfig: any = { + _enableLetSyntax: true, + }; + + describe('when cursor is on the name of the declaration', () => { + let file: OpenBuffer; + beforeEach(() => { + const files = { + 'app.ts': ` + import {Component} from '@angular/core'; + + @Component({templateUrl: './template.ng.html'}) + export class AppCmp { + }`, + 'template.ng.html': ` + @let hobbit = 'Frodo'; + @let greeting = 'Hello, ' + hobbit; + {{hobbit}} + {{greeting}} + `, + }; + env = LanguageServiceTestEnv.setup(); + const project = createModuleAndProjectWithDeclarations(env, 'test', files, parseConfig); + file = project.openFile('template.ng.html'); + file.moveCursorToText('@let hob¦bit ='); + }); + + it('should find references', () => { + const refs = getReferencesAtPosition(file)!; + expect(refs.length).toBe(3); + assertFileNames(refs, ['template.ng.html']); + assertTextSpans(refs, ['hobbit']); + }); + + it('should find rename locations', () => { + const renameLocations = getRenameLocationsAtPosition(file)!; + expect(renameLocations.length).toBe(3); + assertFileNames(renameLocations, ['template.ng.html']); + assertTextSpans(renameLocations, ['hobbit']); + }); + }); + + describe('when cursor is on a usage of the declaration name', () => { + let file: OpenBuffer; + beforeEach(() => { + const files = { + 'app.ts': ` + import {Component} from '@angular/core'; + + @Component({templateUrl: './template.ng.html'}) + export class AppCmp { + }`, + 'template.ng.html': ` + @let hobbit = 'Frodo'; + @let greeting = 'Hello, ' + hobbit; + {{hobbit}} + {{greeting}} + `, + }; + env = LanguageServiceTestEnv.setup(); + const project = createModuleAndProjectWithDeclarations(env, 'test', files, parseConfig); + file = project.openFile('template.ng.html'); + file.moveCursorToText(`'Hello, ' + hob¦bit`); + }); + + it('should find references', () => { + const refs = getReferencesAtPosition(file)!; + expect(refs.length).toBe(3); + assertFileNames(refs, ['template.ng.html']); + assertTextSpans(refs, ['hobbit']); + }); + + it('should find rename locations', () => { + const renameLocations = getRenameLocationsAtPosition(file)!; + expect(renameLocations.length).toBe(3); + assertFileNames(renameLocations, ['template.ng.html']); + assertTextSpans(renameLocations, ['hobbit']); + }); + }); + }); + it('should get references to both input and output for two-way binding', () => { const files = { 'dir.ts': `