From f2fca6d58ebdc183e91d1e0bed2aeb974c30d14c Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Tue, 29 Sep 2020 14:03:07 -0400 Subject: [PATCH] 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 --- .../src/ngtsc/typecheck/api/checker.ts | 15 +- .../src/ngtsc/typecheck/api/completion.ts | 62 ++++++++ .../src/ngtsc/typecheck/api/index.ts | 1 + .../src/ngtsc/typecheck/src/checker.ts | 112 +++++++++----- .../src/ngtsc/typecheck/src/comments.ts | 12 ++ .../ngtsc/typecheck/src/type_check_block.ts | 29 ++++ .../test/type_checker__completion_spec.ts | 138 ++++++++++++++++++ 7 files changed, 331 insertions(+), 38 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/typecheck/api/completion.ts create mode 100644 packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__completion_spec.ts diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 843466a2e4..97f36e6b63 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -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[]; } /** diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/completion.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/completion.ts new file mode 100644 index 0000000000..9d5c5e16ca --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/completion.ts @@ -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; +} diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts index c6994c26d9..675f02d4f0 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/index.ts @@ -8,5 +8,6 @@ export * from './api'; export * from './checker'; +export * from './completion'; export * from './context'; export * from './symbols'; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index b2b2343ae1..ceab60d4c9 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -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); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts index bf74b313e9..10b6c59dda 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/comments.ts @@ -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(visitor: (node: ts.Node) => T | export interface FindOptions { filter: (node: ts.Node) => node is T; + withExpressionIdentifier?: ExpressionIdentifier; withSpan?: AbsoluteSourceSpan|ParseSourceSpan; } @@ -109,6 +111,7 @@ function getSpanFromOptions(opts: FindOptions) { export function findFirstMatchingNode(tcb: ts.Node, opts: FindOptions): T| null { const withSpan = getSpanFromOptions(opts); + const withExpressionIdentifier = opts.withExpressionIdentifier; const sf = tcb.getSourceFile(); const visitor = makeRecursiveVisitor(node => { if (!opts.filter(node)) { @@ -120,6 +123,10 @@ export function findFirstMatchingNode(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(tcb: ts.Node, opts: Fin */ export function findAllMatchingNodes(tcb: ts.Node, opts: FindOptions): 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(tcb: ts.Node, opts: Find continue; } } + if (withExpressionIdentifier !== undefined && + !hasExpressionIdentifier(sf, node, withExpressionIdentifier)) { + continue; + } results.push(node); } diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 1bc4af9798..a5971b595b 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -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 diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__completion_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__completion_spec.ts new file mode 100644 index 0000000000..f9364d787b --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_checker__completion_spec.ts @@ -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': ` +
+
+
+
+
+
+
+ ` + }, + 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': ` +
+ Within this template, 'user' should be a variable, not a reference. +
+
Out here, 'user' is the reference.
+ ` + }, + 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) { + return baseTestSetup( + targets, {inlining: false, config: {...config, enableTemplateTypeChecker: true}}); +}