Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autocompletion in expression contexts #39727

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts
Original file line number Diff line number Diff line change
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about KeyedRead/KeyedWrite/PropertyWrite?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KeyedRead and KeyedWrite are non-trivial - the key is itself an expression, meaning we won't target the KeyedRead but rather a LiteralPrimitive (if a string literal key is used) within the KeyedRead. It's possible to support these forms, but I want to get the 99% case working first.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added support / a test for PropertyWrite, since it's nearly identical to PropertyRead.

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
Original file line number Diff line number Diff line change
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
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 =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have any concerns about memory "leak" since we are not invalidating cache entries?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because the entire CompletionEngine is thrown away on a template change.

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|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm actually a bit curious about this method in general. Shouldn't TTC#getSymbolAtLocation return this same information?

Edit: Ah, I see that you use getEnd whereas the getSymbolAtLocation uses getStart. Maybe there's some more nuance to this as well that I don't see immediately.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's any nuance with getStart() vs getEnd(). TTC#getSymbolOfNode() would almost certainly work in the cases supported by this PR. But something more complex liked KeyedRead may require a completion location that's specifically inside of the quoted key string, for example, which getSymbolOfNode() isn't obligated to provide.

We could add to getSymbolOfNode()'s contract that it provides autocompletion-compatible locations where necessary, but I think a separate method is probably preferable.

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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little weird that you're using parenthesized expression as the filter here. I think this should actually be ts.isConditionalExpression based on the check below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getSymbolOfTemplateExpression does something similar - it looks for any node with the given span, then unwraps any parenthesized expression. I'm relying on the fact that all ternaries are parenthesized.

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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,
};
}
}