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
This commit is contained in:
Kristiyan Kostadinov 2025-05-09 08:43:54 +02:00 committed by Alex Rickabaugh
parent c69dda61c2
commit cec512fdfd
4 changed files with 286 additions and 2 deletions

View file

@ -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);
}

View file

@ -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 = '<Dep/>';
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 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 = '<div @Dep></div>';
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 = '<Dep [someInput]="123"/>';
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 = '<div @Dep([someInput]="123")></div>';
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<void>();
}
`,
'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 = '<Dep (someEvent)="handler()"/>';
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<void>();
}
`,
'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 = '<div @Dep((someEvent)="handler()")></div>';
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 = {

View file

@ -65,7 +65,10 @@ function writeTsconfig(
export type TestableOptions = TypeCheckingOptions &
InternalOptions &
Pick<LegacyNgcOptions, 'fullTemplateTypeCheck'>;
Pick<LegacyNgcOptions, 'fullTemplateTypeCheck'> & {
// This already exists in `InternalOptions`, but it's `internal` so it's stripped away.
_enableSelectorless?: boolean;
};
export class Project {
private tsProject: ts.server.Project;

View file

@ -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<T extends ts.DocumentSpan>(
item: T,
env: LanguageServiceTestEnv,