From 5de1be76b56f2bb298bf6c65580e29edce364549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=80=D0=B5=D0=B4=D1=80=D0=B0=D0=B3=20=D0=9D=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=9B?= Date: Thu, 21 Mar 2024 19:46:07 +0100 Subject: [PATCH] try to pin down --- tests/test_server_requests.py | 224 +++++++++++++++++++ tests/test_single_document.py | 400 ++++++++++++++++++++++++++++++++++ 2 files changed, 624 insertions(+) create mode 100644 tests/test_server_requests.py create mode 100644 tests/test_single_document.py diff --git a/tests/test_server_requests.py b/tests/test_server_requests.py new file mode 100644 index 000000000..2ac9d5814 --- /dev/null +++ b/tests/test_server_requests.py @@ -0,0 +1,224 @@ +from LSP.plugin.core.protocol import ErrorCodes +from LSP.plugin.core.protocol import TextDocumentSyncKind +from LSP.plugin.core.sessions import SessionBufferProtocol +from LSP.plugin.core.types import ClientConfig +from LSP.plugin.core.typing import Any, Dict, Generator, Optional, List +from LSP.plugin.core.url import filename_to_uri +from setup import TextDocumentTestCase +import os +import sublime +import tempfile + + +def get_auto_complete_trigger(sb: SessionBufferProtocol) -> Optional[List[Dict[str, str]]]: + for sv in sb.session_views: + triggers = sv.view.settings().get("auto_complete_triggers") + for trigger in triggers: + if "server" in trigger and "registration_id" in trigger: + return trigger + return None + + +def verify(testcase: TextDocumentTestCase, method: str, input_params: Any, expected_output_params: Any) -> Generator: + promise = testcase.make_server_do_fake_request(method, input_params) + yield from testcase.await_promise(promise) + testcase.assertEqual(promise.result(), expected_output_params) + + +class ServerRequests(TextDocumentTestCase): + + def test_unknown_method(self) -> Generator: + yield from verify(self, "foobar/qux", {}, {"code": ErrorCodes.MethodNotFound, "message": "foobar/qux"}) + + def test_m_workspace_workspaceFolders(self) -> Generator: + expected_output = [{"name": os.path.basename(f), "uri": filename_to_uri(f)} + for f in sublime.active_window().folders()] + self.maxDiff = None + yield from verify(self, "workspace/workspaceFolders", {}, expected_output) + + def test_m_workspace_configuration(self) -> Generator: + self.session.config.settings.set("foo.bar", "$hello") + self.session.config.settings.set("foo.baz", "$world") + self.session.config.settings.set("foo.a", 1) + self.session.config.settings.set("foo.b", None) + self.session.config.settings.set("foo.c", ["asdf ${hello} ${world}"]) + + class TempPlugin: + + @classmethod + def additional_variables(cls) -> Optional[Dict[str, str]]: + return {"hello": "X", "world": "Y"} + + self.session._plugin_class = TempPlugin # type: ignore + method = "workspace/configuration" + params = {"items": [{"section": "foo"}]} + expected_output = [{"bar": "X", "baz": "Y", "a": 1, "b": None, "c": ["asdf X Y"]}] + yield from verify(self, method, params, expected_output) + self.session.config.settings.clear() + + def test_m_workspace_applyEdit(self) -> Generator: + old_change_count = self.insert_characters("hello\nworld\n") + edit = { + "newText": "there", + "range": {"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 5}}} + params = {"edit": {"changes": {filename_to_uri(self.view.file_name()): [edit]}}} + yield from verify(self, "workspace/applyEdit", params, {"applied": True}) + yield lambda: self.view.change_count() > old_change_count + self.assertEqual(self.view.substr(sublime.Region(0, self.view.size())), "hello\nthere\n") + + def test_m_workspace_applyEdit_with_nontrivial_promises(self) -> Generator: + with tempfile.TemporaryDirectory() as dirpath: + initial_text = ["a b", "c d"] + file_paths = [] + for i in range(0, 2): + file_paths.append(os.path.join(dirpath, "file{}.txt".format(i))) + with open(file_paths[-1], "w") as fp: + fp.write(initial_text[i]) + yield from verify( + self, + "workspace/applyEdit", + { + "edit": { + "changes": { + filename_to_uri(file_paths[0]): + [ + { + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "newText": "hello" + }, + { + "range": {"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 3}}, + "newText": "there" + } + ], + filename_to_uri(file_paths[1]): + [ + { + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "newText": "general" + }, + { + "range": {"start": {"line": 0, "character": 2}, "end": {"line": 0, "character": 3}}, + "newText": "kenobi" + } + ] + } + } + }, + {"applied": True} + ) + # Changes should have been applied + expected = ["hello there", "general kenobi"] + for i in range(0, 2): + view = self.view.window().find_open_file(file_paths[i]) + self.assertTrue(view) + view.set_scratch(True) + self.assertTrue(view.is_valid()) + self.assertFalse(view.is_loading()) + self.assertEqual(view.substr(sublime.Region(0, view.size())), expected[i]) + view.close() + + def test_m_client_registerCapability(self) -> Generator: + yield from verify( + self, + "client/registerCapability", + { + "registrations": + [ + {"method": "foo/bar", "id": "hello"}, + {"method": "bar/baz", "id": "world", "registerOptions": {"frobnicatable": True}}, + {"method": "workspace/didChangeWorkspaceFolders", "id": "asdf"}, + {"method": "textDocument/didOpen", "id": "1"}, + {"method": "textDocument/willSaveWaitUntil", "id": "2", + "registerOptions": {"documentSelector": [{"language": "plaintext"}]}}, + {"method": "textDocument/didChange", "id": "adsf", + "registerOptions": {"syncKind": TextDocumentSyncKind.Full, "documentSelector": [ + {"language": "plaintext"} + ]}}, + {"method": "textDocument/completion", "id": "myCompletionRegistrationId", + "registerOptions": {"triggerCharacters": ["!", "@", "#"], "documentSelector": [ + {"language": "plaintext"} + ]}} + ] + }, + None) + self.assertIn("barProvider", self.session.capabilities) + self.assertEqual(self.session.capabilities.get("barProvider.id"), "hello") + self.assertIn("bazProvider", self.session.capabilities) + self.assertEqual(self.session.capabilities.get("bazProvider"), {"id": "world", "frobnicatable": True}) + self.assertEqual(self.session.capabilities.get("workspace.workspaceFolders.changeNotifications"), "asdf") + self.assertEqual(self.session.capabilities.get("textDocumentSync.didOpen"), {"id": "1"}) + self.assertFalse(self.session.capabilities.get("textDocumentSync.didClose")) + + # willSaveWaitUntil is *only* registered on the buffer + self.assertFalse(self.session.capabilities.get("textDocumentSync.willSaveWaitUntil")) + sb = next(self.session.session_buffers_async()) + self.assertEqual(sb.capabilities.text_sync_kind(), TextDocumentSyncKind.Full) + self.assertEqual(sb.capabilities.get("textDocumentSync.willSaveWaitUntil"), {"id": "2"}) + self.assertEqual(self.session.capabilities.text_sync_kind(), TextDocumentSyncKind.Incremental) + + # Check that textDocument/completion was registered onto the SessionBuffer, and check that the trigger + # characters for each view were updated + self.assertEqual(sb.capabilities.get("completionProvider.id"), "myCompletionRegistrationId") + self.assertEqual(sb.capabilities.get("completionProvider.triggerCharacters"), ["!", "@", "#"]) + trigger = get_auto_complete_trigger(sb) + self.assertTrue(trigger) + self.assertEqual(trigger.get("characters"), "!@#") + + def test_m_client_unregisterCapability(self) -> Generator: + yield from verify( + self, + "client/registerCapability", + {"registrations": [{"method": "foo/bar", "id": "hello"}]}, + None) + self.assertIn("barProvider", self.session.capabilities) + yield from verify( + self, + "client/unregisterCapability", + {"unregisterations": [{"method": "foo/bar", "id": "hello"}]}, + None) + self.assertNotIn("barProvider", self.session.capabilities) + + +class ServerRequestsWithAutoCompleteSelector(TextDocumentTestCase): + + @classmethod + def get_stdio_test_config(cls) -> ClientConfig: + return ClientConfig.from_config( + super().get_stdio_test_config(), + { + "auto_complete_selector": "punctuation.section", + "disabled_capabilities": { + "completionProvider": { + "triggerCharacters": True + } + } + } + ) + + def test_m_client_registerCapability(self) -> Generator: + yield from verify( + self, + "client/registerCapability", + { + "registrations": + [ + # Note that the triggerCharacters are disabled in the configuration. + {"method": "textDocument/completion", "id": "anotherCompletionRegistrationId", + "registerOptions": {"triggerCharacters": ["!", "@", "#"], "documentSelector": [ + {"language": "plaintext"} + ]}} + ] + }, + None) + sb = next(self.session.session_buffers_async()) + # Check that textDocument/completion was registered onto the SessionBuffer + self.assertEqual(sb.capabilities.get("completionProvider.id"), "anotherCompletionRegistrationId") + # Trigger characters should not have been registered + self.assertFalse(sb.capabilities.get("completionProvider.triggerCharacters")) + trigger = get_auto_complete_trigger(sb) + self.assertTrue(trigger) + # No triggers should have been assigned + self.assertFalse(trigger.get("characters")) + # The selector should have been set + self.assertEqual(trigger.get("selector"), "punctuation.section") diff --git a/tests/test_single_document.py b/tests/test_single_document.py new file mode 100644 index 000000000..4d1bb10d3 --- /dev/null +++ b/tests/test_single_document.py @@ -0,0 +1,400 @@ +from copy import deepcopy +from LSP.plugin import apply_text_edits, Request +from LSP.plugin.core.protocol import UINT_MAX +from LSP.plugin.core.url import filename_to_uri +from LSP.plugin.core.views import entire_content +from LSP.plugin.hover import _test_contents +from setup import TextDocumentTestCase +from setup import TIMEOUT_TIME +from setup import YieldPromise +import os +import sublime + + +try: + from typing import Generator, Optional, Iterable, Tuple, List + assert Generator and Optional and Iterable and Tuple and List +except ImportError: + pass + +SELFDIR = os.path.dirname(__file__) +TEST_FILE_PATH = os.path.join(SELFDIR, 'testfile.txt') +GOTO_RESPONSE = [ + { + 'uri': filename_to_uri(TEST_FILE_PATH), + 'range': + { + 'start': + { + # Put the cursor at the capital letter "F". + 'character': 5, + 'line': 1 + }, + 'end': + { + 'character': 5, + 'line': 1 + } + } + } +] +GOTO_RESPONSE_LOCATION_LINK = [ + { + 'originSelectionRange': {'start': {'line': 0, 'character': 0}}, + 'targetUri': GOTO_RESPONSE[0]['uri'], + 'targetRange': GOTO_RESPONSE[0]['range'], + 'targetSelectionRange': GOTO_RESPONSE[0]['range'] + } +] +GOTO_CONTENT = r'''abcdefghijklmnopqrstuvwxyz +ABCDEFGHIJKLMNOPQRSTUVWXYZ +0123456789 +''' + + +class SingleDocumentTestCase(TextDocumentTestCase): + + def test_did_open(self) -> None: + # Just the existence of this method checks "initialize" -> "initialized" -> "textDocument/didOpen" + # -> "shutdown" -> client shut down + pass + + def test_out_of_bounds_column_for_text_document_edit(self) -> None: + self.insert_characters("a\nb\nc\n") + apply_text_edits(self.view, [ + { + 'newText': 'hello there', + 'range': { + 'start': { + 'line': 1, + 'character': 0, + }, + 'end': { + 'line': 1, + 'character': 10000, + } + } + }, + ]) + self.assertEqual(entire_content(self.view), "a\nhello there\nc\n") + + def test_did_close(self) -> 'Generator': + self.assertTrue(self.view) + self.assertTrue(self.view.is_valid()) + self.view.close() + yield from self.await_message("textDocument/didClose") + + def test_did_change(self) -> 'Generator': + assert self.view + self.maxDiff = None + self.insert_characters("A") + yield from self.await_message("textDocument/didChange") + # multiple changes are batched into one didChange notification + self.insert_characters("B\n") + self.insert_characters("🙂\n") + self.insert_characters("D") + promise = YieldPromise() + yield from self.await_message("textDocument/didChange", promise) + self.assertEqual(promise.result(), { + 'contentChanges': [ + {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 1}, 'end': {'line': 0, 'character': 1}}, 'text': 'B'}, # noqa + {'rangeLength': 0, 'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 2}}, 'text': '\n'}, # noqa + {'rangeLength': 0, 'range': {'start': {'line': 1, 'character': 0}, 'end': {'line': 1, 'character': 0}}, 'text': '🙂'}, # noqa + # Note that this is character offset (2) is correct (UTF-16). + {'rangeLength': 0, 'range': {'start': {'line': 1, 'character': 2}, 'end': {'line': 1, 'character': 2}}, 'text': '\n'}, # noqa + {'rangeLength': 0, 'range': {'start': {'line': 2, 'character': 0}, 'end': {'line': 2, 'character': 0}}, 'text': 'D'}], # noqa + 'textDocument': { + 'version': self.view.change_count(), + 'uri': filename_to_uri(TEST_FILE_PATH) + } + }) + + def test_sends_save_with_purge(self) -> 'Generator': + assert self.view + self.view.settings().set("lsp_format_on_save", False) + self.insert_characters("A") + self.view.run_command("lsp_save", {'async': True}) + yield from self.await_message("textDocument/didChange") + yield from self.await_message("textDocument/didSave") + yield from self.await_clear_view_and_save() + + def test_formats_on_save(self) -> 'Generator': + assert self.view + self.view.settings().set("lsp_format_on_save", True) + self.insert_characters("A") + yield from self.await_message("textDocument/didChange") + self.set_response('textDocument/formatting', [{ + 'newText': "BBB", + 'range': { + 'start': {'line': 0, 'character': 0}, + 'end': {'line': 0, 'character': 1} + } + }]) + self.view.run_command("lsp_save", {'async': True}) + yield from self.await_message("textDocument/formatting") + yield from self.await_message("textDocument/didChange") + yield from self.await_message("textDocument/didSave") + text = self.view.substr(sublime.Region(0, self.view.size())) + self.assertEquals("BBB", text) + yield from self.await_clear_view_and_save() + + def test_hover_info(self) -> 'Generator': + assert self.view + self.set_response('textDocument/hover', {"contents": "greeting"}) + self.view.run_command('insert', {"characters": "Hello Wrld"}) + self.assertFalse(self.view.is_popup_visible()) + self.view.run_command('lsp_hover', {'point': 3}) + yield lambda: self.view.is_popup_visible() + last_content = _test_contents[-1] + self.assertTrue("greeting" in last_content) + + def test_remove_line_and_then_insert_at_that_line_at_end(self) -> 'Generator': + original = ( + 'a\n' + 'b\n' + 'c' + ) + file_changes = [ + ((2, 0), (3, 0), ''), # out-of-bounds end position, but this is fine + ((3, 0), (3, 0), 'c\n') # out-of-bounds start and end, this line doesn't exist + ] + expected = ( + 'a\n' + 'b\n' + 'c\n' + ) + # Old behavior: + # 1) first we end up with ('a\n', 'b\n', 'cc\n') + # 2) then we end up with ('a\n', 'b\n', '') + # New behavior: + # 1) line index 3 is "created" ('a\n', 'b\n', 'c\n', c\n')) + # 2) deletes line index 2. + yield from self.__run_formatting_test(original, expected, file_changes) + + def test_apply_formatting(self) -> 'Generator': + original = ( + '\n' + '\n' + '\n' + '\n' + ) + file_changes = [ + ((0, 28), (1, 0), ''), # delete first \n + ((1, 0), (1, 15), ''), # delete second line (but not the \n) + ((2, 10), (2, 10), '\n '), # insert after