From bcd8c7f6a6d2c63247d46032e22e930ee0d6cb3f Mon Sep 17 00:00:00 2001 From: ayazhafiz Date: Fri, 25 Oct 2019 21:43:17 -0400 Subject: [PATCH] fixup! feat(language-service): add support for text replacement --- packages/language-service/src/completions.ts | 111 ++++++++---------- .../language-service/test/completions_spec.ts | 44 ++++++- .../language-service/test/ts_plugin_spec.ts | 3 +- 3 files changed, 93 insertions(+), 65 deletions(-) diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index b2b2a302d8815..d9f2d7c71a8e4 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -30,67 +30,59 @@ const hiddenHtmlElements = { const ANGULAR_ELEMENTS: ReadonlyArray = ['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; +/** + * 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; + + // `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. + // To perform word expansion, we want to determine the left and right indeces that hug the cursor. + // There are three cases here. + let templatePosition = position - template.span.start; + // 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) { + 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 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 + if (!templateSrc[left].match(WORD_PART) && !templateSrc[right].match(WORD_PART)) { + // Case like + // .|. + // left ---^ ^--- right + // There is no word here. + return; } - // 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, - }); + // 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; + ++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( @@ -159,13 +151,10 @@ export function getTemplateCompletions( null); } - // 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}, + replacementSpan: getBoundedWordSpan(templateInfo, position), }; }) } diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 89b5b7e0d7681..4f38dd458e907 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -378,7 +378,7 @@ describe('replace completions correctly', () => { ngLS = createLanguageService(ngHost); }); - it('should work for zero-length replacements', () => { + it('should not generate replacement entries for zero-length replacements', () => { const fileName = mockHost.addCode(` @Component({ selector: 'foo-component', @@ -391,11 +391,48 @@ describe('replace completions correctly', () => { } `); 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).toEqual({start: location.start, length: 0}); + 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') !; + 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', () => { @@ -409,8 +446,9 @@ describe('replace completions correctly', () => { 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') !; diff --git a/packages/language-service/test/ts_plugin_spec.ts b/packages/language-service/test/ts_plugin_spec.ts index 8a809b0e1af8a..a6df57ea6a13a 100644 --- a/packages/language-service/test/ts_plugin_spec.ts +++ b/packages/language-service/test/ts_plugin_spec.ts @@ -120,6 +120,7 @@ describe('plugin', () => { expect(semanticDiags).toEqual([]); } const marker = mockHost.getLocationMarkerFor(MY_COMPONENT, 'tree'); + debugger; const completions = plugin.getCompletionsAtPosition(MY_COMPONENT, marker.start, undefined); expect(completions).toBeDefined(); expect(completions !.entries).toEqual([ @@ -127,7 +128,7 @@ describe('plugin', () => { name: 'children', kind: CompletionKind.PROPERTY as any, sortText: 'children', - replacementSpan: {start: 174, length: 8}, + replacementSpan: {start: 182, length: 8}, }, ]); });