diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 2199a91fc7..73be8bef38 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -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; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts index 675f02d4f0..68ab4437fa 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts @@ -10,4 +10,5 @@ export * from './api'; export * from './checker'; export * from './completion'; export * from './context'; +export * from './scope'; export * from './symbols'; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts new file mode 100644 index 0000000000..4395c0934b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/scope.ts @@ -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; +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts index 58428c7ae6..b23cc76b07 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/symbols.ts @@ -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; } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index b3a415691f..4550807500 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -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(); + /** + * 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(); + 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[]; +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts index 999351cd9f..91cc032ae4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts @@ -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; } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts index a8eb8cf6dc..096875c852 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -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(); + 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 { 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(