diff --git a/browser/src/Editor/NeovimEditor/Symbols.ts b/browser/src/Editor/NeovimEditor/Symbols.ts index 598a26c384..64b30be46e 100644 --- a/browser/src/Editor/NeovimEditor/Symbols.ts +++ b/browser/src/Editor/NeovimEditor/Symbols.ts @@ -2,18 +2,20 @@ * CodeAction.ts * */ - +import * as _ from "lodash" +import { ErrorCodes } from "vscode-jsonrpc/lib/messages" import * as types from "vscode-languageserver-types" import * as Oni from "oni-api" - -import { MenuManager } from "./../../Services/Menu" +import * as Log from "oni-core-logging" import { LanguageManager } from "./../../Services/Language" +import { Menu, MenuManager } from "./../../Services/Menu" import * as Helpers from "./../../Plugins/Api/LanguageClient/LanguageClientHelpers" -import { asObservable } from "./../../Utility" +import { asObservable, sleep } from "./../../Utility" + import { Definition } from "./Definition" export class Symbols { @@ -59,19 +61,9 @@ export class Symbols { .debounceTime(25) .do(() => menu.setLoading(true)) .concatMap(async (newText: string) => { - const buffer = this._editor.activeBuffer - const symbols: types.SymbolInformation[] = await this._languageManager.sendLanguageServerRequest( - buffer.language, - buffer.filePath, - "workspace/symbol", - { - textDocument: { - uri: Helpers.wrapPathInFileUri(buffer.filePath), - }, - query: newText, - }, - ) - return symbols + return this._requestSymbols(this._editor.activeBuffer, "workspace/symbol", menu, { + query: newText, + }) }) .subscribe((newItems: types.SymbolInformation[]) => { menu.setLoading(false) @@ -94,15 +86,10 @@ export class Symbols { const buffer = this._editor.activeBuffer - const result: types.SymbolInformation[] = await this._languageManager.sendLanguageServerRequest( - buffer.language, - buffer.filePath, + const result: types.SymbolInformation[] = await this._requestSymbols( + buffer, "textDocument/documentSymbol", - { - textDocument: { - uri: Helpers.wrapPathInFileUri(buffer.filePath), - }, - }, + menu, ) const options: Oni.Menu.MenuOption[] = result.map(item => this._symbolInfoToMenuItem(item)) @@ -175,4 +162,40 @@ export class Symbols { return "question" } } + + /** + * Send a request for symbols, retrying if the server is not ready, as long as the menu is open. + */ + private async _requestSymbols( + buffer: Oni.Buffer, + command: string, + menu: Menu, + options: any = {}, + ): Promise<types.SymbolInformation[]> { + while (menu.isOpen()) { + try { + return await this._languageManager.sendLanguageServerRequest( + buffer.language, + buffer.filePath, + command, + _.extend( + { + textDocument: { + uri: Helpers.wrapPathInFileUri(buffer.filePath), + }, + }, + options, + ), + ) + } catch (e) { + if (e.code === ErrorCodes.ServerNotInitialized) { + Log.warn("[Symbols] Language server not yet initialised, trying again...") + await sleep(1000) + } else { + throw e + } + } + } + return [] + } } diff --git a/browser/test/Editor/NeovimEditor/SymbolsTests.ts b/browser/test/Editor/NeovimEditor/SymbolsTests.ts new file mode 100644 index 0000000000..b14369841e --- /dev/null +++ b/browser/test/Editor/NeovimEditor/SymbolsTests.ts @@ -0,0 +1,208 @@ +import * as assert from "assert" +import * as path from "path" +import * as sinon from "sinon" + +import { Event } from "oni-types" +import { ErrorCodes } from "vscode-jsonrpc/lib/messages" + +import { Definition } from "../../../src/Editor/NeovimEditor/Definition" +import { Symbols } from "../../../src/Editor/NeovimEditor/Symbols" +import { wrapPathInFileUri } from "../../../src/Plugins/Api/LanguageClient/LanguageClientHelpers" +import { LanguageManager } from "../../../src/Services/Language" +import { Menu, MenuManager } from "../../../src/Services/Menu" + +/* tslint:disable:no-string-literal */ + +const clock: any = global["clock"] // tslint:disable-line +const waitForPromiseResolution: any = global["waitForPromiseResolution"] // tslint:disable-line + +describe("Symbols", () => { + let editor: any + let definition: any + let languageManager: any + let menuManager: any + let symbols: any + + beforeEach(() => { + editor = sinon.stub() + editor.activeBuffer = "mock buffer" + definition = sinon.createStubInstance(Definition) + languageManager = sinon.createStubInstance(LanguageManager) + menuManager = sinon.createStubInstance(MenuManager) + + symbols = new Symbols(editor, definition, languageManager, menuManager) + }) + + describe("open workspace/document menus", () => { + let menu: any + let onFilterTextChanged: Event<string> + let onItemSelected: Event<string> + + beforeEach(() => { + menu = sinon.createStubInstance(Menu) + onFilterTextChanged = new Event<string>() + onItemSelected = new Event<string>() + sinon.stub(menu, "onItemSelected").get(() => onItemSelected) + sinon.stub(menu, "onFilterTextChanged").get(() => onFilterTextChanged) + menuManager.create.returns(menu) + + symbols["_requestSymbols"] = sinon.stub().resolves(["first symbol", "second symbol"]) + const _symbolInfoToMenuItem = sinon.stub() + _symbolInfoToMenuItem.onCall(0).returns("first transformed") + _symbolInfoToMenuItem.onCall(1).returns("second transformed") + symbols["_symbolInfoToMenuItem"] = _symbolInfoToMenuItem + }) + + describe("openWorkspaceSymbolsMenu", () => { + let getKey: any + + beforeEach(() => { + getKey = sinon.stub() + symbols["_getDetailFromSymbol"] = sinon.stub().returns(getKey) + }) + + it("requests workspace symbols when filter text is changed", async () => { + // setup + symbols.openWorkspaceSymbolsMenu() + clock.tick(30) + sinon.assert.notCalled(symbols["_requestSymbols"]) + + // action + onFilterTextChanged.dispatch("mock query") + + // confirm + clock.tick(24) + sinon.assert.notCalled(symbols["_requestSymbols"]) + clock.tick(1) + sinon.assert.calledWithExactly( + symbols["_requestSymbols"], + "mock buffer", + "workspace/symbol", + menu, + { query: "mock query" }, + ) + await waitForPromiseResolution() + assertCommon() + }) + }) + + describe("openDocumentSymbolsMenu", () => { + it("requests document symbols when completion menu is opened", async () => { + // action + await symbols.openDocumentSymbolsMenu() + + // confirm + sinon.assert.calledWithExactly( + symbols["_requestSymbols"], + "mock buffer", + "textDocument/documentSymbol", + menu, + ) + assertCommon() + }) + }) + + const assertCommon = () => { + assert.deepEqual(symbols["_symbolInfoToMenuItem"].args, [ + ["first symbol"], + ["second symbol"], + ]) + sinon.assert.calledWithExactly(menu.setItems.lastCall, [ + "first transformed", + "second transformed", + ]) + } + }) // End describe open menus + + describe("_requestSymbols", () => { + let menu: any + let buffer: any + + beforeEach(() => { + menu = sinon.createStubInstance(Menu) + menu.isOpen.returns(true) + buffer = sinon.stub() + buffer.language = "mocklang" + buffer.filePath = path.join("mock", "path") + }) + + it("throws on unknown errors", async () => { + // setup + const error = new Error() + languageManager.sendLanguageServerRequest.throws(error) + + try { + // action + await symbols["_requestSymbols"](buffer, "mock command", menu) + + // confirm + assert.fail("Expected exception to be thrown") + } catch (e) { + assert.strictEqual(e, error) + } + }) + + it("retries whilst server is initialising", async () => { + // setup + const error: any = new Error() + error.code = ErrorCodes.ServerNotInitialized + languageManager.sendLanguageServerRequest.onCall(0).throws(error) + languageManager.sendLanguageServerRequest.onCall(1).throws(error) + languageManager.sendLanguageServerRequest.onCall(2).returns("mock result") + + // action + const request: Promise<any> = symbols["_requestSymbols"](buffer, "mock command", menu, { + mock: "option", + }) + + // confirm + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 1) + clock.tick(999) + await waitForPromiseResolution() + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 1) + clock.tick(1) + await waitForPromiseResolution() + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 2) + clock.tick(1000) + await waitForPromiseResolution() + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 3) + clock.tick(1000) + await waitForPromiseResolution() + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 3) + clock.tick(1000) + await waitForPromiseResolution() + sinon.assert.alwaysCalledWith( + languageManager.sendLanguageServerRequest, + "mocklang", + buffer.filePath, + "mock command", + { mock: "option", textDocument: { uri: wrapPathInFileUri(buffer.filePath) } }, + ) + assert.equal(await request, "mock result") + }) + + it("gives up retrying if menu is closed", async () => { + // setup + const error: any = new Error() + error.code = ErrorCodes.ServerNotInitialized + languageManager.sendLanguageServerRequest.throws(error) + + // action + const request: Promise<any> = symbols["_requestSymbols"](buffer, "mock command", menu) + + // confirm + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 1) + clock.tick(1000) + await waitForPromiseResolution() + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 2) + menu.isOpen.returns(false) + clock.tick(1000) + await waitForPromiseResolution() + sinon.assert.callCount(languageManager.sendLanguageServerRequest, 2) + + const result = await request + + assert.deepEqual(result, []) + }) + }) +})