Skip to content

Commit

Permalink
feat(language-service): autocompletion within expression contexts
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alxhub committed Nov 18, 2020
1 parent 11b4f94 commit 199194e
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 15 deletions.
12 changes: 11 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
*/

import {AST, ParseError, TmplAstNode, TmplAstTemplate} from '@angular/compiler';
import {MethodCall, PropertyRead, SafeMethodCall, SafePropertyRead} from '@angular/compiler/src/compiler';
import * as ts from 'typescript';

import {GlobalCompletion} from './completion';
import {DirectiveInScope, PipeInScope} from './scope';
import {Symbol} from './symbols';
import {ShimLocation, Symbol} from './symbols';

/**
* Interface to the Angular Template Type Checker to extract diagnostics and intelligence from the
Expand Down Expand Up @@ -103,6 +104,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
13 changes: 12 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {AST, ParseError, parseTemplate, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import {MethodCall, PropertyRead, SafeMethodCall, SafePropertyRead} from '@angular/compiler/src/compiler';
import * as ts from 'typescript';

import {absoluteFromSourceFile, AbsoluteFsPath, getSourceFileOrError} from '../../file_system';
Expand All @@ -16,7 +17,7 @@ import {isNamedClassDeclaration, ReflectionHost} from '../../reflection';
import {ComponentScopeReader} from '../../scope';
import {isShim} from '../../shims';
import {getSourceFileOrNull} from '../../util/src/typescript';
import {CompletionKind, DirectiveInScope, GlobalCompletion, OptimizeFor, PipeInScope, ProgramTypeCheckAdapter, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {CompletionKind, DirectiveInScope, GlobalCompletion, OptimizeFor, PipeInScope, ProgramTypeCheckAdapter, ShimLocation, Symbol, TemplateId, TemplateTypeChecker, TypeCheckingConfig, TypeCheckingProgramStrategy, UpdateMode} from '../api';
import {TemplateDiagnostic} from '../diagnostics';

import {ExpressionIdentifier, findFirstMatchingNode} from './comments';
Expand Down Expand Up @@ -252,6 +253,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
Original file line number Diff line number Diff line change
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) {
// 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;
}
}
72 changes: 60 additions & 12 deletions packages/language-service/ivy/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AST, ImplicitReceiver, MethodCall, PropertyRead, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import {AST, ImplicitReceiver, MethodCall, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {CompletionKind, ReferenceSymbol, TemplateDeclarationSymbol, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as ts from 'typescript';

import {DisplayInfoKind, getDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
import {filterAliasImports} from './utils';

type PropertyExpressionCompletionBuilder = CompletionBuilder<PropertyRead|MethodCall>;
type PropertyExpressionCompletionBuilder =
CompletionBuilder<PropertyRead|MethodCall|SafePropertyRead|SafeMethodCall>;

/**
* Performs autocompletion operations on a given node in the template.
Expand Down Expand Up @@ -63,7 +65,6 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
* Analogue for `ts.LanguageService.getCompletionEntrySymbol`.
*/
getCompletionEntrySymbol(name: string): ts.Symbol|undefined {
console.error('getCompletionEntrySymbol()', name);
if (this.isPropertyExpressionCompletion()) {
return this.getPropertyExpressionCompletionSymbol(name);
} else {
Expand All @@ -80,7 +81,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;
return this.node instanceof PropertyRead || this.node instanceof MethodCall ||
this.node instanceof SafePropertyRead || this.node instanceof SafeMethodCall;
}

/**
Expand All @@ -93,8 +95,30 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
if (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 @@ -105,13 +129,24 @@ 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.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 @@ -122,8 +157,13 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
if (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 Down Expand Up @@ -260,3 +300,11 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
}
}
}

function makeReplacementSpan(node: PropertyRead|MethodCall|SafePropertyRead|
SafeMethodCall): ts.TextSpan {
return {
start: node.nameSpan.start,
length: node.nameSpan.end - node.nameSpan.start,
};
}
69 changes: 69 additions & 0 deletions packages/language-service/ivy/test/completions_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,75 @@ describe('completions', () => {
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first', 'last']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first']);
expectContain(completions, ts.ScriptElementKind.memberFunctionElement, ['full']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first']);
expectContain(completions, ts.ScriptElementKind.memberFunctionElement, ['full']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first', 'last']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first']);
expectContain(completions, ts.ScriptElementKind.functionElement, ['full']);
});

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);
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['first']);
});
});
});
});

Expand Down

0 comments on commit 199194e

Please sign in to comment.