feat(language-service): implement autocompletion for global properties (Ivy) (#39250)
This commit adds support in the Ivy Language Service for autocompletion in a global context - e.g. a {{foo|}} completion. Support is added both for the primary function `getCompletionsAtPosition` as well as the detail functions `getCompletionEntryDetails` and `getCompletionEntrySymbol`. These latter operations are not used yet as an upstream change to the extension is required to advertise and support this capability. PR Close #39250
This commit is contained in:
parent
67c5fe06e6
commit
28a0bcb424
@ -50,7 +50,7 @@ export interface TemplateTypeChecker {
|
|||||||
* is not valid. If the template cannot be parsed correctly, no override will occur.
|
* is not valid. If the template cannot be parsed correctly, no override will occur.
|
||||||
*/
|
*/
|
||||||
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
|
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
|
||||||
{nodes: TmplAstNode[], errors?: ParseError[]};
|
{nodes: TmplAstNode[], errors: ParseError[]|null};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all `ts.Diagnostic`s currently available for the given `ts.SourceFile`.
|
* Get all `ts.Diagnostic`s currently available for the given `ts.SourceFile`.
|
||||||
|
@ -32,6 +32,11 @@ export enum SymbolKind {
|
|||||||
export type Symbol = InputBindingSymbol|OutputBindingSymbol|ElementSymbol|ReferenceSymbol|
|
export type Symbol = InputBindingSymbol|OutputBindingSymbol|ElementSymbol|ReferenceSymbol|
|
||||||
VariableSymbol|ExpressionSymbol|DirectiveSymbol|TemplateSymbol|DomBindingSymbol;
|
VariableSymbol|ExpressionSymbol|DirectiveSymbol|TemplateSymbol|DomBindingSymbol;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `Symbol` which declares a new named entity in the template scope.
|
||||||
|
*/
|
||||||
|
export type TemplateDeclarationSymbol = ReferenceSymbol|VariableSymbol;
|
||||||
|
|
||||||
/** Information about where a `ts.Node` can be found in the type check block shim file. */
|
/** Information about where a `ts.Node` can be found in the type check block shim file. */
|
||||||
export interface ShimLocation {
|
export interface ShimLocation {
|
||||||
/**
|
/**
|
||||||
|
@ -138,16 +138,12 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
|
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
|
||||||
{nodes: TmplAstNode[], errors?: ParseError[]} {
|
{nodes: TmplAstNode[], errors: ParseError[]|null} {
|
||||||
const {nodes, errors} = parseTemplate(template, 'override.html', {
|
const {nodes, errors} = parseTemplate(template, 'override.html', {
|
||||||
preserveWhitespaces: true,
|
preserveWhitespaces: true,
|
||||||
leadingTriviaChars: [],
|
leadingTriviaChars: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errors !== null) {
|
|
||||||
return {nodes, errors};
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = absoluteFromSourceFile(component.getSourceFile());
|
const filePath = absoluteFromSourceFile(component.getSourceFile());
|
||||||
|
|
||||||
const fileRecord = this.getFileData(filePath);
|
const fileRecord = this.getFileData(filePath);
|
||||||
@ -169,7 +165,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
|||||||
this.completionCache.delete(component);
|
this.completionCache.delete(component);
|
||||||
this.symbolBuilderCache.delete(component);
|
this.symbolBuilderCache.delete(component);
|
||||||
|
|
||||||
return {nodes};
|
return {nodes, errors};
|
||||||
}
|
}
|
||||||
|
|
||||||
isTrackedTypeCheckFile(filePath: AbsoluteFsPath): boolean {
|
isTrackedTypeCheckFile(filePath: AbsoluteFsPath): boolean {
|
||||||
|
287
packages/language-service/ivy/completions.ts
Normal file
287
packages/language-service/ivy/completions.ts
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
/**
|
||||||
|
* @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 {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, PropertyRead, 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';
|
||||||
|
|
||||||
|
type PropertyExpressionCompletionBuilder =
|
||||||
|
CompletionBuilder<PropertyRead|MethodCall|EmptyExpr|LiteralPrimitive>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs autocompletion operations on a given node in the template.
|
||||||
|
*
|
||||||
|
* This class acts as a closure around all of the context required to perform the 3 autocompletion
|
||||||
|
* operations (completions, get details, and get symbol) at a specific node.
|
||||||
|
*
|
||||||
|
* The generic `N` type for the template node is narrowed internally for certain operations, as the
|
||||||
|
* compiler operations required to implement completion may be different for different node types.
|
||||||
|
*
|
||||||
|
* @param N type of the template node in question, narrowed accordingly.
|
||||||
|
*/
|
||||||
|
export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
|
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
|
||||||
|
private readonly templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
|
||||||
|
private readonly component: ts.ClassDeclaration, private readonly node: N,
|
||||||
|
private readonly nodeParent: TmplAstNode|AST|null,
|
||||||
|
private readonly context: TmplAstTemplate|null) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analogue for `ts.LanguageService.getCompletionsAtPosition`.
|
||||||
|
*/
|
||||||
|
getCompletionsAtPosition(options: ts.GetCompletionsAtPositionOptions|
|
||||||
|
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
|
if (this.isPropertyExpressionCompletion()) {
|
||||||
|
return this.getPropertyExpressionCompletion(options);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analogue for `ts.LanguageService.getCompletionEntryDetails`.
|
||||||
|
*/
|
||||||
|
getCompletionEntryDetails(
|
||||||
|
entryName: string, formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined,
|
||||||
|
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
|
||||||
|
if (this.isPropertyExpressionCompletion()) {
|
||||||
|
return this.getPropertyExpressionCompletionDetails(entryName, formatOptions, preferences);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analogue for `ts.LanguageService.getCompletionEntrySymbol`.
|
||||||
|
*/
|
||||||
|
getCompletionEntrySymbol(name: string): ts.Symbol|undefined {
|
||||||
|
if (this.isPropertyExpressionCompletion()) {
|
||||||
|
return this.getPropertyExpressionCompletionSymbol(name);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the current node is the completion of a property expression, and narrow the type
|
||||||
|
* of `this.node` if so.
|
||||||
|
*
|
||||||
|
* This narrowing gives access to additional methods related to completion of property
|
||||||
|
* expressions.
|
||||||
|
*/
|
||||||
|
private isPropertyExpressionCompletion(this: CompletionBuilder<TmplAstNode|AST>):
|
||||||
|
this is PropertyExpressionCompletionBuilder {
|
||||||
|
return this.node instanceof PropertyRead || this.node instanceof MethodCall ||
|
||||||
|
this.node instanceof EmptyExpr ||
|
||||||
|
isBrokenEmptyBoundEventExpression(this.node, this.nodeParent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get completions for property expressions.
|
||||||
|
*/
|
||||||
|
private getPropertyExpressionCompletion(
|
||||||
|
this: PropertyExpressionCompletionBuilder,
|
||||||
|
options: ts.GetCompletionsAtPositionOptions|
|
||||||
|
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
|
if (this.node instanceof EmptyExpr ||
|
||||||
|
isBrokenEmptyBoundEventExpression(this.node, this.nodeParent) ||
|
||||||
|
this.node.receiver instanceof ImplicitReceiver) {
|
||||||
|
return this.getGlobalPropertyExpressionCompletion(options);
|
||||||
|
} else {
|
||||||
|
// TODO(alxhub): implement completion of non-global expressions.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the details of a specific completion for a property expression.
|
||||||
|
*/
|
||||||
|
private getPropertyExpressionCompletionDetails(
|
||||||
|
this: PropertyExpressionCompletionBuilder, entryName: string,
|
||||||
|
formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined,
|
||||||
|
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
|
||||||
|
if (this.node instanceof EmptyExpr ||
|
||||||
|
isBrokenEmptyBoundEventExpression(this.node, this.nodeParent) ||
|
||||||
|
this.node.receiver instanceof ImplicitReceiver) {
|
||||||
|
return this.getGlobalPropertyExpressionCompletionDetails(
|
||||||
|
entryName, formatOptions, preferences);
|
||||||
|
} else {
|
||||||
|
// TODO(alxhub): implement completion of non-global expressions.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the `ts.Symbol` for a specific completion for a property expression.
|
||||||
|
*/
|
||||||
|
private getPropertyExpressionCompletionSymbol(
|
||||||
|
this: PropertyExpressionCompletionBuilder, name: string): ts.Symbol|undefined {
|
||||||
|
if (this.node instanceof EmptyExpr || this.node instanceof LiteralPrimitive ||
|
||||||
|
this.node.receiver instanceof ImplicitReceiver) {
|
||||||
|
return this.getGlobalPropertyExpressionCompletionSymbol(name);
|
||||||
|
} else {
|
||||||
|
// TODO(alxhub): implement completion of non-global expressions.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get completions for a property expression in a global context (e.g. `{{y|}}`).
|
||||||
|
*/
|
||||||
|
private getGlobalPropertyExpressionCompletion(
|
||||||
|
this: PropertyExpressionCompletionBuilder,
|
||||||
|
options: ts.GetCompletionsAtPositionOptions|
|
||||||
|
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
|
const completions = this.templateTypeChecker.getGlobalCompletions(this.context, this.component);
|
||||||
|
if (completions === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {componentContext, templateContext} = completions;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge TS completion results with results from the template scope.
|
||||||
|
let entries: ts.CompletionEntry[] = [];
|
||||||
|
const tsLsCompletions = this.tsLS.getCompletionsAtPosition(
|
||||||
|
componentContext.shimPath, componentContext.positionInShimFile, options);
|
||||||
|
if (tsLsCompletions !== undefined) {
|
||||||
|
for (const tsCompletion of tsLsCompletions.entries) {
|
||||||
|
// Skip completions that are shadowed by a template entity definition.
|
||||||
|
if (templateContext.has(tsCompletion.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entries.push({
|
||||||
|
...tsCompletion,
|
||||||
|
// Substitute the TS completion's `replacementSpan` (which uses offsets within the TCB)
|
||||||
|
// with the `replacementSpan` within the template source.
|
||||||
|
replacementSpan,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, entity] of templateContext) {
|
||||||
|
entries.push({
|
||||||
|
name,
|
||||||
|
sortText: name,
|
||||||
|
replacementSpan,
|
||||||
|
kindModifiers: ts.ScriptElementKindModifier.none,
|
||||||
|
kind: unsafeCastDisplayInfoKindToScriptElementKind(
|
||||||
|
entity.kind === CompletionKind.Reference ? DisplayInfoKind.REFERENCE :
|
||||||
|
DisplayInfoKind.VARIABLE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
// Although this completion is "global" in the sense of an Angular expression (there is no
|
||||||
|
// explicit receiver), it is not "global" in a TypeScript sense since Angular expressions have
|
||||||
|
// the component as an implicit receiver.
|
||||||
|
isGlobalCompletion: false,
|
||||||
|
isMemberCompletion: true,
|
||||||
|
isNewIdentifierLocation: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the details of a specific completion for a property expression in a global context (e.g.
|
||||||
|
* `{{y|}}`).
|
||||||
|
*/
|
||||||
|
private getGlobalPropertyExpressionCompletionDetails(
|
||||||
|
this: PropertyExpressionCompletionBuilder, entryName: string,
|
||||||
|
formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined,
|
||||||
|
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
|
||||||
|
const completions = this.templateTypeChecker.getGlobalCompletions(this.context, this.component);
|
||||||
|
if (completions === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const {componentContext, templateContext} = completions;
|
||||||
|
|
||||||
|
if (templateContext.has(entryName)) {
|
||||||
|
const entry = templateContext.get(entryName)!;
|
||||||
|
// Entries that reference a symbol in the template context refer either to local references or
|
||||||
|
// variables.
|
||||||
|
const symbol = this.templateTypeChecker.getSymbolOfNode(entry.node, this.component) as
|
||||||
|
TemplateDeclarationSymbol |
|
||||||
|
null;
|
||||||
|
if (symbol === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {kind, displayParts, documentation} =
|
||||||
|
getDisplayInfo(this.tsLS, this.typeChecker, symbol);
|
||||||
|
return {
|
||||||
|
kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
|
||||||
|
name: entryName,
|
||||||
|
kindModifiers: ts.ScriptElementKindModifier.none,
|
||||||
|
displayParts,
|
||||||
|
documentation,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return this.tsLS.getCompletionEntryDetails(
|
||||||
|
componentContext.shimPath, componentContext.positionInShimFile, entryName, formatOptions,
|
||||||
|
/* source */ undefined, preferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the `ts.Symbol` of a specific completion for a property expression in a global context
|
||||||
|
* (e.g.
|
||||||
|
* `{{y|}}`).
|
||||||
|
*/
|
||||||
|
private getGlobalPropertyExpressionCompletionSymbol(
|
||||||
|
this: PropertyExpressionCompletionBuilder, entryName: string): ts.Symbol|undefined {
|
||||||
|
const completions = this.templateTypeChecker.getGlobalCompletions(this.context, this.component);
|
||||||
|
if (completions === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const {componentContext, templateContext} = completions;
|
||||||
|
if (templateContext.has(entryName)) {
|
||||||
|
const node: TmplAstReference|TmplAstVariable = templateContext.get(entryName)!.node;
|
||||||
|
const symbol = this.templateTypeChecker.getSymbolOfNode(node, this.component) as
|
||||||
|
TemplateDeclarationSymbol |
|
||||||
|
null;
|
||||||
|
if (symbol === null || symbol.tsSymbol === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return symbol.tsSymbol;
|
||||||
|
} else {
|
||||||
|
return this.tsLS.getCompletionEntrySymbol(
|
||||||
|
componentContext.shimPath, componentContext.positionInShimFile, entryName,
|
||||||
|
/* source */ undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the given `node` is (most likely) a synthetic node created by the template parser
|
||||||
|
* for an empty event binding `(event)=""`.
|
||||||
|
*
|
||||||
|
* When parsing such an expression, a synthetic `LiteralPrimitive` node is generated for the
|
||||||
|
* `BoundEvent`'s handler with the literal text value 'ERROR'. Detecting this case is crucial to
|
||||||
|
* supporting completions within empty event bindings.
|
||||||
|
*/
|
||||||
|
function isBrokenEmptyBoundEventExpression(
|
||||||
|
node: TmplAstNode|AST, parent: TmplAstNode|AST|null): node is LiteralPrimitive {
|
||||||
|
return node instanceof LiteralPrimitive && parent !== null && parent instanceof BoundEvent &&
|
||||||
|
node.value === 'ERROR';
|
||||||
|
}
|
@ -6,17 +6,19 @@
|
|||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {AST, TmplAstNode} from '@angular/compiler';
|
||||||
import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli';
|
import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli';
|
||||||
import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
|
import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
|
||||||
import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck';
|
import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck';
|
||||||
import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
||||||
import {ReferenceBuilder} from '@angular/language-service/ivy/references';
|
|
||||||
import * as ts from 'typescript/lib/tsserverlibrary';
|
import * as ts from 'typescript/lib/tsserverlibrary';
|
||||||
|
|
||||||
import {LanguageServiceAdapter, LSParseConfigHost} from './adapters';
|
import {LanguageServiceAdapter, LSParseConfigHost} from './adapters';
|
||||||
import {CompilerFactory} from './compiler_factory';
|
import {CompilerFactory} from './compiler_factory';
|
||||||
|
import {CompletionBuilder} from './completions';
|
||||||
import {DefinitionBuilder} from './definitions';
|
import {DefinitionBuilder} from './definitions';
|
||||||
import {QuickInfoBuilder} from './quick_info';
|
import {QuickInfoBuilder} from './quick_info';
|
||||||
|
import {ReferenceBuilder} from './references';
|
||||||
import {getTargetAtPosition} from './template_target';
|
import {getTargetAtPosition} from './template_target';
|
||||||
import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils';
|
import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils';
|
||||||
|
|
||||||
@ -105,6 +107,57 @@ export class LanguageService {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCompletionBuilder(fileName: string, position: number):
|
||||||
|
CompletionBuilder<TmplAstNode|AST>|null {
|
||||||
|
const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName);
|
||||||
|
const templateInfo = getTemplateInfoAtPosition(fileName, position, compiler);
|
||||||
|
if (templateInfo === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const positionDetails = getTargetAtPosition(templateInfo.template, position);
|
||||||
|
if (positionDetails === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new CompletionBuilder(
|
||||||
|
this.tsLS, compiler, templateInfo.component, positionDetails.node, positionDetails.parent,
|
||||||
|
positionDetails.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompletionsAtPosition(
|
||||||
|
fileName: string, position: number, options: ts.GetCompletionsAtPositionOptions|undefined):
|
||||||
|
ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
|
const builder = this.getCompletionBuilder(fileName, position);
|
||||||
|
if (builder === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result = builder.getCompletionsAtPosition(options);
|
||||||
|
this.compilerFactory.registerLastKnownProgram();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompletionEntryDetails(
|
||||||
|
fileName: string, position: number, entryName: string,
|
||||||
|
formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined,
|
||||||
|
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
|
||||||
|
const builder = this.getCompletionBuilder(fileName, position);
|
||||||
|
if (builder === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result = builder.getCompletionEntryDetails(entryName, formatOptions, preferences);
|
||||||
|
this.compilerFactory.registerLastKnownProgram();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompletionEntrySymbol(fileName: string, position: number, name: string): ts.Symbol|undefined {
|
||||||
|
const builder = this.getCompletionBuilder(fileName, position);
|
||||||
|
if (builder === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result = builder.getCompletionEntrySymbol(name);
|
||||||
|
this.compilerFactory.registerLastKnownProgram();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private watchConfigFile(project: ts.server.Project) {
|
private watchConfigFile(project: ts.server.Project) {
|
||||||
// TODO: Check the case when the project is disposed. An InferredProject
|
// TODO: Check the case when the project is disposed. An InferredProject
|
||||||
// could be disposed when a tsconfig.json is added to the workspace,
|
// could be disposed when a tsconfig.json is added to the workspace,
|
||||||
|
169
packages/language-service/ivy/test/completions_spec.ts
Normal file
169
packages/language-service/ivy/test/completions_spec.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* @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 {TmplAstNode} from '@angular/compiler';
|
||||||
|
import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
|
||||||
|
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
import {DisplayInfoKind} from '../display_parts';
|
||||||
|
import {LanguageService} from '../language_service';
|
||||||
|
|
||||||
|
import {LanguageServiceTestEnvironment} from './env';
|
||||||
|
|
||||||
|
describe('completions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initMockFileSystem('Native');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('in the global scope', () => {
|
||||||
|
it('should be able to complete an interpolation', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup('{{ti¦}}', `title!: string; hero!: number;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to complete an empty interpolation', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup('{{ ¦ }}', `title!: string; hero!: number;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to complete a property binding', () => {
|
||||||
|
const {ngLS, fileName, cursor} =
|
||||||
|
setup('<h1 [model]="ti¦"></h1>', `title!: string; hero!: number;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to complete an empty property binding', () => {
|
||||||
|
const {ngLS, fileName, cursor} =
|
||||||
|
setup('<h1 [model]="¦"></h1>', `title!: string; hero!: number;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title', 'hero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to retrieve details for completions', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup('{{ti¦}}', `
|
||||||
|
/** This is the title of the 'AppCmp' Component. */
|
||||||
|
title!: string;
|
||||||
|
/** This comment should not appear in the output of this test. */
|
||||||
|
hero!: number;
|
||||||
|
`);
|
||||||
|
const details = ngLS.getCompletionEntryDetails(
|
||||||
|
fileName, cursor, 'title', /* formatOptions */ undefined,
|
||||||
|
/* preferences */ undefined)!;
|
||||||
|
expect(details).toBeDefined();
|
||||||
|
expect(toText(details.displayParts)).toEqual('(property) AppCmp.title: string');
|
||||||
|
expect(toText(details.documentation))
|
||||||
|
.toEqual('This is the title of the \'AppCmp\' Component.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return reference completions when available', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup(`<div #todo></div>{{t¦}}`, `title!: string;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title']);
|
||||||
|
expectContain(completions, DisplayInfoKind.REFERENCE, ['todo']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return variable completions when available', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup(
|
||||||
|
`<div *ngFor="let hero of heroes">
|
||||||
|
{{h¦}}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
`heroes!: {name: string}[];`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['heroes']);
|
||||||
|
expectContain(completions, DisplayInfoKind.VARIABLE, ['hero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return completions inside an event binding', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup(`<button (click)='t¦'></button>`, `title!: string;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return completions inside an empty event binding', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup(`<button (click)='¦'></button>`, `title!: string;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return completions inside the RHS of a two-way binding', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup(`<h1 [(model)]="t¦"></h1>`, `title!: string;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return completions inside an empty RHS of a two-way binding', () => {
|
||||||
|
const {ngLS, fileName, cursor} = setup(`<h1 [(model)]="¦"></h1>`, `title!: string;`);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(completions, ts.ScriptElementKind.memberVariableElement, ['title']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectContain(
|
||||||
|
completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind,
|
||||||
|
names: string[]) {
|
||||||
|
expect(completions).toBeDefined();
|
||||||
|
for (const name of names) {
|
||||||
|
expect(completions!.entries).toContain(jasmine.objectContaining({name, kind} as any));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toText(displayParts?: ts.SymbolDisplayPart[]): string {
|
||||||
|
return (displayParts ?? []).map(p => p.text).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(templateWithCursor: string, classContents: string): {
|
||||||
|
env: LanguageServiceTestEnvironment,
|
||||||
|
fileName: AbsoluteFsPath,
|
||||||
|
AppCmp: ts.ClassDeclaration,
|
||||||
|
ngLS: LanguageService,
|
||||||
|
cursor: number,
|
||||||
|
nodes: TmplAstNode[],
|
||||||
|
} {
|
||||||
|
const codePath = absoluteFrom('/test.ts');
|
||||||
|
const templatePath = absoluteFrom('/test.html');
|
||||||
|
const env = LanguageServiceTestEnvironment.setup([
|
||||||
|
{
|
||||||
|
name: codePath,
|
||||||
|
contents: `
|
||||||
|
import {Component, NgModule} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
templateUrl: './test.html',
|
||||||
|
selector: 'app-cmp',
|
||||||
|
})
|
||||||
|
export class AppCmp {
|
||||||
|
${classContents}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AppCmp],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
`,
|
||||||
|
isRoot: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: templatePath,
|
||||||
|
contents: 'Placeholder template',
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const {nodes, cursor} = env.overrideTemplateWithCursor(codePath, 'AppCmp', templateWithCursor);
|
||||||
|
return {
|
||||||
|
env,
|
||||||
|
fileName: templatePath,
|
||||||
|
AppCmp: env.getClass(codePath, 'AppCmp'),
|
||||||
|
ngLS: env.ngLS,
|
||||||
|
nodes,
|
||||||
|
cursor,
|
||||||
|
};
|
||||||
|
}
|
@ -68,6 +68,45 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCompletionsAtPosition(
|
||||||
|
fileName: string, position: number,
|
||||||
|
options: ts.GetCompletionsAtPositionOptions): ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
|
if (angularOnly) {
|
||||||
|
return ngLS.getCompletionsAtPosition(fileName, position, options);
|
||||||
|
} else {
|
||||||
|
// If TS could answer the query, then return that result. Otherwise, return from Angular LS.
|
||||||
|
return tsLS.getCompletionsAtPosition(fileName, position, options) ??
|
||||||
|
ngLS.getCompletionsAtPosition(fileName, position, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletionEntryDetails(
|
||||||
|
fileName: string, position: number, entryName: string,
|
||||||
|
formatOptions: ts.FormatCodeOptions|ts.FormatCodeSettings|undefined, source: string|undefined,
|
||||||
|
preferences: ts.UserPreferences|undefined): ts.CompletionEntryDetails|undefined {
|
||||||
|
if (angularOnly) {
|
||||||
|
return ngLS.getCompletionEntryDetails(
|
||||||
|
fileName, position, entryName, formatOptions, preferences);
|
||||||
|
} else {
|
||||||
|
// If TS could answer the query, then return that result. Otherwise, return from Angular LS.
|
||||||
|
return tsLS.getCompletionEntryDetails(
|
||||||
|
fileName, position, entryName, formatOptions, source, preferences) ??
|
||||||
|
ngLS.getCompletionEntryDetails(fileName, position, entryName, formatOptions, preferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletionEntrySymbol(
|
||||||
|
fileName: string, position: number, name: string, source: string|undefined): ts.Symbol|
|
||||||
|
undefined {
|
||||||
|
if (angularOnly) {
|
||||||
|
return ngLS.getCompletionEntrySymbol(fileName, position, name);
|
||||||
|
} else {
|
||||||
|
// If TS could answer the query, then return that result. Otherwise, return from Angular LS.
|
||||||
|
return tsLS.getCompletionEntrySymbol(fileName, position, name, source) ??
|
||||||
|
ngLS.getCompletionEntrySymbol(fileName, position, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...tsLS,
|
...tsLS,
|
||||||
getSemanticDiagnostics,
|
getSemanticDiagnostics,
|
||||||
@ -76,5 +115,8 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
|
|||||||
getDefinitionAndBoundSpan,
|
getDefinitionAndBoundSpan,
|
||||||
getReferencesAtPosition,
|
getReferencesAtPosition,
|
||||||
findRenameLocations,
|
findRenameLocations,
|
||||||
|
getCompletionsAtPosition,
|
||||||
|
getCompletionEntryDetails,
|
||||||
|
getCompletionEntrySymbol,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user