From 76504d1f861aba232fb5a4c47f7f8649e1e0dbf5 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 3 May 2024 19:22:51 +0100 Subject: [PATCH 1/3] test: extend `get_client_for` to accept custom capabilities --- tests/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f4cf9c66..2e89d2eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ import asyncio import pathlib import sys +from typing import Optional import pytest from lsprotocol import types, converters @@ -173,13 +174,15 @@ def server_dir(): def get_client_for_cpython_server(uri_fixture): """Return a client configured to communicate with a server running under cpython.""" - async def fn(server_name: str): + async def fn( + server_name: str, capabilities: Optional[types.ClientCapabilities] = None + ): client = LanguageClient("pygls-test-suite", "v1") await client.start_io(sys.executable, str(SERVER_DIR / server_name)) response = await client.initialize_async( types.InitializeParams( - capabilities=types.ClientCapabilities(), + capabilities=capabilities or types.ClientCapabilities(), root_uri=uri_fixture(""), ) ) From 9b2a85fa4060dbb58f8e083a35244493dcceab7b Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 3 May 2024 19:26:02 +0100 Subject: [PATCH 2/3] test: add end-to-end tests for rename methods --- examples/servers/README.md | 1 + examples/servers/rename.py | 142 ++++++++++++++++++++++ tests/e2e/test_rename.py | 157 +++++++++++++++++++++++++ tests/lsp/test_prepare_rename.py | 111 ------------------ tests/lsp/test_rename.py | 195 ------------------------------- 5 files changed, 300 insertions(+), 306 deletions(-) create mode 100644 examples/servers/rename.py create mode 100644 tests/e2e/test_rename.py delete mode 100644 tests/lsp/test_prepare_rename.py delete mode 100644 tests/lsp/test_rename.py diff --git a/examples/servers/README.md b/examples/servers/README.md index 97d299b7..a7f6da6a 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -11,6 +11,7 @@ | `inlay_hints.py` | `sums.txt` | Use inlay hints to show the binary representation of numbers in the file | | `publish_diagnostics.py` | `sums.txt` | Use "push-model" diagnostics to highlight missing or incorrect answers | | `pull_diagnostics.py` | `sums.txt` | Use "pull-model" diagnostics to highlight missing or incorrect answers | +| `rename.py` | `code.txt` | Implements symbol renaming | [^1]: To enable as-you-type formatting, be sure to uncomment the `editor.formatOnType` option in `.vscode/settings.json` diff --git a/examples/servers/rename.py b/examples/servers/rename.py new file mode 100644 index 00000000..b7e34ad7 --- /dev/null +++ b/examples/servers/rename.py @@ -0,0 +1,142 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +import logging +import re +from typing import List + +from lsprotocol import types + +from pygls.server import LanguageServer +from pygls.workspace import TextDocument + +ARGUMENT = re.compile(r"(?P\w+): (?P\w+)") +FUNCTION = re.compile(r"^fn ([a-z]\w+)\(") +TYPE = re.compile(r"^type ([A-Z]\w+)\(") + + +class RenameLanguageServer(LanguageServer): + """Language server demonstrating symbol renaming.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.index = {} + + def parse(self, doc: TextDocument): + typedefs = {} + funcs = {} + + for linum, line in enumerate(doc.lines): + if (match := TYPE.match(line)) is not None: + name = match.group(1) + start_char = match.start() + line.find(name) + + typedefs[name] = types.Range( + start=types.Position(line=linum, character=start_char), + end=types.Position(line=linum, character=start_char + len(name)), + ) + + elif (match := FUNCTION.match(line)) is not None: + name = match.group(1) + start_char = match.start() + line.find(name) + + funcs[name] = types.Range( + start=types.Position(line=linum, character=start_char), + end=types.Position(line=linum, character=start_char + len(name)), + ) + + self.index[doc.uri] = { + "types": typedefs, + "functions": funcs, + } + logging.info("Index: %s", self.index) + + +server = RenameLanguageServer("rename-server", "v1") + + +@server.feature(types.TEXT_DOCUMENT_DID_OPEN) +def did_open(ls: RenameLanguageServer, params: types.DidOpenTextDocumentParams): + """Parse each document when it is opened""" + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.parse(doc) + + +@server.feature(types.TEXT_DOCUMENT_DID_CHANGE) +def did_change(ls: RenameLanguageServer, params: types.DidOpenTextDocumentParams): + """Parse each document when it is changed""" + doc = ls.workspace.get_text_document(params.text_document.uri) + ls.parse(doc) + + +@server.feature(types.TEXT_DOCUMENT_RENAME) +def rename(ls: RenameLanguageServer, params: types.RenameParams): + """Rename the symbol at the given position.""" + logging.debug("%s", params) + + doc = ls.workspace.get_text_document(params.text_document.uri) + index = ls.index.get(doc.uri) + if index is None: + return None + + word = doc.word_at_position(params.position) + is_object = any([word in index[name] for name in index]) + if not is_object: + return None + + edits: List[types.TextEdit] = [] + for linum, line in enumerate(doc.lines): + for match in re.finditer(f"\\b{word}\\b", line): + edits.append( + types.TextEdit( + new_text=params.new_name, + range=types.Range( + start=types.Position(line=linum, character=match.start()), + end=types.Position(line=linum, character=match.end()), + ), + ) + ) + + return types.WorkspaceEdit(changes={params.text_document.uri: edits}) + + +@server.feature(types.TEXT_DOCUMENT_PREPARE_RENAME) +def prepare_rename(ls: RenameLanguageServer, params: types.PrepareRenameParams): + """Called by the client to determine if renaming the symbol at the given location + is a valid operation.""" + logging.debug("%s", params) + + doc = ls.workspace.get_text_document(params.text_document.uri) + index = ls.index.get(doc.uri) + if index is None: + return None + + word = doc.word_at_position(params.position) + is_object = any([word in index[name] for name in index]) + if not is_object: + return None + + # At this point, we can rename this symbol. + # + # For simplicity we can tell the client to use its default behaviour however, it's + # relatively new to the spec (LSP v3.16+) so a production server should check the + # client's capabilities before responding in this way + return types.PrepareRenameResult_Type2(default_behavior=True) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG, format="%(message)s") + server.start_io() diff --git a/tests/e2e/test_rename.py b/tests/e2e/test_rename.py new file mode 100644 index 00000000..dcd4be13 --- /dev/null +++ b/tests/e2e/test_rename.py @@ -0,0 +1,157 @@ +############################################################################ +# Copyright(c) Open Law Library. All rights reserved. # +# See ThirdPartyNotices.txt in the project root for additional notices. # +# # +# Licensed under the Apache License, Version 2.0 (the "License") # +# you may not use this file except in compliance with the License. # +# You may obtain a copy of the License at # +# # +# http: // www.apache.org/licenses/LICENSE-2.0 # +# # +# Unless required by applicable law or agreed to in writing, software # +# distributed under the License is distributed on an "AS IS" BASIS, # +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # +# See the License for the specific language governing permissions and # +# limitations under the License. # +############################################################################ +from __future__ import annotations + +import typing + +import pytest +import pytest_asyncio +from lsprotocol import types + +if typing.TYPE_CHECKING: + from typing import Tuple + + from pygls.lsp.client import BaseLanguageClient + + +@pytest_asyncio.fixture() +async def rename(get_client_for): + # Indicate to the server that our test client supports `textDocument/prepareRename` + capabilities = types.ClientCapabilities( + text_document=types.TextDocumentClientCapabilities( + rename=types.RenameClientCapabilities(prepare_support=True) + ) + ) + async for result in get_client_for("rename.py", capabilities): + yield result + + +@pytest.mark.parametrize( + "position, expected", + [ + (types.Position(line=5, character=1), None), + ( + types.Position(line=5, character=6), + types.PrepareRenameResult_Type2(default_behavior=True), + ), + ], +) +async def test_prepare_rename( + rename: Tuple[BaseLanguageClient, types.InitializeResult], + path_for, + uri_for, + position: types.Position, + expected, +): + """Ensure that the prepare rename handler in the server works as expected.""" + client, initialize_result = rename + + rename_options = initialize_result.capabilities.rename_provider + assert rename_options == types.RenameOptions(prepare_provider=True) + + test_uri = uri_for("code.txt") + test_path = path_for("code.txt") + + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + result = await client.text_document_prepare_rename_async( + types.PrepareRenameParams( + position=position, text_document=types.TextDocumentIdentifier(uri=test_uri) + ) + ) + + if expected is None: + assert result is None + + else: + assert result == expected + + +@pytest.mark.parametrize( + "position, expected", + [ + (types.Position(line=5, character=1), None), + ( + types.Position(line=3, character=6), + [ + types.TextEdit( + new_text="my_name", + range=types.Range( + start=types.Position(line=3, character=3), + end=types.Position(line=3, character=7), + ), + ), + types.TextEdit( + new_text="my_name", + range=types.Range( + start=types.Position(line=5, character=45), + end=types.Position(line=5, character=49), + ), + ), + ], + ), + ], +) +async def test_rename( + rename: Tuple[BaseLanguageClient, types.InitializeResult], + path_for, + uri_for, + position: types.Position, + expected, +): + """Ensure that the rename handler in the server works as expected.""" + client, initialize_result = rename + + rename_options = initialize_result.capabilities.rename_provider + assert rename_options == types.RenameOptions(prepare_provider=True) + + test_uri = uri_for("code.txt") + test_path = path_for("code.txt") + + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + result = await client.text_document_rename_async( + types.RenameParams( + new_name="my_name", + position=position, + text_document=types.TextDocumentIdentifier(uri=test_uri), + ) + ) + + if expected is None: + assert result is None + + else: + assert result == types.WorkspaceEdit(changes={test_uri: expected}) diff --git a/tests/lsp/test_prepare_rename.py b/tests/lsp/test_prepare_rename.py deleted file mode 100644 index 39d07129..00000000 --- a/tests/lsp/test_prepare_rename.py +++ /dev/null @@ -1,111 +0,0 @@ -############################################################################ -# Copyright(c) Open Law Library. All rights reserved. # -# See ThirdPartyNotices.txt in the project root for additional notices. # -# # -# Licensed under the Apache License, Version 2.0 (the "License") # -# you may not use this file except in compliance with the License. # -# You may obtain a copy of the License at # -# # -# http: // www.apache.org/licenses/LICENSE-2.0 # -# # -# Unless required by applicable law or agreed to in writing, software # -# distributed under the License is distributed on an "AS IS" BASIS, # -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # -# See the License for the specific language governing permissions and # -# limitations under the License. # -############################################################################ - -from typing import Optional, Union - -from lsprotocol.types import TEXT_DOCUMENT_PREPARE_RENAME -from lsprotocol.types import ( - Position, - PrepareRenameResult, - PrepareRenameResult_Type1, - PrepareRenameParams, - Range, - TextDocumentIdentifier, -) - -from ..conftest import ClientServer - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature(TEXT_DOCUMENT_PREPARE_RENAME) - def f( - params: PrepareRenameParams, - ) -> Optional[Union[Range, PrepareRenameResult]]: - return { # type: ignore - "file://return.range": Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - "file://return.prepare_rename": PrepareRenameResult_Type1( - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - placeholder="placeholder", - ), - }.get(params.text_document.uri, None) - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - pass - - -@ConfiguredLS.decorate() -def test_prepare_rename_return_range(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_PREPARE_RENAME, - PrepareRenameParams( - text_document=TextDocumentIdentifier(uri="file://return.range"), - position=Position(line=0, character=0), - ), - ).result() - - assert response - - assert response.start.line == 0 - assert response.start.character == 0 - assert response.end.line == 1 - assert response.end.character == 1 - - -@ConfiguredLS.decorate() -def test_prepare_rename_return_prepare_rename(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_PREPARE_RENAME, - PrepareRenameParams( - text_document=TextDocumentIdentifier(uri="file://return.prepare_rename"), - position=Position(line=0, character=0), - ), - ).result() - - assert response - - assert response.range.start.line == 0 - assert response.range.start.character == 0 - assert response.range.end.line == 1 - assert response.range.end.character == 1 - assert response.placeholder == "placeholder" - - -@ConfiguredLS.decorate() -def test_prepare_rename_return_none(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_PREPARE_RENAME, - PrepareRenameParams( - text_document=TextDocumentIdentifier(uri="file://return.none"), - position=Position(line=0, character=0), - ), - ).result() - - assert response is None diff --git a/tests/lsp/test_rename.py b/tests/lsp/test_rename.py deleted file mode 100644 index 48cface7..00000000 --- a/tests/lsp/test_rename.py +++ /dev/null @@ -1,195 +0,0 @@ -############################################################################ -# Copyright(c) Open Law Library. All rights reserved. # -# See ThirdPartyNotices.txt in the project root for additional notices. # -# # -# Licensed under the Apache License, Version 2.0 (the "License") # -# you may not use this file except in compliance with the License. # -# You may obtain a copy of the License at # -# # -# http: // www.apache.org/licenses/LICENSE-2.0 # -# # -# Unless required by applicable law or agreed to in writing, software # -# distributed under the License is distributed on an "AS IS" BASIS, # -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # -# See the License for the specific language governing permissions and # -# limitations under the License. # -############################################################################ - -from typing import Optional - -from lsprotocol.types import TEXT_DOCUMENT_RENAME -from lsprotocol.types import ( - CreateFile, - CreateFileOptions, - DeleteFile, - DeleteFileOptions, - OptionalVersionedTextDocumentIdentifier, - Position, - Range, - RenameFile, - RenameFileOptions, - RenameOptions, - RenameParams, - ResourceOperationKind, - TextDocumentEdit, - TextDocumentIdentifier, - TextEdit, - WorkspaceEdit, -) - -from ..conftest import ClientServer - -workspace_edit = { - "changes": { - "uri1": [ - TextEdit( - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - new_text="text1", - ), - TextEdit( - range=Range( - start=Position(line=1, character=1), - end=Position(line=2, character=2), - ), - new_text="text2", - ), - ], - }, - "document_changes": [ - TextDocumentEdit( - text_document=OptionalVersionedTextDocumentIdentifier( - uri="uri", - version=3, - ), - edits=[ - TextEdit( - range=Range( - start=Position(line=2, character=2), - end=Position(line=3, character=3), - ), - new_text="text3", - ), - ], - ), - CreateFile( - kind=ResourceOperationKind.Create.value, - uri="create file", - options=CreateFileOptions( - overwrite=True, - ignore_if_exists=True, - ), - ), - RenameFile( - kind=ResourceOperationKind.Rename.value, - old_uri="rename old uri", - new_uri="rename new uri", - options=RenameFileOptions( - overwrite=True, - ignore_if_exists=True, - ), - ), - DeleteFile( - kind=ResourceOperationKind.Delete.value, - uri="delete file", - options=DeleteFileOptions( - recursive=True, - ignore_if_not_exists=True, - ), - ), - ], -} - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature( - TEXT_DOCUMENT_RENAME, - RenameOptions(prepare_provider=True), - ) - def f(params: RenameParams) -> Optional[WorkspaceEdit]: - if params.text_document.uri == "file://return.workspace_edit": - return WorkspaceEdit(**workspace_edit) - else: - return None - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - _, server = client_server - capabilities = server.server_capabilities - - assert capabilities.rename_provider - assert capabilities.rename_provider.prepare_provider - - -@ConfiguredLS.decorate() -def test_rename_return_workspace_edit(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_RENAME, - RenameParams( - text_document=TextDocumentIdentifier(uri="file://return.workspace_edit"), - position=Position(line=0, character=0), - new_name="new name", - ), - ).result() - - assert response - - changes = response.changes["uri1"] - assert changes[0].new_text == "text1" - assert changes[0].range.start.line == 0 - assert changes[0].range.start.character == 0 - assert changes[0].range.end.line == 1 - assert changes[0].range.end.character == 1 - - assert changes[1].new_text == "text2" - assert changes[1].range.start.line == 1 - assert changes[1].range.start.character == 1 - assert changes[1].range.end.line == 2 - assert changes[1].range.end.character == 2 - - changes = response.document_changes - assert changes[0].text_document.uri == "uri" - assert changes[0].text_document.version == 3 - assert changes[0].edits[0].new_text == "text3" - assert changes[0].edits[0].range.start.line == 2 - assert changes[0].edits[0].range.start.character == 2 - assert changes[0].edits[0].range.end.line == 3 - assert changes[0].edits[0].range.end.character == 3 - - assert changes[1].kind == ResourceOperationKind.Create.value - assert changes[1].uri == "create file" - assert changes[1].options.ignore_if_exists - assert changes[1].options.overwrite - - assert changes[2].kind == ResourceOperationKind.Rename.value - assert changes[2].new_uri == "rename new uri" - assert changes[2].old_uri == "rename old uri" - assert changes[2].options.ignore_if_exists - assert changes[2].options.overwrite - - assert changes[3].kind == ResourceOperationKind.Delete.value - assert changes[3].uri == "delete file" - assert changes[3].options.ignore_if_not_exists - assert changes[3].options.recursive - - -@ConfiguredLS.decorate() -def test_rename_return_none(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_RENAME, - RenameParams( - text_document=TextDocumentIdentifier(uri="file://return.none"), - position=Position(line=0, character=0), - new_name="new name", - ), - ).result() - - assert response is None From 03e8a4ceec82d44af749be8b5a400a1dc2ff9221 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 3 May 2024 19:33:58 +0100 Subject: [PATCH 3/3] fix: compute `prepare_provider` for `RenameOptions` --- pygls/capabilities.py | 22 ++++++++++++-- tests/test_feature_manager.py | 54 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/pygls/capabilities.py b/pygls/capabilities.py index 44a6d2ed..48aece7e 100644 --- a/pygls/capabilities.py +++ b/pygls/capabilities.py @@ -248,9 +248,25 @@ def _with_document_on_type_formatting(self): return self def _with_rename(self): - value = self._provider_options(types.TEXT_DOCUMENT_RENAME, default=True) - if value is not None: - self.server_cap.rename_provider = value + server_supports_rename = types.TEXT_DOCUMENT_RENAME in self.features + if server_supports_rename is False: + return self + + client_prepare_support = get_capability( + self.client_capabilities, "text_document.rename.prepare_support", False + ) + + # From the spec: + # > RenameOptions may only be specified if the client states that it supports + # > prepareSupport in its initial initialize request. + if not client_prepare_support: + self.server_cap.rename_provider = server_supports_rename + + else: + self.server_cap.rename_provider = types.RenameOptions( + prepare_provider=types.TEXT_DOCUMENT_PREPARE_RENAME in self.features + ) + return self def _with_folding_range(self): diff --git a/tests/test_feature_manager.py b/tests/test_feature_manager.py index 744fcdb1..48bad6a9 100644 --- a/tests/test_feature_manager.py +++ b/tests/test_feature_manager.py @@ -709,6 +709,60 @@ def _(): assert expected == actual +def test_register_prepare_rename_no_client_support(feature_manager: FeatureManager): + + @feature_manager.feature(lsp.TEXT_DOCUMENT_RENAME) + def _(): + pass + + @feature_manager.feature(lsp.TEXT_DOCUMENT_PREPARE_RENAME) + def _(): + pass + + expected = server_capabilities(rename_provider=True) + + actual = ServerCapabilitiesBuilder( + lsp.ClientCapabilities(), + feature_manager.features.keys(), + feature_manager.feature_options, + [], + None, + None, + ).build() + + assert expected == actual + + +def test_register_prepare_rename_with_client_support(feature_manager: FeatureManager): + + @feature_manager.feature(lsp.TEXT_DOCUMENT_RENAME) + def _(): + pass + + @feature_manager.feature(lsp.TEXT_DOCUMENT_PREPARE_RENAME) + def _(): + pass + + expected = server_capabilities( + rename_provider=lsp.RenameOptions(prepare_provider=True) + ) + + actual = ServerCapabilitiesBuilder( + lsp.ClientCapabilities( + text_document=lsp.TextDocumentClientCapabilities( + rename=lsp.RenameClientCapabilities(prepare_support=True) + ) + ), + feature_manager.features.keys(), + feature_manager.feature_options, + [], + None, + None, + ).build() + + assert expected == actual + + def test_register_inlay_hint_resolve(feature_manager: FeatureManager): @feature_manager.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) def _():