diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index f04bc887d8a9fe..4603bd17398814 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, ParseError, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; +import {AST, MethodCall, ParseError, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import * as ts from 'typescript'; @@ -124,6 +124,15 @@ export interface TemplateTypeChecker { getGlobalCompletions(context: TmplAstTemplate|null, component: ts.ClassDeclaration): GlobalCompletion|null; + + /** + * For the given expression node, retrieve a `ShimLocation` that can be used to perform + * autocompletion at that point in the expression, if such a location exists. + */ + getExpressionCompletionLocation( + expr: PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall, + component: ts.ClassDeclaration): ShimLocation|null; + /** * Get basic metadata on the directives which are in scope for the given component. */ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index 69f4066c22c1bc..0badb22070cef1 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, TmplAstTemplate,} from '@angular/compiler'; +import {AST, MethodCall, ParseError, parseTemplate, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; import * as ts from 'typescript'; import {absoluteFrom, absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system'; @@ -285,6 +285,16 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { return engine.getGlobalCompletions(context); } + getExpressionCompletionLocation( + ast: PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall, + component: ts.ClassDeclaration): ShimLocation|null { + const engine = this.getOrCreateCompletionEngine(component); + if (engine === null) { + return null; + } + return engine.getExpressionCompletionLocation(ast); + } + private getOrCreateCompletionEngine(component: ts.ClassDeclaration): CompletionEngine|null { if (this.completionCache.has(component)) { return this.completionCache.get(component)!; diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts index 7421ef557a5fa2..6157a663d758b5 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts @@ -7,10 +7,11 @@ */ import {TmplAstReference, TmplAstTemplate} from '@angular/compiler'; +import {MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead} from '@angular/compiler/src/compiler'; import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../file_system'; -import {CompletionKind, GlobalCompletion, ReferenceCompletion, VariableCompletion} from '../api'; +import {CompletionKind, GlobalCompletion, ReferenceCompletion, ShimLocation, VariableCompletion} from '../api'; import {ExpressionIdentifier, findFirstMatchingNode} from './comments'; import {TemplateData} from './context'; @@ -28,6 +29,9 @@ export class CompletionEngine { */ private globalCompletionCache = new Map(); + private expressionCompletionCache = + new Map(); + constructor(private tcb: ts.Node, private data: TemplateData, private shimPath: AbsoluteFsPath) {} /** @@ -79,4 +83,52 @@ export class CompletionEngine { this.globalCompletionCache.set(context, completion); return completion; } + + getExpressionCompletionLocation(expr: PropertyRead|PropertyWrite|MethodCall| + SafeMethodCall): ShimLocation|null { + if (this.expressionCompletionCache.has(expr)) { + return this.expressionCompletionCache.get(expr)!; + } + + // Completion works inside property reads and method calls. + let tsExpr: ts.PropertyAccessExpression|null = null; + if (expr instanceof PropertyRead || expr instanceof MethodCall || + expr instanceof PropertyWrite) { + // Non-safe navigation operations are trivial: `foo.bar` or `foo.bar()` + tsExpr = findFirstMatchingNode(this.tcb, { + filter: ts.isPropertyAccessExpression, + withSpan: expr.nameSpan, + }); + } else if (expr instanceof SafePropertyRead || expr instanceof SafeMethodCall) { + // Safe navigation operations are a little more complex, and involve a ternary. Completion + // happens in the "true" case of the ternary. + const ternaryExpr = findFirstMatchingNode(this.tcb, { + filter: ts.isParenthesizedExpression, + withSpan: expr.sourceSpan, + }); + if (ternaryExpr === null || !ts.isConditionalExpression(ternaryExpr.expression)) { + return null; + } + const whenTrue = ternaryExpr.expression.whenTrue; + + if (expr instanceof SafePropertyRead && ts.isPropertyAccessExpression(whenTrue)) { + tsExpr = whenTrue; + } else if ( + expr instanceof SafeMethodCall && ts.isCallExpression(whenTrue) && + ts.isPropertyAccessExpression(whenTrue.expression)) { + tsExpr = whenTrue.expression; + } + } + + if (tsExpr === null) { + return null; + } + + const res: ShimLocation = { + shimPath: this.shimPath, + positionInShimFile: tsExpr.name.getEnd(), + }; + this.expressionCompletionCache.set(expr, res); + return res; + } } diff --git a/packages/language-service/ivy/completions.ts b/packages/language-service/ivy/completions.ts index c3cb1ef04c2ddf..62299420aaabf6 100644 --- a/packages/language-service/ivy/completions.ts +++ b/packages/language-service/ivy/completions.ts @@ -6,16 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, PropertyRead, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler'; +import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {CompletionKind, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import {BoundEvent} from '@angular/compiler/src/render3/r3_ast'; import * as ts from 'typescript'; import {DisplayInfoKind, getDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; +import {filterAliasImports} from './utils'; type PropertyExpressionCompletionBuilder = - CompletionBuilder; + CompletionBuilder; /** * Performs autocompletion operations on a given node in the template. @@ -84,7 +86,8 @@ export class CompletionBuilder { private isPropertyExpressionCompletion(this: CompletionBuilder): this is PropertyExpressionCompletionBuilder { return this.node instanceof PropertyRead || this.node instanceof MethodCall || - this.node instanceof EmptyExpr || + this.node instanceof SafePropertyRead || this.node instanceof SafeMethodCall || + this.node instanceof PropertyWrite || this.node instanceof EmptyExpr || isBrokenEmptyBoundEventExpression(this.node, this.nodeParent); } @@ -100,8 +103,30 @@ export class CompletionBuilder { this.node.receiver instanceof ImplicitReceiver) { return this.getGlobalPropertyExpressionCompletion(options); } else { - // TODO(alxhub): implement completion of non-global expressions. - return undefined; + const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( + this.node, this.component); + if (location === null) { + return undefined; + } + const tsResults = this.tsLS.getCompletionsAtPosition( + location.shimPath, location.positionInShimFile, options); + if (tsResults === undefined) { + return undefined; + } + + const replacementSpan = makeReplacementSpan(this.node); + + let ngResults: ts.CompletionEntry[] = []; + for (const result of tsResults.entries) { + ngResults.push({ + ...result, + replacementSpan, + }); + } + return { + ...tsResults, + entries: ngResults, + }; } } @@ -112,15 +137,26 @@ export class CompletionBuilder { this: PropertyExpressionCompletionBuilder, entryName: string, formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined, preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined { + let details: ts.CompletionEntryDetails|undefined = undefined; if (this.node instanceof EmptyExpr || isBrokenEmptyBoundEventExpression(this.node, this.nodeParent) || this.node.receiver instanceof ImplicitReceiver) { - return this.getGlobalPropertyExpressionCompletionDetails( - entryName, formatOptions, preferences); + details = + this.getGlobalPropertyExpressionCompletionDetails(entryName, formatOptions, preferences); } else { - // TODO(alxhub): implement completion of non-global expressions. - return undefined; + const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( + this.node, this.component); + if (location === null) { + return undefined; + } + details = this.tsLS.getCompletionEntryDetails( + location.shimPath, location.positionInShimFile, entryName, formatOptions, + /* source */ undefined, preferences); + } + if (details !== undefined) { + details.displayParts = filterAliasImports(details.displayParts); } + return details; } /** @@ -132,8 +168,13 @@ export class CompletionBuilder { this.node.receiver instanceof ImplicitReceiver) { return this.getGlobalPropertyExpressionCompletionSymbol(name); } else { - // TODO(alxhub): implement completion of non-global expressions. - return undefined; + const location = this.compiler.getTemplateTypeChecker().getExpressionCompletionLocation( + this.node, this.component); + if (location === null) { + return undefined; + } + return this.tsLS.getCompletionEntrySymbol( + location.shimPath, location.positionInShimFile, name, /* source */ undefined); } } @@ -154,10 +195,7 @@ export class CompletionBuilder { let replacementSpan: ts.TextSpan|undefined = undefined; // Non-empty nodes get replaced with the completion. if (!(this.node instanceof EmptyExpr || this.node instanceof LiteralPrimitive)) { - replacementSpan = { - start: this.node.nameSpan.start, - length: this.node.nameSpan.end - this.node.nameSpan.start, - }; + replacementSpan = makeReplacementSpan(this.node); } // Merge TS completion results with results from the template scope. @@ -285,3 +323,11 @@ function isBrokenEmptyBoundEventExpression( return node instanceof LiteralPrimitive && parent !== null && parent instanceof BoundEvent && node.value === 'ERROR'; } + +function makeReplacementSpan(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| + SafeMethodCall): ts.TextSpan { + return { + start: node.nameSpan.start, + length: node.nameSpan.end - node.nameSpan.start, + }; +} diff --git a/packages/language-service/ivy/test/completions_spec.ts b/packages/language-service/ivy/test/completions_spec.ts index b4c3923ed84090..b318f581ebb9e7 100644 --- a/packages/language-service/ivy/test/completions_spec.ts +++ b/packages/language-service/ivy/test/completions_spec.ts @@ -106,6 +106,98 @@ describe('completions', () => { expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title']); }); }); + + describe('in an expression scope', () => { + it('should return completions in a property access expression', () => { + const {ngLS, fileName, cursor} = + setup(`{{name.f¦}}`, `name!: {first: string; last: string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + last: ts.ScriptElementKind.memberVariableElement, + }); + }); + + it('should return completions in an empty property access expression', () => { + const {ngLS, fileName, cursor} = + setup(`{{name.¦}}`, `name!: {first: string; last: string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + last: ts.ScriptElementKind.memberVariableElement, + }); + }); + + it('should return completions in a property write expression', () => { + const {ngLS, fileName, cursor} = setup( + ``, `name!: {first: string; last: string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + last: ts.ScriptElementKind.memberVariableElement, + }); + }); + + it('should return completions in a method call expression', () => { + const {ngLS, fileName, cursor} = + setup(`{{name.f¦()}}`, `name!: {first: string; full(): string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + full: ts.ScriptElementKind.memberFunctionElement, + }); + }); + + it('should return completions in an empty method call expression', () => { + const {ngLS, fileName, cursor} = + setup(`{{name.¦()}}`, `name!: {first: string; full(): string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + full: ts.ScriptElementKind.memberFunctionElement, + }); + }); + + it('should return completions in a safe property navigation context', () => { + const {ngLS, fileName, cursor} = + setup(`{{name?.f¦}}`, `name?: {first: string; last: string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + last: ts.ScriptElementKind.memberVariableElement, + }); + }); + + it('should return completions in an empty safe property navigation context', () => { + const {ngLS, fileName, cursor} = + setup(`{{name?.¦}}`, `name?: {first: string; last: string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + last: ts.ScriptElementKind.memberVariableElement, + }); + }); + + it('should return completions in a safe method call context', () => { + const {ngLS, fileName, cursor} = + setup(`{{name?.f¦()}}`, `name!: {first: string; full(): string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + full: ts.ScriptElementKind.memberFunctionElement, + }); + }); + + it('should return completions in an empty safe method call context', () => { + const {ngLS, fileName, cursor} = + setup(`{{name?.¦()}}`, `name!: {first: string; full(): string;};`); + const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined); + expectAll(completions, { + first: ts.ScriptElementKind.memberVariableElement, + full: ts.ScriptElementKind.memberFunctionElement, + }); + }); + }); }); function expectContain( @@ -117,6 +209,16 @@ function expectContain( } } +function expectAll( + completions: ts.CompletionInfo|undefined, + contains: {[name: string]: ts.ScriptElementKind|DisplayInfoKind}): void { + expect(completions).toBeDefined(); + for (const [name, kind] of Object.entries(contains)) { + expect(completions!.entries).toContain(jasmine.objectContaining({name, kind} as any)); + } + expect(completions!.entries.length).toEqual(Object.keys(contains).length); +} + function toText(displayParts?: ts.SymbolDisplayPart[]): string { return (displayParts ?? []).map(p => p.text).join(''); } @@ -166,4 +268,4 @@ function setup(templateWithCursor: string, classContents: string): { nodes, cursor, }; -} \ No newline at end of file +}