Skip to content

Commit

Permalink
feat(language-service): provide snippets for attribute
Browse files Browse the repository at this point in the history
Support snippet completions for dom attribute whose value
is empty. For Example `<div (my¦) />`, the `insertText`
will return `(myOutput)="$0"`.

Fixes angular/vscode-ng-language-service#521
  • Loading branch information
ivanwonder committed Sep 25, 2021
1 parent 9d290bd commit 481df56
Show file tree
Hide file tree
Showing 3 changed files with 250 additions and 6 deletions.
27 changes: 26 additions & 1 deletion packages/language-service/ivy/attribute_completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,10 @@ export function buildAttributeCompletionTable(
return table;
}

function buildSnippet(insertSnippet: true|undefined, text: string): string|undefined {
return insertSnippet ? `${text}="$0"` : undefined;
}

/**
* Given an `AttributeCompletion`, add any available completions to a `ts.CompletionEntry` array of
* results.
Expand All @@ -364,13 +368,16 @@ export function buildAttributeCompletionTable(
*/
export function addAttributeCompletionEntries(
entries: ts.CompletionEntry[], completion: AttributeCompletion, isAttributeContext: boolean,
isElementContext: boolean, replacementSpan: ts.TextSpan|undefined): void {
isElementContext: boolean, replacementSpan: ts.TextSpan|undefined,
insertSnippet: true|undefined): void {
switch (completion.kind) {
case AttributeCompletionKind.DirectiveAttribute: {
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
name: completion.attribute,
sortText: completion.attribute,
insertText: buildSnippet(insertSnippet, completion.attribute),
isSnippet: insertSnippet,
replacementSpan,
});
break;
Expand All @@ -383,6 +390,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.DIRECTIVE),
name: prefix + completion.attribute,
insertText: buildSnippet(insertSnippet, prefix + completion.attribute),
isSnippet: insertSnippet,
sortText: prefix + completion.attribute,
replacementSpan,
});
Expand All @@ -394,6 +403,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
name: `[${completion.propertyName}]`,
insertText: buildSnippet(insertSnippet, `[${completion.propertyName}]`),
isSnippet: insertSnippet,
sortText: completion.propertyName,
replacementSpan,
});
Expand All @@ -402,6 +413,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
name: `[(${completion.propertyName})]`,
insertText: buildSnippet(insertSnippet, `[(${completion.propertyName})]`),
isSnippet: insertSnippet,
// This completion should sort after the property binding.
sortText: completion.propertyName + '_1',
replacementSpan,
Expand All @@ -411,6 +424,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
name: completion.propertyName,
insertText: buildSnippet(insertSnippet, completion.propertyName),
isSnippet: insertSnippet,
// This completion should sort after both property binding options (one-way and two-way).
sortText: completion.propertyName + '_2',
replacementSpan,
Expand All @@ -419,6 +434,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
name: completion.propertyName,
insertText: buildSnippet(insertSnippet, completion.propertyName),
isSnippet: insertSnippet,
sortText: completion.propertyName,
replacementSpan,
});
Expand All @@ -430,13 +447,17 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
name: `(${completion.eventName})`,
insertText: buildSnippet(insertSnippet, `(${completion.eventName})`),
isSnippet: insertSnippet,
sortText: completion.eventName,
replacementSpan,
});
} else {
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
name: completion.eventName,
insertText: buildSnippet(insertSnippet, completion.eventName),
isSnippet: insertSnippet,
sortText: completion.eventName,
replacementSpan,
});
Expand All @@ -449,6 +470,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
name: `[${completion.attribute}]`,
insertText: buildSnippet(insertSnippet, `[${completion.attribute}]`),
isSnippet: insertSnippet,
// In the case of DOM attributes, the property binding should sort after the attribute
// binding.
sortText: completion.attribute + '_1',
Expand All @@ -462,6 +485,8 @@ export function addAttributeCompletionEntries(
entries.push({
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
name: completion.property,
insertText: buildSnippet(insertSnippet, completion.property),
isSnippet: insertSnippet,
sortText: completion.property,
replacementSpan,
});
Expand Down
90 changes: 85 additions & 5 deletions packages/language-service/ivy/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
} else if (this.isElementTagCompletion()) {
return this.getElementTagCompletion();
} else if (this.isElementAttributeCompletion()) {
return this.getElementAttributeCompletions();
return this.getElementAttributeCompletions(options);
} else if (this.isPipeCompletion()) {
return this.getPipeCompletions();
} else if (this.isLiteralCompletion()) {
Expand Down Expand Up @@ -538,8 +538,10 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
this.node instanceof TmplAstTextAttribute || this.node instanceof TmplAstBoundEvent);
}

private getElementAttributeCompletions(this: ElementAttributeCompletionBuilder):
ts.WithMetadata<ts.CompletionInfo>|undefined {
private getElementAttributeCompletions(
this: ElementAttributeCompletionBuilder,
options: ts.GetCompletionsAtPositionOptions|
undefined): ts.WithMetadata<ts.CompletionInfo>|undefined {
let element: TmplAstElement|TmplAstTemplate;
if (this.node instanceof TmplAstElement) {
element = this.node;
Expand All @@ -558,6 +560,83 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.keySpan);
}

let insertSnippet: true|undefined;
/**
* If the `insertText` includes the snippet, it should includes the property or event binding
* sugar in some case. For Example `<div (my¦) />`, the `replacementSpan` is `(my)`, and the
* `insertText` is `(myOutput)="$0"`.
*/
let includebBindingSugar = false;
if (options?.includeCompletionsWithSnippetText && options.includeCompletionsWithInsertText) {
if (this.node instanceof TmplAstBoundEvent && this.node.keySpan !== undefined) {
const nodeStart = this.node.keySpan.start.getContext(1, 1);
const nodeEnd = this.node.keySpan.end.getContext(1, 1);
switch (nodeStart?.before) {
case '(':
// (click) -> (click)="¦"
if (nodeEnd?.after[1] !== '=' && replacementSpan) {
replacementSpan.start--;
replacementSpan.length += 2;
insertSnippet = true;
includebBindingSugar = true;
}
break;
case '-':
// on-click -> on-click="¦"
if (nodeEnd?.after[0] !== '=') {
insertSnippet = true;
}
break;
}
}

if (this.node instanceof TmplAstBoundAttribute && this.node.keySpan !== undefined) {
const nodeStart = this.node.keySpan.start.getContext(1, 1);
const nodeEnd = this.node.keySpan.end.getContext(2, 1);
switch (nodeStart?.before) {
case '[':
// [property] -> [property]="¦"
if (nodeEnd?.after[1] !== '=' && replacementSpan) {
replacementSpan.start--;
replacementSpan.length += 2;
insertSnippet = true;
includebBindingSugar = true;
}
break;
case '(':
// [(property)] -> [(property)]="¦"
if (this.nodeContext === CompletionNodeContext.TwoWayBinding &&
nodeEnd?.after[2] !== '=' && replacementSpan) {
replacementSpan.start -= 2;
replacementSpan.length += 4;
insertSnippet = true;
includebBindingSugar = true;
}
break;
case '-':
// bind-property -> bind-property="¦"
// bindon-property -> bindon-property="¦"
if (nodeEnd?.after[0] !== '=') {
insertSnippet = true;
}
break;
}
}

if (this.node instanceof TmplAstTextAttribute && this.node.keySpan !== undefined) {
const nodeEnd = this.node.keySpan.end.getContext(1, 1);
if (nodeEnd?.after[0] !== '=') {
// *ngFor -> *ngFor="¦"
insertSnippet = true;
}
}

if (this.node instanceof TmplAstElement) {
// <div ¦ />
insertSnippet = true;
}
}

const attrTable = buildAttributeCompletionTable(
this.component, element, this.compiler.getTemplateTypeChecker());

Expand Down Expand Up @@ -597,13 +676,14 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
}

// Is the completion in an attribute context (instead of a property context)?
const isAttributeContext =
const isAttributeContext = includebBindingSugar ||
(this.node instanceof TmplAstElement || this.node instanceof TmplAstTextAttribute);
// Is the completion for an element (not an <ng-template>)?
const isElementContext =
this.node instanceof TmplAstElement || this.nodeParent instanceof TmplAstElement;
addAttributeCompletionEntries(
entries, completion, isAttributeContext, isElementContext, replacementSpan);
entries, completion, isAttributeContext, isElementContext, replacementSpan,
insertSnippet);
}

return {
Expand Down
139 changes: 139 additions & 0 deletions packages/language-service/ivy/test/completions_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,135 @@ describe('completions', () => {
expectReplacementText(completions, templateFile.contents, 'title');
});
});

describe('insert snippet text', () => {
it('should be able to complete for an empty attribute', () => {
const {templateFile} = setup(`<input dir >`, '', DIR_WITH_OUTPUT);
templateFile.moveCursorToText('¦>');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
['(myOutput)="$0"']);
});

it('should be able to complete for a partial attribute', () => {
const {templateFile} = setup(`<input dir my>`, '', DIR_WITH_OUTPUT);
templateFile.moveCursorToText('¦>');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
['(myOutput)="$0"']);
expectReplacementText(completions, templateFile.contents, 'my');
});

it('should be able to complete for the event binding via ()', () => {
const {templateFile} = setup(`<input dir ()>`, '', DIR_WITH_OUTPUT);
templateFile.moveCursorToText('(¦)>');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
['(myOutput)="$0"']);
expectReplacementText(completions, templateFile.contents, '()');
});

it('should be able to complete for the event binding via on-', () => {
const {templateFile} = setup(`<input dir on-my>`, '', DIR_WITH_OUTPUT);
templateFile.moveCursorToText('on-my¦>');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
['myOutput="$0"']);
expectReplacementText(completions, templateFile.contents, 'my');
});

it('should be able to complete for the property binding via []', () => {
const {templateFile} = setup(`<input [my]>`, '', DIR_WITH_SELECTED_INPUT);
templateFile.moveCursorToText('[my¦]');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
['[myInput]="$0"']);
expectReplacementText(completions, templateFile.contents, '[my]');
});

it('should be able to complete for the property binding via bind-', () => {
const {templateFile} = setup(`<input bind-my>`, '', DIR_WITH_SELECTED_INPUT);
templateFile.moveCursorToText('bind-my¦');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
['myInput="$0"']);
expectReplacementText(completions, templateFile.contents, 'my');
});

it('should be able to complete for the two way binding via [()]', () => {
const {templateFile} = setup(`<h1 dir [(mod)]></h1>`, ``, DIR_WITH_TWO_WAY_BINDING);
templateFile.moveCursorToText('[(mod¦)]');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
['[(model)]="$0"']);
expectReplacementText(completions, templateFile.contents, '[(mod)]');
});

it('should be able to complete for the two way binding via bindon-', () => {
const {templateFile} = setup(`<h1 dir bindon-mod></h1>`, ``, DIR_WITH_TWO_WAY_BINDING);
templateFile.moveCursorToText('bindon-mod¦');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContainInsertTextWithSnippet(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PROPERTY),
['model="$0"']);
expectReplacementText(completions, templateFile.contents, 'mod');
});

it('should not be included in the complete for an attribute with a value', () => {
const {templateFile} = setup(`<input dir myInput="">`, '', DIR_WITH_SELECTED_INPUT);
templateFile.moveCursorToText('myInput¦="">');

const completions = templateFile.getCompletionsAtPosition({
includeCompletionsWithSnippetText: true,
includeCompletionsWithInsertText: true,
});
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
['myInput']);
expect(completions?.entries[0]).toBeDefined();
expect(completions?.entries[0].isSnippet).toBeUndefined();
expectReplacementText(completions, templateFile.contents, 'myInput');
});
});
});

function expectContainInsertText(
Expand All @@ -864,6 +993,16 @@ function expectContainInsertText(
}
}

function expectContainInsertTextWithSnippet(
completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind,
insertTexts: string[]) {
expect(completions).toBeDefined();
for (const insertText of insertTexts) {
expect(completions!.entries)
.toContain(jasmine.objectContaining({insertText, kind, isSnippet: true} as any));
}
}

function expectContain(
completions: ts.CompletionInfo|undefined, kind: ts.ScriptElementKind|DisplayInfoKind,
names: string[]) {
Expand Down

0 comments on commit 481df56

Please sign in to comment.