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
71 changes: 70 additions & 1 deletion packages/language-service/src/completions.ts
Expand Up @@ -45,6 +45,69 @@ const ANGULAR_ELEMENTS: ReadonlyArray<ng.CompletionEntry> = [
},
];

/**
* 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 WORD_PART = /[0-9a-zA-Z_]/;

const {template} = templateInfo;
const templateSrc = template.source;
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved

// 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 indeces that hug the cursor.
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
// There are three cases here.
//
// 1. Case like
// wo|rd
// there is a clear left and right index.
let left = templatePosition - 1, right = templatePosition;
// 2. Case like
// |rest of template
// the cursor is at the start of the template, hugged only by the right side (0-index).
if (templatePosition === 0) {
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
left = right = 0;
}
// 2. Case like
// rest of template|
// the cursor is at the end of the template, hugged only by the left side (last-index).
if (templatePosition === templateSrc.length) {
left = right = templateSrc.length - 1;
}

if (!templateSrc[left].match(WORD_PART) && !templateSrc[right].match(WORD_PART)) {
// 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 && templateSrc[left].match(WORD_PART)) --left;
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
++left;
while (right < templateSrc.length && templateSrc[right].match(WORD_PART)) ++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 +173,13 @@ export function getTemplateCompletions(
},
null);
}
return result;

return result.map(entry => {
return {
...entry,
replacementSpan: getBoundedWordSpan(templateInfo, position),
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
};
})
}

function attributeCompletions(info: AstResult, path: AstPath<HtmlAst>): ng.CompletionEntry[] {
Expand Down
152 changes: 151 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,156 @@ describe('completions', () => {
});
});

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

beforeEach(() => {
mockHost = new MockTypescriptHost(['/app/main.ts']);
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
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');
debugger;
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).toBeUndefined();
});

it('should work for start of template', () => {
const fileName = mockHost.addCode(`
@Component({
selector: 'foo-component',
template: \`~{start}abc\`,
})
export class FooComponent {
handleClick() {}
}
`);
const location = mockHost.getLocationMarkerFor(fileName, 'start');
const completions = ngLS.getCompletionsAt(fileName, location.start) !;
expect(completions).toBeDefined();
const completion = completions.entries.find(entry => entry.name === 'a') !;
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
expect(completion).toBeDefined();
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 {
handleClick() {}
}
`);
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.replacementSpan).toEqual({start: location.start - 4, length: 4});
});

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'};
}
~{a}`);
const location = mockHost.getLocationMarkerFor(fileName, 'key');
const loca = mockHost.getLocationMarkerFor(fileName, 'a');
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});
ayazhafiz marked this conversation as resolved.
Show resolved Hide resolved
});

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});
});
});
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