refactor(compiler-cli): API to get directives/pipes in scope (#39278)

This commit introduces two new methods to the TemplateTypeChecker, which
retrieve the directives and pipes that are "in scope" for a given component
template. The metadata returned by this API is minimal, but enough to power
autocompletion of selectors and attributes in templates.

PR Close #39278
This commit is contained in:
Alex Rickabaugh 2020-10-09 13:41:52 -07:00
parent 01cc949722
commit 0ecdef9cfa
8 changed files with 289 additions and 34 deletions

View File

@ -10,6 +10,7 @@ import {AST, ParseError, TmplAstNode, TmplAstTemplate} from '@angular/compiler';
import * as ts from 'typescript';
import {GlobalCompletion} from './completion';
import {DirectiveInScope, PipeInScope} from './scope';
import {Symbol} from './symbols';
/**
@ -101,6 +102,16 @@ export interface TemplateTypeChecker {
*/
getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration):
GlobalCompletion|null;
/**
* Get basic metadata on the directives which are in scope for the given component.
*/
getDirectivesInScope(component: ts.ClassDeclaration): DirectiveInScope[]|null;
/**
* Get basic metadata on the pipes which are in scope for the given component.
*/
getPipesInScope(component: ts.ClassDeclaration): PipeInScope[]|null;
}
/**

View File

@ -10,4 +10,5 @@ export * from './api';
export * from './checker';
export * from './completion';
export * from './context';
export * from './scope';
export * from './symbols';

View File

@ -0,0 +1,44 @@
/**
* @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 * as ts from 'typescript';
/**
* Metadata on a directive which is available in the scope of a template.
*/
export interface DirectiveInScope {
/**
* The `ts.Symbol` for the directive class.
*/
tsSymbol: ts.Symbol;
/**
* The selector for the directive or component.
*/
selector: string;
/**
* `true` if this directive is a component.
*/
isComponent: boolean;
}
/**
* Metadata for a pipe which is available in the scope of a template.
*/
export interface PipeInScope {
/**
* The `ts.Symbol` for the pipe class.
*/
tsSymbol: ts.Symbol;
/**
* Name of the pipe.
*/
name: string;
}

View File

@ -11,6 +11,7 @@ import * as ts from 'typescript';
import {AbsoluteFsPath} from '../../file_system';
import {ClassDeclaration} from '../../reflection';
import {DirectiveInScope} from './scope';
export enum SymbolKind {
Input,
@ -222,24 +223,15 @@ export interface TemplateSymbol {
* A representation of a directive/component whose selector matches a node in a component
* template.
*/
export interface DirectiveSymbol {
export interface DirectiveSymbol extends DirectiveInScope {
kind: SymbolKind.Directive;
/** The `ts.Type` for the class declaration. */
tsType: ts.Type;
/** The `ts.Symbol` for the class declaration. */
tsSymbol: ts.Symbol;
/** The location in the shim file for the variable that holds the type of the directive. */
shimLocation: ShimLocation;
/** The selector for the `Directive` / `Component`. */
selector: string|null;
/** `true` if this `DirectiveSymbol` is for a @Component. */
isComponent: boolean;
/** The `NgModule` that this directive is declared in or `null` if it could not be determined. */
ngModule: ClassDeclaration|null;
}

View File

@ -12,11 +12,11 @@ import * as ts from 'typescript';
import {absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system';
import {ReferenceEmitter} from '../../imports';
import {IncrementalBuild} from '../../incremental/api';
import {ReflectionHost} from '../../reflection';
import {isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ComponentScopeReader} from '../../scope';
import {isShim} from '../../shims';
import {getSourceFileOrNull} from '../../util/src/typescript';
import {CompletionKind, GlobalCompletion, OptimizeFor, ProgramTypeCheckAdapter, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {CompletionKind, DirectiveInScope, GlobalCompletion, OptimizeFor, PipeInScope, ProgramTypeCheckAdapter, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {TemplateDiagnostic} from '../diagnostics';
import {ExpressionIdentifier, findFirstMatchingNode} from './comments';
@ -51,6 +51,16 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
*/
private symbolBuilderCache = new Map<ts.ClassDeclaration, SymbolBuilder>();
/**
* Stores directives and pipes that are in scope for each component.
*
* Unlike the other caches, the scope of a component is not affected by its template, so this
* cache does not need to be invalidate if the template is overridden. It will be destroyed when
* the `ts.Program` changes and the `TemplateTypeCheckerImpl` as a whole is destroyed and
* replaced.
*/
private scopeCache = new Map<ts.ClassDeclaration, ScopeData>();
private isComplete = false;
constructor(
@ -433,6 +443,73 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
this.symbolBuilderCache.set(component, builder);
return builder;
}
getDirectivesInScope(component: ts.ClassDeclaration): DirectiveInScope[]|null {
const data = this.getScopeData(component);
if (data === null) {
return null;
}
return data.directives;
}
getPipesInScope(component: ts.ClassDeclaration): PipeInScope[]|null {
const data = this.getScopeData(component);
if (data === null) {
return null;
}
return data.pipes;
}
private getScopeData(component: ts.ClassDeclaration): ScopeData|null {
if (this.scopeCache.has(component)) {
return this.scopeCache.get(component)!;
}
if (!isNamedClassDeclaration(component)) {
throw new Error(`AssertionError: components must have names`);
}
const data: ScopeData = {
directives: [],
pipes: [],
};
const scope = this.componentScopeReader.getScopeForComponent(component);
if (scope === null || scope === 'error') {
return null;
}
const typeChecker = this.typeCheckingStrategy.getProgram().getTypeChecker();
for (const dir of scope.exported.directives) {
if (dir.selector === null) {
// Skip this directive, it can't be added to a template anyway.
continue;
}
const tsSymbol = typeChecker.getSymbolAtLocation(dir.ref.node.name);
if (tsSymbol === undefined) {
continue;
}
data.directives.push({
isComponent: dir.isComponent,
selector: dir.selector,
tsSymbol,
});
}
for (const pipe of scope.exported.pipes) {
const tsSymbol = typeChecker.getSymbolAtLocation(pipe.ref.node.name);
if (tsSymbol === undefined) {
continue;
}
data.pipes.push({
name: pipe.name,
tsSymbol,
});
}
this.scopeCache.set(component, data);
return data;
}
}
function convertDiagnostic(
@ -622,3 +699,11 @@ class SingleShimTypeCheckingHost extends SingleFileTypeCheckingHost {
return !this.fileData.shimData.has(shimPath);
}
}
/**
* Cached scope information for a component.
*/
interface ScopeData {
directives: DirectiveInScope[];
pipes: PipeInScope[];
}

View File

@ -129,12 +129,14 @@ export class SymbolBuilder {
}
const ngModule = this.getDirectiveModule(symbol.tsSymbol.valueDeclaration);
const selector = meta.selector ?? null;
if (meta.selector === null) {
return null;
}
const isComponent = meta.isComponent ?? null;
const directiveSymbol: DirectiveSymbol = {
...symbol,
tsSymbol: symbol.tsSymbol,
selector,
selector: meta.selector,
isComponent,
ngModule,
kind: SymbolKind.Directive
@ -256,7 +258,7 @@ export class SymbolBuilder {
// 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);
if (tsSymbol === undefined || tsSymbol.declarations.length === 0) {
if (tsSymbol === undefined || tsSymbol.declarations.length === 0 || selector === null) {
return null;
}

View File

@ -15,7 +15,7 @@ import {AbsoluteModuleStrategy, LocalIdentifierStrategy, LogicalProjectStrategy,
import {NOOP_INCREMENTAL_BUILD} from '../../incremental';
import {ClassPropertyMapping} from '../../metadata';
import {ClassDeclaration, isNamedClassDeclaration, TypeScriptReflectionHost} from '../../reflection';
import {ComponentScopeReader, ScopeData} from '../../scope';
import {ComponentScopeReader, LocalModuleScope, ScopeData} from '../../scope';
import {makeProgram} from '../../testing';
import {getRootDirs} from '../../util/src/typescript';
import {ProgramTypeCheckAdapter, TemplateTypeChecker, TypeCheckContext} from '../api';
@ -355,6 +355,18 @@ export function setup(targets: TypeCheckingTarget[], overrides: {
]);
const fullConfig = {...ALL_ENABLED_CONFIG, ...config};
// Map out the scope of each target component, which is needed for the ComponentScopeReader.
const scopeMap = new Map<ClassDeclaration, ScopeData>();
for (const target of targets) {
const sf = getSourceFileOrError(program, target.fileName);
const scope = makeScope(program, sf, target.declarations ?? []);
for (const className of Object.keys(target.templates)) {
const classDecl = getClass(sf, className);
scopeMap.set(classDecl, scope);
}
}
const checkAdapter = createTypeCheckAdapter((sf, ctx) => {
for (const target of targets) {
if (getSourceFileOrError(program, target.fileName) !== sf) {
@ -405,27 +417,47 @@ export function setup(targets: TypeCheckingTarget[], overrides: {
(programStrategy as any).supportsInlineOperations = overrides.inlining;
}
const fakeScopeReader = {
const fakeScopeReader: ComponentScopeReader = {
getRequiresRemoteScope() {
return null;
},
// If there is a module with [className] + 'Module' in the same source file, returns
// `LocalModuleScope` with the ngModule class and empty arrays for everything else.
getScopeForComponent(clazz: ClassDeclaration) {
try {
const ngModule = getClass(clazz.getSourceFile(), `${clazz.name.getText()}Module`);
const stubScopeData = {directives: [], ngModules: [], pipes: []};
return {
ngModule,
compilation: stubScopeData,
reexports: [],
schemas: [],
exported: stubScopeData
};
} catch (e) {
return null;
}
}
// If there is a module with [className] + 'Module' in the same source file, that will be
// returned as the NgModule for the class.
getScopeForComponent(clazz: ClassDeclaration): LocalModuleScope |
null {
try {
const ngModule = getClass(clazz.getSourceFile(), `${clazz.name.getText()}Module`);
if (!scopeMap.has(clazz)) {
// This class wasn't part of the target set of components with templates, but is
// probably a declaration used in one of them. Return an empty scope.
const emptyScope: ScopeData = {
directives: [],
ngModules: [],
pipes: [],
};
return {
ngModule,
compilation: emptyScope,
reexports: [],
schemas: [],
exported: emptyScope,
};
}
const scope = scopeMap.get(clazz)!;
return {
ngModule,
compilation: scope,
reexports: [],
schemas: [],
exported: scope,
};
} catch (e) {
// No NgModule was found for this class, so it has no scope.
return null;
}
}
};
const templateTypeChecker = new TemplateTypeCheckerImpl(
@ -492,6 +524,55 @@ export function getClass(sf: ts.SourceFile, name: string): ClassDeclaration<ts.C
throw new Error(`Class ${name} not found in file: ${sf.fileName}: ${sf.text}`);
}
/**
* Synthesize `ScopeData` metadata from an array of `TestDeclaration`s.
*/
function makeScope(program: ts.Program, sf: ts.SourceFile, decls: TestDeclaration[]): ScopeData {
const scope: ScopeData = {
ngModules: [],
directives: [],
pipes: [],
};
for (const decl of decls) {
let declSf = sf;
if (decl.file !== undefined) {
declSf = getSourceFileOrError(program, decl.file);
}
const declClass = getClass(declSf, decl.name);
if (decl.type === 'directive') {
scope.directives.push({
ref: new Reference(declClass),
baseClass: null,
name: decl.name,
selector: decl.selector,
queries: [],
inputs: decl.inputs !== undefined ? ClassPropertyMapping.fromMappedObject(decl.inputs) :
ClassPropertyMapping.empty(),
outputs: decl.outputs !== undefined ? ClassPropertyMapping.fromMappedObject(decl.outputs) :
ClassPropertyMapping.empty(),
isComponent: decl.isComponent ?? false,
exportAs: decl.exportAs ?? null,
ngTemplateGuards: decl.ngTemplateGuards ?? [],
hasNgTemplateContextGuard: decl.hasNgTemplateContextGuard ?? false,
coercedInputFields: new Set(decl.coercedInputFields ?? []),
restrictedInputFields: new Set(decl.restrictedInputFields ?? []),
stringLiteralInputFields: new Set(decl.stringLiteralInputFields ?? []),
undeclaredInputFields: new Set(decl.undeclaredInputFields ?? []),
isGeneric: decl.isGeneric ?? false,
});
} else if (decl.type === 'pipe') {
scope.pipes.push({
ref: new Reference(declClass),
name: decl.pipeName,
});
}
}
return scope;
}
class FakeEnvironment /* implements Environment */ {
constructor(readonly config: TypeCheckingConfig) {}

View File

@ -98,6 +98,45 @@ runInEachFileSystem(() => {
expect(Array.from(afterReset.templateContext.keys())).toEqual(['foo']);
});
});
describe('TemplateTypeChecker scopes', () => {
it('should get directives and pipes in scope for a component', () => {
const MAIN_TS = absoluteFrom('/main.ts');
const {program, templateTypeChecker} = setup([{
fileName: MAIN_TS,
templates: {
'SomeCmp': 'Not important',
},
declarations: [
{
type: 'directive',
file: MAIN_TS,
name: 'OtherDir',
selector: 'other-dir',
},
{
type: 'pipe',
file: MAIN_TS,
name: 'OtherPipe',
pipeName: 'otherPipe',
}
],
source: `
export class SomeCmp {}
export class OtherDir {}
export class OtherPipe {}
export class SomeCmpModule {}
`
}]);
const sf = getSourceFileOrError(program, MAIN_TS);
const SomeCmp = getClass(sf, 'SomeCmp');
const directives = templateTypeChecker.getDirectivesInScope(SomeCmp) ?? [];
const pipes = templateTypeChecker.getPipesInScope(SomeCmp) ?? [];
expect(directives.map(dir => dir.selector)).toEqual(['other-dir']);
expect(pipes.map(pipe => pipe.name)).toEqual(['otherPipe']);
});
});
});
function setupCompletions(