Skip to content

Commit

Permalink
feat(language-service): autocompletion within expression contexts (#3…
Browse files Browse the repository at this point in the history
…9727)

This commit adds support to the Language Service for autocompletion within
expression contexts. Specifically, this is auto completion of property reads
and method calls, both in normal and safe-navigational forms.

PR Close #39727
  • Loading branch information
alxhub committed Dec 10, 2020
1 parent 269a775 commit 93a8326
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 19 deletions.
11 changes: 10 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
*/
Expand Down
12 changes: 11 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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)!;
Expand Down
54 changes: 53 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts
Expand Up @@ -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';
Expand All @@ -28,6 +29,9 @@ export class CompletionEngine {
*/
private globalCompletionCache = new Map<TmplAstTemplate|null, GlobalCompletion>();

private expressionCompletionCache =
new Map<PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall, ShimLocation>();

constructor(private tcb: ts.Node, private data: TemplateData, private shimPath: AbsoluteFsPath) {}

/**
Expand Down Expand Up @@ -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;
}
}
76 changes: 61 additions & 15 deletions packages/language-service/ivy/completions.ts
Expand Up @@ -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<PropertyRead|MethodCall|EmptyExpr|LiteralPrimitive>;
CompletionBuilder<PropertyRead|PropertyWrite|MethodCall|EmptyExpr|SafePropertyRead|
SafeMethodCall>;

/**
* Performs autocompletion operations on a given node in the template.
Expand Down Expand Up @@ -84,7 +86,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
private isPropertyExpressionCompletion(this: CompletionBuilder<TmplAstNode|AST>):
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);
}

Expand All @@ -100,8 +103,30 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
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,
};
}
}

Expand All @@ -112,15 +137,26 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
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;
}

/**
Expand All @@ -132,8 +168,13 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
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);
}
}

Expand All @@ -154,10 +195,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
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.
Expand Down Expand Up @@ -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,
};
}
104 changes: 103 additions & 1 deletion packages/language-service/ivy/test/completions_spec.ts
Expand Up @@ -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(
`<button (click)="name.fi¦ = 'test"></button>`, `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(
Expand All @@ -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('');
}
Expand Down Expand Up @@ -166,4 +268,4 @@ function setup(templateWithCursor: string, classContents: string): {
nodes,
cursor,
};
}
}

0 comments on commit 93a8326

Please sign in to comment.