refactor(compiler-cli): identify structural directives (#40032)

This commit introduces an `isStructural` flag on directive metadata, which
is `true` if the directive injects `TemplateRef` (and thus is at least
theoretically usable as a structural directive). The flag is not used for
anything currently, but will be utilized by the Language Service to offer
better autocompletion results for structural directives.

PR Close #40032
This commit is contained in:
Alex Rickabaugh 2020-12-07 17:15:37 -08:00
parent cbb6eae4a9
commit c55bf4a4a3
18 changed files with 227 additions and 11 deletions

View File

@ -396,6 +396,7 @@ export class ComponentDecoratorHandler implements
baseClass: analysis.baseClass,
...analysis.typeCheckMeta,
isPoisoned: analysis.isPoisoned,
isStructural: false,
});
this.resourceRegistry.registerResources(analysis.resources, node);

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {compileDeclareDirectiveFromMetadata, compileDirectiveFromMetadata, ConstantPool, Expression, Identifiers, makeBindingParser, ParsedHostBindings, ParseError, parseHostBindings, R3DependencyMetadata, R3DirectiveDef, R3DirectiveMetadata, R3FactoryTarget, R3QueryMetadata, Statement, verifyHostBindings, WrappedNodeExpr} from '@angular/compiler';
import {compileDeclareDirectiveFromMetadata, compileDirectiveFromMetadata, ConstantPool, Expression, ExternalExpr, Identifiers, makeBindingParser, ParsedHostBindings, ParseError, parseHostBindings, R3DependencyMetadata, R3DirectiveDef, R3DirectiveMetadata, R3FactoryTarget, R3QueryMetadata, R3ResolvedDependencyType, Statement, verifyHostBindings, WrappedNodeExpr} from '@angular/compiler';
import * as ts from 'typescript';
import {ErrorCode, FatalDiagnosticError} from '../../diagnostics';
@ -42,6 +42,7 @@ export interface DirectiveHandlerData {
inputs: ClassPropertyMapping;
outputs: ClassPropertyMapping;
isPoisoned: boolean;
isStructural: boolean;
}
export class DirectiveDecoratorHandler implements
@ -109,6 +110,7 @@ export class DirectiveDecoratorHandler implements
typeCheckMeta: extractDirectiveTypeCheckMeta(node, directiveResult.inputs, this.reflector),
providersRequiringFactory,
isPoisoned: false,
isStructural: directiveResult.isStructural,
}
};
}
@ -129,6 +131,7 @@ export class DirectiveDecoratorHandler implements
baseClass: analysis.baseClass,
...analysis.typeCheckMeta,
isPoisoned: analysis.isPoisoned,
isStructural: analysis.isStructural,
});
this.injectableRegistry.registerInjectable(node);
@ -226,6 +229,7 @@ export function extractDirectiveMetadata(
metadata: R3DirectiveMetadata,
inputs: ClassPropertyMapping,
outputs: ClassPropertyMapping,
isStructural: boolean;
}|undefined {
let directive: Map<string, ts.Expression>;
if (decorator === null || decorator.args === null || decorator.args.length === 0) {
@ -352,6 +356,17 @@ export function extractDirectiveMetadata(
ctorDeps = unwrapConstructorDependencies(rawCtorDeps);
}
const isStructural = ctorDeps !== null && ctorDeps !== 'invalid' && ctorDeps.some(dep => {
if (dep.resolved !== R3ResolvedDependencyType.Token || !(dep.token instanceof ExternalExpr)) {
return false;
}
if (dep.token.value.moduleName !== '@angular/core' || dep.token.value.name !== 'TemplateRef') {
return false;
}
return true;
});
// Detect if the component inherits from another class
const usesInheritance = reflector.hasBaseClass(clazz);
const type = wrapTypeReference(reflector, clazz);
@ -386,6 +401,7 @@ export function extractDirectiveMetadata(
metadata,
inputs,
outputs,
isStructural,
};
}

View File

@ -105,6 +105,7 @@ runInEachFileSystem(() => {
isComponent: false,
name: 'Dir',
selector: '[dir]',
isStructural: false,
};
matcher.addSelectables(CssSelector.parse('[dir]'), dirMeta);
@ -118,6 +119,30 @@ runInEachFileSystem(() => {
// and field names.
expect(propBindingConsumer).toBe(dirMeta);
});
it('should identify a structural directive', () => {
const src = `
import {Directive, TemplateRef} from '@angular/core';
@Directive({selector: 'test-dir'})
export class TestDir {
constructor(private ref: TemplateRef) {}
}
`;
const {program} = makeProgram([
{
name: _('/node_modules/@angular/core/index.d.ts'),
contents: 'export const Directive: any; export declare class TemplateRef {}',
},
{
name: _('/entry.ts'),
contents: src,
},
]);
const analysis = analyzeDirective(program, 'TestDir');
expect(analysis.isStructural).toBeTrue();
});
});
// Helpers

View File

@ -54,6 +54,7 @@ export function getBoundTemplate(
inputs: ClassPropertyMapping.fromMappedObject({}),
outputs: ClassPropertyMapping.fromMappedObject({}),
exportAs: null,
isStructural: false,
});
});
const binder = new R3TargetBinder(matcher);

View File

@ -114,6 +114,11 @@ export interface DirectiveMeta extends T2DirectiveMeta, DirectiveTypeCheckMeta {
* and reliable metadata.
*/
isPoisoned: boolean;
/**
* Whether the directive is likely a structural directive (injects `TemplateRef`).
*/
isStructural: boolean;
}
/**

View File

@ -9,7 +9,7 @@
import * as ts from 'typescript';
import {Reference} from '../../imports';
import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost, TypeValueReferenceKind} from '../../reflection';
import {DirectiveMeta, MetadataReader, NgModuleMeta, PipeMeta} from './api';
import {ClassPropertyMapping} from './property_mapping';
@ -77,6 +77,19 @@ export class DtsMetadataReader implements MetadataReader {
return null;
}
const isComponent = def.name === 'ɵcmp';
const ctorParams = this.reflector.getConstructorParameters(clazz);
// A directive is considered to be structural if:
// 1) it's a directive, not a component, and
// 2) it injects `TemplateRef`
const isStructural = !isComponent && ctorParams !== null && ctorParams.some(param => {
return param.typeValueReference.kind === TypeValueReferenceKind.IMPORTED &&
param.typeValueReference.moduleName === '@angular/core' &&
param.typeValueReference.importedName === 'TemplateRef';
});
const inputs =
ClassPropertyMapping.fromMappedObject(readStringMapType(def.type.typeArguments[3]));
const outputs =
@ -84,7 +97,7 @@ export class DtsMetadataReader implements MetadataReader {
return {
ref,
name: clazz.name.text,
isComponent: def.name === 'ɵcmp',
isComponent,
selector: readStringType(def.type.typeArguments[1]),
exportAs: readStringArrayType(def.type.typeArguments[2]),
inputs,
@ -93,6 +106,7 @@ export class DtsMetadataReader implements MetadataReader {
...extractDirectiveTypeCheckMeta(clazz, inputs, this.reflector),
baseClass: readBaseClass(clazz, this.checker, this.reflector),
isPoisoned: false,
isStructural,
};
}

View File

@ -37,6 +37,7 @@ export function flattenInheritedDirectiveMetadata(
let isDynamic = false;
let inputs = ClassPropertyMapping.empty();
let outputs = ClassPropertyMapping.empty();
let isStructural: boolean = false;
const addMetadata = (meta: DirectiveMeta): void => {
if (meta.baseClass === 'dynamic') {
@ -51,6 +52,8 @@ export function flattenInheritedDirectiveMetadata(
}
}
isStructural = isStructural || meta.isStructural;
inputs = ClassPropertyMapping.merge(inputs, meta.inputs);
outputs = ClassPropertyMapping.merge(outputs, meta.outputs);
@ -79,5 +82,6 @@ export function flattenInheritedDirectiveMetadata(
restrictedInputFields,
stringLiteralInputFields,
baseClass: isDynamic ? 'dynamic' : null,
isStructural,
};
}

View File

@ -0,0 +1,35 @@
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
package(default_visibility = ["//visibility:public"])
ts_library(
name = "test_lib",
testonly = True,
srcs = glob([
"**/*.ts",
]),
deps =
[
"//packages:types",
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/compiler-cli/src/ngtsc/file_system/testing",
"//packages/compiler-cli/src/ngtsc/imports",
"//packages/compiler-cli/src/ngtsc/metadata",
"//packages/compiler-cli/src/ngtsc/reflection",
"//packages/compiler-cli/src/ngtsc/testing",
"@npm//typescript",
],
)
jasmine_node_test(
name = "test",
bootstrap = ["//tools/testing:node_no_angular_es5"],
data = [
"//packages/compiler-cli/src/ngtsc/testing/fake_core:npm_package",
],
deps =
[
":test_lib",
],
)

View File

@ -0,0 +1,93 @@
/**
* @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 {ExternalExpr} from '@angular/compiler';
import * as ts from 'typescript';
import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../file_system';
import {runInEachFileSystem} from '../../file_system/testing';
import {Reference} from '../../imports';
import {isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
import {loadFakeCore, makeProgram} from '../../testing';
import {DtsMetadataReader} from '../src/dts';
runInEachFileSystem(() => {
beforeEach(() => {
loadFakeCore(getFileSystem());
});
describe('DtsMetadataReader', () => {
it('should not assume directives are structural', () => {
const mainPath = absoluteFrom('/main.d.ts');
const {program} = makeProgram(
[{
name: mainPath,
contents: `
import {ViewContainerRef} from '@angular/core';
import * as i0 from '@angular/core';
export declare class TestDir {
constructor(p0: ViewContainerRef);
static ɵdir: i0.ɵɵDirectiveDefWithMeta<TestDir, "[test]", never, {}, {}, never>
}
`
}],
{
skipLibCheck: true,
lib: ['es6', 'dom'],
});
const sf = getSourceFileOrError(program, mainPath);
const clazz = sf.statements[2];
if (!isNamedClassDeclaration(clazz)) {
return fail('Expected class declaration');
}
const typeChecker = program.getTypeChecker();
const dtsReader =
new DtsMetadataReader(typeChecker, new TypeScriptReflectionHost(typeChecker));
const meta = dtsReader.getDirectiveMetadata(new Reference(clazz))!;
expect(meta.isStructural).toBeFalse();
});
it('should identify a structural directive by its constructor', () => {
const mainPath = absoluteFrom('/main.d.ts');
const {program} = makeProgram(
[{
name: mainPath,
contents: `
import {TemplateRef, ViewContainerRef} from '@angular/core';
import * as i0 from '@angular/core';
export declare class TestDir {
constructor(p0: ViewContainerRef, p1: TemplateRef);
static ɵdir: i0.ɵɵDirectiveDefWithMeta<TestDir, "[test]", never, {}, {}, never>
}
`
}],
{
skipLibCheck: true,
lib: ['es6', 'dom'],
});
const sf = getSourceFileOrError(program, mainPath);
const clazz = sf.statements[2];
if (!isNamedClassDeclaration(clazz)) {
return fail('Expected class declaration');
}
const typeChecker = program.getTypeChecker();
const dtsReader =
new DtsMetadataReader(typeChecker, new TypeScriptReflectionHost(typeChecker));
const meta = dtsReader.getDirectiveMetadata(new Reference(clazz))!;
expect(meta.isStructural).toBeTrue();
});
});
});

View File

@ -36,12 +36,14 @@ export class TypeScriptReflectionHost implements ReflectionHost {
getConstructorParameters(clazz: ClassDeclaration): CtorParameter[]|null {
const tsClazz = castDeclarationToClassOrDie(clazz);
// First, find the constructor with a `body`. The constructors without a `body` are overloads
// whereas we want the implementation since it's the one that'll be executed and which can
// have decorators.
const isDeclaration = tsClazz.getSourceFile().isDeclarationFile;
// For non-declaration files, we want to find the constructor with a `body`. The constructors
// without a `body` are overloads whereas we want the implementation since it's the one that'll
// be executed and which can have decorators. For declaration files, we take the first one that
// we get.
const ctor = tsClazz.members.find(
(member): member is ts.ConstructorDeclaration =>
ts.isConstructorDeclaration(member) && member.body !== undefined);
ts.isConstructorDeclaration(member) && (isDeclaration || member.body !== undefined));
if (ctor === undefined) {
return null;
}

View File

@ -249,6 +249,7 @@ function fakeDirective(ref: Reference<ClassDeclaration>): DirectiveMeta {
isGeneric: false,
baseClass: null,
isPoisoned: false,
isStructural: false,
};
}

View File

@ -13,7 +13,7 @@ import * as ts from 'typescript';
import {FullTemplateMapping, TypeCheckableDirectiveMeta} from './api';
import {GlobalCompletion} from './completion';
import {DirectiveInScope, PipeInScope} from './scope';
import {DirectiveSymbol, ElementSymbol, ShimLocation, Symbol} from './symbols';
import {DirectiveSymbol, ElementSymbol, ShimLocation, Symbol, TemplateSymbol} from './symbols';
/**
* Interface to the Angular Template Type Checker to extract diagnostics and intelligence from the
@ -111,6 +111,7 @@ export interface TemplateTypeChecker {
* @see Symbol
*/
getSymbolOfNode(node: TmplAstElement, component: ts.ClassDeclaration): ElementSymbol|null;
getSymbolOfNode(node: TmplAstTemplate, component: ts.ClassDeclaration): TemplateSymbol|null;
getSymbolOfNode(node: AST|TmplAstNode, component: ts.ClassDeclaration): Symbol|null;
/**

View File

@ -32,6 +32,11 @@ export interface DirectiveInScope {
* `true` if this directive is a component.
*/
isComponent: boolean;
/**
* `true` if this directive is a structural directive.
*/
isStructural: boolean;
}
/**

View File

@ -16,7 +16,7 @@ import {ClassDeclaration, isNamedClassDeclaration, ReflectionHost} from '../../r
import {ComponentScopeReader, TypeCheckScopeRegistry} from '../../scope';
import {isShim} from '../../shims';
import {getSourceFileOrNull} from '../../util/src/typescript';
import {DirectiveInScope, ElementSymbol, FullTemplateMapping, GlobalCompletion, OptimizeFor, PipeInScope, ProgramTypeCheckAdapter, ShimLocation, Symbol, TemplateId, TemplateTypeChecker, TypeCheckableDirectiveMeta, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {DirectiveInScope, ElementSymbol, FullTemplateMapping, GlobalCompletion, OptimizeFor, PipeInScope, ProgramTypeCheckAdapter, ShimLocation, Symbol, TemplateId, TemplateSymbol, TemplateTypeChecker, TypeCheckableDirectiveMeta, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {TemplateDiagnostic} from '../diagnostics';
import {CompletionEngine} from './completion';
@ -472,6 +472,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
return this.state.get(path)!;
}
getSymbolOfNode(node: TmplAstTemplate, component: ts.ClassDeclaration): TemplateSymbol|null;
getSymbolOfNode(node: TmplAstElement, component: ts.ClassDeclaration): ElementSymbol|null;
getSymbolOfNode(node: AST|TmplAstNode, component: ts.ClassDeclaration): Symbol|null {
const builder = this.getOrCreateSymbolBuilder(component);
@ -598,6 +599,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
data.directives.push({
isComponent: dir.isComponent,
isStructural: dir.isStructural,
selector: dir.selector,
tsSymbol,
ngModule,

View File

@ -140,7 +140,8 @@ export class SymbolBuilder {
selector: meta.selector,
isComponent,
ngModule,
kind: SymbolKind.Directive
kind: SymbolKind.Directive,
isStructural: meta.isStructural,
};
return directiveSymbol;
})
@ -281,7 +282,7 @@ export class SymbolBuilder {
private getDirectiveSymbolForAccessExpression(
node: ts.ElementAccessExpression|ts.PropertyAccessExpression,
{isComponent, selector}: TypeCheckableDirectiveMeta): DirectiveSymbol|null {
{isComponent, selector, isStructural}: TypeCheckableDirectiveMeta): DirectiveSymbol|null {
// In either case, `_t1["index"]` or `_t1.index`, `node.expression` is _t1.
// The retrieved symbol for _t1 will be the variable declaration.
const tsSymbol = this.getTypeChecker().getSymbolAtLocation(node.expression);
@ -313,6 +314,7 @@ export class SymbolBuilder {
tsType: symbol.tsType,
shimLocation: symbol.shimLocation,
isComponent,
isStructural,
selector,
ngModule,
};

View File

@ -505,6 +505,7 @@ function prepareDeclarations(
isGeneric: decl.isGeneric ?? false,
outputs: ClassPropertyMapping.fromMappedObject(decl.outputs || {}),
queries: decl.queries || [],
isStructural: false,
};
matcher.addSelectables(selector, meta);
}
@ -567,6 +568,7 @@ function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaratio
undeclaredInputFields: new Set(decl.undeclaredInputFields ?? []),
isGeneric: decl.isGeneric ?? false,
isPoisoned: false,
isStructural: false,
});
} else if (decl.type === 'pipe') {
scope.pipes.push({

View File

@ -74,6 +74,8 @@ export interface DirectiveMeta {
* Null otherwise
*/
exportAs: string[]|null;
isStructural: boolean;
}
/**

View File

@ -38,6 +38,7 @@ function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta> {
inputs: new IdentityInputMapping(['ngForOf']),
outputs: new IdentityInputMapping([]),
isComponent: false,
isStructural: true,
selector: '[ngFor][ngForOf]',
});
matcher.addSelectables(CssSelector.parse('[dir]'), {
@ -46,6 +47,7 @@ function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta> {
inputs: new IdentityInputMapping([]),
outputs: new IdentityInputMapping([]),
isComponent: false,
isStructural: false,
selector: '[dir]'
});
matcher.addSelectables(CssSelector.parse('[hasOutput]'), {
@ -54,6 +56,7 @@ function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta> {
inputs: new IdentityInputMapping([]),
outputs: new IdentityInputMapping(['outputBinding']),
isComponent: false,
isStructural: false,
selector: '[hasOutput]'
});
matcher.addSelectables(CssSelector.parse('[hasInput]'), {
@ -62,6 +65,7 @@ function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta> {
inputs: new IdentityInputMapping(['inputBinding']),
outputs: new IdentityInputMapping([]),
isComponent: false,
isStructural: false,
selector: '[hasInput]'
});
return matcher;
@ -107,6 +111,7 @@ describe('t2 binding', () => {
inputs: new IdentityInputMapping([]),
outputs: new IdentityInputMapping([]),
isComponent: false,
isStructural: false,
selector: 'text[dir]'
});
const binder = new R3TargetBinder(matcher);