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.
|
||||
*/
|
||||
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`.
|
||||
|
@ -32,6 +32,11 @@ export enum SymbolKind {
|
||||
export type Symbol = InputBindingSymbol|OutputBindingSymbol|ElementSymbol|ReferenceSymbol|
|
||||
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. */
|
||||
export interface ShimLocation {
|
||||
/**
|
||||
|
@ -138,16 +138,12 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
||||
}
|
||||
|
||||
overrideComponentTemplate(component: ts.ClassDeclaration, template: string):
|
||||
{nodes: TmplAstNode[], errors?: ParseError[]} {
|
||||
{nodes: TmplAstNode[], errors: ParseError[]|null} {
|
||||
const {nodes, errors} = parseTemplate(template, 'override.html', {
|
||||
preserveWhitespaces: true,
|
||||
leadingTriviaChars: [],
|
||||
});
|
||||
|
||||
if (errors !== null) {
|
||||
return {nodes, errors};
|
||||
}
|
||||
|
||||
const filePath = absoluteFromSourceFile(component.getSourceFile());
|
||||
|
||||
const fileRecord = this.getFileData(filePath);
|
||||
@ -169,7 +165,7 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
||||
this.completionCache.delete(component);
|
||||
this.symbolBuilderCache.delete(component);
|
||||
|
||||
return {nodes};
|
||||
return {nodes, errors};
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
import {AST, TmplAstNode} from '@angular/compiler';
|
||||
import {CompilerOptions, ConfigurationHost, readConfiguration} from '@angular/compiler-cli';
|
||||
import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
|
||||
import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck';
|
||||
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 {LanguageServiceAdapter, LSParseConfigHost} from './adapters';
|
||||
import {CompilerFactory} from './compiler_factory';
|
||||
import {CompletionBuilder} from './completions';
|
||||
import {DefinitionBuilder} from './definitions';
|
||||
import {QuickInfoBuilder} from './quick_info';
|
||||
import {ReferenceBuilder} from './references';
|
||||
import {getTargetAtPosition} from './template_target';
|
||||
import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils';
|
||||
|
||||
@ -105,6 +107,57 @@ export class LanguageService {
|
||||
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) {
|
||||
// TODO: Check the case when the project is disposed. An InferredProject
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
...tsLS,
|
||||
getSemanticDiagnostics,
|
||||
@ -76,5 +115,8 @@ export function create(info: ts.server.PluginCreateInfo): ts.LanguageService {
|
||||
getDefinitionAndBoundSpan,
|
||||
getReferencesAtPosition,
|
||||
findRenameLocations,
|
||||
getCompletionsAtPosition,
|
||||
getCompletionEntryDetails,
|
||||
getCompletionEntrySymbol,
|
||||
};
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user