From ce5f21755ecdb8f41f413bbcd8e48ffe4ced8909 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@inf.u-szeged.hu> Date: Sat, 27 May 2023 18:10:16 +0200 Subject: [PATCH 01/28] setup for client server communication testing and analyser test for line shifts --- setup.py | 1 + tests/__init__.py | 0 tests/analysers/analyser_test.py | 114 ++++++++++++ tests/conftest.py | 66 +++++++ tests/lsp_test_client/__init__.py | 10 ++ tests/lsp_test_client/defaults.py | 214 +++++++++++++++++++++++ tests/lsp_test_client/lsp_run.py | 7 + tests/lsp_test_client/session.py | 279 ++++++++++++++++++++++++++++++ tests/lsp_test_client/utils.py | 31 ++++ textLSP/analysers/analyser.py | 1 - 10 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 tests/__init__.py create mode 100644 tests/analysers/analyser_test.py create mode 100644 tests/conftest.py create mode 100644 tests/lsp_test_client/__init__.py create mode 100644 tests/lsp_test_client/defaults.py create mode 100644 tests/lsp_test_client/lsp_run.py create mode 100644 tests/lsp_test_client/session.py create mode 100644 tests/lsp_test_client/utils.py diff --git a/setup.py b/setup.py index bd00815..7673e27 100644 --- a/setup.py +++ b/setup.py @@ -46,6 +46,7 @@ def read(fname): extras_require={ 'dev': [ 'pytest', + 'python-lsp-jsonrpc', ] }, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py new file mode 100644 index 0000000..2aebb0a --- /dev/null +++ b/tests/analysers/analyser_test.py @@ -0,0 +1,114 @@ +import pytest + +from threading import Event +from lsprotocol.types import ( + DidOpenTextDocumentParams, + TextDocumentItem, + DidChangeTextDocumentParams, + VersionedTextDocumentIdentifier, + TextDocumentContentChangeEvent_Type1, + Range, + Position, +) + +from tests.lsp_test_client import session, utils + + +@pytest.mark.parametrize('text,edit,exp', [ + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'This is another sentence.', + ( + Range( + start=Position(line=2, character=0), + end=Position(line=2, character=0), + ), + '\n', + ), + Range( + start=Position(line=1, character=10), + end=Position(line=1, character=18), + ), + ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'This is another sentence.', + ( + Range( + start=Position(line=0, character=0), + end=Position(line=0, character=0), + ), + '\n\n\n', + ), + Range( + start=Position(line=4, character=10), + end=Position(line=4, character=18), + ), + ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'This is another sentence.', + ( + Range( + start=Position(line=1, character=23), + end=Position(line=1, character=23), + ), + '\n', + ), + Range( + start=Position(line=1, character=10), + end=Position(line=1, character=18), + ), + ), +]) +def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): + done = Event() + results = list() + + langtool_ls_onsave.set_notification_callback( + session.PUBLISH_DIAGNOSTICS, + utils.get_notification_handler( + event=done, + results=results + ), + ) + + open_params = DidOpenTextDocumentParams( + TextDocumentItem( + uri='dummy.txt', + language_id='txt', + version=1, + text=text, + ) + ) + + langtool_ls_onsave.notify_did_open( + json_converter.unstructure(open_params) + ) + done.wait() + done.clear() + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=1, + uri='dummy.txt', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + edit[0], + edit[1], + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + + done.wait() + done.clear() + + res = results[-1]['diagnostics'][0]['range'] + assert res == json_converter.unstructure(exp) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..834100a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +import pytest +import copy + +from pygls.protocol import default_converter + +from tests.lsp_test_client import session, defaults + + +@pytest.fixture +def json_converter(): + return default_converter() + + +@pytest.fixture +def simple_server(): + with session.LspSession() as lsp_session: + lsp_session.initialize() + yield lsp_session + + +@pytest.fixture +def langtool_ls(): + init_params = copy.deepcopy(defaults.VSCODE_DEFAULT_INITIALIZE) + init_params["initializationOptions"] = { + 'textLSP': { + 'analysers': { + 'languagetool': { + 'enabled': True, + 'check_text': { + 'on_open': True, + 'on_save': True, + 'on_change': True, + } + } + } + } + } + + with session.LspSession() as lsp_session: + lsp_session.initialize(init_params) + + yield lsp_session + + +@pytest.fixture +def langtool_ls_onsave(): + init_params = copy.deepcopy(defaults.VSCODE_DEFAULT_INITIALIZE) + init_params["initializationOptions"] = { + 'textLSP': { + 'analysers': { + 'languagetool': { + 'enabled': True, + 'check_text': { + 'on_open': True, + 'on_save': True, + 'on_change': False, + } + } + } + } + } + + with session.LspSession() as lsp_session: + lsp_session.initialize(init_params) + + yield lsp_session diff --git a/tests/lsp_test_client/__init__.py b/tests/lsp_test_client/__init__.py new file mode 100644 index 0000000..3f3eae1 --- /dev/null +++ b/tests/lsp_test_client/__init__.py @@ -0,0 +1,10 @@ +# Taken from: https://github.com/pappasam/jedi-language-server +"""Test client main module.""" + +import py + +from .utils import as_uri + +TEST_ROOT = py.path.local(__file__) / ".." +PROJECT_ROOT = TEST_ROOT / ".." / ".." +PROJECT_URI = as_uri(PROJECT_ROOT) diff --git a/tests/lsp_test_client/defaults.py b/tests/lsp_test_client/defaults.py new file mode 100644 index 0000000..529e52a --- /dev/null +++ b/tests/lsp_test_client/defaults.py @@ -0,0 +1,214 @@ +"""Default values for lsp test client.""" +import os + +import tests.lsp_test_client as lsp_client + +VSCODE_DEFAULT_INITIALIZE = { + "processId": os.getpid(), # pylint: disable=no-member + "clientInfo": {"name": "vscode", "version": "1.45.0"}, + "rootPath": str(lsp_client.PROJECT_ROOT), + "rootUri": lsp_client.PROJECT_URI, + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + }, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + ] + }, + "tagSupport": {"valueSet": [1]}, + }, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "complexDiagnosticCodeSupport": True, + }, + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": True, + }, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + ] + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + ] + }, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "documentLink": { + "dynamicRegistration": True, + "tooltipSupport": True, + }, + "typeDefinition": { + "dynamicRegistration": True, + "linkSupport": True, + }, + "implementation": { + "dynamicRegistration": True, + "linkSupport": True, + }, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + }, + "window": {"workDoneProgress": True}, + }, + "trace": "verbose", + "workspaceFolders": [{"uri": lsp_client.PROJECT_URI, "name": "textLSP"}], + "initializationOptions": { + }, +} diff --git a/tests/lsp_test_client/lsp_run.py b/tests/lsp_test_client/lsp_run.py new file mode 100644 index 0000000..d3c1b91 --- /dev/null +++ b/tests/lsp_test_client/lsp_run.py @@ -0,0 +1,7 @@ +"""Run Language Server for Test.""" + +import sys + +from textLSP.cli import main + +sys.exit(main()) diff --git a/tests/lsp_test_client/session.py b/tests/lsp_test_client/session.py new file mode 100644 index 0000000..2d09421 --- /dev/null +++ b/tests/lsp_test_client/session.py @@ -0,0 +1,279 @@ +"""Provides LSP session helpers for testing.""" + +import os +import subprocess +import sys +from concurrent.futures import Future, ThreadPoolExecutor +from threading import Event + +from pylsp_jsonrpc.dispatchers import MethodDispatcher +from pylsp_jsonrpc.endpoint import Endpoint +from pylsp_jsonrpc.streams import JsonRpcStreamReader, JsonRpcStreamWriter + +from tests.lsp_test_client import defaults + +LSP_EXIT_TIMEOUT = 5000 + + +PUBLISH_DIAGNOSTICS = "textDocument/publishDiagnostics" +WINDOW_LOG_MESSAGE = "window/logMessage" +WINDOW_SHOW_MESSAGE = "window/showMessage" +WINDOW_WORK_DONE_PROGRESS_CREATE = "window/workDoneProgress/create" + +# pylint: disable=no-member + + +class LspSession(MethodDispatcher): + """Send and Receive messages over LSP as a test LS Client.""" + + def __init__(self, cwd=None): + self.cwd = cwd if cwd else os.getcwd() + # pylint: disable=consider-using-with + self._thread_pool = ThreadPoolExecutor() + self._sub = None + self._writer = None + self._reader = None + self._endpoint = None + self._notification_callbacks = {} + + def __enter__(self): + """Context manager entrypoint. + + shell=True needed for pytest-cov to work in subprocess. + """ + # pylint: disable=consider-using-with + self._sub = subprocess.Popen( + [ + sys.executable, + os.path.join(os.path.dirname(__file__), "lsp_run.py"), + ], + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + bufsize=0, + cwd=self.cwd, + env=os.environ, + shell="WITH_COVERAGE" in os.environ, + ) + + self._writer = JsonRpcStreamWriter( + os.fdopen(self._sub.stdin.fileno(), "wb") + ) + self._reader = JsonRpcStreamReader( + os.fdopen(self._sub.stdout.fileno(), "rb") + ) + + dispatcher = { + PUBLISH_DIAGNOSTICS: self._publish_diagnostics, + WINDOW_SHOW_MESSAGE: self._window_show_message, + WINDOW_LOG_MESSAGE: self._window_log_message, + WINDOW_WORK_DONE_PROGRESS_CREATE: self._window_work_done_progress_create, + } + self._endpoint = Endpoint(dispatcher, self._writer.write) + self._thread_pool.submit(self._reader.listen, self._endpoint.consume) + return self + + def __exit__(self, typ, value, _tb): + self.shutdown(True) + try: + self._sub.terminate() + except Exception: # pylint:disable=broad-except + pass + self._endpoint.shutdown() + self._thread_pool.shutdown() + + def initialize( + self, + initialize_params=None, + process_server_capabilities=None, + ): + """Sends the initialize request to LSP server.""" + server_initialized = Event() + + def _after_initialize(fut): + if process_server_capabilities: + process_server_capabilities(fut.result()) + self.initialized() + server_initialized.set() + + self._send_request( + "initialize", + params=( + initialize_params + if initialize_params is not None + else defaults.VSCODE_DEFAULT_INITIALIZE + ), + handle_response=_after_initialize, + ) + + server_initialized.wait() + + def initialized(self, initialized_params=None): + """Sends the initialized notification to LSP server.""" + if initialized_params is None: + initialized_params = {} + self._endpoint.notify("initialized", initialized_params) + + def shutdown(self, should_exit, exit_timeout=LSP_EXIT_TIMEOUT): + """Sends the shutdown request to LSP server.""" + + def _after_shutdown(_): + if should_exit: + self.exit_lsp(exit_timeout) + + self._send_request("shutdown", handle_response=_after_shutdown) + + def exit_lsp(self, exit_timeout=LSP_EXIT_TIMEOUT): + """Handles LSP server process exit.""" + self._endpoint.notify("exit") + assert self._sub.wait(exit_timeout) == 0 + + def text_document_completion(self, completion_params): + """Sends text document completion request to LSP server.""" + fut = self._send_request( + "textDocument/completion", params=completion_params + ) + return fut.result() + + def text_document_rename(self, rename_params): + """Sends text document rename request to LSP server.""" + fut = self._send_request("textDocument/rename", params=rename_params) + return fut.result() + + def text_document_code_action(self, code_action_params): + """Sends text document code action request to LSP server.""" + fut = self._send_request( + "textDocument/codeAction", params=code_action_params + ) + return fut.result() + + def text_document_hover(self, hover_params): + """Sends text document hover request to LSP server.""" + fut = self._send_request("textDocument/hover", params=hover_params) + return fut.result() + + def text_document_signature_help(self, signature_help_params): + """Sends text document hover request to LSP server.""" + fut = self._send_request( + "textDocument/signatureHelp", params=signature_help_params + ) + return fut.result() + + def text_document_definition(self, definition_params): + """Sends text document defintion request to LSP server.""" + fut = self._send_request( + "textDocument/definition", params=definition_params + ) + return fut.result() + + def text_document_symbol(self, document_symbol_params): + """Sends text document symbol request to LSP server.""" + fut = self._send_request( + "textDocument/documentSymbol", params=document_symbol_params + ) + return fut.result() + + def text_document_highlight(self, document_highlight_params): + """Sends text document highlight request to LSP server.""" + fut = self._send_request( + "textDocument/documentHighlight", params=document_highlight_params + ) + return fut.result() + + def text_document_references(self, references_params): + """Sends text document references request to LSP server.""" + fut = self._send_request( + "textDocument/references", params=references_params + ) + return fut.result() + + def workspace_symbol(self, workspace_symbol_params): + """Sends workspace symbol request to LSP server.""" + fut = self._send_request( + "workspace/symbol", params=workspace_symbol_params + ) + return fut.result() + + def completion_item_resolve(self, resolve_params): + """Sends completion item resolve request to LSP server.""" + fut = self._send_request( + "completionItem/resolve", params=resolve_params + ) + return fut.result() + + def notify_did_change(self, did_change_params): + """Sends did change notification to LSP Server.""" + self._send_notification( + "textDocument/didChange", params=did_change_params + ) + + def notify_did_save(self, did_save_params): + """Sends did save notification to LSP Server.""" + self._send_notification("textDocument/didSave", params=did_save_params) + + def notify_did_open(self, did_open_params): + """Sends did open notification to LSP Server.""" + self._send_notification("textDocument/didOpen", params=did_open_params) + + def set_notification_callback(self, notification_name, callback): + """Set custom LS notification handler.""" + self._notification_callbacks[notification_name] = callback + + def get_notification_callback(self, notification_name): + """Gets callback if set or default callback for a given LS + notification.""" + try: + return self._notification_callbacks[notification_name] + except KeyError: + + def _default_handler(_params): + """Default notification handler.""" + + return _default_handler + + def _publish_diagnostics(self, publish_diagnostics_params): + """Internal handler for text document publish diagnostics.""" + return self._handle_notification( + PUBLISH_DIAGNOSTICS, publish_diagnostics_params + ) + + def _window_log_message(self, window_log_message_params): + """Internal handler for window log message.""" + return self._handle_notification( + WINDOW_LOG_MESSAGE, window_log_message_params + ) + + def _window_show_message(self, window_show_message_params): + """Internal handler for window show message.""" + return self._handle_notification( + WINDOW_SHOW_MESSAGE, window_show_message_params + ) + + def _window_work_done_progress_create(self, window_progress_params): + """Internal handler for window/workDoneProgress/create""" + return self._handle_notification( + WINDOW_WORK_DONE_PROGRESS_CREATE, window_progress_params + ) + + def _handle_notification(self, notification_name, params): + """Internal handler for notifications.""" + fut = Future() + + def _handler(): + callback = self.get_notification_callback(notification_name) + callback(params) + fut.set_result(None) + + self._thread_pool.submit(_handler) + return fut + + def _send_request( + self, name, params=None, handle_response=lambda f: f.done() + ): + """Sends {name} request to the LSP server.""" + fut = self._endpoint.request(name, params) + fut.add_done_callback(handle_response) + return fut + + def _send_notification(self, name, params=None): + """Sends {name} notification to the LSP server.""" + self._endpoint.notify(name, params) diff --git a/tests/lsp_test_client/utils.py b/tests/lsp_test_client/utils.py new file mode 100644 index 0000000..a32c8a8 --- /dev/null +++ b/tests/lsp_test_client/utils.py @@ -0,0 +1,31 @@ +"""Provides LSP client side utilities for easier testing.""" + +import pathlib +import platform +import functools + +import py + +# pylint: disable=no-member + + +def normalizecase(path: str) -> str: + """Fixes 'file' uri or path case for easier testing in windows.""" + if platform.system() == "Windows": + return path.lower() + return path + + +def as_uri(path: py.path.local) -> str: + """Return 'file' uri as string.""" + return normalizecase(pathlib.Path(path).as_uri()) + + +def handle_notification(params, event, results=None): + if results is not None: + results.append(params) + event.set() + + +def get_notification_handler(*args, **kwargs): + return functools.partial(handle_notification, *args, **kwargs) diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index 1a6b0f3..343abfc 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -187,7 +187,6 @@ def _update_code_actions(self, doc: BaseDocument): doc, ) - def did_change(self, params: DidChangeTextDocumentParams): # TODO handle shifts within lines line_shifts = self._get_line_shifts(params) From 141ff2878d47e67093f0f40809112ac8a51ea50f Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@inf.u-szeged.hu> Date: Sun, 28 May 2023 16:36:24 +0200 Subject: [PATCH 02/28] better shutdown handling --- textLSP/analysers/handler.py | 4 ++++ textLSP/analysers/languagetool/languagetool.py | 3 +++ textLSP/server.py | 12 ++++++++++++ 3 files changed, 19 insertions(+) diff --git a/textLSP/analysers/handler.py b/textLSP/analysers/handler.py index ea37167..bce1569 100644 --- a/textLSP/analysers/handler.py +++ b/textLSP/analysers/handler.py @@ -72,6 +72,10 @@ def update_settings(self, settings): if name not in self.analysers: analyser.close() + def shutdown(self): + for analyser in self.analysers.values(): + analyser.close() + def get_diagnostics(self, doc: Document): return [analyser.get_diagnostics(doc) for analyser in self.analysers.values()] diff --git a/textLSP/analysers/languagetool/languagetool.py b/textLSP/analysers/languagetool/languagetool.py index 7188d07..92e1fce 100644 --- a/textLSP/analysers/languagetool/languagetool.py +++ b/textLSP/analysers/languagetool/languagetool.py @@ -171,6 +171,9 @@ def close(self): tool.close() self.tool = dict() + def __del__(self): + self.close() + def _get_mapped_language(self, language): return LANGUAGE_MAP[language] diff --git a/textLSP/server.py b/textLSP/server.py index 520ee89..24e6fa4 100644 --- a/textLSP/server.py +++ b/textLSP/server.py @@ -13,6 +13,7 @@ WORKSPACE_DID_CHANGE_CONFIGURATION, INITIALIZE, TEXT_DOCUMENT_COMPLETION, + SHUTDOWN, ) from lsprotocol.types import ( DidOpenTextDocumentParams, @@ -29,6 +30,7 @@ CompletionList, CompletionOptions, CompletionParams, + ShutdownRequest, ) from .workspace import TextLSPWorkspace from .utils import merge_dicts, get_textlsp_version @@ -120,6 +122,11 @@ def publish_stored_diagnostics(self, doc: Document): diagnostics.extend(lst) self.publish_diagnostics(doc.uri, diagnostics) + def shutdown(self): + logger.warning('TextLSP shutting down!') + self.analyser_handler.shutdown() + super().shutdown() + SERVER = TextLSPLanguageServer( name='textLSP', @@ -148,6 +155,11 @@ async def did_close(ls: TextLSPLanguageServer, params: DidCloseTextDocumentParam await ls.analyser_handler.did_close(params) +@SERVER.feature(SHUTDOWN) +def shutdown(ls: TextLSPLanguageServer, params: ShutdownRequest): + ls.shutdown() + + @SERVER.feature(WORKSPACE_DID_CHANGE_CONFIGURATION) def did_change_configuration(ls: TextLSPLanguageServer, params: DidChangeConfigurationParams): ls.update_settings(params.settings) From 888d1d62b194125fc95f7278c35a5e6c0ac2b8f2 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@inf.u-szeged.hu> Date: Sun, 28 May 2023 08:41:16 +0200 Subject: [PATCH 03/28] initial steps to rework diagnostics and code action handling --- textLSP/analysers/analyser.py | 54 ++++++++++++++++------------- textLSP/types.py | 65 +++++++++++++++++++++++++++++++++++ textLSP/utils.py | 5 +++ 3 files changed, 100 insertions(+), 24 deletions(-) diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index 343abfc..6a0c93d 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -21,14 +21,13 @@ TextEdit, Command, VersionedTextDocumentIdentifier, - MessageType, CompletionParams, CompletionList, ) from ..documents.document import BaseDocument, ChangeTracker from ..utils import merge_dicts -from ..types import Interval, TextLSPCodeActionKind, ProgressBar +from ..types import Interval, TextLSPCodeActionKind, ProgressBar, PositionDict class Analyser(): @@ -76,6 +75,9 @@ def _did_change(self, doc: Document, changes: List[Interval]): raise NotImplementedError() def _get_line_shifts(self, params: DidChangeTextDocumentParams) -> List: + """ + return: List of tuples (line, shift) should be sorted + """ res = list() for change in params.content_changes: if type(change) == TextDocumentContentChangeEvent_Type2: @@ -88,10 +90,13 @@ def _get_line_shifts(self, params: DidChangeTextDocumentParams) -> List: return res - def _handle_line_shifts(self, doc: BaseDocument, line_shifts: List): + def _handle_line_shifts(self, params: DidChangeTextDocumentParams): """ - params: line_shifts: List of tuples (line, shift) should be sorted + Handlines line shifts and position shifts within lines """ + should_update_diagnostics = False + doc = self.get_document(params) + line_shifts = self._get_line_shifts(params) if len(line_shifts) == 0: return @@ -106,8 +111,8 @@ def _handle_line_shifts(self, doc: BaseDocument, line_shifts: List): # TODO extract to function # diagnostics - diagnostics = list() - for diag in self._diagnostics_dict[doc.uri]: + # diagnostics = list() + for diag in list(self._diagnostics_dict[doc.uri]): range = diag.range idx = bisect.bisect_left(bisect_lst, range.start.line) idx = min(idx, num_shifts-1) @@ -126,8 +131,14 @@ def _handle_line_shifts(self, doc: BaseDocument, line_shifts: List): character=range.end.character ) ) - diagnostics.append(diag) - self._diagnostics_dict[doc.uri] = diagnostics + self._diagnostics_dict[doc.uri].update( + range.start, + diag.range.start, + diag + ) + should_update_diagnostics = True + # diagnostics.append(diag) + # self._diagnostics_dict[doc.uri] = diagnostics # code actions code_actions = list() @@ -153,14 +164,12 @@ def _handle_line_shifts(self, doc: BaseDocument, line_shifts: List): code_actions.append(action) self._code_actions_dict[doc.uri] = code_actions + return should_update_diagnostics + def _remove_overflown_code_items(self, doc: BaseDocument): last_position = doc.last_position(True) - self._diagnostics_dict[doc.uri] = [ - diag - for diag in self._diagnostics_dict[doc.uri] - if diag.range.start <= last_position - ] + self._diagnostics_dict[doc.uri].remove_from(last_position, False) self._code_actions_dict[doc.uri] = [ action @@ -189,9 +198,8 @@ def _update_code_actions(self, doc: BaseDocument): def did_change(self, params: DidChangeTextDocumentParams): # TODO handle shifts within lines - line_shifts = self._get_line_shifts(params) doc = self.get_document(params) - self._handle_line_shifts(doc, line_shifts) + should_update_diagnostics = self._handle_line_shifts(params) self._remove_overflown_code_items(doc) self._update_code_actions(doc) @@ -209,7 +217,7 @@ def did_change(self, params: DidChangeTextDocumentParams): ): self._did_change(doc, changes) self._content_change_dict[doc.uri] = ChangeTracker(doc, True) - elif len(line_shifts) > 0: + elif should_update_diagnostics: self.language_server.publish_stored_diagnostics(doc) def update_document(self, doc: Document, change: TextDocumentContentChangeEvent): @@ -272,21 +280,19 @@ def should_run_on(self, event: str) -> bool: ) def init_diagnostics(self, doc: Document): - self._diagnostics_dict[doc.uri] = list() + self._diagnostics_dict[doc.uri] = PositionDict() def get_diagnostics(self, doc: Document): - return self._diagnostics_dict.get(doc.uri, list()) + return self._diagnostics_dict.get(doc.uri, PositionDict()) def add_diagnostics(self, doc: Document, diagnostics: List[Diagnostic]): - self._diagnostics_dict[doc.uri] += diagnostics + for diag in diagnostics: + self._diagnostics_dict[doc.uri].add(diag.range.start, diag) self.language_server.publish_stored_diagnostics(doc) def remove_code_items_at_rage(self, doc: Document, pos_range: Range): - diagnostics = list() - for diag in self.get_diagnostics(doc): - if diag.range.end < pos_range.start or diag.range.start > pos_range.end: - diagnostics.append(diag) - self._diagnostics_dict[doc.uri] = diagnostics + # FIXME: some items are disappearin on save + self._diagnostics_dict[doc.uri].remove_between(pos_range) code_actions = list() for action in self._code_actions_dict[doc.uri]: diff --git a/textLSP/types.py b/textLSP/types.py index fa99ea2..91efd4f 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -6,6 +6,7 @@ from typing import Optional, Any, List from dataclasses import dataclass +from sortedcontainers import SortedDict from lsprotocol.types import ( Position, @@ -16,6 +17,8 @@ WorkDoneProgressEnd, ) +from .utils import position_to_tuple + TEXT_PASSAGE_PATTERN = re.compile('[.?!] |\\n') LINE_PATTERN = re.compile('\\n') @@ -211,6 +214,68 @@ def get_interval_at_position(self, position: Position, strict=True) -> OffsetPos return self.get_interval(idx) +class PositionDict(): + + def __init__(self): + self._positions = SortedDict() + + def add(self, position: Position, item): + position = position_to_tuple(position) + self._positions[position] = item + + def get(self, position: Position): + position = position_to_tuple(position) + return self._positions[position] + + def update(self, old_position: Position, new_position: Position = None, + new_value=None): + assert new_position is not None or new_value is not None, 'Either' + ' new_position or new_value should be specified.' + + old_position = position_to_tuple(old_position) + new_position = position_to_tuple(new_position) + if new_position is None: + self._positions[old_position] = new_value + return + + if new_value is None: + new_value = self._positions.popitem(old_position) + else: + del self._positions[old_position] + + self._positions[new_position] = new_value + + def remove(self, position: Position): + position = position_to_tuple(position) + del self._positions[position] + + def remove_from(self, position: Position, inclusive=True): + position = position_to_tuple(position) + for key in list(self._positions.irange( + minimum=position, + inclusive=(inclusive, False) + )): + del self._positions[key] + + def remove_between(self, range: Range, inclusive=(True, True)): + minimum = position_to_tuple(range.start) + maximum = position_to_tuple(range.end) + for key in list(self._positions.irange( + minimum=minimum, + maximum=maximum, + inclusive=inclusive, + )): + del self._positions[key] + + def irange(self, minimum: Position, maximum: Position, *args, **kwargs): + minimum = position_to_tuple(minimum) + maximum = position_to_tuple(maximum) + return self._positions.irange(*args, **kwargs) + + def __iter__(self): + return iter(self._positions.values()) + + @enum.unique class TextLSPCodeActionKind(str, enum.Enum): AcceptSuggestion = CodeActionKind.QuickFix + '.accept_suggestion' diff --git a/textLSP/utils.py b/textLSP/utils.py index bca0186..65598a3 100644 --- a/textLSP/utils.py +++ b/textLSP/utils.py @@ -8,6 +8,7 @@ from threading import RLock from git import Repo from appdirs import user_cache_dir +from lsprotocol.types import Position def merge_dicts(dict1, dict2): @@ -99,3 +100,7 @@ def batch_text(text: str, pattern: re.Pattern, max_size: int, min_size: int = 0) if sidx <= text_len: yield text[sidx:text_len] + + +def position_to_tuple(position: Position): + return (position.line, position.character) From 30cb86ee424ea1e8bf6897d95c8e2df28177fa27 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@inf.u-szeged.hu> Date: Mon, 29 May 2023 07:51:49 +0200 Subject: [PATCH 04/28] fixing a bug related to updating positions in PositionDict --- tests/analysers/analyser_test.py | 110 ++++++++++++++++++++++++++++--- textLSP/types.py | 2 +- 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py index 2aebb0a..a0e8da7 100644 --- a/tests/analysers/analyser_test.py +++ b/tests/analysers/analyser_test.py @@ -9,6 +9,8 @@ TextDocumentContentChangeEvent_Type1, Range, Position, + DidSaveTextDocumentParams, + TextDocumentIdentifier, ) from tests.lsp_test_client import session, utils @@ -25,6 +27,7 @@ end=Position(line=2, character=0), ), '\n', + False ), Range( start=Position(line=1, character=10), @@ -41,30 +44,110 @@ end=Position(line=0, character=0), ), '\n\n\n', + True ), Range( start=Position(line=4, character=10), end=Position(line=4, character=18), ), ), + # ( + # 'This is a sentence.\n' + # 'This is a sAntence with an error.\n' + # 'This is another sentence.', + # ( + # Range( + # start=Position(line=1, character=23), + # end=Position(line=1, character=23), + # ), + # '\n', + # False + # ), + # Range( + # start=Position(line=1, character=10), + # end=Position(line=1, character=18), + # ), + # ), +]) +def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): + done = Event() + results = list() + + langtool_ls_onsave.set_notification_callback( + session.PUBLISH_DIAGNOSTICS, + utils.get_notification_handler( + event=done, + results=results + ), + ) + + open_params = DidOpenTextDocumentParams( + TextDocumentItem( + uri='dummy.txt', + language_id='txt', + version=1, + text=text, + ) + ) + + langtool_ls_onsave.notify_did_open( + json_converter.unstructure(open_params) + ) + done.wait() + done.clear() + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=1, + uri='dummy.txt', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + edit[0], + edit[1], + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + + ret = done.wait(1) + done.clear() + + # no diagnostics notification of none has changed + assert ret == edit[2] + if edit[2]: + assert len(results) == 2 + else: + assert len(results) == 1 + + res = results[-1]['diagnostics'][0]['range'] + assert res == json_converter.unstructure(exp) + + +@pytest.mark.parametrize('text,edit,exp', [ ( + 'Introduction\n' + '\n' 'This is a sentence.\n' - 'This is a sAntence with an error.\n' - 'This is another sentence.', + 'This is another.\n' + '\n' + 'Thes is bold.', ( Range( - start=Position(line=1, character=23), - end=Position(line=1, character=23), + start=Position(line=1, character=0), + end=Position(line=1, character=0), ), - '\n', + '\n\n', ), Range( - start=Position(line=1, character=10), - end=Position(line=1, character=18), + start=Position(line=7, character=0), + end=Position(line=7, character=7), ), ), ]) -def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): +def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): done = Event() results = list() @@ -106,9 +189,20 @@ def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_on langtool_ls_onsave.notify_did_change( json_converter.unstructure(change_params) ) + done.wait() + done.clear() + save_params = DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier( + 'dummy.txt' + ) + ) + langtool_ls_onsave.notify_did_save( + json_converter.unstructure(save_params) + ) done.wait() done.clear() + print(results) res = results[-1]['diagnostics'][0]['range'] assert res == json_converter.unstructure(exp) diff --git a/textLSP/types.py b/textLSP/types.py index 91efd4f..bdc080a 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -236,7 +236,7 @@ def update(self, old_position: Position, new_position: Position = None, new_position = position_to_tuple(new_position) if new_position is None: self._positions[old_position] = new_value - return + return if new_value is None: new_value = self._positions.popitem(old_position) From 87feb9a6f7c13a900769ba17b9d2bdab669e688a Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@inf.u-szeged.hu> Date: Mon, 29 May 2023 10:15:57 +0200 Subject: [PATCH 05/28] updating code_action handling --- tests/analysers/analyser_test.py | 78 +++++++++++++++++++++++++++----- textLSP/analysers/analyser.py | 54 ++++++++++------------ textLSP/types.py | 22 +++++++-- 3 files changed, 109 insertions(+), 45 deletions(-) diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py index a0e8da7..8d0ae3c 100644 --- a/tests/analysers/analyser_test.py +++ b/tests/analysers/analyser_test.py @@ -11,6 +11,9 @@ Position, DidSaveTextDocumentParams, TextDocumentIdentifier, + CodeActionParams, + CodeActionContext, + Diagnostic, ) from tests.lsp_test_client import session, utils @@ -68,16 +71,50 @@ # end=Position(line=1, character=18), # ), # ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'This is another sentence.', + ( + Range( + start=Position(line=1, character=33), + end=Position(line=1, character=33), + ), + ' too', + False + ), + Range( + start=Position(line=1, character=10), + end=Position(line=1, character=18), + ), + ), + # ( + # 'This is a sentence.\n' + # 'This is a sAntence with an error.\n' + # 'This is another sentence.', + # ( + # Range( + # start=Position(line=1, character=4), + # end=Position(line=1, character=4), + # ), + # ' word', + # False + # ), + # Range( + # start=Position(line=1, character=15), + # end=Position(line=1, character=23), + # ), + # ), ]) -def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): +def test_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): done = Event() - results = list() + diag_lst = list() langtool_ls_onsave.set_notification_callback( session.PUBLISH_DIAGNOSTICS, utils.get_notification_handler( event=done, - results=results + results=diag_lst ), ) @@ -93,7 +130,7 @@ def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_on langtool_ls_onsave.notify_did_open( json_converter.unstructure(open_params) ) - done.wait() + assert done.wait(10) done.clear() change_params = DidChangeTextDocumentParams( @@ -111,18 +148,37 @@ def test_diagnostics_line_shifts(text, edit, exp, json_converter, langtool_ls_on langtool_ls_onsave.notify_did_change( json_converter.unstructure(change_params) ) - ret = done.wait(1) done.clear() # no diagnostics notification of none has changed assert ret == edit[2] if edit[2]: - assert len(results) == 2 + assert len(diag_lst) == 2 else: - assert len(results) == 1 + assert len(diag_lst) == 1 - res = results[-1]['diagnostics'][0]['range'] + res = diag_lst[-1]['diagnostics'][0]['range'] + assert res == json_converter.unstructure(exp) + + diag = diag_lst[-1]['diagnostics'][0] + diag = Diagnostic( + range=Range( + start=Position(**res['start']), + end=Position(**res['end']), + ), + message=diag['message'], + ) + code_action_params = CodeActionParams( + TextDocumentIdentifier('dummy.txt'), + exp, + CodeActionContext([diag]), + ) + actions_lst = langtool_ls_onsave.text_document_code_action( + json_converter.unstructure(code_action_params) + ) + assert len(actions_lst) == 1 + res = actions_lst[-1]['diagnostics'][0]['range'] assert res == json_converter.unstructure(exp) @@ -171,7 +227,7 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_open( json_converter.unstructure(open_params) ) - done.wait() + assert done.wait(10) done.clear() change_params = DidChangeTextDocumentParams( @@ -189,7 +245,7 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_change( json_converter.unstructure(change_params) ) - done.wait() + assert done.wait(10) done.clear() save_params = DidSaveTextDocumentParams( @@ -200,7 +256,7 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_save( json_converter.unstructure(save_params) ) - done.wait() + assert done.wait(10) done.clear() print(results) diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index 6a0c93d..718d66f 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -27,7 +27,12 @@ from ..documents.document import BaseDocument, ChangeTracker from ..utils import merge_dicts -from ..types import Interval, TextLSPCodeActionKind, ProgressBar, PositionDict +from ..types import ( + Interval, + TextLSPCodeActionKind, + ProgressBar, + PositionDict, +) class Analyser(): @@ -94,6 +99,7 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): """ Handlines line shifts and position shifts within lines """ + # TODO handle shifts within lines should_update_diagnostics = False doc = self.get_document(params) line_shifts = self._get_line_shifts(params) @@ -110,8 +116,6 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): num_shifts = len(accumulative_shifts) # TODO extract to function - # diagnostics - # diagnostics = list() for diag in list(self._diagnostics_dict[doc.uri]): range = diag.range idx = bisect.bisect_left(bisect_lst, range.start.line) @@ -137,12 +141,9 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): diag ) should_update_diagnostics = True - # diagnostics.append(diag) - # self._diagnostics_dict[doc.uri] = diagnostics # code actions - code_actions = list() - for action in self._code_actions_dict[doc.uri]: + for action in list(self._code_actions_dict[doc.uri]): range = action.edit.document_changes[0].edits[0].range idx = bisect.bisect_left(bisect_lst, range.start.line) idx = min(idx, num_shifts-1) @@ -161,8 +162,11 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): character=range.end.character ) ) - code_actions.append(action) - self._code_actions_dict[doc.uri] = code_actions + self._code_actions_dict[doc.uri].update( + range.start, + action.edit.document_changes[0].edits[0].range.start, + action + ) return should_update_diagnostics @@ -170,12 +174,7 @@ def _remove_overflown_code_items(self, doc: BaseDocument): last_position = doc.last_position(True) self._diagnostics_dict[doc.uri].remove_from(last_position, False) - - self._code_actions_dict[doc.uri] = [ - action - for action in self._code_actions_dict[doc.uri] - if action.edit.document_changes[0].edits[0].range.start <= last_position - ] + self._code_actions_dict[doc.uri].remove_from(last_position, False) def _update_single_code_action(self, action: CodeAction, doc: BaseDocument): # update document version @@ -197,7 +196,6 @@ def _update_code_actions(self, doc: BaseDocument): ) def did_change(self, params: DidChangeTextDocumentParams): - # TODO handle shifts within lines doc = self.get_document(params) should_update_diagnostics = self._handle_line_shifts(params) self._remove_overflown_code_items(doc) @@ -291,18 +289,11 @@ def add_diagnostics(self, doc: Document, diagnostics: List[Diagnostic]): self.language_server.publish_stored_diagnostics(doc) def remove_code_items_at_rage(self, doc: Document, pos_range: Range): - # FIXME: some items are disappearin on save self._diagnostics_dict[doc.uri].remove_between(pos_range) - - code_actions = list() - for action in self._code_actions_dict[doc.uri]: - range = action.edit.document_changes[0].edits[0].range - if range.end < pos_range.start or range.start > pos_range.end: - code_actions.append(action) - self._code_actions_dict[doc.uri] = code_actions + self._code_actions_dict[doc.uri].remove_between(pos_range) def init_code_actions(self, doc: Document): - self._code_actions_dict[doc.uri] = list() + self._code_actions_dict[doc.uri] = PositionDict() def get_code_actions(self, params: CodeActionParams) -> Optional[List[CodeAction]]: doc = self.get_document(params) @@ -311,11 +302,12 @@ def get_code_actions(self, params: CodeActionParams) -> Optional[List[CodeAction # TODO make this faster? res = [ action - for action in self._code_actions_dict[doc.uri] + for action in self._code_actions_dict[doc.uri].irange_values(maximum=range.start) if ( ( - action.edit.document_changes[0].edits[0].range.start <= range.start - and action.edit.document_changes[0].edits[0].range.end >= range.end + # action.edit.document_changes[0].edits[0].range.start <= range.start + # and + action.edit.document_changes[0].edits[0].range.end >= range.end ) # if it's not reachable by the cursor or ( @@ -384,7 +376,11 @@ def get_code_actions(self, params: CodeActionParams) -> Optional[List[CodeAction return res def add_code_actions(self, doc: Document, actions: List[CodeAction]): - self._code_actions_dict[doc.uri] += actions + for action in actions: + self._code_actions_dict[doc.uri].add( + action.edit.document_changes[0].edits[0].range.start, + action, + ) @staticmethod def build_single_suggestion_action( diff --git a/textLSP/types.py b/textLSP/types.py index bdc080a..fac7696 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -227,10 +227,14 @@ def get(self, position: Position): position = position_to_tuple(position) return self._positions[position] + def pop(self, position: Position): + position = position_to_tuple(position) + return self._positions.popitem(position) + def update(self, old_position: Position, new_position: Position = None, new_value=None): - assert new_position is not None or new_value is not None, 'Either' - ' new_position or new_value should be specified.' + assert new_position is not None or new_value is not None, ' new_position' + ' or new_value should be specified.' old_position = position_to_tuple(old_position) new_position = position_to_tuple(new_position) @@ -267,11 +271,19 @@ def remove_between(self, range: Range, inclusive=(True, True)): )): del self._positions[key] - def irange(self, minimum: Position, maximum: Position, *args, **kwargs): - minimum = position_to_tuple(minimum) - maximum = position_to_tuple(maximum) + def irange(self, minimum: Position = None, maximum: Position = None, *args, + **kwargs): + if minimum is not None: + minimum = position_to_tuple(minimum) + if maximum is not None: + maximum = position_to_tuple(maximum) + return self._positions.irange(*args, **kwargs) + def irange_values(self, *args, **kwargs): + for key in self.irange(*args, **kwargs): + yield self._positions[key] + def __iter__(self): return iter(self._positions.values()) From 4d2fb6cdd4a932532349d085c85c634b8a20289d Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@inf.u-szeged.hu> Date: Mon, 29 May 2023 12:27:15 +0200 Subject: [PATCH 06/28] updating diganostics and code_action shift handling --- tests/analysers/analyser_test.py | 108 +++++++++++------ textLSP/analysers/analyser.py | 194 +++++++++++++++++++++---------- textLSP/types.py | 2 +- 3 files changed, 207 insertions(+), 97 deletions(-) diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py index 8d0ae3c..464aca7 100644 --- a/tests/analysers/analyser_test.py +++ b/tests/analysers/analyser_test.py @@ -23,7 +23,7 @@ ( 'This is a sentence.\n' 'This is a sAntence with an error.\n' - 'This is another sentence.', + 'And another sentence.', ( Range( start=Position(line=2, character=0), @@ -40,7 +40,7 @@ ( 'This is a sentence.\n' 'This is a sAntence with an error.\n' - 'This is another sentence.', + 'And another sentence.', ( Range( start=Position(line=0, character=0), @@ -54,27 +54,44 @@ end=Position(line=4, character=18), ), ), - # ( - # 'This is a sentence.\n' - # 'This is a sAntence with an error.\n' - # 'This is another sentence.', - # ( - # Range( - # start=Position(line=1, character=23), - # end=Position(line=1, character=23), - # ), - # '\n', - # False - # ), - # Range( - # start=Position(line=1, character=10), - # end=Position(line=1, character=18), - # ), - # ), ( 'This is a sentence.\n' 'This is a sAntence with an error.\n' - 'This is another sentence.', + 'And another sentence.', + ( + Range( + start=Position(line=0, character=0), + end=Position(line=1, character=0), + ), + '', + True + ), + Range( + start=Position(line=0, character=10), + end=Position(line=0, character=18), + ), + ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'And another sentence.', + ( + Range( + start=Position(line=1, character=23), + end=Position(line=1, character=23), + ), + '\n', + False + ), + Range( + start=Position(line=1, character=10), + end=Position(line=1, character=18), + ), + ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'And another sentence.', ( Range( start=Position(line=1, character=33), @@ -88,23 +105,40 @@ end=Position(line=1, character=18), ), ), - # ( - # 'This is a sentence.\n' - # 'This is a sAntence with an error.\n' - # 'This is another sentence.', - # ( - # Range( - # start=Position(line=1, character=4), - # end=Position(line=1, character=4), - # ), - # ' word', - # False - # ), - # Range( - # start=Position(line=1, character=15), - # end=Position(line=1, character=23), - # ), - # ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'And another sentence.', + ( + Range( + start=Position(line=1, character=4), + end=Position(line=1, character=4), + ), + ' word', + True + ), + Range( + start=Position(line=1, character=15), + end=Position(line=1, character=23), + ), + ), + ( + 'This is a sentence.\n' + 'This is a sAntence with an error.\n' + 'And another sentence.', + ( + Range( + start=Position(line=1, character=4), + end=Position(line=1, character=4), + ), + '\n', + True + ), + Range( + start=Position(line=2, character=5), + end=Position(line=2, character=13), + ), + ), ]) def test_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): done = Event() diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index 718d66f..04948f5 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -1,4 +1,5 @@ import bisect +import copy from typing import List, Optional from pygls.server import LanguageServer @@ -79,91 +80,157 @@ def did_open(self, params: DidOpenTextDocumentParams): def _did_change(self, doc: Document, changes: List[Interval]): raise NotImplementedError() - def _get_line_shifts(self, params: DidChangeTextDocumentParams) -> List: - """ - return: List of tuples (line, shift) should be sorted - """ - res = list() + def _handle_line_shifts(self, params: DidChangeTextDocumentParams): + # FIXME: this method is very complex, try to make it easier to read + should_update_diagnostics = False + doc = self.get_document(params) + + val = 0 + accumulative_shifts = list() + # handling inline shifts and building a list of line shifts for later for change in params.content_changes: if type(change) == TextDocumentContentChangeEvent_Type2: continue line_diff = change.range.end.line - change.range.start.line diff = change.text.count('\n') - line_diff - if diff != 0: - res.append((change.range.start.line, diff)) - - return res - - def _handle_line_shifts(self, params: DidChangeTextDocumentParams): - """ - Handlines line shifts and position shifts within lines - """ - # TODO handle shifts within lines - should_update_diagnostics = False - doc = self.get_document(params) - line_shifts = self._get_line_shifts(params) - if len(line_shifts) == 0: - return + if diff == 0: + in_line_diff = change.range.end.character - change.range.start.character + in_line_diff += len(change.text) + if in_line_diff >= 0: + # in only some edit in a given line, let's shift the items + # in the line + next_pos = Position( + line=change.range.start.line+1, + character=0, + ) - val = 0 - bisect_lst = [line_shifts[0][0]] - accumulative_shifts = [(line_shifts[0][0], 0)] - for shift in line_shifts: - val += shift[1] - accumulative_shifts.append((shift[0]+1, val)) - bisect_lst.append(shift[0]+1) - num_shifts = len(accumulative_shifts) - - # TODO extract to function - for diag in list(self._diagnostics_dict[doc.uri]): - range = diag.range - idx = bisect.bisect_left(bisect_lst, range.start.line) - idx = min(idx, num_shifts-1) + for diag in list( + self._diagnostics_dict[doc.uri].irange_values( + minimum=change.range.start, + maximum=next_pos, + inclusive=(True, False) + ) + ): + item_range = diag.range + diag.range = Range( + start=Position( + line=item_range.start.line, + character=item_range.start.character+in_line_diff + ), + end=Position( + line=item_range.end.line, + character=item_range.end.character + + (in_line_diff if item_range.start.line == + item_range.end.line else 0) + ) + ) + self._diagnostics_dict[doc.uri].update( + item_range.start, + diag.range.start, + diag + ) + should_update_diagnostics = True + + for action in list( + self._code_actions_dict[doc.uri].irange_values( + minimum=change.range.start, + maximum=next_pos, + inclusive=(True, False) + ) + ): + item_range = action.edit.document_changes[0].edits[0].range + action.edit.document_changes[0].edits[0].range = Range( + start=Position( + line=item_range.start.line, + character=item_range.start.character+in_line_diff + ), + end=Position( + line=item_range.end.line, + character=item_range.end.character + + (in_line_diff if item_range.start.line == + item_range.end.line else 0) + ) + ) + self._code_actions_dict[doc.uri].update( + item_range.start, + action.edit.document_changes[0].edits[0].range.start, + action + ) + else: + # There is a line shift: diff > 0 + val += diff + accumulative_shifts.append((change.range.start, val, change)) + pos = doc.last_position(True) + pos = Position(line=pos.line+1, character=0) + accumulative_shifts.append((pos, val)) + + if len(accumulative_shifts) == 0: + return should_update_diagnostics + + # handling line shifts ############################################ + for idx in range(len(accumulative_shifts)-1): + pos = accumulative_shifts[idx][0] + next_pos = accumulative_shifts[idx+1][0] shift = accumulative_shifts[idx][1] - if shift != 0: - if range.start.line + shift < 0: - continue + for diag in list( + self._diagnostics_dict[doc.uri].irange_values( + minimum=pos, + maximum=next_pos, + inclusive=(True, False) + ) + ): + item_range = diag.range + char_shift = 0 + if item_range.start.line == pos.line: + char_shift = item_range.start.character - \ + (pos.character + len(accumulative_shifts[idx][2].text)) diag.range = Range( start=Position( - line=range.start.line + shift, - character=range.start.character + line=item_range.start.line + shift, + character=item_range.start.character - char_shift ), end=Position( - line=range.end.line + shift, - character=range.end.character + line=item_range.end.line + shift, + character=item_range.end.character - + (char_shift if item_range.start.line == + item_range.end.line else 0) ) ) self._diagnostics_dict[doc.uri].update( - range.start, + item_range.start, diag.range.start, diag ) should_update_diagnostics = True - # code actions - for action in list(self._code_actions_dict[doc.uri]): - range = action.edit.document_changes[0].edits[0].range - idx = bisect.bisect_left(bisect_lst, range.start.line) - idx = min(idx, num_shifts-1) - shift = accumulative_shifts[idx][1] - - if shift != 0: - if range.start.line + shift < 0: - continue + for action in list( + self._code_actions_dict[doc.uri].irange_values( + minimum=pos, + maximum=next_pos, + inclusive=(True, False) + ) + ): + item_range = action.edit.document_changes[0].edits[0].range + char_shift = 0 + if item_range.start.line == pos.line: + char_shift = item_range.start.character - \ + (pos.character + len(accumulative_shifts[idx][2].text)) action.edit.document_changes[0].edits[0].range = Range( start=Position( - line=range.start.line + shift, - character=range.start.character + line=item_range.start.line + shift, + character=item_range.start.character - char_shift ), end=Position( - line=range.end.line + shift, - character=range.end.character + line=item_range.end.line + shift, + character=item_range.end.character - + (char_shift if item_range.start.line == + item_range.end.line else 0) ) ) self._code_actions_dict[doc.uri].update( - range.start, + item_range.start, action.edit.document_changes[0].edits[0].range.start, action ) @@ -176,6 +243,16 @@ def _remove_overflown_code_items(self, doc: BaseDocument): self._diagnostics_dict[doc.uri].remove_from(last_position, False) self._code_actions_dict[doc.uri].remove_from(last_position, False) + def _handle_shifts(self, params: DidChangeTextDocumentParams): + """ + Handlines line shifts and position shifts within lines + """ + doc = self.get_document(params) + should_update_diagnostics = self._handle_line_shifts(params) + self._remove_overflown_code_items(doc) + + return should_update_diagnostics + def _update_single_code_action(self, action: CodeAction, doc: BaseDocument): # update document version if action.edit is not None: @@ -197,8 +274,7 @@ def _update_code_actions(self, doc: BaseDocument): def did_change(self, params: DidChangeTextDocumentParams): doc = self.get_document(params) - should_update_diagnostics = self._handle_line_shifts(params) - self._remove_overflown_code_items(doc) + should_update_diagnostics = self._handle_shifts(params) self._update_code_actions(doc) if self.should_run_on(Analyser.CONFIGURATION_CHECK_ON_CHANGE): diff --git a/textLSP/types.py b/textLSP/types.py index fac7696..10db0d7 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -278,7 +278,7 @@ def irange(self, minimum: Position = None, maximum: Position = None, *args, if maximum is not None: maximum = position_to_tuple(maximum) - return self._positions.irange(*args, **kwargs) + return self._positions.irange(minimum, maximum, *args, **kwargs) def irange_values(self, *args, **kwargs): for key in self.irange(*args, **kwargs): From 316bbd598372cbc0a27f2b605599a2e255b99feb Mon Sep 17 00:00:00 2001 From: hangyav <hangyav@gmail.com> Date: Sun, 9 Jul 2023 09:41:30 +0200 Subject: [PATCH 07/28] implementing TreeSitter tree editing for faster text cleaning --- tests/analysers/analyser_test.py | 9 +- tests/documents/latex_test.py | 275 ++++++++++++++++++++- textLSP/analysers/analyser.py | 6 +- textLSP/documents/document.py | 330 ++++++++++++++++++++++++- textLSP/documents/latex/latex.py | 10 +- textLSP/documents/markdown/markdown.py | 10 +- textLSP/documents/org/org.py | 10 +- textLSP/types.py | 2 +- textLSP/utils.py | 23 ++ 9 files changed, 651 insertions(+), 24 deletions(-) diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py index 464aca7..9bb4ee3 100644 --- a/tests/analysers/analyser_test.py +++ b/tests/analysers/analyser_test.py @@ -164,7 +164,7 @@ def test_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_open( json_converter.unstructure(open_params) ) - assert done.wait(10) + assert done.wait(30) done.clear() change_params = DidChangeTextDocumentParams( @@ -261,7 +261,7 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_open( json_converter.unstructure(open_params) ) - assert done.wait(10) + assert done.wait(30) done.clear() change_params = DidChangeTextDocumentParams( @@ -279,7 +279,7 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_change( json_converter.unstructure(change_params) ) - assert done.wait(10) + assert done.wait(30) done.clear() save_params = DidSaveTextDocumentParams( @@ -290,9 +290,8 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_save( json_converter.unstructure(save_params) ) - assert done.wait(10) + assert done.wait(30) done.clear() - print(results) res = results[-1]['diagnostics'][0]['range'] assert res == json_converter.unstructure(exp) diff --git a/tests/documents/latex_test.py b/tests/documents/latex_test.py index bcd80c2..ae7ee0f 100644 --- a/tests/documents/latex_test.py +++ b/tests/documents/latex_test.py @@ -1,4 +1,6 @@ import pytest +import time +import logging from lsprotocol.types import ( Position, @@ -571,7 +573,7 @@ def test_get_paragraphs_at_range(content, range, exp): ], ), ]) -def test_updates(content, edits, exp): +def test_change_tracker(content, edits, exp): doc = LatexDocument('DUMMY_URL', content) tracker = ChangeTracker(doc, True) @@ -579,3 +581,274 @@ def test_updates(content, edits, exp): tracker.update_document(edit) assert tracker.get_changes() == exp + + +@pytest.mark.parametrize('content,change,exp,offset_test,position_test', [ + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + + 'This is a sentence.\n'*2 + + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + # add 'o' to Introduction + range=Range( + start=Position( + line=3, + character=13, + ), + end=Position( + line=3, + character=13, + ), + ), + text='o', + ), + 'Introoduction\n' + '\n' + + ' '.join(['This is a sentence.']*2) + + '\n', + None, + None, + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + + 'This is a sentence.\n'*2 + + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + # delete 'o' from Introduction + range=Range( + start=Position( + line=3, + character=13, + ), + end=Position( + line=3, + character=14, + ), + ), + text='', + ), + 'Intrduction\n' + '\n' + + ' '.join(['This is a sentence.']*2) + + '\n', + None, + None, + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'An initial sentence.\n' + '\n' + '\\section{Introduction}\n' + '\n' + + 'This is a sentence.\n'*2 + + '\n' + '\\section{Conclusions}\n' + '\n' + 'A final sentence.\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + # replace the word initial + range=Range( + start=Position( + line=5, + character=3, + ), + end=Position( + line=5, + character=10, + ), + ), + text='\n\naaaaaaa', + ), + 'Introduction\n' + '\n' + 'An\n' + '\n' + 'aaaaaaa sentence.\n' + '\n' + 'Introduction\n' + '\n' + + ' '.join(['This is a sentence.']*2) + + '\n\n' + 'Conclusions\n' + '\n' + 'A final sentence.\n', + ( + -16, + 'final', + ), + ( + Position( + line=16, + character=2, + ), + 'final', + ), + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence. \\section{Inline} FooBar\n' + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=5, + character=2, + ), + end=Position( + line=5, + character=2, + ), + ), + text='oooooo', + ), + 'Introduction\n' + '\n' + 'Thoooooois is a sentence.\n' + '\n' + 'Inline\n' + '\n' + 'FooBar\n', + None, + ( + Position( + line=5, + character=43, + ), + 'FooBar', + ), + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=6, + character=0, + ), + end=Position( + line=6, + character=0, + ), + ), + text='o', + ), + 'Introduction\n' + '\n' + + 'This is a sentence. o\n', + None, + None, + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\n' + '\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=2, + character=0, + ), + end=Position( + line=2, + character=0, + ), + ), + text='o', + ), + 'o\n' + '\n' + 'Introduction\n' + '\n' + + 'This is a sentence.\n', + None, + None, + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + 'o\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=2, + character=0, + ), + end=Position( + line=3, + character=0, + ), + ), + text='', + ), + 'Introduction\n' + '\n' + + 'This is a sentence.\n', + ( + 0, + 'Introduction', + ), + ( + Position( + line=2, + character=9, + ), + 'Introduction', + ), + ), +]) +def test_edits(content, change, exp, offset_test, position_test): + doc = LatexDocument('DUMMY_URL', content) + doc.cleaned_source + start = time.time() + doc.apply_change(change) + assert doc.cleaned_source == exp + logging.warning(time.time() - start) + + if offset_test is not None: + offset = offset_test[0] + if offset < 0: + offset = len(exp) + offset + assert doc.text_at_offset(offset, len(offset_test[1]), True) == offset_test[1] + if position_test is not None: + offset = doc.offset_at_position(position_test[0], True) + assert doc.text_at_offset(offset, len(position_test[1]), True) == position_test[1] diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index 04948f5..855a21e 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -1,5 +1,4 @@ -import bisect -import copy +import logging from typing import List, Optional from pygls.server import LanguageServer @@ -36,6 +35,9 @@ ) +logger = logging.getLogger(__name__) + + class Analyser(): CONFIGURATION_SEVERITY = 'severity' CONFIGURATION_CHECK = 'check_text' diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index f238d89..e3c294a 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -1,16 +1,19 @@ import logging import tempfile +import sys from typing import Optional, Generator, List, Dict from dataclasses import dataclass +from itertools import chain from lsprotocol.types import ( Range, Position, TextDocumentContentChangeEvent, + TextDocumentContentChangeEvent_Type1, TextDocumentContentChangeEvent_Type2, ) -from pygls.workspace import Document, position_from_utf16 +from pygls.workspace import Document, position_from_utf16, range_from_utf16 from tree_sitter import Language, Parser, Tree, Node from ..utils import get_class, synchronized, git_clone, get_user_cache @@ -242,8 +245,8 @@ def _clean_source(self): raise NotImplementedError() def apply_change(self, change: TextDocumentContentChangeEvent) -> None: - super().apply_change(change) self._cleaned_source = None + super().apply_change(change) def position_at_offset(self, offset: int, cleaned=False) -> Position: if not cleaned: @@ -337,6 +340,8 @@ def __init__(self, language_name, grammar_url, branch, *args, **kwargs): self._language ) self._text_intervals = None + self._tree = None + self._query = self._build_query() @classmethod def build_library(cls, name, url, branch=None) -> None: @@ -371,15 +376,25 @@ def get_parser(cls, name=None, url=None, branch=None, language=None) -> Parser: parser.set_language(language) return parser + def _build_query(self): + raise NotImplementedError() + def _parse_source(self): return self._parser.parse(bytes(self.source, 'utf-8')) - def _clean_source(self): - tree = self._parse_source() + @property + def tree(self) -> Tree: + if self._tree is None: + self._tree = self._parse_source() + return self._tree + + def _clean_source(self, change: TextDocumentContentChangeEvent_Type1 = None): self._text_intervals = OffsetPositionIntervalList() offset = 0 - for node in self._iterate_text_nodes(tree): + start_point = (0, 0) + end_point = (sys.maxsize, sys.maxsize) + for node in self._iterate_text_nodes(self.tree, start_point, end_point): node_len = len(node) self._text_intervals.add_interval_values( offset, @@ -394,9 +409,312 @@ def _clean_source(self): self._cleaned_source = ''.join(self._text_intervals.values) - def _iterate_text_nodes(self, tree: Tree) -> Generator[TextNode, None, None]: + def _iterate_text_nodes( + self, + tree: Tree, + start_point, + end_point, + ) -> Generator[TextNode, None, None]: raise NotImplementedError() + def _get_edit_positions(self, change): + lines = self.lines + change_range = change.range + change_range = range_from_utf16(lines, change_range) + start_line = change_range.start.line + start_col = change_range.start.character + end_line = change_range.end.line + end_col = change_range.end.character + + start_byte = len(bytes( + ''.join( + lines[:start_line] + [lines[start_line][:start_col+1]] + ), + 'utf-8', + )) + end_byte = len(bytes( + ''.join( + lines[:end_line] + [lines[end_line][:end_col+1]] + ), + 'utf-8', + )) + text_bytes = len(bytes(change.text, 'utf-8')) + + if end_byte - start_byte == 0: + # INSERT + old_end_byte = start_byte + new_end_byte = start_byte + text_bytes + start_point = (start_line, start_col) + old_end_point = start_point + new_lines = change.text.count('\n') + new_end_point = ( + start_line + new_lines, + (start_col + text_bytes) if new_lines == 0 else len(bytes( + change.text.split('\n')[-1], + 'utf-8' + )), + ) + elif text_bytes == 0: + # DELETE + old_end_byte = end_byte + new_end_byte = start_byte + start_point = (start_line, start_col) + old_end_point = (end_line, end_col) + new_end_point = start_point + else: + # REPLACE + old_end_byte = end_byte + new_end_byte = start_byte + text_bytes + start_point = (start_line, start_col) + old_end_point = (end_line, end_col) + + new_lines = change.text.count('\n') + deleted_lines = end_line - start_line + if new_lines == 0 and deleted_lines == 0: + new_end_line = end_line + new_end_col = end_col + text_bytes - (end_col - start_col) + elif new_lines > 0 and deleted_lines == 0: + new_end_line = end_line + new_lines + new_end_col = len(bytes(change.text.split('\n')[-1], 'utf-8')) + elif new_lines == 0 and deleted_lines > 0: + new_end_line = end_line - deleted_lines + new_end_col = end_col + text_bytes - (end_col - start_col) + else: + new_end_line = end_line + new_lines - deleted_lines + new_end_col = len(bytes(change.text.split('\n')[-1], 'utf-8')) + + new_end_point = ( + new_end_line, + new_end_col, + ) + + return ( + start_line, + start_col, + end_line, + end_col, + start_byte, + old_end_byte, + new_end_byte, + text_bytes, + start_point, + old_end_point, + new_end_point, + ) + + def _get_last_node_for_edit(self, tree, start_point, end_point): + node = None + edit_on_top = False + old_tree_end_point = None + for node in self._query.captures(tree.root_node, start_point=start_point, end_point=end_point): + pass + if node is None: + # edit in empty line + for node in self._query.captures( + tree.root_node, + start_point=(start_point[0]-1, start_point[1]), + end_point=end_point + ): + pass + if node is None: + # edit in empty line at the top of the file + edit_on_top = True + old_tree_end_point = self._query.captures( + tree.root_node, + )[0][0].end_point + for node in self._query.captures( + tree.root_node, + start_point=(0, 0), + end_point=old_tree_end_point + ): + pass + return node[0], edit_on_top, old_tree_end_point + + def _build_updated_text_intervals( + self, + start_line, + start_col, + end_line, + end_col, + start_point, + old_end_point, + new_end_point, + text_bytes, + old_last_edited_node, + old_tree_end_point, + edit_on_top, + ): + text_intervals = OffsetPositionIntervalList() + + offset = 0 + node_iter = self._iterate_text_nodes( + self.tree, + start_point if not edit_on_top else (0, 0), + new_end_point if not edit_on_top else old_tree_end_point, + ) + node = next(node_iter) + # copy the text intervals up to the start of the change + for interval_idx in range(len(self._text_intervals)): + interval = self._text_intervals.get_interval(interval_idx) + interval_end = ( + interval.position_range.end.line, + interval.position_range.end.character, + ) + if interval_end >= node.start_point: + break + + offset += len(interval.value) + text_intervals.add_interval(interval) + + # handle the nodes that were in the edited subtree + tmp_intvals = list() + for node in chain([node], node_iter): + node_len = len(node) + tmp_intvals.append(( + offset, + offset+node_len-1, + node.start_point[0], + node.start_point[1], + node.end_point[0], + node.end_point[1], + node.text, + )) + offset += node_len + for interval in tmp_intvals[:-1]: + # there's always a newline return at the end of the file which + # is not needed if we are not really at the end of the file yet + text_intervals.add_interval_values(*interval) + offset -= len(tmp_intvals[-1][6]) + + # add remaining intervals shifted + last_idx = self._text_intervals.get_idx_at_position( + Position( + line=old_last_edited_node.start_point[0], + character=old_last_edited_node.start_point[1], + ), + strict=False, + ) + last_idx += 1 + if last_idx+1 >= len(self._text_intervals): + # we are actully at the end of the file so add the final newline + text_intervals.add_interval_values(*tmp_intvals[-1]) + else: + for interval_idx in range(last_idx, len(self._text_intervals)): + interval = self._text_intervals.get_interval(interval_idx) + if ( + len(text_intervals) == 0 + and interval.value.count('\n') > 0 + and interval.value.strip() == '' + ): + continue + node_len = len(interval.value) + # FIXME should not calculate for each but once for all after edit + # and separately for those which are affected by the edit, do we have those? + if interval.position_range.start.line > end_line: + tmp = new_end_point[0] - old_end_point[0] + start_line_offset = tmp + start_char_offset = 0 + end_line_offset = tmp + end_char_offset = 0 + elif (interval.position_range.start.line == end_line + and interval.position_range.start.character > end_col): + tmp = text_bytes - (end_col - start_col) + start_line_offset = 0 + start_char_offset = tmp + end_line_offset = 0 + if interval.position_range.end.line > interval.position_range.start.line: + end_char_offset = 0 + else: + end_char_offset = tmp + else: + start_line_offset = 0 + start_char_offset = 0 + end_line_offset = 0 + end_char_offset = 0 + + text_intervals.add_interval_values( + offset, + offset+node_len-1, + interval.position_range.start.line + start_line_offset, + interval.position_range.start.character + start_char_offset, + interval.position_range.end.line + end_line_offset, + interval.position_range.end.character + end_char_offset, + interval.value, + ) + offset += node_len + + return text_intervals + + def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1) -> None: + """Apply an ``Incremental`` text change to the document""" + if self._tree is None: + super()._apply_incremental_change(change) + return + + tree = self.tree + ( + start_line, + start_col, + end_line, + end_col, + start_byte, + old_end_byte, + new_end_byte, + text_bytes, + start_point, + old_end_point, + new_end_point, + ) = self._get_edit_positions(change) + + # bookkeeping for later source cleaning + ( + old_last_edited_node, + edit_on_top, + old_tree_end_point + ) = self._get_last_node_for_edit( + tree, + start_point, + old_end_point, + ) + + tree.edit( + start_byte=start_byte, + old_end_byte=old_end_byte, + new_end_byte=new_end_byte, + start_point=start_point, + old_end_point=old_end_point, + new_end_point=new_end_point, + ) + super()._apply_incremental_change(change) + new_source = bytes(self.source, 'utf-8') + self._tree = self._parser.parse( + new_source, + tree + ) + + # rebuild the cleaned source + text_intervals = self._build_updated_text_intervals( + start_line, + start_col, + end_line, + end_col, + start_point, + old_end_point, + new_end_point, + text_bytes, + old_last_edited_node, + old_tree_end_point, + edit_on_top, + ) + + self._text_intervals = text_intervals + self._cleaned_source = ''.join(self._text_intervals.values) + + def _apply_full_change(self, change: TextDocumentContentChangeEvent) -> None: + """Apply a ``Full`` text change to the document.""" + super()._apply_full_change(change) + self._tree = None + def position_at_offset(self, offset: int, cleaned=False) -> Position: if not cleaned: return super().position_at_offset(offset, cleaned) diff --git a/textLSP/documents/latex/latex.py b/textLSP/documents/latex/latex.py index ecca371..dae4115 100644 --- a/textLSP/documents/latex/latex.py +++ b/textLSP/documents/latex/latex.py @@ -44,7 +44,6 @@ def __init__(self, *args, **kwargs): *args, **kwargs, ) - self._query = self._build_query() def _build_query(self): query_str = '' @@ -59,13 +58,18 @@ def _build_query(self): return self._language.query(query_str) - def _iterate_text_nodes(self, tree: Tree) -> Generator[TextNode, None, None]: + def _iterate_text_nodes( + self, + tree: Tree, + start_point, + end_point, + ) -> Generator[TextNode, None, None]: lines = tree.text.decode('utf-8').split('\n') last_sent = None new_lines_after = list() - for node in self._query.captures(tree.root_node): + for node in self._query.captures(tree.root_node, start_point=start_point, end_point=end_point): # Check if we need some newlines after previous elements while len(new_lines_after) > 0: if node[0].start_point > new_lines_after[0]: diff --git a/textLSP/documents/markdown/markdown.py b/textLSP/documents/markdown/markdown.py index a029174..3d199bc 100644 --- a/textLSP/documents/markdown/markdown.py +++ b/textLSP/documents/markdown/markdown.py @@ -55,7 +55,6 @@ def __init__(self, *args, **kwargs): *args, **kwargs, ) - self._query = self._build_query() def _build_query(self): query_str = '' @@ -71,13 +70,18 @@ def _build_query(self): return self._language.query(query_str) - def _iterate_text_nodes(self, tree: Tree) -> Generator[TextNode, None, None]: + def _iterate_text_nodes( + self, + tree: Tree, + start_point, + end_point, + ) -> Generator[TextNode, None, None]: lines = tree.text.decode('utf-8').split('\n') last_sent = None new_lines_after = list() - for node in self._query.captures(tree.root_node): + for node in self._query.captures(tree.root_node, start_point=start_point, end_point=end_point): # Check if we need some newlines after previous elements while len(new_lines_after) > 0: if node[0].start_point > new_lines_after[0]: diff --git a/textLSP/documents/org/org.py b/textLSP/documents/org/org.py index 957562c..d52fa0c 100644 --- a/textLSP/documents/org/org.py +++ b/textLSP/documents/org/org.py @@ -45,7 +45,6 @@ def __init__(self, *args, **kwargs): *args, **kwargs, ) - self._query = self._build_query() keywords = self.config.setdefault( self.CONFIGURATION_TODO_KEYWORDS, self.DEFAULT_TODO_KEYWORDS, @@ -70,13 +69,18 @@ def _build_query(self): return self._language.query(query_str) - def _iterate_text_nodes(self, tree: Tree) -> Generator[TextNode, None, None]: + def _iterate_text_nodes( + self, + tree: Tree, + start_point, + end_point, + ) -> Generator[TextNode, None, None]: lines = tree.text.decode('utf-8').split('\n') last_sent = None new_lines_after = list() - for node in self._query.captures(tree.root_node): + for node in self._query.captures(tree.root_node, start_point=start_point, end_point=end_point): # Check if we need some newlines after previous elements while len(new_lines_after) > 0: if node[0].start_point > new_lines_after[0]: diff --git a/textLSP/types.py b/textLSP/types.py index 10db0d7..16c8a8d 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -86,7 +86,7 @@ def add_interval_values( def add_interval(self, interval: OffsetPositionInterval): self.add_interval_values( interval.offset_interval.start, - interval.offset_interval.end, + interval.offset_interval.start + interval.offset_interval.length, interval.position_range.start.line, interval.position_range.start.character, interval.position_range.end.line, diff --git a/textLSP/utils.py b/textLSP/utils.py index 65598a3..a3a2283 100644 --- a/textLSP/utils.py +++ b/textLSP/utils.py @@ -104,3 +104,26 @@ def batch_text(text: str, pattern: re.Pattern, max_size: int, min_size: int = 0) def position_to_tuple(position: Position): return (position.line, position.character) + + +def traverse_tree(tree): + cursor = tree.walk() + + reached_root = False + while reached_root: + yield cursor.node + + if cursor.goto_first_child(): + continue + + if cursor.goto_next_sibling(): + continue + + retracing = True + while retracing: + if not cursor.goto_parent(): + retracing = False + reached_root = True + + if cursor.goto_next_sibling(): + retracing = False From 1efcd94c8fbbb8a7c626cf7d32bf9f7a71074f61 Mon Sep 17 00:00:00 2001 From: hangyav <hangyav@gmail.com> Date: Sun, 9 Jul 2023 15:50:12 +0200 Subject: [PATCH 08/28] fix: 2 issues in ChangeTracker; 1 issue in TreeSitterDocument edit --- tests/analysers/languagetool_test.py | 103 +++++++++++++++++++++++++++ tests/documents/document_test.py | 40 ++++++++++- tests/documents/latex_test.py | 32 ++++++++- textLSP/analysers/analyser.py | 2 +- textLSP/documents/document.py | 46 ++++++++++-- 5 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 tests/analysers/languagetool_test.py diff --git a/tests/analysers/languagetool_test.py b/tests/analysers/languagetool_test.py new file mode 100644 index 0000000..d671f3c --- /dev/null +++ b/tests/analysers/languagetool_test.py @@ -0,0 +1,103 @@ +import pytest + +from threading import Event +from lsprotocol.types import ( + DidOpenTextDocumentParams, + TextDocumentItem, + DidChangeTextDocumentParams, + VersionedTextDocumentIdentifier, + TextDocumentContentChangeEvent_Type1, + Range, + Position, + DidSaveTextDocumentParams, + TextDocumentIdentifier, +) + +from tests.lsp_test_client import session, utils + + +@pytest.mark.skip(reason="Not finished. See TODO below.") +def test_bug1(json_converter, langtool_ls_onsave): + text = ('\\documentclass[11pt]{article}\n' + + '\\begin{document}\n' + + '\n' + + '\\section{Introduction}\n' + + '\n' + + 'This is a sentence.\n' + + '\n' + + '\\end{document}') + + done = Event() + results = list() + + # TODO This should wait for error messages from the server. The test should + # not cause any server errors. + langtool_ls_onsave.set_notification_callback( + session.WINDOW_LOG_MESSAGE, + utils.get_notification_handler( + event=done, + results=results + ), + ) + + open_params = DidOpenTextDocumentParams( + TextDocumentItem( + uri='dummy.tex', + language_id='tex', + version=1, + text=text, + ) + ) + + langtool_ls_onsave.notify_did_open( + json_converter.unstructure(open_params) + ) + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=1, + uri='dummy.tex', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + Range( + start=Position(line=5, character=19), + end=Position(line=6, character=0), + ), + '\nThis is a sentence.\n', + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=2, + uri='dummy.tex', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + Range( + start=Position(line=6, character=19), + end=Position(line=7, character=0), + ), + '\nThis is a sentence.\n', + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + + save_params = DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier( + 'dummy.tex' + ) + ) + langtool_ls_onsave.notify_did_save( + json_converter.unstructure(save_params) + ) + assert done.wait(30) + done.clear() diff --git a/tests/documents/document_test.py b/tests/documents/document_test.py index 927d70b..62a3a98 100644 --- a/tests/documents/document_test.py +++ b/tests/documents/document_test.py @@ -350,7 +350,7 @@ def test_get_sentence_at_offset(content, offset, length, exp): ), ], [ - Interval(169, 1), + Interval(169, 0), ], ), ( @@ -409,12 +409,48 @@ def test_get_sentence_at_offset(content, offset, length, exp): Interval(171, 1), ], ), + ( + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=0, + character=19, + ), + end=Position( + line=1, + character=0, + ), + ), + text='\nThis is a sentence.\n', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=1, + character=19, + ), + end=Position( + line=2, + character=0, + ), + ), + text='\nThis is a sentence.\n', + ), + ], + [ + Interval(19, 20), + Interval(39, 21), + ], + ), ]) def test_updates(content, edits, exp): doc = BaseDocument('DUMMY_URL', content) tracker = ChangeTracker(doc, True) for edit in edits: - tracker.update_document(edit) + doc.apply_change(edit) + tracker.update_document(edit, doc) assert tracker.get_changes() == exp diff --git a/tests/documents/latex_test.py b/tests/documents/latex_test.py index ae7ee0f..4c4cadf 100644 --- a/tests/documents/latex_test.py +++ b/tests/documents/latex_test.py @@ -578,7 +578,8 @@ def test_change_tracker(content, edits, exp): tracker = ChangeTracker(doc, True) for edit in edits: - tracker.update_document(edit) + doc.apply_change(edit) + tracker.update_document(edit, doc) assert tracker.get_changes() == exp @@ -835,6 +836,35 @@ def test_change_tracker(content, edits, exp): 'Introduction', ), ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + # delete last character: '.' + range=Range( + start=Position( + line=5, + character=18, + ), + end=Position( + line=5, + character=19, + ), + ), + text='', + ), + 'Introduction\n' + '\n' + + 'This is a sentence\n', + None, + None, + ), ]) def test_edits(content, change, exp, offset_test, position_test): doc = LatexDocument('DUMMY_URL', content) diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index 855a21e..e55368a 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -297,7 +297,7 @@ def did_change(self, params: DidChangeTextDocumentParams): self.language_server.publish_stored_diagnostics(doc) def update_document(self, doc: Document, change: TextDocumentContentChangeEvent): - self._content_change_dict[doc.uri].update_document(change) + self._content_change_dict[doc.uri].update_document(change, doc) def did_save(self, params: DidSaveTextDocumentParams): if self.should_run_on(Analyser.CONFIGURATION_CHECK_ON_SAVE): diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index e3c294a..41b38a9 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -1,6 +1,7 @@ import logging import tempfile import sys +import copy from typing import Optional, Generator, List, Dict from dataclasses import dataclass @@ -332,6 +333,8 @@ class TreeSitterDocument(CleanableDocument): def __init__(self, language_name, grammar_url, branch, *args, **kwargs): super().__init__(*args, **kwargs) + ####################################################################### + # Do not deepcopy these self._language = self.get_language(language_name, grammar_url, branch) self._parser = self.get_parser( language_name, @@ -339,9 +342,22 @@ def __init__(self, language_name, grammar_url, branch, *args, **kwargs): branch, self._language ) - self._text_intervals = None self._tree = None self._query = self._build_query() + ####################################################################### + + self._text_intervals = None + + def __deepcopy__(self, memo): + cls = self.__class__ + result = cls.__new__(cls) + memo[id(self)] = result + for k, v in self.__dict__.items(): + if k not in {'_language', '_parser', '_tree', '_query'}: + setattr(result, k, copy.deepcopy(v, memo)) + else: + setattr(result, k, v) + return result @classmethod def build_library(cls, name, url, branch=None) -> None: @@ -546,6 +562,14 @@ def _build_updated_text_intervals( ): text_intervals = OffsetPositionIntervalList() + if start_point == new_end_point: + # DELETE + # We might select an empty subtree -> extend the range + start_point = ( + start_point[0] if start_point[1] > 0 else start_point[0]-1, + start_point[1]-1 if start_point[1] > 0 else 0, + ) + offset = 0 node_iter = self._iterate_text_nodes( self.tree, @@ -866,7 +890,8 @@ def get_document( class ChangeTracker(): def __init__(self, doc: BaseDocument, cleaned=False): - self.document = doc + self.document = None + self._set_document(doc) self.cleaned = cleaned length = len(doc.cleaned_source) if cleaned else len(doc.source) # list of tuples (span_length, was_changed) @@ -874,7 +899,15 @@ def __init__(self, doc: BaseDocument, cleaned=False): self._items = [(length, False)] self.full_document_change = False - def update_document(self, change: TextDocumentContentChangeEvent): + def _set_document(self, doc: BaseDocument): + # XXX not too memory efficient + self.document = copy.deepcopy(doc) + + def update_document( + self, + change: TextDocumentContentChangeEvent, + updated_doc: BaseDocument + ): if self.full_document_change: return @@ -908,12 +941,15 @@ def update_document(self, change: TextDocumentContentChangeEvent): effective_change_length = max(effective_change_length, -1*start_offset) new_lst.append((effective_change_length, True)) - new_lst.append(( + tmp_item = ( self._items[item_idx][0]-start_offset-range_length, self._items[item_idx][1] - )) + ) + if tmp_item[0] > 0: + new_lst.append(tmp_item) self._replace_at(item_idx, new_lst) + self._set_document(updated_doc) def _get_offset_idx(self, offset): pos = 0 From 4388c015308881c543972f5f992656ced1aa95b7 Mon Sep 17 00:00:00 2001 From: hangyav <hangyav@gmail.com> Date: Sun, 16 Jul 2023 18:09:17 +0200 Subject: [PATCH 09/28] fix: fixing a set of bugs related to TreeSitter change handling and diagnostics position shift; adding better error handling --- tests/analysers/analyser_test.py | 118 ++++++++++++++++++++++- tests/analysers/languagetool_test.py | 91 ++++++++++++++++-- tests/documents/document_test.py | 78 +++++++++++++++- tests/documents/latex_test.py | 123 ++++++++++++++++++++++++ textLSP/analysers/analyser.py | 28 ++++-- textLSP/analysers/handler.py | 69 +++++++++++--- textLSP/documents/document.py | 135 ++++++++++++++++++++------- textLSP/documents/latex/latex.py | 2 + textLSP/types.py | 8 ++ 9 files changed, 587 insertions(+), 65 deletions(-) diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py index 9bb4ee3..890b88f 100644 --- a/tests/analysers/analyser_test.py +++ b/tests/analysers/analyser_test.py @@ -237,7 +237,7 @@ def test_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): ), ), ]) -def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): +def test_diagnostics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): done = Event() results = list() @@ -295,3 +295,119 @@ def test_diagnosttics_bug1(text, edit, exp, json_converter, langtool_ls_onsave): res = results[-1]['diagnostics'][0]['range'] assert res == json_converter.unstructure(exp) + + +def test_diagnostics_bug2(json_converter, langtool_ls_onsave): + text = ('\\documentclass[11pt]{article}\n' + + '\\begin{document}\n' + + 'o\n' + + '\\section{Thes}\n' + + '\n' + + 'This is a sentence.\n' + + '\n' + + '\\end{document}') + + done = Event() + results = list() + + langtool_ls_onsave.set_notification_callback( + session.PUBLISH_DIAGNOSTICS, + utils.get_notification_handler( + event=done, + results=results + ), + ) + + open_params = DidOpenTextDocumentParams( + TextDocumentItem( + uri='dummy.tex', + language_id='tex', + version=1, + text=text, + ) + ) + + langtool_ls_onsave.notify_did_open( + json_converter.unstructure(open_params) + ) + assert done.wait(30) + done.clear() + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=1, + uri='dummy.tex', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + Range( + start=Position(line=2, character=0), + end=Position(line=3, character=0), + ), + '', + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + assert done.wait(30) + done.clear() + + save_params = DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier( + 'dummy.tex' + ) + ) + langtool_ls_onsave.notify_did_save( + json_converter.unstructure(save_params) + ) + assert done.wait(30) + done.clear() + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=2, + uri='dummy.tex', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + Range( + start=Position(line=1, character=16), + end=Position(line=2, character=0), + ), + '\no\n', + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + assert done.wait(30) + done.clear() + + save_params = DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier( + 'dummy.tex' + ) + ) + langtool_ls_onsave.notify_did_save( + json_converter.unstructure(save_params) + ) + assert done.wait(30) + done.clear() + + exp_lst = [ + Range( + start=Position(line=2, character=0), + end=Position(line=2, character=1), + ), + Range( + start=Position(line=3, character=9), + end=Position(line=3, character=13), + ), + ] + res_lst = results[-1]['diagnostics'] + assert len(res_lst) == len(exp_lst) + for exp, res in zip(exp_lst, res_lst): + assert res['range'] == json_converter.unstructure(exp) diff --git a/tests/analysers/languagetool_test.py b/tests/analysers/languagetool_test.py index d671f3c..1525bcb 100644 --- a/tests/analysers/languagetool_test.py +++ b/tests/analysers/languagetool_test.py @@ -1,5 +1,3 @@ -import pytest - from threading import Event from lsprotocol.types import ( DidOpenTextDocumentParams, @@ -16,7 +14,6 @@ from tests.lsp_test_client import session, utils -@pytest.mark.skip(reason="Not finished. See TODO below.") def test_bug1(json_converter, langtool_ls_onsave): text = ('\\documentclass[11pt]{article}\n' + '\\begin{document}\n' @@ -30,10 +27,8 @@ def test_bug1(json_converter, langtool_ls_onsave): done = Event() results = list() - # TODO This should wait for error messages from the server. The test should - # not cause any server errors. langtool_ls_onsave.set_notification_callback( - session.WINDOW_LOG_MESSAGE, + session.WINDOW_SHOW_MESSAGE, utils.get_notification_handler( event=done, results=results @@ -99,5 +94,87 @@ def test_bug1(json_converter, langtool_ls_onsave): langtool_ls_onsave.notify_did_save( json_converter.unstructure(save_params) ) - assert done.wait(30) + assert not done.wait(20) + done.clear() + + +def test_bug2(json_converter, langtool_ls_onsave): + text = ( + 'This is a sentence.\n' + + 'This is a sentence.\n' + + 'This is a sentence.\n' + ) + + done = Event() + results = list() + + langtool_ls_onsave.set_notification_callback( + session.WINDOW_SHOW_MESSAGE, + utils.get_notification_handler( + event=done, + results=results + ), + ) + + open_params = DidOpenTextDocumentParams( + TextDocumentItem( + uri='dummy.txt', + language_id='txt', + version=1, + text=text, + ) + ) + + langtool_ls_onsave.notify_did_open( + json_converter.unstructure(open_params) + ) + + for i, edit_range in enumerate([ + # Last two sentences deleted as done by nvim + Range( + start=Position(line=0, character=19), + end=Position(line=0, character=19), + ), + Range( + start=Position(line=1, character=0), + end=Position(line=2, character=0), + ), + Range( + start=Position(line=1, character=0), + end=Position(line=1, character=19), + ), + Range( + start=Position(line=0, character=19), + end=Position(line=0, character=19), + ), + Range( + start=Position(line=1, character=0), + end=Position(line=2, character=0), + ), + ], 1): + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=i, + uri='dummy.txt', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + edit_range, + '', + ) + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + + save_params = DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier( + 'dummy.txt' + ) + ) + langtool_ls_onsave.notify_did_save( + json_converter.unstructure(save_params) + ) + assert not done.wait(20) done.clear() diff --git a/tests/documents/document_test.py b/tests/documents/document_test.py index 62a3a98..8ba05db 100644 --- a/tests/documents/document_test.py +++ b/tests/documents/document_test.py @@ -350,7 +350,7 @@ def test_get_sentence_at_offset(content, offset, length, exp): ), ], [ - Interval(169, 0), + Interval(168, 1), ], ), ( @@ -444,6 +444,82 @@ def test_get_sentence_at_offset(content, offset, length, exp): Interval(39, 21), ], ), + ( + 'This is a sentence.\n' + 'This is a sentence.\n' + 'This is a sentence.\n', + [ + # Last two sentences deleted as done by nvim + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=0, + character=19, + ), + end=Position( + line=0, + character=19, + ), + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=1, + character=0, + ), + end=Position( + line=2, + character=0, + ), + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=1, + character=0, + ), + end=Position( + line=1, + character=19, + ), + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=0, + character=19, + ), + end=Position( + line=0, + character=19, + ), + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=1, + character=0, + ), + end=Position( + line=2, + character=0, + ), + ), + text='', + ), + ], + [ + Interval(18, 1), + ], + ), ]) def test_updates(content, edits, exp): doc = BaseDocument('DUMMY_URL', content) diff --git a/tests/documents/latex_test.py b/tests/documents/latex_test.py index 4c4cadf..d1855f2 100644 --- a/tests/documents/latex_test.py +++ b/tests/documents/latex_test.py @@ -572,6 +572,33 @@ def test_get_paragraphs_at_range(content, range, exp): Interval(35, 1), ], ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=6, + character=0, + ), + end=Position( + line=7, + character=0, + ), + ), + text='\n\\end{document}\n', + ), + ], + [ + Interval(33, 16), + ], + ), ]) def test_change_tracker(content, edits, exp): doc = LatexDocument('DUMMY_URL', content) @@ -691,6 +718,10 @@ def test_change_tracker(content, edits, exp): ( -16, 'final', + Range( + start=Position(16, 2), + end=Position(16, 6), + ), ), ( Position( @@ -827,6 +858,10 @@ def test_change_tracker(content, edits, exp): ( 0, 'Introduction', + Range( + start=Position(2, 9), + end=Position(2, 20), + ), ), ( Position( @@ -865,6 +900,92 @@ def test_change_tracker(content, edits, exp): None, None, ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n' + '\\end{document}\n' + '\n', + TextDocumentContentChangeEvent_Type1( + # delete last character: '.' + range=Range( + start=Position( + line=8, + character=0, + ), + end=Position( + line=9, + character=0, + ), + ), + text='', + ), + 'Introduction\n' + '\n' + + 'This is a sentence.\n', + None, + None, + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=6, + character=0, + ), + end=Position( + line=7, + character=0, + ), + ), + text='\n\\end{document}\n', + ), + 'Introduction\n' + '\n' + + 'This is a sentence.\n', + None, + None, + ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + '\\section{Introduction}\n' + '\n' + 'This is a sentence.\n' + '\n' + '\\end{document}', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=1, + character=16, + ), + end=Position( + line=2, + character=0, + ), + ), + text='\no\n', + ), + 'o\n' + '\n' + 'Introduction\n' + '\n' + + 'This is a sentence.\n', + None, + None, + ), ]) def test_edits(content, change, exp, offset_test, position_test): doc = LatexDocument('DUMMY_URL', content) @@ -879,6 +1000,8 @@ def test_edits(content, change, exp, offset_test, position_test): if offset < 0: offset = len(exp) + offset assert doc.text_at_offset(offset, len(offset_test[1]), True) == offset_test[1] + if len(offset_test) > 2: + assert doc.range_at_offset(offset, len(offset_test[1]), True) == offset_test[2] if position_test is not None: offset = doc.offset_at_position(position_test[0], True) assert doc.text_at_offset(offset, len(position_test[1]), True) == position_test[1] diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index e55368a..da1f008 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -94,12 +94,17 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): if type(change) == TextDocumentContentChangeEvent_Type2: continue + if change.range.start != change.range.end: + num = self.remove_code_items_at_rage(doc, change.range, (True, False)) + should_update_diagnostics = should_update_diagnostics or num > 0 + + change_text_len = len(change.text) line_diff = change.range.end.line - change.range.start.line diff = change.text.count('\n') - line_diff if diff == 0: - in_line_diff = change.range.end.character - change.range.start.character - in_line_diff += len(change.text) - if in_line_diff >= 0: + in_line_diff = change.range.start.character - change.range.end.character + in_line_diff += change_text_len + if in_line_diff != 0: # in only some edit in a given line, let's shift the items # in the line next_pos = Position( @@ -164,7 +169,10 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): val += diff accumulative_shifts.append((change.range.start, val, change)) pos = doc.last_position(True) - pos = Position(line=pos.line+1, character=0) + pos = Position( + line=pos.line - (accumulative_shifts[-1][1] if len(accumulative_shifts) else 0) + 1, + character=0 + ) accumulative_shifts.append((pos, val)) if len(accumulative_shifts) == 0: @@ -286,13 +294,13 @@ def did_change(self, params: DidChangeTextDocumentParams): ) else: changes = self._content_change_dict[doc.uri].get_changes() + self._content_change_dict[doc.uri] = ChangeTracker(doc, True) with ProgressBar( self.language_server, f'{self.name} checking', token=self._progressbar_token ): self._did_change(doc, changes) - self._content_change_dict[doc.uri] = ChangeTracker(doc, True) elif should_update_diagnostics: self.language_server.publish_stored_diagnostics(doc) @@ -310,13 +318,13 @@ def did_save(self, params: DidSaveTextDocumentParams): ) else: changes = self._content_change_dict[doc.uri].get_changes() + self._content_change_dict[doc.uri] = ChangeTracker(doc, True) with ProgressBar( self.language_server, f'{self.name} checking', token=self._progressbar_token ): self._did_change(doc, changes) - self._content_change_dict[doc.uri] = ChangeTracker(doc, True) def _did_close(self, doc: Document): pass @@ -366,9 +374,11 @@ def add_diagnostics(self, doc: Document, diagnostics: List[Diagnostic]): self._diagnostics_dict[doc.uri].add(diag.range.start, diag) self.language_server.publish_stored_diagnostics(doc) - def remove_code_items_at_rage(self, doc: Document, pos_range: Range): - self._diagnostics_dict[doc.uri].remove_between(pos_range) - self._code_actions_dict[doc.uri].remove_between(pos_range) + def remove_code_items_at_rage(self, doc: Document, pos_range: Range, inclusive=(True, True)): + num = 0 + num += self._diagnostics_dict[doc.uri].remove_between(pos_range, inclusive) + num += self._code_actions_dict[doc.uri].remove_between(pos_range, inclusive) + return num def init_code_actions(self, doc: Document): self._code_actions_dict[doc.uri] = PositionDict() diff --git a/textLSP/analysers/handler.py b/textLSP/analysers/handler.py index bce1569..6622815 100644 --- a/textLSP/analysers/handler.py +++ b/textLSP/analysers/handler.py @@ -77,14 +77,32 @@ def shutdown(self): analyser.close() def get_diagnostics(self, doc: Document): - return [analyser.get_diagnostics(doc) for analyser in self.analysers.values()] + try: + return [ + analyser.get_diagnostics(doc) + for analyser in self.analysers.values() + ] + except Exception as e: + self.language_server.show_message( + str('Server error. See log for details.'), + MessageType.Error, + ) + logger.exception(str(e)) + return [] def get_code_actions(self, params: CodeActionParams) -> Optional[List[CodeAction]]: res = list() - for analyser in self.analysers.values(): - tmp_lst = analyser.get_code_actions(params) - if tmp_lst is not None and len(tmp_lst) > 0: - res.extend(tmp_lst) + try: + for analyser in self.analysers.values(): + tmp_lst = analyser.get_code_actions(params) + if tmp_lst is not None and len(tmp_lst) > 0: + res.extend(tmp_lst) + except Exception as e: + self.language_server.show_message( + str('Server error. See log for details.'), + MessageType.Error, + ) + logger.exception(str(e)) return res if len(res) > 0 else None @@ -100,7 +118,16 @@ async def _submit_task(self, function, *args, **kwargs): if len(functions) == 0: return - await asyncio.wait(functions) + done, pending = await asyncio.wait(functions) + for task in done: + try: + task.result() + except Exception as e: + self.language_server.show_message( + str('Server error. See log for details.'), + MessageType.Error, + ) + logger.exception(str(e)) async def _did_open( self, @@ -210,6 +237,12 @@ async def command_analyse(self, *args): str(f'{analyser_name}: {e}'), MessageType.Error, ) + except Exception as e: + self.language_server.show_message( + str('Server error. See log for details.'), + MessageType.Error, + ) + logger.exception(str(e)) else: await self._submit_task(self._command_analyse, args) @@ -221,7 +254,14 @@ async def command_custom_command(self, *args): ext_command = f'command_{command}' if hasattr(analyser, ext_command): - getattr(analyser, ext_command)(**args) + try: + getattr(analyser, ext_command)(**args) + except Exception as e: + self.language_server.show_message( + str('Server error. See log for details.'), + MessageType.Error, + ) + logger.exception(str(e)) else: self.language_server.show_message( str(f'No custom command supported by {analyser}: {command}'), @@ -234,10 +274,17 @@ def update_document(self, doc: Document, change: TextDocumentContentChangeEvent) def get_completions(self, params: Optional[CompletionParams] = None) -> CompletionList: comp_lst = list() - for _, analyser in self.analysers.items(): - tmp = analyser.get_completions(params) - if tmp is not None and len(tmp) > 0: - comp_lst.extend(tmp) + try: + for _, analyser in self.analysers.items(): + tmp = analyser.get_completions(params) + if tmp is not None and len(tmp) > 0: + comp_lst.extend(tmp) + except Exception as e: + self.language_server.show_message( + str('Server error. See log for details.'), + MessageType.Error, + ) + logger.exception(str(e)) return CompletionList( is_incomplete=False, diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 41b38a9..0a7b27a 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -441,6 +441,10 @@ def _get_edit_positions(self, change): start_col = change_range.start.character end_line = change_range.end.line end_col = change_range.end.character + if end_line >= len(lines): + # this could happen eg when the last line is deleted + end_line = len(lines) - 1 + end_col = len(lines[end_line]) - 1 start_byte = len(bytes( ''.join( @@ -521,29 +525,35 @@ def _get_edit_positions(self, change): def _get_last_node_for_edit(self, tree, start_point, end_point): node = None edit_on_top = False - old_tree_end_point = None - for node in self._query.captures(tree.root_node, start_point=start_point, end_point=end_point): - pass + # old_tree_end_point = None + old_tree_end_point = self._query.captures( + tree.root_node, + )[-1][0].end_point + + nodes = self._query.captures(tree.root_node, start_point=start_point, end_point=end_point) + if len(nodes) > 0: + node = nodes[-1] + if node is None: # edit in empty line - for node in self._query.captures( - tree.root_node, - start_point=(start_point[0]-1, start_point[1]), - end_point=end_point - ): - pass + nodes = self._query.captures( + tree.root_node, + start_point=(start_point[0]-1, start_point[1]), + end_point=end_point + ) + if len(nodes) > 0: + node = nodes[-1] + if node is None: # edit in empty line at the top of the file edit_on_top = True - old_tree_end_point = self._query.captures( + nodes = self._query.captures( tree.root_node, - )[0][0].end_point - for node in self._query.captures( - tree.root_node, - start_point=(0, 0), - end_point=old_tree_end_point - ): - pass + start_point=(0, 0), + end_point=old_tree_end_point + ) + node = nodes[-1] + return node[0], edit_on_top, old_tree_end_point def _build_updated_text_intervals( @@ -571,10 +581,25 @@ def _build_updated_text_intervals( ) offset = 0 + if edit_on_top: + sp = (0, 0) + ep = old_tree_end_point + elif start_point > old_tree_end_point: + # edit at the end of the file + # need to extend the range to include the last node to avoid getting + # a single newline node in node_iter below + if old_end_point[1] > 0: + sp = (old_tree_end_point[0], old_tree_end_point[1]-1) + else: + sp = (old_tree_end_point[0]-1, 0) + ep = new_end_point + else: + sp = start_point + ep = new_end_point node_iter = self._iterate_text_nodes( self.tree, - start_point if not edit_on_top else (0, 0), - new_end_point if not edit_on_top else old_tree_end_point, + sp, + ep, ) node = next(node_iter) # copy the text intervals up to the start of the change @@ -642,10 +667,11 @@ def _build_updated_text_intervals( end_char_offset = 0 elif (interval.position_range.start.line == end_line and interval.position_range.start.character > end_col): + row_tmp = new_end_point[0] - old_end_point[0] tmp = text_bytes - (end_col - start_col) - start_line_offset = 0 + start_line_offset = row_tmp start_char_offset = tmp - end_line_offset = 0 + end_line_offset = row_tmp if interval.position_range.end.line > interval.position_range.start.line: end_char_offset = 0 else: @@ -929,24 +955,47 @@ def update_document( item_idx, item_offset = self._get_offset_idx(start_offset) change_length = len(change.text) range_length = end_offset-start_offset - start_offset = start_offset - item_offset + relative_start_offset = start_offset - item_offset + + if relative_start_offset > 0: + # add item from the beginning of the item to the start of the change + new_lst.append((relative_start_offset, self._items[item_idx][1])) + + if start_offset == end_offset and change_length == 0: + # nothing to do (I'm not sure what this is) + self._set_document(updated_doc) + return - if start_offset > 0: - new_lst.append((start_offset, self._items[item_idx][1])) + if change_length == 0: + # deletion + new_lst.append((0, True)) - if change_length >= range_length: - effective_change_length = change_length + tmp_item = ( + self._items[item_idx][0]-relative_start_offset-range_length, + self._items[item_idx][1] + ) + if tmp_item[0] != 0: + new_lst.append(tmp_item) + elif range_length == 0: + # insertion + new_lst.append((change_length, True)) + + tmp_item = ( + self._items[item_idx][0]-relative_start_offset, + self._items[item_idx][1] + ) + if tmp_item[0] > 0: + new_lst.append(tmp_item) else: - effective_change_length = change_length-range_length - effective_change_length = max(effective_change_length, -1*start_offset) - new_lst.append((effective_change_length, True)) + # replacement + new_lst.append((change_length, True)) - tmp_item = ( - self._items[item_idx][0]-start_offset-range_length, - self._items[item_idx][1] - ) - if tmp_item[0] > 0: - new_lst.append(tmp_item) + tmp_item = ( + self._items[item_idx][0]-relative_start_offset-(change_length-range_length), + self._items[item_idx][1] + ) + if tmp_item[0] > 0: + new_lst.append(tmp_item) self._replace_at(item_idx, new_lst) self._set_document(updated_doc) @@ -979,6 +1028,7 @@ def get_changes(self) -> List[Interval]: return [Interval(0, doc_length)] res = list() + seen = set() pos = 0 for item in self._items: if item[1]: @@ -989,7 +1039,20 @@ def get_changes(self) -> List[Interval]: length = min(length*-1, doc_length-pos) else: position = pos - res.append(Interval(position, length)) + + if position >= doc_length: + position = doc_length-1 + length = 0 + + if length == 0 and position > 0: + position -= 1 + length = 1 + + intv = Interval(position, length) + + if intv not in seen: + res.append(intv) + seen.add(intv) pos += max(0, item[0]) return res diff --git a/textLSP/documents/latex/latex.py b/textLSP/documents/latex/latex.py index dae4115..129c215 100644 --- a/textLSP/documents/latex/latex.py +++ b/textLSP/documents/latex/latex.py @@ -13,6 +13,7 @@ class LatexDocument(TreeSitterDocument): CURLY_GROUP = 'curly_group' ENUM_ITEM = 'enum_item' GENERIC_ENVIRONMENT = 'generic_environment' + ERROR = 'ERROR' # content in syntex error, e.g. missing closing environment NODE_CONTENT = 'content' NODE_NEWLINE_BEFORE_AFTER = 'newline_before_after' @@ -24,6 +25,7 @@ class LatexDocument(TreeSitterDocument): CURLY_GROUP, ENUM_ITEM, GENERIC_ENVIRONMENT, + ERROR, } NEWLINE_BEFORE_AFTER_CURLY_PARENT = { diff --git a/textLSP/types.py b/textLSP/types.py index 16c8a8d..64848e3 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -255,21 +255,29 @@ def remove(self, position: Position): def remove_from(self, position: Position, inclusive=True): position = position_to_tuple(position) + num = 0 for key in list(self._positions.irange( minimum=position, inclusive=(inclusive, False) )): del self._positions[key] + num += 1 + + return num def remove_between(self, range: Range, inclusive=(True, True)): minimum = position_to_tuple(range.start) maximum = position_to_tuple(range.end) + num = 0 for key in list(self._positions.irange( minimum=minimum, maximum=maximum, inclusive=inclusive, )): del self._positions[key] + num += 1 + + return num def irange(self, minimum: Position = None, maximum: Position = None, *args, **kwargs): From 9682f77c34101405d7c6a4a9f18d9e34caf35a3e Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Tue, 18 Jul 2023 14:04:34 +0200 Subject: [PATCH 10/28] fix: adding missing requirement --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 7673e27..a365a9d 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ def read(fname): 'torch==1.13.1', 'openai==0.26.4', 'transformers==4.25.1', + 'sortedcontainers==2.4.0', ], extras_require={ 'dev': [ From fecc161303e6eef525ee9f556cf6126a6b0f794e Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@gmail.com> Date: Sat, 16 Sep 2023 17:51:56 +0200 Subject: [PATCH 11/28] bugfix: small change to correctly handle edits of md coarse-grained TS structure --- tests/documents/markdown_test.py | 44 ++++++++++++++++++++++++++++++++ textLSP/documents/document.py | 5 ++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 1b9d86a..5869fd4 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -1,6 +1,11 @@ import pytest from textLSP.documents.markdown.markdown import MarkDownDocument +from lsprotocol.types import ( + Position, + Range, + TextDocumentContentChangeEvent_Type1 +) @pytest.mark.parametrize('src,clean', [ @@ -103,3 +108,42 @@ def test_highlight(src, offset, exp): res += lines[pos_range.end.line][:pos_range.end.character+1] assert res == exp + + +@pytest.mark.parametrize('content,change,exp,offset_test,position_test', [ + ( + 'This is a sentence.', + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=0, + character=0, + ), + end=Position( + line=0, + character=4, + ), + ), + text='That', + ), + 'That is a sentence.\n', + None, + None, + ), +]) +def test_edits(content, change, exp, offset_test, position_test): + doc = MarkDownDocument('DUMMY_URL', content) + doc.cleaned_source + doc.apply_change(change) + assert doc.cleaned_source == exp + + if offset_test is not None: + offset = offset_test[0] + if offset < 0: + offset = len(exp) + offset + assert doc.text_at_offset(offset, len(offset_test[1]), True) == offset_test[1] + if len(offset_test) > 2: + assert doc.range_at_offset(offset, len(offset_test[1]), True) == offset_test[2] + if position_test is not None: + offset = doc.offset_at_position(position_test[0], True) + assert doc.text_at_offset(offset, len(position_test[1]), True) == position_test[1] diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 0a7b27a..10ebbaa 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -638,12 +638,11 @@ def _build_updated_text_intervals( # add remaining intervals shifted last_idx = self._text_intervals.get_idx_at_position( Position( - line=old_last_edited_node.start_point[0], - character=old_last_edited_node.start_point[1], + line=old_last_edited_node.end_point[0], + character=old_last_edited_node.end_point[1], ), strict=False, ) - last_idx += 1 if last_idx+1 >= len(self._text_intervals): # we are actully at the end of the file so add the final newline text_intervals.add_interval_values(*tmp_intvals[-1]) From b321b2e236433809a6f666d07f77b0c31fbf7c8d Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@gmail.com> Date: Tue, 26 Sep 2023 17:31:04 +0200 Subject: [PATCH 12/28] handling empty TS tree --- textLSP/documents/document.py | 43 ++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 10ebbaa..567ce49 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -526,9 +526,13 @@ def _get_last_node_for_edit(self, tree, start_point, end_point): node = None edit_on_top = False # old_tree_end_point = None - old_tree_end_point = self._query.captures( + capture = self._query.captures( tree.root_node, - )[-1][0].end_point + ) + if len(capture) == 0: + return None, None, None + + old_tree_end_point = capture[-1][0].end_point nodes = self._query.captures(tree.root_node, start_point=start_point, end_point=end_point) if len(nodes) > 0: @@ -741,23 +745,26 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 tree ) - # rebuild the cleaned source - text_intervals = self._build_updated_text_intervals( - start_line, - start_col, - end_line, - end_col, - start_point, - old_end_point, - new_end_point, - text_bytes, - old_last_edited_node, - old_tree_end_point, - edit_on_top, - ) + if old_tree_end_point is not None: + # rebuild the cleaned source + text_intervals = self._build_updated_text_intervals( + start_line, + start_col, + end_line, + end_col, + start_point, + old_end_point, + new_end_point, + text_bytes, + old_last_edited_node, + old_tree_end_point, + edit_on_top, + ) - self._text_intervals = text_intervals - self._cleaned_source = ''.join(self._text_intervals.values) + self._text_intervals = text_intervals + self._cleaned_source = ''.join(self._text_intervals.values) + else: + self._clean_source() def _apply_full_change(self, change: TextDocumentContentChangeEvent) -> None: """Apply a ``Full`` text change to the document.""" From e4b0482a9ccc2fc600fd1be6da1bb87391f8ca1c Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Fri, 3 Nov 2023 15:57:41 +0100 Subject: [PATCH 13/28] bugfix: removing reference to a TS node that can change in the background and better handling of edits in empty lines --- tests/documents/markdown_test.py | 121 +++++++++++++++++++++++++++---- textLSP/documents/document.py | 52 +++++-------- 2 files changed, 124 insertions(+), 49 deletions(-) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 5869fd4..9e01039 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -110,31 +110,124 @@ def test_highlight(src, offset, exp): assert res == exp -@pytest.mark.parametrize('content,change,exp,offset_test,position_test', [ +@pytest.mark.parametrize('content,changes,exp,offset_test,position_test', [ ( 'This is a sentence.', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=0, - character=0, - ), - end=Position( - line=0, - character=4, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=0, + character=0, + ), + end=Position( + line=0, + character=4, + ), ), + text='That', ), - text='That', - ), + ], 'That is a sentence.\n', None, None, ), + ( + # Based on a bug in nvim + 'This is a sentence. This is another.\n' + '\n' + 'This is a new paragraph.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=19), + end=Position(line=0, character=36) + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=0), + end=Position(line=2, character=0) + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=0), + end=Position(line=1, character=24) + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=19), + end=Position(line=0, character=19) + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=0), + end=Position(line=2, character=0) + ), + text='', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=19), + end=Position(line=1, character=0) + ), + text='\n\n', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=0), + end=Position(line=1, character=0) + ), + text='\n', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=0) + ), + text='A', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=1), + end=Position(line=2, character=1) + ), + text='s', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=2), + end=Position(line=2, character=2) + ), + text='d', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=3), + end=Position(line=2, character=3) + ), + text='f', + ), + ], + 'This is a sentence.\n' + '\n' + 'Asdf\n', + None, + None, + ), ]) -def test_edits(content, change, exp, offset_test, position_test): +def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) doc.cleaned_source - doc.apply_change(change) + for change in changes: + doc.apply_change(change) assert doc.cleaned_source == exp if offset_test is not None: diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 567ce49..bb6af2b 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -524,41 +524,29 @@ def _get_edit_positions(self, change): def _get_last_node_for_edit(self, tree, start_point, end_point): node = None - edit_on_top = False - # old_tree_end_point = None capture = self._query.captures( tree.root_node, ) if len(capture) == 0: - return None, None, None + return None, None old_tree_end_point = capture[-1][0].end_point - nodes = self._query.captures(tree.root_node, start_point=start_point, end_point=end_point) - if len(nodes) > 0: - node = nodes[-1] + while True: + nodes = self._query.captures(tree.root_node, start_point=start_point, end_point=end_point) - if node is None: - # edit in empty line - nodes = self._query.captures( - tree.root_node, - start_point=(start_point[0]-1, start_point[1]), - end_point=end_point - ) if len(nodes) > 0: node = nodes[-1] + break - if node is None: - # edit in empty line at the top of the file - edit_on_top = True - nodes = self._query.captures( - tree.root_node, - start_point=(0, 0), - end_point=old_tree_end_point - ) - node = nodes[-1] + start_point = (start_point[0]-1, 0) + if start_point[0] < 0: + return None, None - return node[0], edit_on_top, old_tree_end_point + return Range( + start=Position(*node[0].start_point), + end=Position(*node[0].end_point) + ), old_tree_end_point def _build_updated_text_intervals( self, @@ -572,7 +560,6 @@ def _build_updated_text_intervals( text_bytes, old_last_edited_node, old_tree_end_point, - edit_on_top, ): text_intervals = OffsetPositionIntervalList() @@ -585,17 +572,14 @@ def _build_updated_text_intervals( ) offset = 0 - if edit_on_top: - sp = (0, 0) - ep = old_tree_end_point - elif start_point > old_tree_end_point: + if start_point > old_tree_end_point: # edit at the end of the file # need to extend the range to include the last node to avoid getting # a single newline node in node_iter below if old_end_point[1] > 0: - sp = (old_tree_end_point[0], old_tree_end_point[1]-1) + sp = (old_tree_end_point[0], max(0, old_tree_end_point[1]-1)) else: - sp = (old_tree_end_point[0]-1, 0) + sp = (max(0, old_tree_end_point[0]-1), 0) ep = new_end_point else: sp = start_point @@ -642,8 +626,8 @@ def _build_updated_text_intervals( # add remaining intervals shifted last_idx = self._text_intervals.get_idx_at_position( Position( - line=old_last_edited_node.end_point[0], - character=old_last_edited_node.end_point[1], + line=old_last_edited_node.end.line, + character=old_last_edited_node.end.character, ), strict=False, ) @@ -669,7 +653,7 @@ def _build_updated_text_intervals( end_line_offset = tmp end_char_offset = 0 elif (interval.position_range.start.line == end_line - and interval.position_range.start.character > end_col): + and interval.position_range.start.character >= end_col): row_tmp = new_end_point[0] - old_end_point[0] tmp = text_bytes - (end_col - start_col) start_line_offset = row_tmp @@ -722,7 +706,6 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 # bookkeeping for later source cleaning ( old_last_edited_node, - edit_on_top, old_tree_end_point ) = self._get_last_node_for_edit( tree, @@ -758,7 +741,6 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 text_bytes, old_last_edited_node, old_tree_end_point, - edit_on_top, ) self._text_intervals = text_intervals From c6163bfb59debde58e7b9834486b52c316708896 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 4 Nov 2023 11:54:39 +0100 Subject: [PATCH 14/28] bugfix: in non-strict interval search --- tests/documents/markdown_test.py | 63 ++++++++++++++++++++++++++++++++ textLSP/documents/document.py | 6 ++- textLSP/types.py | 5 ++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 9e01039..ac67271 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -222,6 +222,69 @@ def test_highlight(src, offset, exp): None, None, ), + ( + # Based on a bug in nvim + 'This is paragraph one.\n' + '\n' + 'Sentence one. Sentence two.\n' + '\n' + 'Sentence three.\n' + '\n' + '# Header\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=13), + end=Position(line=2, character=27), + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=3, character=0), + end=Position(line=4, character=0), + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=3, character=0), + end=Position(line=3, character=15), + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=13), + end=Position(line=2, character=13), + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=3, character=0), + end=Position(line=4, character=0), + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=13), + end=Position(line=2, character=13), + ), + text=' Sentence two.\n\nSentence three.' + ), + ], + 'This is paragraph one.\n' + '\n' + 'Sentence one. Sentence two.\n' + '\n' + 'Sentence three.\n' + '\n' + 'Header\n', + None, + None, + ), ]) def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index bb6af2b..ab9e993 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -533,7 +533,11 @@ def _get_last_node_for_edit(self, tree, start_point, end_point): old_tree_end_point = capture[-1][0].end_point while True: - nodes = self._query.captures(tree.root_node, start_point=start_point, end_point=end_point) + nodes = self._query.captures( + tree.root_node, + start_point=start_point, + end_point=end_point + ) if len(nodes) > 0: node = nodes[-1] diff --git a/textLSP/types.py b/textLSP/types.py index 64848e3..b4a6ab4 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -199,7 +199,10 @@ def get_idx_at_position(self, position: Position, strict=True) -> int: if self._position_start_character[idx] <= position.character <= self._position_end_character[idx]: return idx - if position.character < self._position_start_character[idx]: + if ( + position.line < self._position_start_line[idx] or + position.character < self._position_start_character[idx] + ): return None if strict else idx return None if strict else min(idx+1, length-1) From 0b99b36612696c904a119fac7299d246f1846e7b Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 4 Nov 2023 16:10:02 +0100 Subject: [PATCH 15/28] bugfix: handling multiple edits per change event when removing diagnostics/actions --- textLSP/analysers/analyser.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index da1f008..d3e73e1 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -95,7 +95,17 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): continue if change.range.start != change.range.end: - num = self.remove_code_items_at_rage(doc, change.range, (True, False)) + tmp_range = Range( + start=Position( + line=change.range.start.line-val, + character=change.range.start.character, + ), + end=Position( + line=change.range.end.line-val, + character=change.range.start.character, + ), + ) + num = self.remove_code_items_at_rage(doc, tmp_range, (True, False)) should_update_diagnostics = should_update_diagnostics or num > 0 change_text_len = len(change.text) From 6c257774573752f899372d8b5d191ed73bf15c5c Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 4 Nov 2023 17:03:34 +0100 Subject: [PATCH 16/28] bugfix: inline diagnostic/action shift --- textLSP/analysers/analyser.py | 10 +++++----- textLSP/analysers/gramformer/gramformer.py | 2 +- textLSP/analysers/grammarbot/grammarbot.py | 2 +- textLSP/analysers/hf_checker/hf_checker.py | 2 +- textLSP/analysers/languagetool/languagetool.py | 2 +- textLSP/analysers/openai/openai.py | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/textLSP/analysers/analyser.py b/textLSP/analysers/analyser.py index d3e73e1..77d1071 100644 --- a/textLSP/analysers/analyser.py +++ b/textLSP/analysers/analyser.py @@ -105,7 +105,7 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): character=change.range.start.character, ), ) - num = self.remove_code_items_at_rage(doc, tmp_range, (True, False)) + num = self.remove_code_items_at_range(doc, tmp_range, (True, False)) should_update_diagnostics = should_update_diagnostics or num > 0 change_text_len = len(change.text) @@ -115,7 +115,7 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): in_line_diff = change.range.start.character - change.range.end.character in_line_diff += change_text_len if in_line_diff != 0: - # in only some edit in a given line, let's shift the items + # if only edits in a given line, let's shift the items # in the line next_pos = Position( line=change.range.start.line+1, @@ -124,7 +124,7 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): for diag in list( self._diagnostics_dict[doc.uri].irange_values( - minimum=change.range.start, + minimum=change.range.end, maximum=next_pos, inclusive=(True, False) ) @@ -151,7 +151,7 @@ def _handle_line_shifts(self, params: DidChangeTextDocumentParams): for action in list( self._code_actions_dict[doc.uri].irange_values( - minimum=change.range.start, + minimum=change.range.end, maximum=next_pos, inclusive=(True, False) ) @@ -384,7 +384,7 @@ def add_diagnostics(self, doc: Document, diagnostics: List[Diagnostic]): self._diagnostics_dict[doc.uri].add(diag.range.start, diag) self.language_server.publish_stored_diagnostics(doc) - def remove_code_items_at_rage(self, doc: Document, pos_range: Range, inclusive=(True, True)): + def remove_code_items_at_range(self, doc: Document, pos_range: Range, inclusive=(True, True)): num = 0 num += self._diagnostics_dict[doc.uri].remove_between(pos_range, inclusive) num += self._code_actions_dict[doc.uri].remove_between(pos_range, inclusive) diff --git a/textLSP/analysers/gramformer/gramformer.py b/textLSP/analysers/gramformer/gramformer.py index 3b1354c..e9b3122 100644 --- a/textLSP/analysers/gramformer/gramformer.py +++ b/textLSP/analysers/gramformer/gramformer.py @@ -149,7 +149,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): paragraph.length, True ) - self.remove_code_items_at_rage(doc, pos_range) + self.remove_code_items_at_range(doc, pos_range) diags, actions = self._analyse_sentences( doc.text_at_offset( diff --git a/textLSP/analysers/grammarbot/grammarbot.py b/textLSP/analysers/grammarbot/grammarbot.py index 335bc17..64f21a5 100644 --- a/textLSP/analysers/grammarbot/grammarbot.py +++ b/textLSP/analysers/grammarbot/grammarbot.py @@ -119,7 +119,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): paragraph.length, True ) - self.remove_code_items_at_rage(doc, pos_range) + self.remove_code_items_at_range(doc, pos_range) paragraph_text = doc.text_at_offset(paragraph.start, paragraph.length) text += paragraph_text diff --git a/textLSP/analysers/hf_checker/hf_checker.py b/textLSP/analysers/hf_checker/hf_checker.py index 27d5197..72a3824 100644 --- a/textLSP/analysers/hf_checker/hf_checker.py +++ b/textLSP/analysers/hf_checker/hf_checker.py @@ -151,7 +151,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): paragraph.length, True ) - self.remove_code_items_at_rage(doc, pos_range) + self.remove_code_items_at_range(doc, pos_range) diags, actions = self._analyse_lines( doc.text_at_offset( diff --git a/textLSP/analysers/languagetool/languagetool.py b/textLSP/analysers/languagetool/languagetool.py index 92e1fce..69d7791 100644 --- a/textLSP/analysers/languagetool/languagetool.py +++ b/textLSP/analysers/languagetool/languagetool.py @@ -127,7 +127,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): end_sent.start-paragraph.start-1 + end_sent.length, True ) - self.remove_code_items_at_rage(doc, pos_range) + self.remove_code_items_at_range(doc, pos_range) diags, actions = self._analyse( doc.text_at_offset( diff --git a/textLSP/analysers/openai/openai.py b/textLSP/analysers/openai/openai.py index 0207c44..1aae271 100644 --- a/textLSP/analysers/openai/openai.py +++ b/textLSP/analysers/openai/openai.py @@ -189,7 +189,7 @@ def _handle_paragraph(self, doc: BaseDocument, paragraph: Interval): paragraph.length, True ) - self.remove_code_items_at_rage(doc, pos_range) + self.remove_code_items_at_range(doc, pos_range) diags, actions = self._analyse( doc.text_at_offset( From 7c5c46e3c5fd8ad49791db5bac8d8ec365f31a1b Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 4 Nov 2023 17:53:11 +0100 Subject: [PATCH 17/28] bugfix: incorrect search for edited paragraphs in analysers --- textLSP/analysers/gramformer/gramformer.py | 2 +- textLSP/analysers/grammarbot/grammarbot.py | 2 +- textLSP/analysers/hf_checker/hf_checker.py | 2 +- textLSP/analysers/languagetool/languagetool.py | 2 +- textLSP/analysers/openai/openai.py | 4 ++-- textLSP/documents/document.py | 10 +++++----- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/textLSP/analysers/gramformer/gramformer.py b/textLSP/analysers/gramformer/gramformer.py index e9b3122..c28035b 100644 --- a/textLSP/analysers/gramformer/gramformer.py +++ b/textLSP/analysers/gramformer/gramformer.py @@ -138,7 +138,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): for change in changes: paragraph = doc.paragraph_at_offset( change.start, - min_length=change.length, + min_offset=change.start + change.length-1, cleaned=True, ) if paragraph in checked: diff --git a/textLSP/analysers/grammarbot/grammarbot.py b/textLSP/analysers/grammarbot/grammarbot.py index 64f21a5..91a9389 100644 --- a/textLSP/analysers/grammarbot/grammarbot.py +++ b/textLSP/analysers/grammarbot/grammarbot.py @@ -107,7 +107,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): for change in changes: paragraph = doc.paragraph_at_offset( change.start, - min_length=change.length, + min_offset=change.start + change.length-1, cleaned=True, ) if paragraph in checked: diff --git a/textLSP/analysers/hf_checker/hf_checker.py b/textLSP/analysers/hf_checker/hf_checker.py index 72a3824..9736e95 100644 --- a/textLSP/analysers/hf_checker/hf_checker.py +++ b/textLSP/analysers/hf_checker/hf_checker.py @@ -140,7 +140,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): for change in changes: paragraph = doc.paragraph_at_offset( change.start, - min_length=change.length, + min_offset=change.start + change.length-1, cleaned=True, ) if paragraph in checked: diff --git a/textLSP/analysers/languagetool/languagetool.py b/textLSP/analysers/languagetool/languagetool.py index 69d7791..c83b0dc 100644 --- a/textLSP/analysers/languagetool/languagetool.py +++ b/textLSP/analysers/languagetool/languagetool.py @@ -82,7 +82,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): for change in changes: paragraph = doc.paragraph_at_offset( change.start, - min_length=change.length, + min_offset=change.start + change.length-1, cleaned=True, ) if paragraph in checked: diff --git a/textLSP/analysers/openai/openai.py b/textLSP/analysers/openai/openai.py index 1aae271..7039c5c 100644 --- a/textLSP/analysers/openai/openai.py +++ b/textLSP/analysers/openai/openai.py @@ -150,7 +150,7 @@ def _did_open(self, doc: BaseDocument): diagnostics = list() code_actions = list() checked = set() - for paragraph in doc.paragraphs_at_offset(0, len(doc.cleaned_source), True): + for paragraph in doc.paragraphs_at_offset(0, len(doc.cleaned_source), cleaned=True): diags, actions = self._handle_paragraph(doc, paragraph) diagnostics.extend(diags) code_actions.extend(actions) @@ -166,7 +166,7 @@ def _did_change(self, doc: BaseDocument, changes: List[Interval]): for change in changes: paragraph = doc.paragraph_at_offset( change.start, - min_length=change.length, + min_offset=change.start + change.length-1, cleaned=True, ) if paragraph in checked: diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index ab9e993..8672ea4 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -138,7 +138,7 @@ def sentence_at_offset(self, offset: int, min_length=0, cleaned=False) -> Interv return Interval(start_idx, end_idx-start_idx+1) - def paragraph_at_offset(self, offset: int, min_length=0, cleaned=False) -> Interval: + def paragraph_at_offset(self, offset: int, min_length=0, min_offset=0, cleaned=False) -> Interval: """ returns (start_offset, length) """ @@ -169,7 +169,7 @@ def paragraph_at_offset(self, offset: int, min_length=0, cleaned=False) -> Inter ): end_idx += 1 - if end_idx < len_source-1 and end_idx-start_idx+1 < min_length: + if end_idx < len_source-1 and (end_idx-start_idx+1 < min_length or end_idx <= min_offset): end_idx += 1 else: break @@ -182,12 +182,12 @@ def paragraph_at_position(self, position: Position, cleaned=False) -> Interval: return None return self.paragraph_at_offset(offset, cleaned=cleaned) - def paragraphs_at_offset(self, offset: int, min_length=0, cleaned=False) -> List[Interval]: + def paragraphs_at_offset(self, offset: int, min_length=0, min_offset=0, cleaned=False) -> List[Interval]: res = list() - doc_lenght = len(self.cleaned_source if cleaned else self.source) + doc_length = len(self.cleaned_source if cleaned else self.source) length = 0 - while offset < doc_lenght and (length < min_length or length == 0): + while offset < doc_length and (length < min_length or offset <= min_offset or length == 0): paragraph = self.paragraph_at_offset(offset, cleaned=cleaned) res.append(paragraph) From 2e2163c12cc2c6d803f41e6f4ee1e579cf4084e8 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sun, 5 Nov 2023 13:44:35 +0100 Subject: [PATCH 18/28] bugfix: handling edits which are empty parsed trees --- tests/documents/markdown_test.py | 20 ++++++++++++++++ textLSP/documents/document.py | 41 ++++++++++++++++---------------- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index ac67271..1dd302f 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -285,6 +285,26 @@ def test_highlight(src, offset, exp): None, None, ), + ( + 'This is paragraph one.\n' + '\n' + '\n' + 'Sentence one. Sentence two.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=0), + end=Position(line=2, character=0), + ), + text='\n\n', + ), + ], + 'This is paragraph one.\n' + '\n' + 'Sentence one. Sentence two.\n', + None, + None, + ), ]) def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 8672ea4..0fcf6c6 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -566,34 +566,35 @@ def _build_updated_text_intervals( old_tree_end_point, ): text_intervals = OffsetPositionIntervalList() - - if start_point == new_end_point: - # DELETE - # We might select an empty subtree -> extend the range - start_point = ( - start_point[0] if start_point[1] > 0 else start_point[0]-1, - start_point[1]-1 if start_point[1] > 0 else 0, - ) - offset = 0 + sp = start_point + ep = new_end_point + if start_point > old_tree_end_point: # edit at the end of the file - # need to extend the range to include the last node to avoid getting - # a single newline node in node_iter below + # need to extend the range to include the last node since there + # might be relevant content (e.g. multiple newlines) that was + # ignored since it was at the end if old_end_point[1] > 0: sp = (old_tree_end_point[0], max(0, old_tree_end_point[1]-1)) else: sp = (max(0, old_tree_end_point[0]-1), 0) - ep = new_end_point - else: - sp = start_point - ep = new_end_point - node_iter = self._iterate_text_nodes( - self.tree, - sp, - ep, - ) + + node_iter = self._iterate_text_nodes(self.tree, sp, ep) node = next(node_iter) + while node.text == '\n' and node.start_point == (0, 1) and node.end_point == (0, 1): + # empty tree is selected + assert next(node_iter, None) is None + if sp > (0, 0): + sp = (max(0, sp[0]-1), 0) + else: + node.start_point = start_point + node.end_point = start_point + break + + node_iter = self._iterate_text_nodes(self.tree, sp, ep) + node = next(node_iter) + # copy the text intervals up to the start of the change for interval_idx in range(len(self._text_intervals)): interval = self._text_intervals.get_interval(interval_idx) From c5a6d84830aa6518d6f1c17dd555b7d63beb791d Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sun, 5 Nov 2023 16:23:50 +0100 Subject: [PATCH 19/28] bugfix: incorrect addition of OffsetPositionInterval in OffsetPositionIntervalList; incorrect handling of separator newlines when parsing edits --- tests/analysers/analyser_test.py | 97 +++++++++++++++++++++++++++++++- tests/documents/markdown_test.py | 37 ++++++++++++ textLSP/documents/document.py | 36 +++++++----- textLSP/types.py | 2 +- 4 files changed, 155 insertions(+), 17 deletions(-) diff --git a/tests/analysers/analyser_test.py b/tests/analysers/analyser_test.py index 890b88f..aaa4c40 100644 --- a/tests/analysers/analyser_test.py +++ b/tests/analysers/analyser_test.py @@ -185,7 +185,7 @@ def test_line_shifts(text, edit, exp, json_converter, langtool_ls_onsave): ret = done.wait(1) done.clear() - # no diagnostics notification of none has changed + # no diagnostics notification if none has changed assert ret == edit[2] if edit[2]: assert len(diag_lst) == 2 @@ -411,3 +411,98 @@ def test_diagnostics_bug2(json_converter, langtool_ls_onsave): assert len(res_lst) == len(exp_lst) for exp, res in zip(exp_lst, res_lst): assert res['range'] == json_converter.unstructure(exp) + + +def test_diagnostics_bug3(json_converter, langtool_ls_onsave): + text = ('Thiiiis is paragraph one.\n' + '\n' + '\n' + '\n' + 'Sentence one. Sentence two.\n') + + done = Event() + results = list() + + langtool_ls_onsave.set_notification_callback( + session.PUBLISH_DIAGNOSTICS, + utils.get_notification_handler( + event=done, + results=results + ), + ) + + open_params = DidOpenTextDocumentParams( + TextDocumentItem( + uri='dummy.md', + language_id='md', + version=1, + text=text, + ) + ) + + langtool_ls_onsave.notify_did_open( + json_converter.unstructure(open_params) + ) + assert done.wait(30) + done.clear() + + change_params = DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier( + version=1, + uri='dummy.md', + ), + content_changes=[ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=0) + ), + text='A' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=1), + end=Position(line=2, character=1) + ), + text='s' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=2), + end=Position(line=2, character=2) + ), + text='d' + ), + ] + ) + langtool_ls_onsave.notify_did_change( + json_converter.unstructure(change_params) + ) + assert not done.wait(10) + done.clear() + + save_params = DidSaveTextDocumentParams( + text_document=TextDocumentIdentifier( + 'dummy.md' + ) + ) + langtool_ls_onsave.notify_did_save( + json_converter.unstructure(save_params) + ) + assert done.wait(30) + done.clear() + + exp_lst = [ + Range( + start=Position(line=0, character=0), + end=Position(line=0, character=7), + ), + Range( + start=Position(line=2, character=0), + end=Position(line=2, character=3), + ), + ] + res_lst = results[-1]['diagnostics'] + assert len(res_lst) == len(exp_lst) + for exp, res in zip(exp_lst, res_lst): + assert res['range'] == json_converter.unstructure(exp) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 1dd302f..4cc65e7 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -305,6 +305,43 @@ def test_highlight(src, offset, exp): None, None, ), + ( + 'This is paragraph one.\n' + '\n' + '\n' + '\n' + 'Sentence one. Sentence two.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=0) + ), + text='A' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=1), + end=Position(line=2, character=1) + ), + text='s' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=2), + end=Position(line=2, character=2) + ), + text='d' + ), + ], + 'This is paragraph one.\n' + '\n' + 'Asd\n' + '\n' + 'Sentence one. Sentence two.\n', + None, + None, + ), ]) def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 0fcf6c6..84e737a 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -640,6 +640,8 @@ def _build_updated_text_intervals( # we are actully at the end of the file so add the final newline text_intervals.add_interval_values(*tmp_intvals[-1]) else: + row_diff = new_end_point[0] - old_end_point[0] + col_diff = text_bytes - (end_col - start_col) for interval_idx in range(last_idx, len(self._text_intervals)): interval = self._text_intervals.get_interval(interval_idx) if ( @@ -649,30 +651,34 @@ def _build_updated_text_intervals( ): continue node_len = len(interval.value) - # FIXME should not calculate for each but once for all after edit - # and separately for those which are affected by the edit, do we have those? if interval.position_range.start.line > end_line: - tmp = new_end_point[0] - old_end_point[0] - start_line_offset = tmp + start_line_offset = row_diff start_char_offset = 0 - end_line_offset = tmp + end_line_offset = row_diff end_char_offset = 0 elif (interval.position_range.start.line == end_line and interval.position_range.start.character >= end_col): - row_tmp = new_end_point[0] - old_end_point[0] - tmp = text_bytes - (end_col - start_col) - start_line_offset = row_tmp - start_char_offset = tmp - end_line_offset = row_tmp + start_line_offset = row_diff + start_char_offset = col_diff + end_line_offset = row_diff if interval.position_range.end.line > interval.position_range.start.line: end_char_offset = 0 else: - end_char_offset = tmp + end_char_offset = col_diff else: - start_line_offset = 0 - start_char_offset = 0 - end_line_offset = 0 - end_char_offset = 0 + # These are the special newlines which are not in the source + # but added by the parser to separate paragraphs + assert (interval.value == '\n' and interval.position_range.start == + interval.position_range.end) + last_interval_range = text_intervals.get_interval(-1).position_range + interval_range = interval.position_range + # we need to set start and end position to the same value + # which is the same line as the last item in text_intervals + # and one column to the right + end_line_offset = last_interval_range.end.line - interval_range.end.line + start_line_offset = interval_range.end.line - interval_range.start.line + end_line_offset + end_char_offset = last_interval_range.end.character - interval_range.end.character + 1 + start_char_offset = interval_range.end.character - interval_range.start.character + end_char_offset text_intervals.add_interval_values( offset, diff --git a/textLSP/types.py b/textLSP/types.py index b4a6ab4..96da06e 100644 --- a/textLSP/types.py +++ b/textLSP/types.py @@ -86,7 +86,7 @@ def add_interval_values( def add_interval(self, interval: OffsetPositionInterval): self.add_interval_values( interval.offset_interval.start, - interval.offset_interval.start + interval.offset_interval.length, + interval.offset_interval.start + interval.offset_interval.length - 1, interval.position_range.start.line, interval.position_range.start.character, interval.position_range.end.line, From 9e1602b335fde17215d4317e28fe945a208fd1ab Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sun, 5 Nov 2023 17:31:29 +0100 Subject: [PATCH 20/28] bugfix: still issues with dummy newlines, these should be refactored --- tests/documents/markdown_test.py | 33 ++++++++++++++++++++++++++++++++ textLSP/documents/document.py | 23 ++++++++++++++++------ 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 4cc65e7..8d9caf9 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -342,6 +342,39 @@ def test_highlight(src, offset, exp): None, None, ), + ( + 'This is paragraph one.\n' + '\n' + 'Sentence one. Sentence two.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=22), + end=Position(line=0, character=22) + ), + text=' ' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=22), + end=Position(line=0, character=23) + ), + text='\n' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=0), + end=Position(line=1, character=0) + ), + text='A' + ), + ], + 'This is paragraph one. A\n' + '\n' + 'Sentence one. Sentence two.\n', + None, + None, + ), ]) def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 84e737a..0070620 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -598,12 +598,23 @@ def _build_updated_text_intervals( # copy the text intervals up to the start of the change for interval_idx in range(len(self._text_intervals)): interval = self._text_intervals.get_interval(interval_idx) - interval_end = ( - interval.position_range.end.line, - interval.position_range.end.character, - ) - if interval_end >= node.start_point: - break + if interval.value == '\n' and interval.position_range.start == interval.position_range.end: + # newline added by parser but not in source + interval_end = (interval.position_range.end.line+1, 0) + if interval_end >= node.start_point: + # FIXME This is very messy. Handling these dummy newlines + # should be refactored. + interval.value = ' ' + offset += len(interval.value) + text_intervals.add_interval(interval) + break + else: + interval_end = ( + interval.position_range.end.line, + interval.position_range.end.character, + ) + if interval_end >= node.start_point: + break offset += len(interval.value) text_intervals.add_interval(interval) From c61e1b6902a8eac2c9989e3263d6064503bfe74f Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 11 Nov 2023 14:15:34 +0100 Subject: [PATCH 21/28] bugfix: handling empty file --- textLSP/analysers/openai/openai.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/textLSP/analysers/openai/openai.py b/textLSP/analysers/openai/openai.py index 7039c5c..af84ef2 100644 --- a/textLSP/analysers/openai/openai.py +++ b/textLSP/analysers/openai/openai.py @@ -265,7 +265,10 @@ def get_code_actions(self, params: CodeActionParams) -> Optional[List[CodeAction if params.range.start != params.range.end: return res - line = doc.lines[params.range.start.line].strip() + if len(doc.lines) > 0: + line = doc.lines[params.range.start.line].strip() + else: + line = '' magic = self.config.get(self.CONFIGURATION_PROMPT_MAGIC, self.SETTINGS_DEFAULT_PROMPT_MAGIC) if magic in line: if res is None: From 3f3b77a08428981794fcfe743fc0e0f3d03c1a2d Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sun, 12 Nov 2023 16:11:30 +0100 Subject: [PATCH 22/28] bugfix: handling merged subtrees in TS edit --- tests/documents/latex_test.py | 325 +++++++++++++++++++------------ tests/documents/markdown_test.py | 125 +++++++++++- textLSP/documents/document.py | 69 +++++-- 3 files changed, 371 insertions(+), 148 deletions(-) diff --git a/tests/documents/latex_test.py b/tests/documents/latex_test.py index d1855f2..8c688ac 100644 --- a/tests/documents/latex_test.py +++ b/tests/documents/latex_test.py @@ -611,7 +611,7 @@ def test_change_tracker(content, edits, exp): assert tracker.get_changes() == exp -@pytest.mark.parametrize('content,change,exp,offset_test,position_test', [ +@pytest.mark.parametrize('content,changes,exp,offset_test,position_test', [ ( '\\documentclass[11pt]{article}\n' '\\begin{document}\n' @@ -621,20 +621,22 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n'*2 + '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - # add 'o' to Introduction - range=Range( - start=Position( - line=3, - character=13, - ), - end=Position( - line=3, - character=13, + [ + TextDocumentContentChangeEvent_Type1( + # add 'o' to Introduction + range=Range( + start=Position( + line=3, + character=13, + ), + end=Position( + line=3, + character=13, + ), ), + text='o', ), - text='o', - ), + ], 'Introoduction\n' '\n' + ' '.join(['This is a sentence.']*2) + @@ -651,20 +653,22 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n'*2 + '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - # delete 'o' from Introduction - range=Range( - start=Position( - line=3, - character=13, - ), - end=Position( - line=3, - character=14, + [ + TextDocumentContentChangeEvent_Type1( + # delete 'o' from Introduction + range=Range( + start=Position( + line=3, + character=13, + ), + end=Position( + line=3, + character=14, + ), ), + text='', ), - text='', - ), + ], 'Intrduction\n' '\n' + ' '.join(['This is a sentence.']*2) + @@ -688,20 +692,22 @@ def test_change_tracker(content, edits, exp): '\n' 'A final sentence.\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - # replace the word initial - range=Range( - start=Position( - line=5, - character=3, - ), - end=Position( - line=5, - character=10, + [ + TextDocumentContentChangeEvent_Type1( + # replace the word initial + range=Range( + start=Position( + line=5, + character=3, + ), + end=Position( + line=5, + character=10, + ), ), + text='\n\naaaaaaa', ), - text='\n\naaaaaaa', - ), + ], 'Introduction\n' '\n' 'An\n' @@ -740,19 +746,21 @@ def test_change_tracker(content, edits, exp): 'This is a sentence. \\section{Inline} FooBar\n' '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=5, - character=2, - ), - end=Position( - line=5, - character=2, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=5, + character=2, + ), + end=Position( + line=5, + character=2, + ), ), + text='oooooo', ), - text='oooooo', - ), + ], 'Introduction\n' '\n' 'Thoooooois is a sentence.\n' @@ -778,19 +786,21 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n' '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=6, - character=0, - ), - end=Position( - line=6, - character=0, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=6, + character=0, + ), + end=Position( + line=6, + character=0, + ), ), + text='o', ), - text='o', - ), + ], 'Introduction\n' '\n' + 'This is a sentence. o\n', @@ -809,19 +819,21 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n' '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=2, - character=0, - ), - end=Position( - line=2, - character=0, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=2, + character=0, + ), + end=Position( + line=2, + character=0, + ), ), + text='o', ), - text='o', - ), + ], 'o\n' '\n' 'Introduction\n' @@ -839,19 +851,21 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n' '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=2, - character=0, - ), - end=Position( - line=3, - character=0, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=2, + character=0, + ), + end=Position( + line=3, + character=0, + ), ), + text='', ), - text='', - ), + ], 'Introduction\n' '\n' + 'This is a sentence.\n', @@ -880,20 +894,22 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n' '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - # delete last character: '.' - range=Range( - start=Position( - line=5, - character=18, - ), - end=Position( - line=5, - character=19, + [ + TextDocumentContentChangeEvent_Type1( + # delete last character: '.' + range=Range( + start=Position( + line=5, + character=18, + ), + end=Position( + line=5, + character=19, + ), ), + text='', ), - text='', - ), + ], 'Introduction\n' '\n' + 'This is a sentence\n', @@ -910,20 +926,22 @@ def test_change_tracker(content, edits, exp): '\n' '\\end{document}\n' '\n', - TextDocumentContentChangeEvent_Type1( - # delete last character: '.' - range=Range( - start=Position( - line=8, - character=0, - ), - end=Position( - line=9, - character=0, + [ + TextDocumentContentChangeEvent_Type1( + # delete last character: '.' + range=Range( + start=Position( + line=8, + character=0, + ), + end=Position( + line=9, + character=0, + ), ), + text='', ), - text='', - ), + ], 'Introduction\n' '\n' + 'This is a sentence.\n', @@ -938,19 +956,21 @@ def test_change_tracker(content, edits, exp): '\n' 'This is a sentence.\n' '\n', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=6, - character=0, - ), - end=Position( - line=7, - character=0, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=6, + character=0, + ), + end=Position( + line=7, + character=0, + ), ), + text='\n\\end{document}\n', ), - text='\n\\end{document}\n', - ), + ], 'Introduction\n' '\n' + 'This is a sentence.\n', @@ -965,19 +985,21 @@ def test_change_tracker(content, edits, exp): 'This is a sentence.\n' '\n' '\\end{document}', - TextDocumentContentChangeEvent_Type1( - range=Range( - start=Position( - line=1, - character=16, - ), - end=Position( - line=2, - character=0, + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=1, + character=16, + ), + end=Position( + line=2, + character=0, + ), ), + text='\no\n', ), - text='\no\n', - ), + ], 'o\n' '\n' 'Introduction\n' @@ -986,12 +1008,57 @@ def test_change_tracker(content, edits, exp): None, None, ), + ( + '\\documentclass[11pt]{article}\n' + '\\begin{document}\n' + 'A sentence.\n' + 'Introduction\n' + 'This is a sentence.\n' + '\n' + '\\end{document}', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=3, + character=0, + ), + end=Position( + line=3, + character=0, + ), + ), + text='\\section{', + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position( + line=3, + character=21, + ), + end=Position( + line=3, + character=21, + ), + ), + text='}', + ), + ], + 'A sentence.\n' + '\n' + 'Introduction\n' + '\n' + + 'This is a sentence.\n', + None, + None, + ), ]) -def test_edits(content, change, exp, offset_test, position_test): +def test_edits(content, changes, exp, offset_test, position_test): doc = LatexDocument('DUMMY_URL', content) doc.cleaned_source start = time.time() - doc.apply_change(change) + for change in changes: + doc.apply_change(change) assert doc.cleaned_source == exp logging.warning(time.time() - start) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 8d9caf9..9747006 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -133,7 +133,7 @@ def test_highlight(src, offset, exp): None, ), ( - # Based on a bug in nvim + # Based on a bug, as done by in nvim 'This is a sentence. This is another.\n' '\n' 'This is a new paragraph.\n', @@ -375,6 +375,129 @@ def test_highlight(src, offset, exp): None, None, ), + ( + 'This is a sentence.\n' + '\n' + 'Header\n' + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=0) + ), + text='#' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=1), + end=Position(line=2, character=1) + ), + text=' ' + ), + ], + 'This is a sentence.\n' + '\n' + 'Header\n' + '\n' + 'This is a sentence.\n', + None, + None, + ), + ( + 'Header\n' + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=0), + end=Position(line=0, character=0) + ), + text='#' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=1), + end=Position(line=0, character=1) + ), + text=' ' + ), + ], + 'Header\n' + '\n' + 'This is a sentence.\n', + None, + None, + ), + ( + 'This is a sentence.\n' + '\n' + '# Header\n' + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=1), + end=Position(line=2, character=2) + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=1) + ), + text='' + ), + ], + 'This is a sentence.\n' + '\n' + 'Header This is a sentence.\n', + None, + None, + ), + ( + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=0), + end=Position(line=1, character=0) + ), + text='' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=0), + end=Position(line=0, character=0) + ), + text='This is a sentence.' + ), + ], + 'This is a sentence.\n', + None, + None, + ), + ( + '* This is point one.\n' + '* This is point two.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=0, character=0), + end=Position(line=0, character=0) + ), + text='* This is point one.\n' + ), + ], + 'This is point one.\n' + '\n' + 'This is point one.\n' + '\n' + 'This is point two.\n', + None, + None, + ), ]) def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 0070620..838450d 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -441,23 +441,28 @@ def _get_edit_positions(self, change): start_col = change_range.start.character end_line = change_range.end.line end_col = change_range.end.character - if end_line >= len(lines): - # this could happen eg when the last line is deleted - end_line = len(lines) - 1 - end_col = len(lines[end_line]) - 1 - - start_byte = len(bytes( - ''.join( - lines[:start_line] + [lines[start_line][:start_col+1]] - ), - 'utf-8', - )) - end_byte = len(bytes( - ''.join( - lines[:end_line] + [lines[end_line][:end_col+1]] - ), - 'utf-8', - )) + len_lines = len(lines) + if len_lines == 0: + start_byte = 0 + end_byte = 0 + else: + if end_line >= len(lines): + # this could happen eg when the last line is deleted + end_line = len(lines) - 1 + end_col = len(lines[end_line]) - 1 + + start_byte = len(bytes( + ''.join( + lines[:start_line] + [lines[start_line][:start_col]] + ), + 'utf-8', + )) + end_byte = len(bytes( + ''.join( + lines[:end_line] + [lines[end_line][:end_col]] + ), + 'utf-8', + )) text_bytes = len(bytes(change.text, 'utf-8')) if end_byte - start_byte == 0: @@ -532,6 +537,9 @@ def _get_last_node_for_edit(self, tree, start_point, end_point): old_tree_end_point = capture[-1][0].end_point + if start_point == end_point: + # avoid empty interval + end_point = (end_point[0], end_point[1]+1) while True: nodes = self._query.captures( tree.root_node, @@ -558,6 +566,9 @@ def _build_updated_text_intervals( start_col, end_line, end_col, + start_byte, + old_end_byte, + new_end_byte, start_point, old_end_point, new_end_point, @@ -568,7 +579,18 @@ def _build_updated_text_intervals( text_intervals = OffsetPositionIntervalList() offset = 0 sp = start_point - ep = new_end_point + if new_end_byte > old_end_byte: + # the node could have been broken into multiple nodes + # we parse all + ep = max( + new_end_point, + ( + old_last_edited_node.end.line, + old_last_edited_node.end.character + ) + ) + else: + ep = new_end_point if start_point > old_tree_end_point: # edit at the end of the file @@ -651,6 +673,14 @@ def _build_updated_text_intervals( # we are actully at the end of the file so add the final newline text_intervals.add_interval_values(*tmp_intvals[-1]) else: + while last_idx > 0: + interval = self._text_intervals.get_interval(last_idx-1) + if (interval.value != '\n' or interval.position_range.start != + interval.position_range.end): + # not dummy newline + break + last_idx -= 1 + row_diff = new_end_point[0] - old_end_point[0] col_diff = text_bytes - (end_col - start_col) for interval_idx in range(last_idx, len(self._text_intervals)): @@ -757,6 +787,9 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 start_col, end_line, end_col, + start_byte, + old_end_byte, + new_end_byte, start_point, old_end_point, new_end_point, From 4106baa0df59cacac3f70b9b2227c75533993643 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Mon, 13 Nov 2023 17:50:06 +0100 Subject: [PATCH 23/28] bugfix: handling issues related to edits resulting in merges TS subtrees --- textLSP/documents/document.py | 80 ++++++++++++++++++++------ textLSP/documents/markdown/markdown.py | 10 ++++ 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 838450d..d607dd7 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -573,24 +573,15 @@ def _build_updated_text_intervals( old_end_point, new_end_point, text_bytes, - old_last_edited_node, + last_changed_point, old_tree_end_point, ): text_intervals = OffsetPositionIntervalList() offset = 0 sp = start_point - if new_end_byte > old_end_byte: - # the node could have been broken into multiple nodes - # we parse all - ep = max( - new_end_point, - ( - old_last_edited_node.end.line, - old_last_edited_node.end.character - ) - ) - else: - ep = new_end_point + # last_changed_point is needed to handle subtrees being broken into + # multiple ones + ep = max(new_end_point, last_changed_point) if start_point > old_tree_end_point: # edit at the end of the file @@ -643,6 +634,8 @@ def _build_updated_text_intervals( # handle the nodes that were in the edited subtree tmp_intvals = list() + last_new_node = None + tmp_node = None for node in chain([node], node_iter): node_len = len(node) tmp_intvals.append(( @@ -655,6 +648,12 @@ def _build_updated_text_intervals( node.text, )) offset += node_len + last_new_node = tmp_node + tmp_node = node + + if last_new_node is None: + return None + for interval in tmp_intvals[:-1]: # there's always a newline return at the end of the file which # is not needed if we are not really at the end of the file yet @@ -662,10 +661,49 @@ def _build_updated_text_intervals( offset -= len(tmp_intvals[-1][6]) # add remaining intervals shifted + last_new_end_point = last_new_node.end_point + row_diff = new_end_point[0] - old_end_point[0] + if last_new_end_point[0] < new_end_point[0]: + # parse ended before the edit, happens when non parseable + # part is edited or all content was deleted + last_new_end_point = ( + max(old_end_point, new_end_point)[0], + max(old_end_point, new_end_point)[1] + 1 + ) + elif last_new_end_point[0] > new_end_point[0]: + # parse ended in a later line as the edit, i.e. its + # position is only affected by line shift + last_new_end_point = ( + # last_new_end_point[0] - row_diff, + # last_new_end_point[1] + 1 + max(last_changed_point, last_new_end_point)[0] - row_diff, + max(last_changed_point, last_new_end_point)[1] + 1 + ) + elif row_diff == 0: + # the parse ended in the line of the edit + last_new_end_point = ( + last_new_end_point[0], + last_new_end_point[1] - (new_end_point[1] - old_end_point[1]) + 1 + ) + elif row_diff > 0: + # the edit was in the line of the last node which is now + # shifted + last_new_end_point = ( + last_new_end_point[0] - row_diff, + old_end_point[1] + last_new_end_point[1] - new_end_point[1] + 1 + ) + else: + # the edit was in the line of the last node which is now + # shifted + last_new_end_point = ( + last_new_end_point[0] - row_diff, + new_end_point[1] + last_new_end_point[1] - old_end_point[1] + 1 + ) + last_idx = self._text_intervals.get_idx_at_position( Position( - line=old_last_edited_node.end.line, - character=old_last_edited_node.end.character, + line=max(0, last_new_end_point[0]), + character=max(0, last_new_end_point[1]) ), strict=False, ) @@ -756,6 +794,7 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 ) = self._get_edit_positions(change) # bookkeeping for later source cleaning + # TODO remove this part ( old_last_edited_node, old_tree_end_point @@ -780,6 +819,10 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 tree ) + last_changed_point = (0, 0) + for change in tree.get_changed_ranges(self.tree): + last_changed_point = max(last_changed_point, change.end_point) + if old_tree_end_point is not None: # rebuild the cleaned source text_intervals = self._build_updated_text_intervals( @@ -794,12 +837,13 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 old_end_point, new_end_point, text_bytes, - old_last_edited_node, + last_changed_point, old_tree_end_point, ) - self._text_intervals = text_intervals - self._cleaned_source = ''.join(self._text_intervals.values) + if text_intervals is not None: + self._text_intervals = text_intervals + self._cleaned_source = ''.join(self._text_intervals.values) else: self._clean_source() diff --git a/textLSP/documents/markdown/markdown.py b/textLSP/documents/markdown/markdown.py index 3d199bc..16af8e9 100644 --- a/textLSP/documents/markdown/markdown.py +++ b/textLSP/documents/markdown/markdown.py @@ -81,6 +81,16 @@ def _iterate_text_nodes( last_sent = None new_lines_after = list() + if start_point == end_point: + # FIXME This is a weird issue, it seems that in some cases nothing + # is selected if the interval is empty, but not in all cases. See + # markdown_text.py test_edits() where first two characters of + # '# Header' is removed + end_point = ( + end_point[0], + end_point[1] + 1 + ) + for node in self._query.captures(tree.root_node, start_point=start_point, end_point=end_point): # Check if we need some newlines after previous elements while len(new_lines_after) > 0: From 93f8cedf9a1ac0c8d2a5423e5c91b6a3d31a425b Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Mon, 13 Nov 2023 18:56:58 +0100 Subject: [PATCH 24/28] bit of refactoring --- textLSP/documents/document.py | 288 ++++++++++++++++++++++------------ 1 file changed, 192 insertions(+), 96 deletions(-) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index d607dd7..0a4e955 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -19,6 +19,7 @@ from ..utils import get_class, synchronized, git_clone, get_user_cache from ..types import ( + OffsetPositionInterval, OffsetPositionIntervalList, Interval ) @@ -560,24 +561,14 @@ def _get_last_node_for_edit(self, tree, start_point, end_point): end=Position(*node[0].end_point) ), old_tree_end_point - def _build_updated_text_intervals( + def _get_node_and_iterator_for_edit( self, - start_line, - start_col, - end_line, - end_col, - start_byte, - old_end_byte, - new_end_byte, start_point, old_end_point, new_end_point, - text_bytes, last_changed_point, old_tree_end_point, ): - text_intervals = OffsetPositionIntervalList() - offset = 0 sp = start_point # last_changed_point is needed to handle subtrees being broken into # multiple ones @@ -608,7 +599,13 @@ def _build_updated_text_intervals( node_iter = self._iterate_text_nodes(self.tree, sp, ep) node = next(node_iter) - # copy the text intervals up to the start of the change + return node, chain([node], node_iter) + + def _get_intervals_before_edit( + self, + node, + ): + # offset = 0 for interval_idx in range(len(self._text_intervals)): interval = self._text_intervals.get_interval(interval_idx) if interval.value == '\n' and interval.position_range.start == interval.position_range.end: @@ -618,8 +615,9 @@ def _build_updated_text_intervals( # FIXME This is very messy. Handling these dummy newlines # should be refactored. interval.value = ' ' - offset += len(interval.value) - text_intervals.add_interval(interval) + # offset += len(interval.value) + # text_intervals.add_interval(interval) + yield interval break else: interval_end = ( @@ -629,39 +627,52 @@ def _build_updated_text_intervals( if interval_end >= node.start_point: break - offset += len(interval.value) - text_intervals.add_interval(interval) + # offset += len(interval.value) + # text_intervals.add_interval(interval) + yield interval - # handle the nodes that were in the edited subtree + def _get_edited_intervals_and_last_node( + self, + node_iter, + offset, + ): tmp_intvals = list() last_new_node = None tmp_node = None - for node in chain([node], node_iter): + for node in node_iter: node_len = len(node) - tmp_intvals.append(( - offset, - offset+node_len-1, - node.start_point[0], - node.start_point[1], - node.end_point[0], - node.end_point[1], - node.text, - )) + tmp_intvals.append( + OffsetPositionInterval( + offset_interval=Interval( + start=offset, + length=node_len + ), + position_range=Range( + start=Position( + line=node.start_point[0], + character=node.start_point[1], + ), + end=Position( + line=node.end_point[0], + character=node.end_point[1], + ), + ), + value=node.text, + ) + ) offset += node_len last_new_node = tmp_node tmp_node = node - if last_new_node is None: - return None + return tmp_intvals, last_new_node - for interval in tmp_intvals[:-1]: - # there's always a newline return at the end of the file which - # is not needed if we are not really at the end of the file yet - text_intervals.add_interval_values(*interval) - offset -= len(tmp_intvals[-1][6]) - - # add remaining intervals shifted - last_new_end_point = last_new_node.end_point + def _get_idx_after_edited_tree( + self, + old_end_point, + new_end_point, + last_new_end_point, + last_changed_point + ): row_diff = new_end_point[0] - old_end_point[0] if last_new_end_point[0] < new_end_point[0]: # parse ended before the edit, happens when non parseable @@ -683,7 +694,7 @@ def _build_updated_text_intervals( # the parse ended in the line of the edit last_new_end_point = ( last_new_end_point[0], - last_new_end_point[1] - (new_end_point[1] - old_end_point[1]) + 1 + last_new_end_point[1] - (new_end_point[1] - old_end_point[1])+1 ) elif row_diff > 0: # the edit was in the line of the last node which is now @@ -707,68 +718,153 @@ def _build_updated_text_intervals( ), strict=False, ) + return last_idx + + def _handle_intervals_after_edit_shifted( + self, + last_idx, + start_col, + end_line, + end_col, + old_end_point, + new_end_point, + text_bytes, + offset, + text_intervals, + ): + while last_idx > 0: + interval = self._text_intervals.get_interval(last_idx-1) + if (interval.value != '\n' or interval.position_range.start != + interval.position_range.end): + # not dummy newline + break + last_idx -= 1 + + row_diff = new_end_point[0] - old_end_point[0] + col_diff = text_bytes - (end_col - start_col) + for interval_idx in range(last_idx, len(self._text_intervals)): + interval = self._text_intervals.get_interval(interval_idx) + if ( + len(text_intervals) == 0 + and interval.value.count('\n') > 0 + and interval.value.strip() == '' + ): + continue + node_len = len(interval.value) + if interval.position_range.start.line > end_line: + start_line_offset = row_diff + start_char_offset = 0 + end_line_offset = row_diff + end_char_offset = 0 + elif (interval.position_range.start.line == end_line + and interval.position_range.start.character >= end_col): + start_line_offset = row_diff + start_char_offset = col_diff + end_line_offset = row_diff + if interval.position_range.end.line > interval.position_range.start.line: + end_char_offset = 0 + else: + end_char_offset = col_diff + else: + # These are the special newlines which are not in the source + # but added by the parser to separate paragraphs + assert (interval.value == '\n' and interval.position_range.start == + interval.position_range.end) + last_interval_range = text_intervals.get_interval(-1).position_range + interval_range = interval.position_range + # we need to set start and end position to the same value + # which is the same line as the last item in text_intervals + # and one column to the right + end_line_offset = last_interval_range.end.line - interval_range.end.line + start_line_offset = interval_range.end.line - interval_range.start.line + end_line_offset + end_char_offset = last_interval_range.end.character - interval_range.end.character + 1 + start_char_offset = interval_range.end.character - interval_range.start.character + end_char_offset + + text_intervals.add_interval_values( + offset, + offset+node_len-1, + interval.position_range.start.line + start_line_offset, + interval.position_range.start.character + start_char_offset, + interval.position_range.end.line + end_line_offset, + interval.position_range.end.character + end_char_offset, + interval.value, + ) + offset += node_len + + def _build_updated_text_intervals( + self, + start_line, + start_col, + end_line, + end_col, + start_byte, + old_end_byte, + new_end_byte, + start_point, + old_end_point, + new_end_point, + text_bytes, + last_changed_point, + old_tree_end_point, + ): + text_intervals = OffsetPositionIntervalList() + + # get first edited node and iterator for all edited nodes + node, node_iter = self._get_node_and_iterator_for_edit( + start_point, + old_end_point, + new_end_point, + last_changed_point, + old_tree_end_point, + ) + + # copy the text intervals up to the start of the change + for interval in self._get_intervals_before_edit(node): + text_intervals.add_interval(interval) + + if len(text_intervals) > 0: + offset = interval.offset_interval.start + interval.offset_interval.length + else: + offset = 0 + + # handle the nodes that were in the edited subtree + new_intervals, last_new_node = self._get_edited_intervals_and_last_node( + node_iter, + offset, + ) + if last_new_node is None: + return None + + for interval in new_intervals[:-1]: + # there's always a newline return at the end of the file which + # is not needed if we are not really at the end of the file yet + # text_intervals.add_interval_values(*interval) + text_intervals.add_interval(interval) + offset = interval.offset_interval.start + interval.offset_interval.length + + # add remaining intervals shifted + last_new_end_point = last_new_node.end_point + last_idx = self._get_idx_after_edited_tree( + old_end_point, + new_end_point, + last_new_end_point, + last_changed_point + ) if last_idx+1 >= len(self._text_intervals): # we are actully at the end of the file so add the final newline - text_intervals.add_interval_values(*tmp_intvals[-1]) + text_intervals.add_interval(new_intervals[-1]) else: - while last_idx > 0: - interval = self._text_intervals.get_interval(last_idx-1) - if (interval.value != '\n' or interval.position_range.start != - interval.position_range.end): - # not dummy newline - break - last_idx -= 1 - - row_diff = new_end_point[0] - old_end_point[0] - col_diff = text_bytes - (end_col - start_col) - for interval_idx in range(last_idx, len(self._text_intervals)): - interval = self._text_intervals.get_interval(interval_idx) - if ( - len(text_intervals) == 0 - and interval.value.count('\n') > 0 - and interval.value.strip() == '' - ): - continue - node_len = len(interval.value) - if interval.position_range.start.line > end_line: - start_line_offset = row_diff - start_char_offset = 0 - end_line_offset = row_diff - end_char_offset = 0 - elif (interval.position_range.start.line == end_line - and interval.position_range.start.character >= end_col): - start_line_offset = row_diff - start_char_offset = col_diff - end_line_offset = row_diff - if interval.position_range.end.line > interval.position_range.start.line: - end_char_offset = 0 - else: - end_char_offset = col_diff - else: - # These are the special newlines which are not in the source - # but added by the parser to separate paragraphs - assert (interval.value == '\n' and interval.position_range.start == - interval.position_range.end) - last_interval_range = text_intervals.get_interval(-1).position_range - interval_range = interval.position_range - # we need to set start and end position to the same value - # which is the same line as the last item in text_intervals - # and one column to the right - end_line_offset = last_interval_range.end.line - interval_range.end.line - start_line_offset = interval_range.end.line - interval_range.start.line + end_line_offset - end_char_offset = last_interval_range.end.character - interval_range.end.character + 1 - start_char_offset = interval_range.end.character - interval_range.start.character + end_char_offset - - text_intervals.add_interval_values( + self._handle_intervals_after_edit_shifted( + last_idx, + start_col, + end_line, + end_col, + old_end_point, + new_end_point, + text_bytes, offset, - offset+node_len-1, - interval.position_range.start.line + start_line_offset, - interval.position_range.start.character + start_char_offset, - interval.position_range.end.line + end_line_offset, - interval.position_range.end.character + end_char_offset, - interval.value, - ) - offset += node_len + text_intervals, + ) return text_intervals From 9bc4ad0705ed358440e0a9ae45bdb864ff3e040d Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Wed, 15 Nov 2023 16:27:48 +0100 Subject: [PATCH 25/28] updateing dependency versions --- .github/workflows/publish-to-pypi.yml | 2 +- setup.py | 24 ++++++++++++------------ textLSP/analysers/openai/openai.py | 17 +++++++++-------- textLSP/documents/document.py | 14 ++++++++------ textLSP/server.py | 2 +- textLSP/utils.py | 4 ++-- textLSP/workspace.py | 18 ++++++++++-------- 7 files changed, 43 insertions(+), 38 deletions(-) diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index d03aa7e..dba9243 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Install pypa/setuptools run: >- python -m diff --git a/setup.py b/setup.py index a365a9d..e580122 100644 --- a/setup.py +++ b/setup.py @@ -2,9 +2,9 @@ import sys from setuptools import setup, find_packages -if sys.version_info >= (3, 11, 0): +if sys.version_info >= (3, 12, 0): # due to current pytorch limitations - print('Required python version <= 3.11.0') + print('Required python version <= 3.12.0') sys.exit(-1) @@ -29,25 +29,25 @@ def read(fname): "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", ], - entry_points = { + entry_points={ 'console_scripts': ['textlsp=textLSP.cli:main'], }, install_requires=[ - 'pygls==1.0.0', - 'lsprotocol==2022.0.0a9', + 'pygls==1.1.2', + 'lsprotocol==2023.0.0b1', 'language-tool-python==2.7.1', - 'tree_sitter==0.20.1', - 'gitpython==3.1.29', + 'tree_sitter==0.20.4', + 'gitpython==3.1.40', 'appdirs==1.4.4', - 'torch==1.13.1', - 'openai==0.26.4', - 'transformers==4.25.1', + 'torch==2.1.0', + 'openai==1.2.4', + 'transformers==4.35.1', 'sortedcontainers==2.4.0', ], extras_require={ 'dev': [ - 'pytest', - 'python-lsp-jsonrpc', + 'pytest==7.4.3', + 'python-lsp-jsonrpc==1.1.2', ] }, ) diff --git a/textLSP/analysers/openai/openai.py b/textLSP/analysers/openai/openai.py index af84ef2..4c22656 100644 --- a/textLSP/analysers/openai/openai.py +++ b/textLSP/analysers/openai/openai.py @@ -1,6 +1,6 @@ import logging import openai -from openai.error import OpenAIError +from openai import OpenAI, APIError from typing import List, Tuple, Optional from lsprotocol.types import ( @@ -54,19 +54,20 @@ def __init__(self, language_server: LanguageServer, config: dict, name: str): super().__init__(language_server, config, name) if self.CONFIGURATION_API_KEY not in self.config: raise ConfigurationError(f'Reqired parameter: {name}.{self.CONFIGURATION_API_KEY}') - openai.api_key = self.config[self.CONFIGURATION_API_KEY] + self._client = OpenAI(api_key=self.config[self.CONFIGURATION_API_KEY]) def _edit(self, text) -> List[TokenDiff]: try: - res = openai.Edit.create( + # res = openai.Edit.create( + res = self._client.edits.create( model=self.config.get(self.CONFIGURATION_EDIT_MODEL, self.SETTINGS_DEFAULT_EDIT_MODEL), instruction=self.config.get(self.CONFIGURATION_EDIT_INSTRUCTION, self.SETTINGS_DEFAULT_EDIT_INSTRUCTION), input=text, temperature=self.config.get(self.CONFIGURATION_TEMPERATURE, self.SETTINGS_DEFAULT_TEMPERATURE), ) if len(res.choices) > 0: - return TokenDiff.token_level_diff(text, res.choices[0]['text'].strip()) - except OpenAIError as e: + return TokenDiff.token_level_diff(text, res.choices[0].text.strip()) + except APIError as e: self.language_server.show_message( str(e), MessageType.Error, @@ -76,7 +77,7 @@ def _edit(self, text) -> List[TokenDiff]: def _generate(self, text) -> Optional[str]: try: - res = openai.Completion.create( + res = self._client.completions.create( model=self.config.get(self.CONFIGURATION_MODEL, self.SETTINGS_DEFAULT_MODEL), prompt=text, temperature=self.config.get(self.CONFIGURATION_TEMPERATURE, self.SETTINGS_DEFAULT_TEMPERATURE), @@ -84,8 +85,8 @@ def _generate(self, text) -> Optional[str]: ) if len(res.choices) > 0: - return res.choices[0]['text'].strip() - except OpenAIError as e: + return res.choices[0].text.strip() + except APIError as e: self.language_server.show_message( str(e), MessageType.Error, diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 0a4e955..36d9737 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -14,7 +14,8 @@ TextDocumentContentChangeEvent_Type1, TextDocumentContentChangeEvent_Type2, ) -from pygls.workspace import Document, position_from_utf16, range_from_utf16 +from pygls.workspace import TextDocument +from pygls.workspace.position_codec import PositionCodec from tree_sitter import Language, Parser, Tree, Node from ..utils import get_class, synchronized, git_clone, get_user_cache @@ -26,9 +27,10 @@ from .. import documents logger = logging.getLogger(__name__) +_codec = PositionCodec() -class BaseDocument(Document): +class BaseDocument(TextDocument): def __init__(self, *args, config: Dict = None, **kwargs): super().__init__(*args, **kwargs) if config is None: @@ -102,7 +104,7 @@ def range_at_offset(self, offset: int, length: int, cleaned=False) -> Range: def offset_at_position(self, position: Position, cleaned=False) -> int: # doesn't really matter lines = self.cleaned_lines if cleaned else self.lines - pos = position_from_utf16(lines, position) + pos = _codec.position_from_client_units(lines, position) row, col = pos.line, pos.character return col + sum(len(line) for line in lines[:row]) @@ -437,7 +439,7 @@ def _iterate_text_nodes( def _get_edit_positions(self, change): lines = self.lines change_range = change.range - change_range = range_from_utf16(lines, change_range) + change_range = _codec.range_from_client_units(lines, change_range) start_line = change_range.start.line start_col = change_range.start.character end_line = change_range.end.line @@ -916,7 +918,7 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 ) last_changed_point = (0, 0) - for change in tree.get_changed_ranges(self.tree): + for change in tree.changed_ranges(self.tree): last_changed_point = max(last_changed_point, change.end_point) if old_tree_end_point is not None: @@ -1068,7 +1070,7 @@ def get_document( version: Optional[int] = None, language_id: Optional[str] = None, sync_kind=None, - ) -> Document: + ) -> TextDocument: try: type = DocumentTypeFactory.get_file_type(language_id) cls = get_class( diff --git a/textLSP/server.py b/textLSP/server.py index 24e6fa4..506a2eb 100644 --- a/textLSP/server.py +++ b/textLSP/server.py @@ -47,7 +47,7 @@ def __init__(self, *args, **kwargs): @lsp_method(INITIALIZE) def lsp_initialize(self, params: InitializeParams) -> InitializeResult: result = super().lsp_initialize(params) - self.workspace = TextLSPWorkspace.workspace2textlspworkspace( + self._workspace = TextLSPWorkspace.workspace2textlspworkspace( self.workspace, self._server.analyser_handler, self._server.settings, diff --git a/textLSP/utils.py b/textLSP/utils.py index a3a2283..63a4d93 100644 --- a/textLSP/utils.py +++ b/textLSP/utils.py @@ -1,9 +1,9 @@ import sys import importlib import inspect -import pkg_resources import re +from importlib.metadata import version from functools import wraps from threading import RLock from git import Repo @@ -72,7 +72,7 @@ def get_textlsp_name(): def get_textlsp_version(): - pkg_resources.require(get_textlsp_name())[0].version + return version(get_textlsp_name()) def get_user_cache(app_name=None): diff --git a/textLSP/workspace.py b/textLSP/workspace.py index 8d5003a..9dddbf5 100644 --- a/textLSP/workspace.py +++ b/textLSP/workspace.py @@ -6,7 +6,7 @@ TextDocumentContentChangeEvent, VersionedTextDocumentIdentifier, ) -from pygls.workspace import Workspace, Document +from pygls.workspace import Workspace, TextDocument from .documents.document import DocumentTypeFactory from .analysers.handler import AnalyserHandler @@ -21,13 +21,13 @@ def __init__(self, analyser_handler: AnalyserHandler, settings: Dict, *args, **k self.analyser_handler = analyser_handler self.settings = settings - def _create_document( + def _create_text_document( self, doc_uri: str, source: Optional[str] = None, version: Optional[int] = None, language_id: Optional[str] = None, - ) -> Document: + ) -> TextDocument: return DocumentTypeFactory.get_document( doc_uri=doc_uri, config=self.settings, @@ -59,9 +59,11 @@ def update_settings(self, settings): self.settings = merge_dicts(self.settings, settings) - def update_document(self, - text_doc: VersionedTextDocumentIdentifier, - change: TextDocumentContentChangeEvent): - doc = self._docs[text_doc.uri] + def update_text_document( + self, + text_doc: VersionedTextDocumentIdentifier, + change: TextDocumentContentChangeEvent + ): + doc = self._text_documents[text_doc.uri] self.analyser_handler.update_document(doc, change) - super().update_document(text_doc, change) + super().update_text_document(text_doc, change) From 84b2302441294f3d9ad550af8df8e224a9a29525 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Thu, 16 Nov 2023 19:12:25 +0100 Subject: [PATCH 26/28] further cleanup and some small bugfixes --- tests/documents/markdown_test.py | 90 ++++++++++++++++++++++++++++ textLSP/documents/document.py | 100 ++++++++++++++++--------------- 2 files changed, 141 insertions(+), 49 deletions(-) diff --git a/tests/documents/markdown_test.py b/tests/documents/markdown_test.py index 9747006..1dd8216 100644 --- a/tests/documents/markdown_test.py +++ b/tests/documents/markdown_test.py @@ -498,6 +498,96 @@ def test_highlight(src, offset, exp): None, None, ), + ( + 'This is a sentence.\n' + 'A\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=1), + end=Position(line=1, character=1) + ), + text='B' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=2), + end=Position(line=1, character=2) + ), + text=' ' + ), + ], + 'This is a sentence. AB\n', + None, + None, + ), + ( + 'This is a sentence.\n' + 'A\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=1), + end=Position(line=1, character=1) + ), + text=' ' + ), + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=1, character=2), + end=Position(line=1, character=2) + ), + text=' ' + ), + ], + 'This is a sentence. A\n', + None, + None, + ), + ( + 'This is a sentence.\n' + '\n' + ' This will be an unparsed part.\n' + '\n' + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=0) + ), + text=' ' + ), + ], + 'This is a sentence.\n' + '\n' + 'This is a sentence.\n', + None, + None, + ), + ( + 'This is a sentence.\n' + '\n' + ' This will be a parsed part.\n' + '\n' + 'This is a sentence.\n', + [ + TextDocumentContentChangeEvent_Type1( + range=Range( + start=Position(line=2, character=0), + end=Position(line=2, character=1) + ), + text='' + ), + ], + 'This is a sentence.\n' + '\n' + 'This will be a parsed part.\n' + '\n' + 'This is a sentence.\n', + None, + None, + ), ]) def test_edits(content, changes, exp, offset_test, position_test): doc = MarkDownDocument('DUMMY_URL', content) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index 36d9737..e48e26e 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -530,51 +530,33 @@ def _get_edit_positions(self, change): new_end_point, ) - def _get_last_node_for_edit(self, tree, start_point, end_point): - node = None - capture = self._query.captures( - tree.root_node, - ) - if len(capture) == 0: - return None, None - - old_tree_end_point = capture[-1][0].end_point - - if start_point == end_point: - # avoid empty interval - end_point = (end_point[0], end_point[1]+1) - while True: - nodes = self._query.captures( - tree.root_node, - start_point=start_point, - end_point=end_point - ) - - if len(nodes) > 0: - node = nodes[-1] - break - - start_point = (start_point[0]-1, 0) - if start_point[0] < 0: - return None, None - - return Range( - start=Position(*node[0].start_point), - end=Position(*node[0].end_point) - ), old_tree_end_point - def _get_node_and_iterator_for_edit( self, start_point, old_end_point, new_end_point, last_changed_point, + old_tree_first_node_new_end_point, old_tree_end_point, ): sp = start_point + if len(self._text_intervals) > 0: + old_first_interval_end_point = ( + self._text_intervals.get_interval(0).position_range.end.line, + self._text_intervals.get_interval(0).position_range.end.character + ) + else: + old_first_interval_end_point = (0, 0) + if start_point < old_first_interval_end_point: + # there's new content at the beginning, we need to parse the next + # subtree as well, since there are no necesary whitespace tokens in + # the current text_intervals + tmp_point = old_tree_first_node_new_end_point + else: + tmp_point = (0, 0) # last_changed_point is needed to handle subtrees being broken into # multiple ones - ep = max(new_end_point, last_changed_point) + ep = max(tmp_point, new_end_point, last_changed_point) if start_point > old_tree_end_point: # edit at the end of the file @@ -672,9 +654,14 @@ def _get_idx_after_edited_tree( self, old_end_point, new_end_point, + text_bytes, last_new_end_point, last_changed_point ): + # we take the max since none parseable content could have been + # added at the end + last_new_end_point = max(last_changed_point, last_new_end_point) + row_diff = new_end_point[0] - old_end_point[0] if last_new_end_point[0] < new_end_point[0]: # parse ended before the edit, happens when non parseable @@ -687,16 +674,14 @@ def _get_idx_after_edited_tree( # parse ended in a later line as the edit, i.e. its # position is only affected by line shift last_new_end_point = ( - # last_new_end_point[0] - row_diff, - # last_new_end_point[1] + 1 - max(last_changed_point, last_new_end_point)[0] - row_diff, - max(last_changed_point, last_new_end_point)[1] + 1 + last_new_end_point[0] - row_diff, + last_new_end_point[1] + 1 ) elif row_diff == 0: # the parse ended in the line of the edit last_new_end_point = ( last_new_end_point[0], - last_new_end_point[1] - (new_end_point[1] - old_end_point[1])+1 + last_new_end_point[1] - (new_end_point[1] - old_end_point[1]) + text_bytes + 1 ) elif row_diff > 0: # the edit was in the line of the last node which is now @@ -734,7 +719,7 @@ def _handle_intervals_after_edit_shifted( offset, text_intervals, ): - while last_idx > 0: + while last_idx > 1: interval = self._text_intervals.get_interval(last_idx-1) if (interval.value != '\n' or interval.position_range.start != interval.position_range.end): @@ -807,6 +792,7 @@ def _build_updated_text_intervals( new_end_point, text_bytes, last_changed_point, + old_tree_first_node_new_end_point, old_tree_end_point, ): text_intervals = OffsetPositionIntervalList() @@ -817,6 +803,7 @@ def _build_updated_text_intervals( old_end_point, new_end_point, last_changed_point, + old_tree_first_node_new_end_point, old_tree_end_point, ) @@ -849,6 +836,7 @@ def _build_updated_text_intervals( last_idx = self._get_idx_after_edited_tree( old_end_point, new_end_point, + text_bytes, last_new_end_point, last_changed_point ) @@ -892,15 +880,15 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 ) = self._get_edit_positions(change) # bookkeeping for later source cleaning - # TODO remove this part - ( - old_last_edited_node, - old_tree_end_point - ) = self._get_last_node_for_edit( - tree, - start_point, - old_end_point, + capture = self._query.captures( + tree.root_node, ) + if len(capture) == 0: + old_tree_first_node = None + old_tree_end_point = None + else: + old_tree_first_node = capture[0][0] + old_tree_end_point = capture[-1][0].end_point tree.edit( start_byte=start_byte, @@ -917,7 +905,20 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 tree ) - last_changed_point = (0, 0) + if old_tree_first_node is not None: + old_tree_first_node.edit( + start_byte=start_byte, + old_end_byte=old_end_byte, + new_end_byte=new_end_byte, + start_point=start_point, + old_end_point=old_end_point, + new_end_point=new_end_point, + ) + old_tree_first_node_new_end_point = old_tree_first_node.end_point + else: + old_tree_first_node_new_end_point = None + + last_changed_point = (-1, -1) for change in tree.changed_ranges(self.tree): last_changed_point = max(last_changed_point, change.end_point) @@ -936,6 +937,7 @@ def _apply_incremental_change(self, change: TextDocumentContentChangeEvent_Type1 new_end_point, text_bytes, last_changed_point, + old_tree_first_node_new_end_point, old_tree_end_point, ) From 73a8156d73e87e88ddbf3998d97d60e039daca83 Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 25 Nov 2023 11:43:01 +0100 Subject: [PATCH 27/28] handling out of bounds offset search --- textLSP/documents/document.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/textLSP/documents/document.py b/textLSP/documents/document.py index e48e26e..c1f0433 100644 --- a/textLSP/documents/document.py +++ b/textLSP/documents/document.py @@ -143,15 +143,17 @@ def sentence_at_offset(self, offset: int, min_length=0, cleaned=False) -> Interv def paragraph_at_offset(self, offset: int, min_length=0, min_offset=0, cleaned=False) -> Interval: """ + Returns the last paragraph if offset is over the content length. returns (start_offset, length) """ - start_idx = offset - end_idx = offset source = self.cleaned_source if cleaned else self.source len_source = len(source) + start_idx = offset assert start_idx >= 0 - assert end_idx < len_source + if start_idx >= len_source: + start_idx = len_source - 1 + end_idx = start_idx while ( start_idx >= 0 From 1c1a85d8a9f074a089eaf267b65f3e52c45f477e Mon Sep 17 00:00:00 2001 From: Viktor Hangya <hangyav@cis.lmu.de> Date: Sat, 25 Nov 2023 11:57:46 +0100 Subject: [PATCH 28/28] adding test workflow --- .github/workflows/test_main.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/test_main.yml diff --git a/.github/workflows/test_main.yml b/.github/workflows/test_main.yml new file mode 100644 index 0000000..6fe9af2 --- /dev/null +++ b/.github/workflows/test_main.yml @@ -0,0 +1,29 @@ +# This workflow will install Python dependencies and run tests with a single version of Python. + +name: Test main branch + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + - name: Test with pytest + run: | + pytest