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:
parent
72755eadd2
commit
f2fca6d58e
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -8,5 +8,6 @@
|
|||
|
||||
export * from './api';
|
||||
export * from './checker';
|
||||
export * from './completion';
|
||||
export * from './context';
|
||||
export * from './symbols';
|
||||
|
|
|
@ -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());
|
||||
|
||||
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}`);
|
||||
return this.getLatestComponentState(component).tcb;
|
||||
}
|
||||
|
||||
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);
|
||||
getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration):
|
||||
GlobalCompletion[] {
|
||||
const {tcb, data, shimPath} = this.getLatestComponentState(component);
|
||||
if (tcb === null || data === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return node;
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}});
|
||||
}
|
Loading…
Reference in New Issue