Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(language-service): add support for text replacement #33091

Closed
wants to merge 9 commits into from
79 changes: 78 additions & 1 deletion packages/language-service/src/completions.ts
Expand Up @@ -8,6 +8,7 @@

import {AST, AstPath, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, Element, ElementAst, ImplicitReceiver, NAMED_ENTITIES, Node as HtmlAst, NullTemplateVisitor, ParseSpan, PropertyRead, TagContentType, Text, findNode, getHtmlTagDefinition} from '@angular/compiler';
import {getExpressionScope} from '@angular/compiler-cli/src/language_services';
import {$$, $_, isAsciiLetter, isDigit} from '@angular/compiler/src/chars';

import {AstResult} from './common';
import {getExpressionCompletions} from './expressions';
Expand Down Expand Up @@ -45,6 +46,76 @@ const ANGULAR_ELEMENTS: ReadonlyArray<ng.CompletionEntry> = [
},
];

function isIdentifierPart(code: number) {
// Identifiers consist of alphanumeric characters, '_', or '$'.
return isAsciiLetter(code) || isDigit(code) || code == $$ || code == $_;
}

/**
* Gets the span of word in a template that surrounds `position`. If there is no word around
* `position`, nothing is returned.
*/
function getBoundedWordSpan(templateInfo: AstResult, position: number): ts.TextSpan|undefined {
const {template} = templateInfo;
const templateSrc = template.source;
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved

if (!templateSrc) return;

// TODO(ayazhafiz): A solution based on word expansion will always be expensive compared to one
// based on ASTs. Whatever penalty we incur is probably manageable for small-length (i.e. the
// majority of) identifiers, but the current solution involes a number of branchings and we can't
// control potentially very long identifiers. Consider moving to an AST-based solution once
// existing difficulties with AST spans are more clearly resolved (see #31898 for discussion of
// known problems, and #33091 for how they affect text replacement).
//
// `templatePosition` represents the right-bound location of a cursor in the template.
// key.ent|ry
// ^---- cursor, at position `r` is at.
// A cursor is not itself a character in the template; it has a left (lower) and right (upper)
// index bound that hugs the cursor itself.
let templatePosition = position - template.span.start;
// To perform word expansion, we want to determine the left and right indices that hug the cursor.
// There are three cases here.
let left, right;
if (templatePosition === 0) {
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
// 1. Case like
// |rest of template
// the cursor is at the start of the template, hugged only by the right side (0-index).
left = right = 0;
} else if (templatePosition === templateSrc.length) {
// 2. Case like
// rest of template|
// the cursor is at the end of the template, hugged only by the left side (last-index).
left = right = templateSrc.length - 1;
} else {
// 3. Case like
// wo|rd
// there is a clear left and right index.
left = templatePosition - 1;
right = templatePosition;
}

if (!isIdentifierPart(templateSrc.charCodeAt(left)) &&
!isIdentifierPart(templateSrc.charCodeAt(right))) {
// Case like
// .|.
// left ---^ ^--- right
// There is no word here.
return;
}

// Expand on the left and right side until a word boundary is hit. Back up one expansion on both
// side to stay inside the word.
while (left >= 0 && isIdentifierPart(templateSrc.charCodeAt(left))) --left;
++left;
while (right < templateSrc.length && isIdentifierPart(templateSrc.charCodeAt(right))) ++right;
--right;

const absoluteStartPosition = position - (templatePosition - left);
const length = right - left + 1;
return {start: absoluteStartPosition, length};
}

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

const replacementSpan = getBoundedWordSpan(templateInfo, position);
return result.map(entry => {
return {
...entry, replacementSpan,
};
});
}

function attributeCompletions(info: AstResult, path: AstPath<HtmlAst>): ng.CompletionEntry[] {
Expand Down
189 changes: 188 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 @@ -372,6 +372,193 @@ describe('completions', () => {
});
});

describe('replace completions correctly', () => {
const mockHost = new MockTypescriptHost(['/app/main.ts']);
let ngLS: LanguageService;

beforeEach(() => {
mockHost.reset();
const tsLS = ts.createLanguageService(mockHost);
const ngHost = new TypeScriptServiceHost(mockHost, tsLS);
ngLS = createLanguageService(ngHost);
});

it('should not generate replacement entries 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.kind).toBe('property');
expect(completion.replacementSpan).toBeUndefined();
});

it('should work for start of template', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`~{start}abc\`,
})
export class FooComponent {}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'start');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'acronym') !;
expect(completion).toBeDefined();
expect(completion.kind).toBe('html element');
expect(completion.replacementSpan).toEqual({start: location.start, length: 3});
});

it('should work for end of template', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`acro~{end}\`,
})
export class FooComponent {}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'end');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'acronym') !;
expect(completion).toBeDefined();
expect(completion.kind).toBe('html element');
expect(completion.replacementSpan).toEqual({start: location.start - 4, length: 4});
});

it('should work for middle-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.kind).toBe('property');
expect(completion.replacementSpan).toEqual({start: location.start - 2, length: 5});
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
});

it('should work for all kinds of identifier characters', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div>{{~{field}$title_1}}</div>
\`,
})
export class FooComponent {
$title_1: string;
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'field');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === '$title_1') !;
expect(completion).toBeDefined();
expect(completion.kind).toBe('property');
expect(completion.replacementSpan).toEqual({start: location.start, length: 8});
});

it('should work for attributes', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<div cl~{click}></div>
\`,
})
export class FooComponent {}
`);
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.kind).toBe('attribute');
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.kind).toBe('method');
expect(completion.replacementSpan).toEqual({start: location.start - 3, length: 3});
});

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

it('should work for bindings', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`
<input ngMod~{model} />
\`,
})
export class FooComponent {}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'model');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === '[(ngModel)]') !;
expect(completion).toBeDefined();
expect(completion.kind).toBe('attribute');
expect(completion.replacementSpan).toEqual({start: location.start - 5, length: 5});
});
});
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved

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: 182, length: 8},
},
]);
});
Expand Down