Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example server for textDocument/rename and textDocument/prepareRename #452

Merged
merged 3 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/servers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
142 changes: 142 additions & 0 deletions examples/servers/rename.py
Original file line number Diff line number Diff line change
@@ -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<name>\w+): (?P<type>\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()
22 changes: 19 additions & 3 deletions pygls/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import asyncio
import pathlib
import sys
from typing import Optional

import pytest
from lsprotocol import types, converters
Expand Down Expand Up @@ -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(""),
)
)
Expand Down
157 changes: 157 additions & 0 deletions tests/e2e/test_rename.py
Original file line number Diff line number Diff line change
@@ -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})
Loading
Loading