Skip to content

Commit

Permalink
feat(language-service): add support for text replacement
Browse files Browse the repository at this point in the history
Adds a `replacementSpan` field on a completion that will allow typed
text to be replaced with the suggested completion value if a user
selects the completion. Previously, the completion value would simply be
appended to the text already typed. E.g. if we had

```
{{ti}}
```

typed in a template and `title` was recommended as a completion and
selected, the template would become

```
{{tititle}}
```

With `replacementSpan`, the original text `ti` will be replaced for
`title`.
  • Loading branch information
ayazhafiz committed Oct 25, 2019
1 parent ee4fc12 commit 7861004
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 4 deletions.
76 changes: 74 additions & 2 deletions packages/language-service/src/completions.ts
Expand Up @@ -10,7 +10,7 @@ import {AST, AstPath, Attribute, BoundDirectivePropertyAst, BoundElementProperty
import {getExpressionScope} from '@angular/compiler-cli/src/language_services';

import {AstResult} from './common';
import {getExpressionCompletions} from './expressions';
import {findAstAt, getExpressionCompletions} from './expressions';
import {attributeNames, elementNames, eventNames, propertyNames} from './html_info';
import * as ng from './types';
import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getSelectors, hasTemplateReference, inSpan, spanOf} from './utils';
Expand All @@ -30,6 +30,69 @@ const hiddenHtmlElements = {

const ANGULAR_ELEMENTS: ReadonlyArray<string> = ['ng-container', 'ng-content', 'ng-template'];

function getClosestTextStartOffset(templateInfo: AstResult, position: number): number {
const {htmlAst, templateAst, template} = templateInfo;
const templatePosition = position - template.span.start;
const templatePath = findTemplateAstAt(templateInfo.templateAst, templatePosition);
// Closest HTML template AST node to the queried template position, like an element or attribute.
const templateAstTail = templatePath.tail;
if (!templateAstTail) {
return 0;
}

// If the closest HTML template node has a Angular template syntax AST inside it, extract it.
// Otherwise, get the text of the HTML template node, which is the closest starting text offset.
const ast: AST|string = templateAstTail.visit(
{
visitNgContent: () => '',
visitEmbeddedTemplate: () => '',
visitElement: ast => ast.name,
visitReference: ast => ast.name,
visitVariable: ast => ast.name,
visitEvent: ast => ast.handler,
visitElementProperty: ast => ast.value,
visitAttr: ast => ast.name,
visitBoundText: ast => ast.value,
visitText: ast => ast.value,
visitDirective: ast => '',
visitDirectiveProperty: ast => ast.value,
},
null);
if (!(ast instanceof AST)) {
return ast.length; // offset of HTML template node text
}

// Find the Angular template syntax AST closest to queried template position.
const closestAst = findAstAt(ast, templatePosition);
const closestTail = closestAst.tail;
if (!closestTail) return 0;

// Return the closest starting text offset in the template syntax AST, which is either the value
// of the AST or nothing at all.
return closestTail.visit({
visitBinary: ast => 0,
visitChain: ast => 0,
visitConditional: ast => 0,
visitFunctionCall: ast => 0,
visitImplicitReceiver: ast => 0,
visitInterpolation: ast => 0,
visitKeyedRead: ast => 0,
visitKeyedWrite: ast => 0,
visitLiteralArray: ast => 0,
visitLiteralMap: ast => 0,
visitLiteralPrimitive: ast => 0,
visitMethodCall: ast => 0,
visitPipe: ast => ast.name.length,
visitPrefixNot: ast => 0,
visitNonNullAssert: ast => 0,
visitPropertyRead: ast => ast.name.length,
visitPropertyWrite: ast => ast.name.length,
visitQuote: ast => ast.uninterpretedExpression.length,
visitSafeMethodCall: ast => ast.name.length,
visitSafePropertyRead: ast => ast.name.length,
});
}

export function getTemplateCompletions(
templateInfo: AstResult, position: number): ng.CompletionEntry[] {
let result: ng.CompletionEntry[] = [];
Expand Down Expand Up @@ -95,7 +158,16 @@ export function getTemplateCompletions(
},
null);
}
return result;

// Define the span of the partial word the completion query was called on, which will be replaced
// by a selected completion.
const offset = getClosestTextStartOffset(templateInfo, position);
return result.map(entry => {
return {
...entry,
replacementSpan: {start: position - offset, length: offset},
};
})
}

function attributeCompletions(info: AstResult, path: AstPath<HtmlAst>): ng.CompletionEntry[] {
Expand Down
2 changes: 1 addition & 1 deletion packages/language-service/src/expressions.ts
Expand Up @@ -14,7 +14,7 @@ import {inSpan} from './utils';

type AstPath = AstPathBase<AST>;

function findAstAt(ast: AST, position: number, excludeEmpty: boolean = false): AstPath {
export function findAstAt(ast: AST, position: number, excludeEmpty: boolean = false): AstPath {
const path: AST[] = [];
const visitor = new class extends NullAstVisitor {
visit(ast: AST) {
Expand Down
114 changes: 113 additions & 1 deletion packages/language-service/test/completions_spec.ts
Expand Up @@ -9,7 +9,7 @@
import * as ts from 'typescript';

import {createLanguageService} from '../src/language_service';
import {CompletionKind} from '../src/types';
import {CompletionKind, LanguageService} from '../src/types';
import {TypeScriptServiceHost} from '../src/typescript_host';

import {MockTypescriptHost} from './test_utils';
Expand Down Expand Up @@ -367,6 +367,118 @@ describe('completions', () => {
});
});

describe('replace completions correctly', () => {
let mockHost: MockTypescriptHost;
let ngLS: LanguageService;

beforeEach(() => {
mockHost = new MockTypescriptHost(['/app/main.ts']);
const tsLS = ts.createLanguageService(mockHost);
const ngHost = new TypeScriptServiceHost(mockHost, tsLS);
ngLS = createLanguageService(ngHost);
});

it('should work for zero-length replacements', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div>{{obj.~{key}}}</div>
\`,
})
export class FooComponent {
obj: {key: 'value'};
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'key');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'key') !;
expect(completion).toBeDefined();
expect(completion.replacementSpan).toEqual({start: location.start, length: 0});
});

it('should work for post-word replacements', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div>{{obj.ke~{key}key}}</div>
\`,
})
export class FooComponent {
obj: {key: 'value'};
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'key');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'key') !;
expect(completion).toBeDefined();
expect(completion.replacementSpan).toEqual({start: location.start - 2, length: 5});
});

it('should work for interpolations', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div>{{ti~{title}}}</div>
\`,
})
export class FooComponent {
title: string;
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'title');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'title') !;
expect(completion).toBeDefined();
expect(completion.replacementSpan).toEqual({start: location.start - 2, length: 2});
});

it('should work for attributes', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div cl~{click}></div>
\`,
})
export class FooComponent {
title: string;
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'click');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === '(click)') !;
expect(completion).toBeDefined();
expect(completion.replacementSpan).toEqual({start: location.start - 2, length: 2});
});

it('should work for events', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div (click)="han~{handleClick}"></div>
\`,
})
export class FooComponent {
handleClick() {}
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'handleClick');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'handleClick') !;
expect(completion).toBeDefined();
expect(completion.replacementSpan).toEqual({start: location.start - 3, length: 3});
});
});

function expectContain(
completions: ts.CompletionInfo | undefined, kind: CompletionKind, names: string[]) {
expect(completions).toBeDefined();
Expand Down
1 change: 1 addition & 0 deletions packages/language-service/test/ts_plugin_spec.ts
Expand Up @@ -127,6 +127,7 @@ describe('plugin', () => {
name: 'children',
kind: CompletionKind.PROPERTY as any,
sortText: 'children',
replacementSpan: {start: 174, length: 8},
},
]);
});
Expand Down

0 comments on commit 7861004

Please sign in to comment.