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:
parent
01cc949722
commit
0ecdef9cfa
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -10,4 +10,5 @@ export * from './api';
|
|||
export * from './checker';
|
||||
export * from './completion';
|
||||
export * from './context';
|
||||
export * from './scope';
|
||||
export * from './symbols';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue