refactor(compiler-cli): move global completion into new CompletionEngine (#39278)

This commit refactors the previously introduced `getGlobalCompletions()` API
for the template type-checker in a couple ways:

 * The return type is adjusted to use a `Map` instead of an array, and
   separate out the component context completion position. This allows for a
   cleaner integration in the language service.
 * A new `CompletionEngine` class is introduced which powers autocompletion
   for a single component, and can cache completion results.
 * The `CompletionEngine` for each component is itself cached on the
   `TemplateTypeCheckerImpl` and is invalidated when the component template
   is overridden or reset.

This refactoring simplifies the `TemplateTypeCheckerImpl` class by
extracting the autocompletion logic, enables caching for better performance,
and prepares for the introduction of other autocompletion APIs.

PR Close #39278
This commit is contained in:
Alex Rickabaugh 2020-10-08 12:56:05 -07:00
parent ebd6ccd004
commit c4f99b6e52
5 changed files with 248 additions and 158 deletions

View File

@ -100,7 +100,7 @@ export interface TemplateTypeChecker {
* template variables which are in scope for that expression.
*/
getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration):
GlobalCompletion[];
GlobalCompletion|null;
}
/**

View File

@ -13,34 +13,21 @@ import {ShimLocation} from './symbols';
/**
* An autocompletion source of any kind.
*/
export type Completion = CompletionContextComponent|CompletionReference|CompletionVariable;
/**
* An autocompletion source that drives completion in a global context.
*/
export type GlobalCompletion = CompletionContextComponent|CompletionReference|CompletionVariable;
export type Completion = ReferenceCompletion|VariableCompletion;
/**
* Discriminant of an autocompletion source (a `Completion`).
*/
export enum CompletionKind {
ContextComponent,
Reference,
Variable,
}
/**
* An autocompletion source backed by a shim file position where TS APIs can be used to retrieve
* completions for the context component of a template.
*/
export interface CompletionContextComponent extends ShimLocation {
kind: CompletionKind.ContextComponent;
}
/**
* An autocompletion result representing a local reference declared in the template.
*/
export interface CompletionReference {
export interface ReferenceCompletion {
kind: CompletionKind.Reference;
/**
@ -52,7 +39,7 @@ export interface CompletionReference {
/**
* An autocompletion result representing a variable declared in the template.
*/
export interface CompletionVariable {
export interface VariableCompletion {
kind: CompletionKind.Variable;
/**
@ -60,3 +47,28 @@ export interface CompletionVariable {
*/
node: TmplAstVariable;
}
/**
* Autocompletion data for an expression in the global scope.
*
* Global completion is accomplished by merging data from two sources:
* * TypeScript completion of the component's class members.
* * Local references and variables that are in scope at a given template level.
*/
export interface GlobalCompletion {
/**
* A location within the type-checking shim where TypeScript's completion APIs can be used to
* access completions for the template's component context (component class members).
*/
componentContext: ShimLocation;
/**
* `Map` of local references and variables that are visible at the requested level of the
* template.
*
* Shadowing of references/variables from multiple levels of the template has already been
* accounted for in the preparation of `templateContext`. Entries here shadow component members of
* the same name (from the `componentContext` completions).
*/
templateContext: Map<string, ReferenceCompletion|VariableCompletion>;
}

View File

@ -20,6 +20,7 @@ import {CompletionKind, GlobalCompletion, OptimizeFor, ProgramTypeCheckAdapter,
import {TemplateDiagnostic} from '../diagnostics';
import {ExpressionIdentifier, findFirstMatchingNode} from './comments';
import {CompletionEngine} from './completion';
import {InliningMode, ShimTypeCheckingData, TemplateData, TypeCheckContextImpl, TypeCheckingHost} from './context';
import {findTypeCheckBlock, shouldReportDiagnostic, TemplateSourceResolver, translateDiagnostic} from './diagnostics';
import {TemplateSourceManager} from './source';
@ -32,6 +33,16 @@ import {SymbolBuilder} from './template_symbol_builder';
*/
export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
private state = new Map<AbsoluteFsPath, FileTypeCheckingData>();
/**
* Stores the `CompletionEngine` which powers autocompletion for each component class.
*
* Must be invalidated whenever the component's template or the `ts.Program` changes. Invalidation
* on template changes is performed within this `TemplateTypeCheckerImpl` instance. When the
* `ts.Program` changes, the `TemplateTypeCheckerImpl` as a whole is destroyed and replaced.
*/
private completionCache = new Map<ts.ClassDeclaration, CompletionEngine>();
private isComplete = false;
constructor(
@ -51,6 +62,11 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
fileRecord.isComplete = false;
}
}
// Ideally only those components with overridden templates would have their caches invalidated,
// but the `TemplateTypeCheckerImpl` does not track the class for components with overrides. As
// a quick workaround, clear the entire cache instead.
this.completionCache.clear();
}
getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null {
@ -130,6 +146,9 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
fileRecord.isComplete = false;
this.isComplete = false;
// Overriding a component's template invalidates its autocompletion results.
this.completionCache.delete(component);
return {nodes};
}
@ -209,51 +228,27 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration):
GlobalCompletion[] {
GlobalCompletion|null {
const engine = this.getOrCreateCompletionEngine(component);
if (engine === null) {
return null;
}
return engine.getGlobalCompletions(context);
}
private getOrCreateCompletionEngine(component: ts.ClassDeclaration): CompletionEngine|null {
if (this.completionCache.has(component)) {
return this.completionCache.get(component)!;
}
const {tcb, data, shimPath} = this.getLatestComponentState(component);
if (tcb === null || data === null) {
return [];
return null;
}
const {boundTarget} = data;
// Global completions are the union of two separate pieces: a `ContextComponentCompletion` which
// is created from an expression within the TCB, and a list of named entities (variables and
// references) which are visible within the given `context` template.
const completions: GlobalCompletion[] = [];
const globalRead = findFirstMatchingNode(tcb, {
filter: ts.isPropertyAccessExpression,
withExpressionIdentifier: ExpressionIdentifier.COMPONENT_COMPLETION
});
if (globalRead === null) {
return [];
}
completions.push({
kind: CompletionKind.ContextComponent,
shimPath,
positionInShimFile: globalRead.name.getStart(),
});
// Add completions for each entity in the template scope. Since each entity is uniquely named,
// there is no special ordering applied here.
for (const node of boundTarget.getEntitiesInTemplateScope(context)) {
if (node instanceof TmplAstReference) {
completions.push({
kind: CompletionKind.Reference,
node: node,
});
} else {
completions.push({
kind: CompletionKind.Variable,
node: node,
});
}
}
return completions;
const engine = new CompletionEngine(tcb, data, shimPath);
this.completionCache.set(component, engine);
return engine;
}
private maybeAdoptPriorResultsForFile(sf: ts.SourceFile): void {

View File

@ -0,0 +1,82 @@
/**
* @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 {TmplAstReference, TmplAstTemplate} from '@angular/compiler';
import * as ts from 'typescript';
import {AbsoluteFsPath} from '../../file_system';
import {CompletionKind, GlobalCompletion, ReferenceCompletion, VariableCompletion} from '../api';
import {ExpressionIdentifier, findFirstMatchingNode} from './comments';
import {TemplateData} from './context';
/**
* Powers autocompletion for a specific component.
*
* Internally caches autocompletion results, and must be discarded if the component template or
* surrounding TS program have changed.
*/
export class CompletionEngine {
/**
* Cache of `GlobalCompletion`s for various levels of the template, including the root template
* (`null`).
*/
private globalCompletionCache = new Map<TmplAstTemplate|null, GlobalCompletion>();
constructor(private tcb: ts.Node, private data: TemplateData, private shimPath: AbsoluteFsPath) {}
/**
* Get global completions within the given template context - either a `TmplAstTemplate` embedded
* view, or `null` for the root template context.
*/
getGlobalCompletions(context: TmplAstTemplate|null): GlobalCompletion|null {
if (this.globalCompletionCache.has(context)) {
return this.globalCompletionCache.get(context)!;
}
// Find the component completion expression within the TCB. This looks like: `ctx. /* ... */;`
const globalRead = findFirstMatchingNode(this.tcb, {
filter: ts.isPropertyAccessExpression,
withExpressionIdentifier: ExpressionIdentifier.COMPONENT_COMPLETION
});
if (globalRead === null) {
return null;
}
const completion: GlobalCompletion = {
componentContext: {
shimPath: this.shimPath,
// `globalRead.name` is an empty `ts.Identifier`, so its start position immediately follows
// the `.` in `ctx.`. TS autocompletion APIs can then be used to access completion results
// for the component context.
positionInShimFile: globalRead.name.getStart(),
},
templateContext: new Map<string, ReferenceCompletion|VariableCompletion>(),
};
// The bound template already has details about the references and variables in scope in the
// `context` template - they just need to be converted to `Completion`s.
for (const node of this.data.boundTarget.getEntitiesInTemplateScope(context)) {
if (node instanceof TmplAstReference) {
completion.templateContext.set(node.name, {
kind: CompletionKind.Reference,
node,
});
} else {
completion.templateContext.set(node.name, {
kind: CompletionKind.Variable,
node,
});
}
}
this.globalCompletionCache.set(context, completion);
return completion;
}
}

View File

@ -12,127 +12,128 @@ import * as ts from 'typescript';
import {absoluteFrom, getSourceFileOrError} from '../../file_system';
import {runInEachFileSystem} from '../../file_system/testing';
import {getTokenAtPosition} from '../../util/src/typescript';
import {CompletionKind, TypeCheckingConfig} from '../api';
import {CompletionKind, GlobalCompletion, TemplateTypeChecker, TypeCheckingConfig} from '../api';
import {getClass, setup as baseTestSetup, TypeCheckingTarget} from './test_utils';
import {getClass, setup, TypeCheckingTarget} from './test_utils';
runInEachFileSystem(() => {
describe('TemplateTypeChecker.getGlobalCompletions()', () => {
it('should return a completion point in the TCB for the component context', () => {
const MAIN_TS = absoluteFrom('/main.ts');
const {templateTypeChecker, programStrategy} = setup([
{
fileName: MAIN_TS,
templates: {'SomeCmp': `No special template needed`},
source: `
export class SomeCmp {}
`,
},
]);
const sf = getSourceFileOrError(programStrategy.getProgram(), MAIN_TS);
const SomeCmp = getClass(sf, 'SomeCmp');
const [global, ...rest] =
templateTypeChecker.getGlobalCompletions(/* root template */ null, SomeCmp);
expect(rest.length).toBe(0);
if (global.kind !== CompletionKind.ContextComponent) {
return fail(`Expected a ContextComponent completion`);
}
const tcbSf =
getSourceFileOrError(programStrategy.getProgram(), absoluteFrom(global.shimPath));
const node = getTokenAtPosition(tcbSf, global.positionInShimFile).parent;
const {completions, program} = setupCompletions(`No special template needed`);
expect(completions.templateContext.size).toBe(0);
const {shimPath, positionInShimFile} = completions.componentContext;
const tcbSf = getSourceFileOrError(program, shimPath);
const node = getTokenAtPosition(tcbSf, positionInShimFile).parent;
if (!ts.isExpressionStatement(node)) {
return fail(`Expected a ts.ExpressionStatement`);
}
expect(node.expression.getText()).toEqual('ctx.');
// The position should be between the '.' and a following space.
expect(tcbSf.text.substr(global.positionInShimFile - 1, 2)).toEqual('. ');
expect(tcbSf.text.substr(positionInShimFile - 1, 2)).toEqual('. ');
});
it('should return additional completions for references and variables when available', () => {
const MAIN_TS = absoluteFrom('/main.ts');
const {templateTypeChecker, programStrategy} = setup([
{
fileName: MAIN_TS,
templates: {
'SomeCmp': `
<div *ngFor="let user of users">
<div #innerRef></div>
<div *ngIf="user">
<div #notInScope></div>
</div>
</div>
<div #topLevelRef></div>
`
},
source: `
export class SomeCmp {
users: string[];
}
`,
},
]);
const sf = getSourceFileOrError(programStrategy.getProgram(), MAIN_TS);
const SomeCmp = getClass(sf, 'SomeCmp');
const tmpl = templateTypeChecker.getTemplate(SomeCmp)!;
const ngForTemplate = tmpl[0] as TmplAstTemplate;
const [contextCmp, ...rest] =
templateTypeChecker.getGlobalCompletions(ngForTemplate, SomeCmp);
if (contextCmp.kind !== CompletionKind.ContextComponent) {
return fail(`Expected first completion to be a ContextComponent`);
}
const completionKeys: string[] = [];
for (const completion of rest) {
if (completion.kind !== CompletionKind.Reference &&
completion.kind !== CompletionKind.Variable) {
return fail(`Unexpected CompletionKind, expected a Reference or Variable`);
}
completionKeys.push(completion.node.name);
}
expect(new Set(completionKeys)).toEqual(new Set(['innerRef', 'user', 'topLevelRef']));
const template = `
<div *ngFor="let user of users">
<div #innerRef></div>
<div *ngIf="user">
<div #notInScope></div>
</div>
</div>
<div #topLevelRef></div>
`;
const members = `users: string[];`;
// Embedded view in question is the first node in the template (index 0).
const {completions} = setupCompletions(template, members, 0);
expect(new Set(completions.templateContext.keys())).toEqual(new Set([
'innerRef', 'user', 'topLevelRef'
]));
});
it('should support shadowing between outer and inner templates ', () => {
const MAIN_TS = absoluteFrom('/main.ts');
const {templateTypeChecker, programStrategy} = setup([
{
fileName: MAIN_TS,
templates: {
'SomeCmp': `
<div *ngFor="let user of users">
Within this template, 'user' should be a variable, not a reference.
</div>
<div #user>Out here, 'user' is the reference.</div>
`
},
source: `
export class SomeCmp {
users: string[];
}
`,
},
]);
const sf = getSourceFileOrError(programStrategy.getProgram(), MAIN_TS);
const SomeCmp = getClass(sf, 'SomeCmp');
const template = `
<div *ngFor="let user of users">
Within this template, 'user' should be a variable, not a reference.
</div>
<div #user>Out here, 'user' is the reference.</div>
`;
const members = `users: string[];`;
// Completions for the top level.
const {completions: topLevel} = setupCompletions(template, members);
// Completions within the embedded view at index 0.
const {completions: inNgFor} = setupCompletions(template, members, 0);
const tmpl = templateTypeChecker.getTemplate(SomeCmp)!;
const ngForTemplate = tmpl[0] as TmplAstTemplate;
const [_a, userAtTopLevel] =
templateTypeChecker.getGlobalCompletions(/* root template */ null, SomeCmp);
const [_b, userInNgFor] = templateTypeChecker.getGlobalCompletions(ngForTemplate, SomeCmp);
expect(topLevel.templateContext.has('user')).toBeTrue();
const userAtTopLevel = topLevel.templateContext.get('user')!;
expect(inNgFor.templateContext.has('user')).toBeTrue();
const userInNgFor = inNgFor.templateContext.get('user')!;
expect(userAtTopLevel.kind).toBe(CompletionKind.Reference);
expect(userInNgFor.kind).toBe(CompletionKind.Variable);
});
it('should invalidate cached completions when overrides change', () => {
// The template starts with a #foo local reference.
const {completions: before, templateTypeChecker, component} =
setupCompletions('<div #foo></div>');
expect(Array.from(before.templateContext.keys())).toEqual(['foo']);
// Override the template and change the name of the local reference to #bar. This should
// invalidate any cached completions.
templateTypeChecker.overrideComponentTemplate(component, '<div #bar></div>');
// Fresh completions should include the #bar reference instead.
const afterOverride =
templateTypeChecker.getGlobalCompletions(/* root template */ null, component)!;
expect(afterOverride).toBeDefined();
expect(Array.from(afterOverride.templateContext.keys())).toEqual(['bar']);
// Reset the template to its original. This should also invalidate any cached completions.
templateTypeChecker.resetOverrides();
// Fresh completions should include the original #foo now.
const afterReset =
templateTypeChecker.getGlobalCompletions(/* root template */ null, component)!;
expect(afterReset).toBeDefined();
expect(Array.from(afterReset.templateContext.keys())).toEqual(['foo']);
});
});
});
function setup(targets: TypeCheckingTarget[], config?: Partial<TypeCheckingConfig>) {
return baseTestSetup(
targets, {inlining: false, config: {...config, enableTemplateTypeChecker: true}});
function setupCompletions(
template: string, componentMembers: string = '', inChildTemplateAtIndex: number|null = null): {
completions: GlobalCompletion,
program: ts.Program,
templateTypeChecker: TemplateTypeChecker,
component: ts.ClassDeclaration,
} {
const MAIN_TS = absoluteFrom('/main.ts');
const {templateTypeChecker, programStrategy} = setup(
[{
fileName: MAIN_TS,
templates: {'SomeCmp': template},
source: `export class SomeCmp { ${componentMembers} }`,
}],
({inlining: false, config: {enableTemplateTypeChecker: true}}));
const sf = getSourceFileOrError(programStrategy.getProgram(), MAIN_TS);
const SomeCmp = getClass(sf, 'SomeCmp');
let context: TmplAstTemplate|null = null;
if (inChildTemplateAtIndex !== null) {
const tmpl = templateTypeChecker.getTemplate(SomeCmp)![inChildTemplateAtIndex];
if (!(tmpl instanceof TmplAstTemplate)) {
throw new Error(
`AssertionError: expected TmplAstTemplate at index ${inChildTemplateAtIndex}`);
}
context = tmpl;
}
const completions = templateTypeChecker.getGlobalCompletions(context, SomeCmp)!;
expect(completions).toBeDefined();
return {
completions,
program: programStrategy.getProgram(),
templateTypeChecker,
component: SomeCmp,
};
}