feat(language-service): autocomplete pipe binding expressions (#40032)
This commit adds autocompletion for pipe expressions, built on existing APIs for checking which pipes are in scope. PR Close #40032
This commit is contained in:
parent
66378ed0ef
commit
cbb6eae4a9
|
@ -6,7 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
|
import {AST, BindingPipe, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
|
||||||
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
|
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
|
||||||
import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
|
||||||
import {BoundEvent} from '@angular/compiler/src/render3/r3_ast';
|
import {BoundEvent} from '@angular/compiler/src/render3/r3_ast';
|
||||||
|
@ -23,6 +23,7 @@ type PropertyExpressionCompletionBuilder =
|
||||||
type ElementAttributeCompletionBuilder =
|
type ElementAttributeCompletionBuilder =
|
||||||
CompletionBuilder<TmplAstElement|TmplAstBoundAttribute|TmplAstTextAttribute|TmplAstBoundEvent>;
|
CompletionBuilder<TmplAstElement|TmplAstBoundAttribute|TmplAstTextAttribute|TmplAstBoundEvent>;
|
||||||
|
|
||||||
|
type PipeCompletionBuilder = CompletionBuilder<BindingPipe>;
|
||||||
|
|
||||||
export enum CompletionNodeContext {
|
export enum CompletionNodeContext {
|
||||||
None,
|
None,
|
||||||
|
@ -65,6 +66,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
return this.getElementTagCompletion();
|
return this.getElementTagCompletion();
|
||||||
} else if (this.isElementAttributeCompletion()) {
|
} else if (this.isElementAttributeCompletion()) {
|
||||||
return this.getElementAttributeCompletions();
|
return this.getElementAttributeCompletions();
|
||||||
|
} else if (this.isPipeCompletion()) {
|
||||||
|
return this.getPipeCompletions();
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -577,6 +580,34 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
const completion = attrTable.get(name)!;
|
const completion = attrTable.get(name)!;
|
||||||
return getAttributeCompletionSymbol(completion, this.typeChecker) ?? undefined;
|
return getAttributeCompletionSymbol(completion, this.typeChecker) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPipeCompletion(): this is PipeCompletionBuilder {
|
||||||
|
return this.node instanceof BindingPipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPipeCompletions(this: PipeCompletionBuilder):
|
||||||
|
ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
|
const pipes = this.templateTypeChecker.getPipesInScope(this.component);
|
||||||
|
if (pipes === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacementSpan = makeReplacementSpanFromAst(this.node);
|
||||||
|
|
||||||
|
const entries: ts.CompletionEntry[] =
|
||||||
|
pipes.map(pipe => ({
|
||||||
|
name: pipe.name,
|
||||||
|
sortText: pipe.name,
|
||||||
|
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
|
||||||
|
replacementSpan,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
isGlobalCompletion: false,
|
||||||
|
isMemberCompletion: false,
|
||||||
|
isNewIdentifierLocation: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan {
|
function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan {
|
||||||
|
@ -587,7 +618,7 @@ function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextS
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead|
|
function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead|
|
||||||
SafeMethodCall): ts.TextSpan {
|
SafeMethodCall|BindingPipe): ts.TextSpan {
|
||||||
return {
|
return {
|
||||||
start: node.nameSpan.start,
|
start: node.nameSpan.start,
|
||||||
length: node.nameSpan.end - node.nameSpan.start,
|
length: node.nameSpan.end - node.nameSpan.start,
|
||||||
|
|
|
@ -51,6 +51,19 @@ const DIR_WITH_SELECTED_INPUT = {
|
||||||
`
|
`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SOME_PIPE = {
|
||||||
|
'SomePipe': `
|
||||||
|
@Pipe({
|
||||||
|
name: 'somePipe',
|
||||||
|
})
|
||||||
|
export class SomePipe {
|
||||||
|
transform(value: string): string {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
describe('completions', () => {
|
describe('completions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initMockFileSystem('Native');
|
initMockFileSystem('Native');
|
||||||
|
@ -445,6 +458,37 @@ describe('completions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pipe scope', () => {
|
||||||
|
it('should complete a pipe binding', () => {
|
||||||
|
const {ngLS, fileName, cursor, text} = setup(`{{ foo | some¦ }}`, '', SOME_PIPE);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(
|
||||||
|
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
|
||||||
|
['somePipe']);
|
||||||
|
expectReplacementText(completions, text, 'some');
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO(alxhub): currently disabled as the template targeting system identifies the cursor
|
||||||
|
// position as the entire Interpolation node, not the BindingPipe node. This happens because the
|
||||||
|
// BindingPipe node's span ends at the '|' character. To make this case work, the targeting
|
||||||
|
// system will need to artificially expand the BindingPipe's span to encompass any trailing
|
||||||
|
// spaces, which will be done in a future PR.
|
||||||
|
xit('should complete an empty pipe binding', () => {
|
||||||
|
const {ngLS, fileName, cursor, text} = setup(`{{ foo | ¦ }}`, '', SOME_PIPE);
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expectContain(
|
||||||
|
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
|
||||||
|
['somePipe']);
|
||||||
|
expectReplacementText(completions, text, 'some');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return extraneous completions', () => {
|
||||||
|
const {ngLS, fileName, cursor, text} = setup(`{{ foo | some¦ }}`, '');
|
||||||
|
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
|
||||||
|
expect(completions?.entries.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function expectContain(
|
function expectContain(
|
||||||
|
@ -495,7 +539,7 @@ function toText(displayParts?: ts.SymbolDisplayPart[]): string {
|
||||||
|
|
||||||
function setup(
|
function setup(
|
||||||
templateWithCursor: string, classContents: string,
|
templateWithCursor: string, classContents: string,
|
||||||
otherDirectives: {[name: string]: string} = {}): {
|
otherDeclarations: {[name: string]: string} = {}): {
|
||||||
env: LanguageServiceTestEnvironment,
|
env: LanguageServiceTestEnvironment,
|
||||||
fileName: AbsoluteFsPath,
|
fileName: AbsoluteFsPath,
|
||||||
AppCmp: ts.ClassDeclaration,
|
AppCmp: ts.ClassDeclaration,
|
||||||
|
@ -507,15 +551,15 @@ function setup(
|
||||||
const codePath = absoluteFrom('/test.ts');
|
const codePath = absoluteFrom('/test.ts');
|
||||||
const templatePath = absoluteFrom('/test.html');
|
const templatePath = absoluteFrom('/test.html');
|
||||||
|
|
||||||
const decls = ['AppCmp', ...Object.keys(otherDirectives)];
|
const decls = ['AppCmp', ...Object.keys(otherDeclarations)];
|
||||||
|
|
||||||
const otherDirectiveClassDecls = Object.values(otherDirectives).join('\n\n');
|
const otherDirectiveClassDecls = Object.values(otherDeclarations).join('\n\n');
|
||||||
|
|
||||||
const env = LanguageServiceTestEnvironment.setup([
|
const env = LanguageServiceTestEnvironment.setup([
|
||||||
{
|
{
|
||||||
name: codePath,
|
name: codePath,
|
||||||
contents: `
|
contents: `
|
||||||
import {Component, Directive, NgModule} from '@angular/core';
|
import {Component, Directive, NgModule, Pipe} from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
templateUrl: './test.html',
|
templateUrl: './test.html',
|
||||||
|
|
Loading…
Reference in New Issue