diff --git a/packages/language-service/ivy/test/diagnostic_spec.ts b/packages/language-service/ivy/test/diagnostic_spec.ts index 5ebeb615b8..45eeb72e2f 100644 --- a/packages/language-service/ivy/test/diagnostic_spec.ts +++ b/packages/language-service/ivy/test/diagnostic_spec.ts @@ -26,13 +26,13 @@ describe('diagnostic', () => { }); it('should report member does not exist', () => { - const content = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}'); + const {text} = service.overwriteInlineTemplate(APP_COMPONENT, '{{ nope }}'); const diags = ngLS.getSemanticDiagnostics(APP_COMPONENT); expect(diags.length).toBe(1); const {category, file, start, length, messageText} = diags[0]; expect(category).toBe(ts.DiagnosticCategory.Error); expect(file?.fileName).toBe(APP_COMPONENT); - expect(content.substring(start!, start! + length!)).toBe('nope'); + expect(text.substring(start!, start! + length!)).toBe('nope'); expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`); }); }); diff --git a/packages/language-service/ivy/test/mock_host.ts b/packages/language-service/ivy/test/mock_host.ts index 8541399306..152c2200f4 100644 --- a/packages/language-service/ivy/test/mock_host.ts +++ b/packages/language-service/ivy/test/mock_host.ts @@ -105,6 +105,17 @@ export function setup() { }; } +interface OverwriteResult { + /** + * Position of the cursor, -1 if there isn't one. + */ + position: number; + /** + * Overwritten content without the cursor. + */ + text: string; +} + class MockService { private readonly overwritten = new Set(); @@ -113,20 +124,32 @@ class MockService { private readonly ps: ts.server.ProjectService, ) {} - overwrite(fileName: string, newText: string): string { + /** + * Overwrite the entire content of `fileName` with `newText`. If cursor is + * present in `newText`, it will be removed and the position of the cursor + * will be returned. + */ + overwrite(fileName: string, newText: string): OverwriteResult { const scriptInfo = this.getScriptInfo(fileName); - this.overwriteScriptInfo(scriptInfo, preprocess(newText)); - return newText; + return this.overwriteScriptInfo(scriptInfo, newText); } - overwriteInlineTemplate(fileName: string, newTemplate: string): string { + /** + * Overwrite an inline template defined in `fileName` and return the entire + * content of the source file (not just the template). If a cursor is present + * in `newTemplate`, it will be removed and the position of the cursor in the + * source file will be returned. + */ + overwriteInlineTemplate(fileName: string, newTemplate: string): OverwriteResult { const scriptInfo = this.getScriptInfo(fileName); const snapshot = scriptInfo.getSnapshot(); - const originalContent = snapshot.getText(0, snapshot.getLength()); - const newContent = - originalContent.replace(/template: `([\s\S]+)`/, `template: \`${newTemplate}\``); - this.overwriteScriptInfo(scriptInfo, preprocess(newContent)); - return newContent; + const originalText = snapshot.getText(0, snapshot.getLength()); + const {position, text} = + replaceOnce(originalText, /template: `([\s\S]+?)`/, `template: \`${newTemplate}\``); + if (position === -1) { + throw new Error(`${fileName} does not contain a component with template`); + } + return this.overwriteScriptInfo(scriptInfo, text); } reset() { @@ -151,16 +174,37 @@ class MockService { return scriptInfo; } - private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string) { + /** + * Remove the cursor from `newText`, then replace `scriptInfo` with the new + * content and return the position of the cursor. + * @param scriptInfo + * @param newText Text that possibly contains a cursor + */ + private overwriteScriptInfo(scriptInfo: ts.server.ScriptInfo, newText: string): OverwriteResult { + const result = replaceOnce(newText, /¦/, ''); const snapshot = scriptInfo.getSnapshot(); - scriptInfo.editContent(0, snapshot.getLength(), newText); + scriptInfo.editContent(0, snapshot.getLength(), result.text); this.overwritten.add(scriptInfo.fileName); + return result; } } -const REGEX_CURSOR = /¦/g; -function preprocess(text: string): string { - return text.replace(REGEX_CURSOR, ''); +/** + * Replace at most one occurence that matches `regex` in the specified + * `searchText` with the specified `replaceText`. Throw an error if there is + * more than one occurrence. + */ +function replaceOnce(searchText: string, regex: RegExp, replaceText: string): OverwriteResult { + regex = new RegExp(regex.source, regex.flags + 'g' /* global */); + let position = -1; + const text = searchText.replace(regex, (...args) => { + if (position !== -1) { + throw new Error(`${regex} matches more than one occurrence in text: ${searchText}`); + } + position = args[args.length - 2]; // second last argument is always the index + return replaceText; + }); + return {position, text}; } const REF_MARKER = /«(((\w|\-)+)|([^ᐱ]*ᐱ(\w+)ᐱ.[^»]*))»/g; diff --git a/packages/language-service/ivy/test/mock_host_spec.ts b/packages/language-service/ivy/test/mock_host_spec.ts index cb4b3d7427..e12dd112c1 100644 --- a/packages/language-service/ivy/test/mock_host_spec.ts +++ b/packages/language-service/ivy/test/mock_host_spec.ts @@ -8,7 +8,7 @@ import * as ts from 'typescript/lib/tsserverlibrary'; -import {APP_MAIN, setup, TEST_SRCDIR} from './mock_host'; +import {APP_COMPONENT, APP_MAIN, setup, TEST_SRCDIR} from './mock_host'; describe('mock host', () => { const {project, service, tsLS} = setup(); @@ -58,13 +58,93 @@ describe('mock host', () => { expect(getText(scriptInfo)).toBe('const x: string = 0'); }); - it('can find the cursor', () => { - const content = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`); - // content returned by overwrite() is the original content with cursor - expect(content).toBe(`const fo¦o = 'hello world';`); - const scriptInfo = service.getScriptInfo(APP_MAIN); - // script info content should not contain cursor - expect(getText(scriptInfo)).toBe(`const foo = 'hello world';`); + describe('overwrite()', () => { + it('will return the cursor position', () => { + const {position} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`); + expect(position).toBe(8); + }); + + it('will remove the cursor in overwritten text', () => { + const {text} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`); + expect(text).toBe(`const foo = 'hello world';`); + }); + + it('will update script info without cursor', () => { + const {text} = service.overwrite(APP_MAIN, `const fo¦o = 'hello world';`); + const scriptInfo = service.getScriptInfo(APP_MAIN); + const snapshot = getText(scriptInfo); + expect(snapshot).toBe(`const foo = 'hello world';`); + expect(snapshot).toBe(text); + }); + + it('will throw if there is more than one cursor', () => { + expect(() => service.overwrite(APP_MAIN, `const f¦oo = 'hello wo¦rld';`)) + .toThrowError(/matches more than one occurrence in text/); + }); + + it('will return -1 if cursor is not present', () => { + const {position} = service.overwrite(APP_MAIN, `const foo = 'hello world';`); + expect(position).toBe(-1); + }); + }); + + describe('overwriteInlineTemplate()', () => { + it('will return the cursor position', () => { + const {position, text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`); + // The position returned should be relative to the start of the source + // file, not the start of the template. + expect(position).not.toBe(5); + expect(text.substring(position, position + 4)).toBe('o }}'); + }); + + it('will remove the cursor in overwritten text', () => { + const {text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`); + expect(text).toContain(`{{ foo }}`); + }); + + it('will return the entire content of the source file', () => { + const {text} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`); + expect(text).toContain(`@Component`); + }); + + it('will update script info without cursor', () => { + service.overwriteInlineTemplate(APP_COMPONENT, `{{ fo¦o }}`); + const scriptInfo = service.getScriptInfo(APP_COMPONENT); + expect(getText(scriptInfo)).toContain(`{{ foo }}`); + }); + + it('will throw if there is no template in file', () => { + expect(() => service.overwriteInlineTemplate(APP_MAIN, `{{ foo }}`)) + .toThrowError(/does not contain a component with template/); + }); + + it('will throw if there is more than one cursor', () => { + expect(() => service.overwriteInlineTemplate(APP_COMPONENT, `{{ f¦o¦o }}`)) + .toThrowError(/matches more than one occurrence in text/); + }); + + it('will return -1 if cursor is not present', () => { + const {position} = service.overwriteInlineTemplate(APP_COMPONENT, `{{ foo }}`); + expect(position).toBe(-1); + }); + + it('will throw if there is more than one component with template', () => { + service.overwrite(APP_COMPONENT, ` + import {Component} from '@angular/core'; + + @Component({ + template: \`

\`, + }) + export class ComponentA {} + + @Component({ + template: \`

\`, + }) + export class ComponentB {} + `); + expect(() => service.overwriteInlineTemplate(APP_COMPONENT, `

`)) + .toThrowError(/matches more than one occurrence in text/); + }); }); });