From 7861004390d59d6374b747e15a2c7e43e83695d7 Mon Sep 17 00:00:00 2001 From: ayazhafiz Date: Thu, 24 Oct 2019 21:29:24 -0400 Subject: [PATCH] feat(language-service): add support for text replacement 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`. --- packages/language-service/src/completions.ts | 76 +++++++++++- packages/language-service/src/expressions.ts | 2 +- .../language-service/test/completions_spec.ts | 114 +++++++++++++++++- .../language-service/test/ts_plugin_spec.ts | 1 + 4 files changed, 189 insertions(+), 4 deletions(-) diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index 297fbf32b2d1e..b2b2a302d8815 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -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'; @@ -30,6 +30,69 @@ 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; + } + + // 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[] = []; @@ -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): ng.CompletionEntry[] { diff --git a/packages/language-service/src/expressions.ts b/packages/language-service/src/expressions.ts index 062fc203b12fb..aa51a0534ddf8 100644 --- a/packages/language-service/src/expressions.ts +++ b/packages/language-service/src/expressions.ts @@ -14,7 +14,7 @@ import {inSpan} from './utils'; type AstPath = AstPathBase; -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) { diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 2ae39da15edcd..89b5b7e0d7681 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -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'; @@ -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: \` +
{{obj.~{key}}}
+ \`, + }) + 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: \` +
{{obj.ke~{key}key}}
+ \`, + }) + 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: \` +
{{ti~{title}}}
+ \`, + }) + 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: \` +
+ \`, + }) + 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: \` +
+ \`, + }) + 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(); diff --git a/packages/language-service/test/ts_plugin_spec.ts b/packages/language-service/test/ts_plugin_spec.ts index 6cadb918306b5..8a809b0e1af8a 100644 --- a/packages/language-service/test/ts_plugin_spec.ts +++ b/packages/language-service/test/ts_plugin_spec.ts @@ -127,6 +127,7 @@ describe('plugin', () => { name: 'children', kind: CompletionKind.PROPERTY as any, sortText: 'children', + replacementSpan: {start: 174, length: 8}, }, ]); });