From cec512fdfdb3db5aee17b48a7b54a69253ccc3ca Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 9 May 2025 08:43:54 +0200 Subject: [PATCH] refactor(language-service): support definitions for selectorless (#61240) Updates the language service to handle producing definition information for selectorless components and directives. PR Close #61240 --- packages/language-service/src/definitions.ts | 16 +- .../language-service/test/definitions_spec.ts | 247 ++++++++++++++++++ .../language-service/testing/src/project.ts | 5 +- packages/language-service/testing/src/util.ts | 20 ++ 4 files changed, 286 insertions(+), 2 deletions(-) diff --git a/packages/language-service/src/definitions.ts b/packages/language-service/src/definitions.ts index 6f0b649a369..ebc4442329e 100644 --- a/packages/language-service/src/definitions.ts +++ b/packages/language-service/src/definitions.ts @@ -22,6 +22,8 @@ import { DirectiveSymbol, DomBindingSymbol, ElementSymbol, + SelectorlessComponentSymbol, + SelectorlessDirectiveSymbol, Symbol, SymbolKind, TcbLocation, @@ -118,6 +120,8 @@ export class DefinitionBuilder { case SymbolKind.Element: case SymbolKind.Template: case SymbolKind.DomBinding: + case SymbolKind.SelectorlessComponent: + case SymbolKind.SelectorlessDirective: // Though it is generally more appropriate for the above symbol definitions to be // associated with "type definitions" since the location in the template is the // actual definition location, the better user experience would be to allow @@ -238,6 +242,8 @@ export class DefinitionBuilder { case SymbolKind.DomBinding: case SymbolKind.Element: case SymbolKind.Template: + case SymbolKind.SelectorlessComponent: + case SymbolKind.SelectorlessDirective: definitions.push(...this.getTypeDefinitionsForTemplateInstance(symbol, node)); break; case SymbolKind.Output: @@ -286,7 +292,13 @@ export class DefinitionBuilder { } private getTypeDefinitionsForTemplateInstance( - symbol: TemplateSymbol | ElementSymbol | DomBindingSymbol | DirectiveSymbol, + symbol: + | TemplateSymbol + | ElementSymbol + | DomBindingSymbol + | DirectiveSymbol + | SelectorlessComponentSymbol + | SelectorlessDirectiveSymbol, node: AST | TmplAstNode, ): ts.DefinitionInfo[] { switch (symbol.kind) { @@ -313,6 +325,8 @@ export class DefinitionBuilder { ); return this.getTypeDefinitionsForSymbols(...dirs); } + case SymbolKind.SelectorlessComponent: + case SymbolKind.SelectorlessDirective: case SymbolKind.Directive: return this.getTypeDefinitionsForSymbols(symbol); } diff --git a/packages/language-service/test/definitions_spec.ts b/packages/language-service/test/definitions_spec.ts index e1c711334d4..130c9182add 100644 --- a/packages/language-service/test/definitions_spec.ts +++ b/packages/language-service/test/definitions_spec.ts @@ -13,6 +13,7 @@ import { assertFileNames, assertTextSpans, createModuleAndProjectWithDeclarations, + createProjectWithStandaloneDeclarations, humanizeDocumentSpanLike, LanguageServiceTestEnv, OpenBuffer, @@ -472,6 +473,252 @@ describe('definitions', () => { assertFileNames(Array.from(definitions), ['app.html']); }); + it('gets definition for selectorless component', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'dep.ts': ` + import {Component} from '@angular/core'; + + @Component({template: ''}) + export class Dep {} + `, + 'app.ts': ` + import {Component} from '@angular/core'; + import {Dep} from './dep'; + + @Component({ + templateUrl: '/app.html', + }) + export class AppCmp {} + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createProjectWithStandaloneDeclarations(env, 'test', files, { + _enableSelectorless: true, + }); + const template = project.openFile('app.html'); + template.contents = ''; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText(''); + const {definitions} = getDefinitionsAndAssertBoundSpan(env, template); + expect(definitions[0].name).toEqual('Dep'); + expect(definitions[0].kind).toBe(ts.ScriptElementKind.classElement); + expect(definitions[0].textSpan).toBe('Dep'); + assertFileNames(Array.from(definitions), ['dep.ts']); + }); + + it('gets definition for selectorless directive', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'dep.ts': ` + import {Directive} from '@angular/core'; + + @Directive() + export class Dep {} + `, + 'app.ts': ` + import {Component} from '@angular/core'; + import {Dep} from './dep'; + + @Component({ + templateUrl: '/app.html', + }) + export class AppCmp {} + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createProjectWithStandaloneDeclarations(env, 'test', files, { + _enableSelectorless: true, + }); + const template = project.openFile('app.html'); + template.contents = '
'; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText('@De¦p'); + const {definitions} = getDefinitionsAndAssertBoundSpan(env, template); + expect(definitions[0].name).toEqual('Dep'); + expect(definitions[0].kind).toBe(ts.ScriptElementKind.classElement); + expect(definitions[0].textSpan).toBe('Dep'); + assertFileNames(Array.from(definitions), ['dep.ts']); + }); + + it('gets definition of selectorless component input', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'dep.ts': ` + import {Component, Input} from '@angular/core'; + + @Component({template: ''}) + export class Dep { + @Input() someInput: any; + } + `, + 'app.ts': ` + import {Component} from '@angular/core'; + import {Dep} from './dep'; + + @Component({ + templateUrl: '/app.html', + }) + export class AppCmp {} + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createProjectWithStandaloneDeclarations(env, 'test', files, { + _enableSelectorless: true, + }); + const template = project.openFile('app.html'); + template.contents = ''; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText('[some¦Input]'); + const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template); + + expect(template.contents.slice(textSpan.start, textSpan.start + textSpan.length)).toEqual( + 'someInput', + ); + expect(definitions.length).toBe(1); + expect(definitions[0].textSpan).toContain('someInput'); + assertFileNames(definitions, ['dep.ts']); + }); + + it('gets definition of selectorless directive input', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'dep.ts': ` + import {Directive, Input} from '@angular/core'; + + @Directive() + export class Dep { + @Input() someInput: any; + } + `, + 'app.ts': ` + import {Component} from '@angular/core'; + import {Dep} from './dep'; + + @Component({ + templateUrl: '/app.html', + }) + export class AppCmp {} + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createProjectWithStandaloneDeclarations(env, 'test', files, { + _enableSelectorless: true, + }); + const template = project.openFile('app.html'); + template.contents = '
'; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText('[some¦Input]'); + const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template); + + expect(template.contents.slice(textSpan.start, textSpan.start + textSpan.length)).toEqual( + 'someInput', + ); + expect(definitions.length).toBe(1); + expect(definitions[0].textSpan).toContain('someInput'); + assertFileNames(definitions, ['dep.ts']); + }); + + it('gets definition of selectorless component output', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'dep.ts': ` + import {Component, Output, EventEmitter} from '@angular/core'; + + @Component({template: ''}) + export class Dep { + @Output() someEvent = new EventEmitter(); + } + `, + 'app.ts': ` + import {Component} from '@angular/core'; + import {Dep} from './dep'; + + @Component({ + templateUrl: '/app.html', + }) + export class AppCmp { + handler() {} + } + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createProjectWithStandaloneDeclarations(env, 'test', files, { + _enableSelectorless: true, + }); + const template = project.openFile('app.html'); + template.contents = ''; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText('(some¦Event)'); + const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template); + + expect(template.contents.slice(textSpan.start, textSpan.start + textSpan.length)).toEqual( + 'someEvent', + ); + expect(definitions.length).toBe(1); + expect(definitions[0].textSpan).toContain('someEvent'); + assertFileNames(definitions, ['dep.ts']); + }); + + it('gets definition of selectorless directive output', () => { + initMockFileSystem('Native'); + const files = { + 'app.html': '', + 'dep.ts': ` + import {Directive, Output, EventEmitter} from '@angular/core'; + + @Directive() + export class Dep { + @Output() someEvent = new EventEmitter(); + } + `, + 'app.ts': ` + import {Component} from '@angular/core'; + import {Dep} from './dep'; + + @Component({ + templateUrl: '/app.html', + }) + export class AppCmp { + handler() {} + } + `, + }; + const env = LanguageServiceTestEnv.setup(); + + const project = createProjectWithStandaloneDeclarations(env, 'test', files, { + _enableSelectorless: true, + }); + const template = project.openFile('app.html'); + template.contents = '
'; + project.expectNoSourceDiagnostics(); + + template.moveCursorToText('(some¦Event)'); + const {textSpan, definitions} = getDefinitionsAndAssertBoundSpan(env, template); + + expect(template.contents.slice(textSpan.start, textSpan.start + textSpan.length)).toEqual( + 'someEvent', + ); + expect(definitions.length).toBe(1); + expect(definitions[0].textSpan).toContain('someEvent'); + assertFileNames(definitions, ['dep.ts']); + }); + it('gets definition for a method in a void expression', () => { initMockFileSystem('Native'); const files = { diff --git a/packages/language-service/testing/src/project.ts b/packages/language-service/testing/src/project.ts index ad9ca9daf95..614d75a2d8a 100644 --- a/packages/language-service/testing/src/project.ts +++ b/packages/language-service/testing/src/project.ts @@ -65,7 +65,10 @@ function writeTsconfig( export type TestableOptions = TypeCheckingOptions & InternalOptions & - Pick; + Pick & { + // This already exists in `InternalOptions`, but it's `internal` so it's stripped away. + _enableSelectorless?: boolean; + }; export class Project { private tsProject: ts.server.Project; diff --git a/packages/language-service/testing/src/util.ts b/packages/language-service/testing/src/util.ts index e32ae3d3977..ec05789e1af 100644 --- a/packages/language-service/testing/src/util.ts +++ b/packages/language-service/testing/src/util.ts @@ -111,6 +111,26 @@ export function createModuleAndProjectWithDeclarations( return env.addProject(projectName, {...projectFiles, ...standaloneFiles}, angularCompilerOptions); } +export function createProjectWithStandaloneDeclarations( + env: LanguageServiceTestEnv, + projectName: string, + projectFiles: ProjectFiles, + angularCompilerOptions: TestableOptions = {}, + standaloneFiles: ProjectFiles = {}, +): Project { + const externalClasses: string[] = []; + const externalImports: string[] = []; + for (const [fileName, fileContents] of Object.entries(projectFiles)) { + if (!fileName.endsWith('.ts')) { + continue; + } + const className = getFirstClassDeclaration(fileContents); + externalClasses.push(className); + externalImports.push(`import {${className}} from './${fileName.replace('.ts', '')}';`); + } + return env.addProject(projectName, {...projectFiles, ...standaloneFiles}, angularCompilerOptions); +} + export function humanizeDocumentSpanLike( item: T, env: LanguageServiceTestEnv,