mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
fix(language-service): correctly handle host directive inputs/outputs (#48147)
Adds some logic to correctly handle hidden or aliased inputs/outputs in the language service. Fixes #48102. PR Close #48147
This commit is contained in:
parent
c5d176ceb5
commit
fd2eea5961
6 changed files with 327 additions and 41 deletions
|
|
@ -10,9 +10,8 @@ import {AbsoluteSourceSpan, BoundTarget, DirectiveMeta, ParseSourceSpan, SchemaM
|
|||
import ts from 'typescript';
|
||||
|
||||
import {ErrorCode} from '../../diagnostics';
|
||||
import {AbsoluteFsPath} from '../../file_system';
|
||||
import {Reference} from '../../imports';
|
||||
import {ClassPropertyMapping, DirectiveTypeCheckMeta} from '../../metadata';
|
||||
import {ClassPropertyMapping, DirectiveTypeCheckMeta, HostDirectiveMeta} from '../../metadata';
|
||||
import {ClassDeclaration} from '../../reflection';
|
||||
|
||||
|
||||
|
|
@ -26,6 +25,7 @@ export interface TypeCheckableDirectiveMeta extends DirectiveMeta, DirectiveType
|
|||
inputs: ClassPropertyMapping;
|
||||
outputs: ClassPropertyMapping;
|
||||
isStandalone: boolean;
|
||||
hostDirectives: HostDirectiveMeta[]|null;
|
||||
}
|
||||
|
||||
export type TemplateId = string&{__brand: 'TemplateId'};
|
||||
|
|
|
|||
|
|
@ -258,11 +258,8 @@ export interface TemplateSymbol {
|
|||
templateNode: TmplAstTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of a directive/component whose selector matches a node in a component
|
||||
* template.
|
||||
*/
|
||||
export interface DirectiveSymbol extends PotentialDirective {
|
||||
/** Interface shared between host and non-host directives. */
|
||||
interface DirectiveSymbolBase extends PotentialDirective {
|
||||
kind: SymbolKind.Directive;
|
||||
|
||||
/** The `ts.Type` for the class declaration. */
|
||||
|
|
@ -272,6 +269,16 @@ export interface DirectiveSymbol extends PotentialDirective {
|
|||
tcbLocation: TcbLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* A representation of a directive/component whose selector matches a node in a component
|
||||
* template.
|
||||
*/
|
||||
export type DirectiveSymbol = (DirectiveSymbolBase&{isHostDirective: false})|(DirectiveSymbolBase&{
|
||||
isHostDirective: true;
|
||||
exposedInputs: Record<string, string>|null;
|
||||
exposedOutputs: Record<string, string>|null;
|
||||
});
|
||||
|
||||
/**
|
||||
* A representation of an attribute on an element or template. These bindings aren't currently
|
||||
* type-checked (see `checkTypeOfDomBindings`) so they won't have a `ts.Type`, `ts.Symbol`, or shim
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import ts from 'typescript';
|
|||
|
||||
import {AbsoluteFsPath} from '../../file_system';
|
||||
import {Reference} from '../../imports';
|
||||
import {HostDirectiveMeta} from '../../metadata';
|
||||
import {ClassDeclaration} from '../../reflection';
|
||||
import {ComponentScopeKind, ComponentScopeReader} from '../../scope';
|
||||
import {isAssignment, isSymbolWithValueDeclaration} from '../../util/src/typescript';
|
||||
|
|
@ -118,38 +119,80 @@ export class SymbolBuilder {
|
|||
|
||||
const nodes = findAllMatchingNodes(
|
||||
this.typeCheckBlock, {withSpan: elementSourceSpan, filter: isDirectiveDeclaration});
|
||||
return nodes
|
||||
.map(node => {
|
||||
const symbol = this.getSymbolOfTsNode(node.parent);
|
||||
if (symbol === null || !isSymbolWithValueDeclaration(symbol.tsSymbol) ||
|
||||
!ts.isClassDeclaration(symbol.tsSymbol.valueDeclaration)) {
|
||||
return null;
|
||||
}
|
||||
const meta = this.getDirectiveMeta(element, symbol.tsSymbol.valueDeclaration);
|
||||
if (meta === null) {
|
||||
return null;
|
||||
}
|
||||
const symbols: DirectiveSymbol[] = [];
|
||||
|
||||
const ngModule = this.getDirectiveModule(symbol.tsSymbol.valueDeclaration);
|
||||
if (meta.selector === null) {
|
||||
return null;
|
||||
}
|
||||
const isComponent = meta.isComponent ?? null;
|
||||
const ref = new Reference<ClassDeclaration>(symbol.tsSymbol.valueDeclaration as any);
|
||||
const directiveSymbol: DirectiveSymbol = {
|
||||
...symbol,
|
||||
ref,
|
||||
tsSymbol: symbol.tsSymbol,
|
||||
selector: meta.selector,
|
||||
isComponent,
|
||||
ngModule,
|
||||
kind: SymbolKind.Directive,
|
||||
isStructural: meta.isStructural,
|
||||
isInScope: true,
|
||||
};
|
||||
return directiveSymbol;
|
||||
})
|
||||
.filter((d): d is DirectiveSymbol => d !== null);
|
||||
for (const node of nodes) {
|
||||
const symbol = this.getSymbolOfTsNode(node.parent);
|
||||
if (symbol === null || !isSymbolWithValueDeclaration(symbol.tsSymbol) ||
|
||||
!ts.isClassDeclaration(symbol.tsSymbol.valueDeclaration)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta = this.getDirectiveMeta(element, symbol.tsSymbol.valueDeclaration);
|
||||
|
||||
if (meta !== null && meta.selector !== null) {
|
||||
const ref = new Reference<ClassDeclaration>(symbol.tsSymbol.valueDeclaration as any);
|
||||
|
||||
if (meta.hostDirectives !== null) {
|
||||
this.addHostDirectiveSymbols(element, meta.hostDirectives, symbols);
|
||||
}
|
||||
|
||||
const directiveSymbol: DirectiveSymbol = {
|
||||
...symbol,
|
||||
ref,
|
||||
tsSymbol: symbol.tsSymbol,
|
||||
selector: meta.selector,
|
||||
isComponent: meta.isComponent,
|
||||
ngModule: this.getDirectiveModule(symbol.tsSymbol.valueDeclaration),
|
||||
kind: SymbolKind.Directive,
|
||||
isStructural: meta.isStructural,
|
||||
isInScope: true,
|
||||
isHostDirective: false,
|
||||
};
|
||||
|
||||
symbols.push(directiveSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private addHostDirectiveSymbols(
|
||||
host: TmplAstTemplate|TmplAstElement, hostDirectives: HostDirectiveMeta[],
|
||||
symbols: DirectiveSymbol[]): void {
|
||||
for (const current of hostDirectives) {
|
||||
if (!ts.isClassDeclaration(current.directive.node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const symbol = this.getSymbolOfTsNode(current.directive.node);
|
||||
const meta = this.getDirectiveMeta(host, current.directive.node);
|
||||
|
||||
if (meta !== null && symbol !== null && isSymbolWithValueDeclaration(symbol.tsSymbol)) {
|
||||
if (meta.hostDirectives !== null) {
|
||||
this.addHostDirectiveSymbols(host, meta.hostDirectives, symbols);
|
||||
}
|
||||
|
||||
const directiveSymbol: DirectiveSymbol = {
|
||||
...symbol,
|
||||
isHostDirective: true,
|
||||
ref: current.directive,
|
||||
tsSymbol: symbol.tsSymbol,
|
||||
exposedInputs: current.inputs,
|
||||
exposedOutputs: current.outputs,
|
||||
// TODO(crisbeto): rework `DirectiveSymbol` to make
|
||||
// `selector` nullable and remove the `|| ''` here.
|
||||
selector: meta.selector || '',
|
||||
isComponent: meta.isComponent,
|
||||
ngModule: this.getDirectiveModule(current.directive.node),
|
||||
kind: SymbolKind.Directive,
|
||||
isStructural: meta.isStructural,
|
||||
isInScope: true,
|
||||
};
|
||||
|
||||
symbols.push(directiveSymbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getDirectiveMeta(
|
||||
|
|
@ -376,6 +419,7 @@ export class SymbolBuilder {
|
|||
isStructural,
|
||||
selector,
|
||||
ngModule,
|
||||
isHostDirective: false,
|
||||
isInScope: true, // TODO: this should always be in scope in this context, right?
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export interface TestDirective extends Partial<Pick<
|
|||
Exclude<
|
||||
keyof TypeCheckableDirectiveMeta,
|
||||
'ref'|'coercedInputFields'|'restrictedInputFields'|'stringLiteralInputFields'|
|
||||
'undeclaredInputFields'|'inputs'|'outputs'>>> {
|
||||
'undeclaredInputFields'|'inputs'|'outputs'|'hostDirectives'>>> {
|
||||
selector: string;
|
||||
name: string;
|
||||
file?: AbsoluteFsPath;
|
||||
|
|
|
|||
|
|
@ -220,7 +220,18 @@ export function buildAttributeCompletionTable(
|
|||
continue;
|
||||
}
|
||||
|
||||
for (const [classPropertyName, propertyName] of meta.inputs) {
|
||||
for (const [classPropertyName, rawProperyName] of meta.inputs) {
|
||||
let propertyName: string;
|
||||
|
||||
if (dirSymbol.isHostDirective) {
|
||||
if (!dirSymbol.exposedInputs?.hasOwnProperty(rawProperyName)) {
|
||||
continue;
|
||||
}
|
||||
propertyName = dirSymbol.exposedInputs[rawProperyName];
|
||||
} else {
|
||||
propertyName = rawProperyName;
|
||||
}
|
||||
|
||||
if (table.has(propertyName)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -234,7 +245,18 @@ export function buildAttributeCompletionTable(
|
|||
});
|
||||
}
|
||||
|
||||
for (const [classPropertyName, propertyName] of meta.outputs) {
|
||||
for (const [classPropertyName, rawProperyName] of meta.outputs) {
|
||||
let propertyName: string;
|
||||
|
||||
if (dirSymbol.isHostDirective) {
|
||||
if (!dirSymbol.exposedOutputs?.hasOwnProperty(rawProperyName)) {
|
||||
continue;
|
||||
}
|
||||
propertyName = dirSymbol.exposedOutputs[rawProperyName];
|
||||
} else {
|
||||
propertyName = rawProperyName;
|
||||
}
|
||||
|
||||
if (table.has(propertyName)) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -613,6 +613,129 @@ describe('completions', () => {
|
|||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['myInput']);
|
||||
});
|
||||
|
||||
it('should return completion for input coming from a host directive', () => {
|
||||
const {templateFile} = setup(`<input dir my>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
inputs: ['myInput']
|
||||
})
|
||||
export class HostDir {
|
||||
myInput = 'foo';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [{
|
||||
directive: HostDir,
|
||||
inputs: ['myInput']
|
||||
}]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('my¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[myInput]']);
|
||||
});
|
||||
|
||||
it('should not return completion for hidden host directive input', () => {
|
||||
const {templateFile} = setup(`<input dir my>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
inputs: ['myInput']
|
||||
})
|
||||
export class HostDir {
|
||||
myInput = 'foo';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [HostDir]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('my¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
|
||||
expectDoesNotContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[myInput]']);
|
||||
});
|
||||
|
||||
it('should return completion for aliased host directive input', () => {
|
||||
const {templateFile} = setup(`<input dir ali>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
inputs: ['myInput']
|
||||
})
|
||||
export class HostDir {
|
||||
myInput = 'foo';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [{
|
||||
directive: HostDir,
|
||||
inputs: ['myInput: alias']
|
||||
}]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('ali¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[alias]']);
|
||||
});
|
||||
|
||||
it('should return completion for aliased host directive input that has a different public name',
|
||||
() => {
|
||||
const {templateFile} = setup(`<input dir ali>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
inputs: ['myInput: myPublicInput']
|
||||
})
|
||||
export class HostDir {
|
||||
myInput = 'foo';
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [{
|
||||
directive: HostDir,
|
||||
inputs: ['myPublicInput: alias']
|
||||
}]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('ali¦>');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
|
||||
expectContain(
|
||||
completions,
|
||||
unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
|
||||
['[alias]']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structural directive present', () => {
|
||||
|
|
@ -868,6 +991,96 @@ describe('completions', () => {
|
|||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['customModelChange']);
|
||||
});
|
||||
|
||||
it('should return completion for output coming from a host directive', () => {
|
||||
const {templateFile} = setup(`<input dir (my)>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
outputs: ['myOutput']
|
||||
})
|
||||
export class HostDir {
|
||||
myOutput: any;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [{
|
||||
directive: HostDir,
|
||||
outputs: ['myOutput']
|
||||
}]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('(my¦)');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['myOutput']);
|
||||
expectReplacementText(completions, templateFile.contents, 'my');
|
||||
});
|
||||
|
||||
it('should not return completion for hidden host directive output', () => {
|
||||
const {templateFile} = setup(`<input dir (my)>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
outputs: ['myOutput']
|
||||
})
|
||||
export class HostDir {
|
||||
myOutput: any;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [HostDir]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('(my¦)');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
expectDoesNotContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['myOutput']);
|
||||
});
|
||||
|
||||
it('should return completion for aliased host directive output that has a different public name',
|
||||
() => {
|
||||
const {templateFile} = setup(`<input dir (ali)>`, '', {
|
||||
'Dir': `
|
||||
@Directive({
|
||||
standalone: true,
|
||||
outputs: ['myOutput: myPublicOutput']
|
||||
})
|
||||
export class HostDir {
|
||||
myOutput: any;
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[dir]',
|
||||
hostDirectives: [{
|
||||
directive: HostDir,
|
||||
outputs: ['myPublicOutput: alias']
|
||||
}]
|
||||
})
|
||||
export class Dir {
|
||||
}
|
||||
`
|
||||
});
|
||||
templateFile.moveCursorToText('(ali¦)');
|
||||
|
||||
const completions = templateFile.getCompletionsAtPosition();
|
||||
expectContain(
|
||||
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
|
||||
['alias']);
|
||||
expectReplacementText(completions, templateFile.contents, 'ali');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue