refactor(compiler-cli): add a global autocompletion API (#39048)

This commit introduces a new API for the `TemplateTypeChecker` which allows
for autocompletion in a global expression context (for example, in a new
interpolation expression such as `{{|}}`). This API returns instances of the
type `GlobalCompletion`, which can represent either a completion result from
the template's component context or a declaration such as a local reference
or template variable. The Language Service will use this API to implement
autocompletion within templates.

PR Close #39048
This commit is contained in:
Alex Rickabaugh 2020-09-29 14:03:07 -04:00 committed by Joey Perrott
parent 72755eadd2
commit f2fca6d58e
7 changed files with 331 additions and 38 deletions

View File

@ -6,9 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, ParseError, TmplAstNode,} from '@angular/compiler';
import {AST, ParseError, TmplAstNode, TmplAstTemplate} from '@angular/compiler';
import * as ts from 'typescript';
import {GlobalCompletion} from './completion';
import {Symbol} from './symbols';
/**
@ -88,6 +89,18 @@ export interface TemplateTypeChecker {
* @see Symbol
*/
getSymbolOfNode(node: AST|TmplAstNode, component: ts.ClassDeclaration): Symbol|null;
/**
* Get "global" `Completion`s in the given context.
*
* Global completions are completions in the global context, as opposed to completions within an
* existing expression. For example, completing inside a new interpolation expression (`{{|}}`) or
* inside a new property binding `[input]="|" should retrieve global completions, which will
* include completions from the template's context component, as well as any local references or
* template variables which are in scope for that expression.
*/
getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration):
GlobalCompletion[];
}
/**

View File

@ -0,0 +1,62 @@
/**
* @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, TmplAstVariable} from '@angular/compiler';
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;
/**
* 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 {
kind: CompletionKind.Reference;
/**
* The `TmplAstReference` from the template which should be available as a completion.
*/
node: TmplAstReference;
}
/**
* An autocompletion result representing a variable declared in the template.
*/
export interface CompletionVariable {
kind: CompletionKind.Variable;
/**
* The `TmplAstVariable` from the template which should be available as a completion.
*/
node: TmplAstVariable;
}

View File

@ -8,5 +8,6 @@
export * from './api';
export * from './checker';
export * from './completion';
export * from './context';
export * from './symbols';

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, ParseError, parseTemplate, TmplAstNode} from '@angular/compiler';
import {AST, ParseError, parseTemplate, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import * as ts from 'typescript';
import {absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system';
@ -16,9 +16,10 @@ import {ReflectionHost} from '../../reflection';
import {ComponentScopeReader} from '../../scope';
import {isShim} from '../../shims';
import {getSourceFileOrNull} from '../../util/src/typescript';
import {OptimizeFor, ProgramTypeCheckAdapter, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {CompletionKind, GlobalCompletion, OptimizeFor, ProgramTypeCheckAdapter, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {TemplateDiagnostic} from '../diagnostics';
import {ExpressionIdentifier, findFirstMatchingNode} from './comments';
import {InliningMode, ShimTypeCheckingData, TemplateData, TypeCheckContextImpl, TypeCheckingHost} from './context';
import {findTypeCheckBlock, shouldReportDiagnostic, TemplateSourceResolver, translateDiagnostic} from './diagnostics';
import {TemplateSourceManager} from './source';
@ -53,14 +54,15 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
getTemplate(component: ts.ClassDeclaration): TmplAstNode[]|null {
const templateData = this.getTemplateData(component);
if (templateData === null) {
const {data} = this.getLatestComponentState(component);
if (data === null) {
return null;
}
return templateData.template;
return data.template;
}
private getTemplateData(component: ts.ClassDeclaration): TemplateData|null {
private getLatestComponentState(component: ts.ClassDeclaration):
{data: TemplateData|null, tcb: ts.Node|null, shimPath: AbsoluteFsPath} {
this.ensureShimForComponent(component);
const sf = component.getSourceFile();
@ -70,17 +72,34 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
const fileRecord = this.getFileData(sfPath);
if (!fileRecord.shimData.has(shimPath)) {
return null;
return {data: null, tcb: null, shimPath};
}
const templateId = fileRecord.sourceManager.getTemplateId(component);
const shimRecord = fileRecord.shimData.get(shimPath)!;
const id = fileRecord.sourceManager.getTemplateId(component);
if (!shimRecord.templates.has(templateId)) {
return null;
const program = this.typeCheckingStrategy.getProgram();
const shimSf = getSourceFileOrNull(program, shimPath);
if (shimSf === null || !fileRecord.shimData.has(shimPath)) {
throw new Error(`Error: no shim file in program: ${shimPath}`);
}
return shimRecord.templates.get(templateId)!;
let tcb: ts.Node|null = findTypeCheckBlock(shimSf, id);
if (tcb === null) {
// Try for an inline block.
const inlineSf = getSourceFileOrError(program, sfPath);
tcb = findTypeCheckBlock(inlineSf, id);
}
let data: TemplateData|null = null;
if (shimRecord.templates.has(templateId)) {
data = shimRecord.templates.get(templateId)!;
}
return {data, tcb, shimPath};
}
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
@ -186,31 +205,55 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
getTypeCheckBlock(component: ts.ClassDeclaration): ts.Node|null {
this.ensureAllShimsForOneFile(component.getSourceFile());
return this.getLatestComponentState(component).tcb;
}
const program = this.typeCheckingStrategy.getProgram();
const filePath = absoluteFromSourceFile(component.getSourceFile());
const shimPath = this.typeCheckingStrategy.shimPathForComponent(component);
if (!this.state.has(filePath)) {
throw new Error(`Error: no data for source file: ${filePath}`);
}
const fileRecord = this.state.get(filePath)!;
const id = fileRecord.sourceManager.getTemplateId(component);
const shimSf = getSourceFileOrNull(program, shimPath);
if (shimSf === null || !fileRecord.shimData.has(shimPath)) {
throw new Error(`Error: no shim file in program: ${shimPath}`);
getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration):
GlobalCompletion[] {
const {tcb, data, shimPath} = this.getLatestComponentState(component);
if (tcb === null || data === null) {
return [];
}
let node: ts.Node|null = findTypeCheckBlock(shimSf, id);
if (node === null) {
// Try for an inline block.
const inlineSf = getSourceFileOrError(program, filePath);
node = findTypeCheckBlock(inlineSf, id);
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 [];
}
return node;
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;
}
private maybeAdoptPriorResultsForFile(sf: ts.SourceFile): void {
@ -362,17 +405,12 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
}
getSymbolOfNode(node: AST|TmplAstNode, component: ts.ClassDeclaration): Symbol|null {
const tcb = this.getTypeCheckBlock(component);
if (tcb === null) {
const {tcb, data, shimPath} = this.getLatestComponentState(component);
if (tcb === null || data === null) {
return null;
}
const typeChecker = this.typeCheckingStrategy.getProgram().getTypeChecker();
const shimPath = this.typeCheckingStrategy.shimPathForComponent(component);
const data = this.getTemplateData(component);
if (data === null) {
return null;
}
return new SymbolBuilder(typeChecker, shimPath, tcb, data, this.componentScopeReader)
.getSymbol(node);

View File

@ -42,6 +42,7 @@ export enum CommentTriviaType {
/** Identifies what the TCB expression is for (for example, a directive declaration). */
export enum ExpressionIdentifier {
DIRECTIVE = 'DIR',
COMPONENT_COMPLETION = 'COMPCOMP',
}
/** Tags the node with the given expression identifier. */
@ -85,6 +86,7 @@ function makeRecursiveVisitor<T extends ts.Node>(visitor: (node: ts.Node) => T |
export interface FindOptions<T extends ts.Node> {
filter: (node: ts.Node) => node is T;
withExpressionIdentifier?: ExpressionIdentifier;
withSpan?: AbsoluteSourceSpan|ParseSourceSpan;
}
@ -109,6 +111,7 @@ function getSpanFromOptions(opts: FindOptions<ts.Node>) {
export function findFirstMatchingNode<T extends ts.Node>(tcb: ts.Node, opts: FindOptions<T>): T|
null {
const withSpan = getSpanFromOptions(opts);
const withExpressionIdentifier = opts.withExpressionIdentifier;
const sf = tcb.getSourceFile();
const visitor = makeRecursiveVisitor<T>(node => {
if (!opts.filter(node)) {
@ -120,6 +123,10 @@ export function findFirstMatchingNode<T extends ts.Node>(tcb: ts.Node, opts: Fin
return null;
}
}
if (withExpressionIdentifier !== undefined &&
!hasExpressionIdentifier(sf, node, withExpressionIdentifier)) {
return null;
}
return node;
});
return tcb.forEachChild(visitor) ?? null;
@ -135,6 +142,7 @@ export function findFirstMatchingNode<T extends ts.Node>(tcb: ts.Node, opts: Fin
*/
export function findAllMatchingNodes<T extends ts.Node>(tcb: ts.Node, opts: FindOptions<T>): T[] {
const withSpan = getSpanFromOptions(opts);
const withExpressionIdentifier = opts.withExpressionIdentifier;
const results: T[] = [];
const stack: ts.Node[] = [tcb];
const sf = tcb.getSourceFile();
@ -153,6 +161,10 @@ export function findAllMatchingNodes<T extends ts.Node>(tcb: ts.Node, opts: Find
continue;
}
}
if (withExpressionIdentifier !== undefined &&
!hasExpressionIdentifier(sf, node, withExpressionIdentifier)) {
continue;
}
results.push(node);
}

View File

@ -962,6 +962,30 @@ class TcbUnclaimedOutputsOp extends TcbOp {
}
}
/**
* A `TcbOp` which generates a completion point for the component context.
*
* This completion point looks like `ctx. ;` in the TCB output, and does not produce diagnostics.
* TypeScript autocompletion APIs can be used at this completion point (after the '.') to produce
* autocompletion results of properties and methods from the template's component context.
*/
class TcbComponentContextCompletionOp extends TcbOp {
constructor(private scope: Scope) {
super();
}
readonly optional = false;
execute(): null {
const ctx = ts.createIdentifier('ctx');
const ctxDot = ts.createPropertyAccess(ctx, '');
markIgnoreDiagnostics(ctxDot);
addExpressionIdentifier(ctxDot, ExpressionIdentifier.COMPONENT_COMPLETION);
this.scope.addStatement(ts.createExpressionStatement(ctxDot));
return null;
}
}
/**
* Value used to break a circular reference between `TcbOp`s.
*
@ -1089,6 +1113,11 @@ class Scope {
guard: ts.Expression|null): Scope {
const scope = new Scope(tcb, parent, guard);
if (parent === null && tcb.env.config.enableTemplateTypeChecker) {
// Add an autocompletion point for the component context.
scope.opQueue.push(new TcbComponentContextCompletionOp(scope));
}
let children: TmplAstNode[];
// If given an actual `TmplAstTemplate` instance, then process any additional information it

View File

@ -0,0 +1,138 @@
/**
* @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 {TmplAstTemplate} from '@angular/compiler';
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 {getClass, setup as baseTestSetup, 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;
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('. ');
});
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']));
});
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 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(userAtTopLevel.kind).toBe(CompletionKind.Reference);
expect(userInNgFor.kind).toBe(CompletionKind.Variable);
});
});
});
function setup(targets: TypeCheckingTarget[], config?: Partial<TypeCheckingConfig>) {
return baseTestSetup(
targets, {inlining: false, config: {...config, enableTemplateTypeChecker: true}});
}