From d42ca948f6beeab42e53084a05a7a5660647fb85 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Thu, 6 Jun 2024 21:14:15 +0100 Subject: [PATCH] docs: add Document and Workspace symbol example server --- docs/source/examples/symbols.rst | 5 + docs/source/getting-started.rst | 7 + examples/servers/symbols.py | 247 +++++++++++++++++++++ tests/e2e/test_symbols.py | 345 ++++++++++++++++++++++++++++++ tests/lsp/test_document_symbol.py | 168 --------------- 5 files changed, 604 insertions(+), 168 deletions(-) create mode 100644 docs/source/examples/symbols.rst create mode 100644 examples/servers/symbols.py create mode 100644 tests/e2e/test_symbols.py delete mode 100644 tests/lsp/test_document_symbol.py diff --git a/docs/source/examples/symbols.rst b/docs/source/examples/symbols.rst new file mode 100644 index 00000000..4f1689c9 --- /dev/null +++ b/docs/source/examples/symbols.rst @@ -0,0 +1,5 @@ +Document & Workspace Symbols +============================ + +.. example-server:: symbols.py + :start-at: import logging diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index 14646e29..d421314a 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -98,6 +98,13 @@ Each of the following example servers are focused on implementing a particular s :octicon:`pencil` + .. grid-item-card:: Symbols + :link: /examples/symbols + :link-type: doc + :text-align: center + + :octicon:`code` + These servers are dedicated to demonstrating features of *pygls* itself .. grid:: 1 2 2 4 diff --git a/examples/servers/symbols.py b/examples/servers/symbols.py new file mode 100644 index 00000000..5ff76cba --- /dev/null +++ b/examples/servers/symbols.py @@ -0,0 +1,247 @@ +############################################################################ +# 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. # +############################################################################ +"""This implements the :lsp:`textDocument/documentSymbol` and :lsp:`workspace/symbol` +requests from the LSP specification. + +In VSCode, the ``textDocument/documentSymbol`` request features like the +`Outline View `__ +or `Goto Symbol in File `__. +While `Goto Symbol in Workspace `__ +is powered by the ``workspace/symbol`` request. + +The results the server should return for the two requests is similar, but not identical. +The key difference is that ``textDocument/documentSymbol`` can provide a symbol hierarchy +whereas ``workspace/symbol`` is a flat list. + +This server implements these requests for the pretend programming language featured in +the ``code.txt`` in the example workspace in the *pygls* repository. + +.. literalinclude:: ../../../examples/servers/workspace/code.txt + :language: none +""" +import logging +import re + +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 SymbolsLanguageServer(LanguageServer): + """Language server demonstrating the document and workspace symbol methods in the LSP + specification.""" + + 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: + self.parse_typedef(typedefs, linum, line, match) + + elif (match := FUNCTION.match(line)) is not None: + self.parse_function(funcs, linum, line, match) + + self.index[doc.uri] = { + "types": typedefs, + "functions": funcs, + } + logging.info("Index: %s", self.index) + + def parse_function(self, funcs, linum, line, match): + """Parse a function definition on the given line.""" + name = match.group(1) + args = match.group(2) + + start_char = match.start() + line.find(name) + args_offset = match.start() + line.find(args) + + funcs[name] = dict( + range_=types.Range( + start=types.Position(line=linum, character=start_char), + end=types.Position(line=linum, character=start_char + len(name)), + ), + args=self.parse_args(args, linum, args_offset), + ) + + def parse_typedef(self, typedefs, linum, line, match): + """Parse a type definition on the given line.""" + name = match.group(1) + fields = match.group(2) + + start_char = match.start() + line.find(name) + field_offset = match.start() + line.find(fields) + + typedefs[name] = dict( + range_=types.Range( + start=types.Position(line=linum, character=start_char), + end=types.Position(line=linum, character=start_char + len(name)), + ), + fields=self.parse_args(fields, linum, field_offset), + ) + + def parse_args(self, text: str, linum: int, offset: int): + """Parse arguments for a type or function definition""" + arguments = {} + + for match in ARGUMENT.finditer(text): + name = match.group("name") + start_char = offset + text.find(name) + + arguments[name] = types.Range( + start=types.Position(line=linum, character=start_char), + end=types.Position(line=linum, character=start_char + len(name)), + ) + + return arguments + + +server = SymbolsLanguageServer("symbols-server", "v1") + + +@server.feature(types.TEXT_DOCUMENT_DID_OPEN) +def did_open(ls: SymbolsLanguageServer, 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: SymbolsLanguageServer, 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_DOCUMENT_SYMBOL) +def document_symbol(ls: SymbolsLanguageServer, params: types.DocumentSymbolParams): + """Return all the symbols defined in the given document.""" + if (index := ls.index.get(params.text_document.uri)) is None: + return None + + results = [] + for name, info in index.get("types", {}).items(): + range_ = info["range_"] + type_symbol = types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Class, + range=types.Range( + start=types.Position(line=range_.start.line, character=0), + end=types.Position(line=range_.start.line + 1, character=0), + ), + selection_range=range_, + children=[], + ) + + for name, range_ in info["fields"].items(): + field_symbol = types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Field, + range=range_, + selection_range=range_, + ) + type_symbol.children.append(field_symbol) + + results.append(type_symbol) + + for name, info in index.get("functions", {}).items(): + range_ = info["range_"] + func_symbol = types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Function, + range=types.Range( + start=types.Position(line=range_.start.line, character=0), + end=types.Position(line=range_.start.line + 1, character=0), + ), + selection_range=range_, + children=[], + ) + + for name, range_ in info["args"].items(): + arg_symbol = types.DocumentSymbol( + name=name, + kind=types.SymbolKind.Variable, + range=range_, + selection_range=range_, + ) + func_symbol.children.append(arg_symbol) + + results.append(func_symbol) + + return results + + +@server.feature(types.WORKSPACE_SYMBOL) +def workspace_symbol(ls: SymbolsLanguageServer, params: types.WorkspaceSymbolParams): + """Return all the symbols defined in the given document.""" + query = params.query + results = [] + + for uri, symbols in ls.index.items(): + for type_name, info in symbols.get("types", {}).items(): + if params.query == "" or type_name.startswith(query): + func_symbol = types.WorkspaceSymbol( + name=type_name, + kind=types.SymbolKind.Class, + location=types.Location(uri=uri, range=info["range_"]), + ) + results.append(func_symbol) + + for field_name, range_ in info["fields"].items(): + if params.query == "" or field_name.startswith(query): + field_symbol = types.WorkspaceSymbol( + name=field_name, + kind=types.SymbolKind.Field, + location=types.Location(uri=uri, range=range_), + container_name=type_name, + ) + results.append(field_symbol) + + for func_name, info in symbols.get("functions", {}).items(): + if params.query == "" or func_name.startswith(query): + func_symbol = types.WorkspaceSymbol( + name=func_name, + kind=types.SymbolKind.Function, + location=types.Location(uri=uri, range=info["range_"]), + ) + results.append(func_symbol) + + for arg_name, range_ in info["args"].items(): + if params.query == "" or arg_name.startswith(query): + arg_symbol = types.WorkspaceSymbol( + name=arg_name, + kind=types.SymbolKind.Variable, + location=types.Location(uri=uri, range=range_), + container_name=func_name, + ) + results.append(arg_symbol) + + return results + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + server.start_io() diff --git a/tests/e2e/test_symbols.py b/tests/e2e/test_symbols.py new file mode 100644 index 00000000..9e0fc0eb --- /dev/null +++ b/tests/e2e/test_symbols.py @@ -0,0 +1,345 @@ +############################################################################ +# 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(scope="module") +async def symbols(get_client_for): + async for result in get_client_for("symbols.py"): + yield result + + +def range_from_str(range_: str) -> types.Range: + start, end = range_.split("-") + start_line, start_char = start.split(":") + end_line, end_char = end.split(":") + + return types.Range( + start=types.Position(line=int(start_line), character=int(start_char)), + end=types.Position(line=int(end_line), character=int(end_char)), + ) + + +@pytest.mark.asyncio(scope="module") +async def test_document_symbols( + symbols: Tuple[BaseLanguageClient, types.InitializeResult], uri_for, path_for +): + """Ensure that the example symbols server is working as expected.""" + client, initialize_result = symbols + + document_symbols_options = initialize_result.capabilities.document_symbol_provider + assert document_symbols_options is True + + test_uri = uri_for("code.txt") + test_path = path_for("code.txt") + + # Needed so that the server parses the document + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + expected = [ + types.DocumentSymbol( + name="Rectangle", + kind=types.SymbolKind.Class, + range=range_from_str("0:0-1:0"), + selection_range=range_from_str("0:5-0:14"), + children=[ + types.DocumentSymbol( + name="x", + kind=types.SymbolKind.Field, + range=range_from_str("0:15-0:16"), + selection_range=range_from_str("0:15-0:16"), + ), + types.DocumentSymbol( + name="y", + kind=types.SymbolKind.Field, + range=range_from_str("0:18-0:19"), + selection_range=range_from_str("0:18-0:19"), + ), + types.DocumentSymbol( + name="w", + kind=types.SymbolKind.Field, + range=range_from_str("0:21-0:22"), + selection_range=range_from_str("0:21-0:22"), + ), + types.DocumentSymbol( + name="h", + kind=types.SymbolKind.Field, + range=range_from_str("0:24-0:25"), + selection_range=range_from_str("0:24-0:25"), + ), + ], + ), + types.DocumentSymbol( + name="Square", + kind=types.SymbolKind.Class, + range=range_from_str("1:0-2:0"), + selection_range=range_from_str("1:5-1:11"), + children=[ + types.DocumentSymbol( + name="x", + kind=types.SymbolKind.Field, + range=range_from_str("1:12-1:13"), + selection_range=range_from_str("1:12-1:13"), + ), + types.DocumentSymbol( + name="y", + kind=types.SymbolKind.Field, + range=range_from_str("1:15-1:16"), + selection_range=range_from_str("1:15-1:16"), + ), + types.DocumentSymbol( + name="s", + kind=types.SymbolKind.Field, + range=range_from_str("1:18-1:19"), + selection_range=range_from_str("1:18-1:19"), + ), + ], + ), + types.DocumentSymbol( + name="area", + kind=types.SymbolKind.Function, + range=range_from_str("3:0-4:0"), + selection_range=range_from_str("3:3-3:7"), + children=[ + types.DocumentSymbol( + name="rect", + kind=types.SymbolKind.Variable, + range=range_from_str("3:8-3:12"), + selection_range=range_from_str("3:8-3:12"), + ), + ], + ), + types.DocumentSymbol( + name="volume", + kind=types.SymbolKind.Function, + range=range_from_str("5:0-6:0"), + selection_range=range_from_str("5:3-5:9"), + children=[ + types.DocumentSymbol( + name="rect", + kind=types.SymbolKind.Variable, + range=range_from_str("5:10-5:14"), + selection_range=range_from_str("5:10-5:14"), + ), + types.DocumentSymbol( + name="length", + kind=types.SymbolKind.Variable, + range=range_from_str("5:27-5:33"), + selection_range=range_from_str("5:27-5:33"), + ), + ], + ), + ] + + response = await client.text_document_document_symbol_async( + types.DocumentSymbolParams( + text_document=types.TextDocumentIdentifier(uri=test_uri), + ), + ) + assert len(response) == len(expected) + for expected_symbol, actual_symbol in zip(expected, response): + check_document_symbol(expected_symbol, actual_symbol) + + +@pytest.mark.parametrize( + "query, expected", + [ + ( + "", + [ + types.SymbolInformation( + name="Rectangle", + kind=types.SymbolKind.Class, + location=types.Location(uri="", range=range_from_str("0:5-0:14")), + ), + types.SymbolInformation( + name="x", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("0:15-0:16")), + container_name="Rectangle", + ), + types.SymbolInformation( + name="y", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("0:18-0:19")), + container_name="Rectangle", + ), + types.SymbolInformation( + name="w", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("0:21-0:22")), + container_name="Rectangle", + ), + types.SymbolInformation( + name="h", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("0:24-0:25")), + container_name="Rectangle", + ), + types.SymbolInformation( + name="Square", + kind=types.SymbolKind.Class, + location=types.Location(uri="", range=range_from_str("1:5-1:11")), + ), + types.SymbolInformation( + name="x", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("1:12-1:13")), + container_name="Square", + ), + types.SymbolInformation( + name="y", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("1:15-1:16")), + container_name="Square", + ), + types.SymbolInformation( + name="s", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("1:18-1:19")), + container_name="Square", + ), + types.SymbolInformation( + name="area", + kind=types.SymbolKind.Function, + location=types.Location(uri="", range=range_from_str("3:3-3:7")), + ), + types.SymbolInformation( + name="rect", + kind=types.SymbolKind.Variable, + location=types.Location(uri="", range=range_from_str("3:8-3:12")), + container_name="area", + ), + types.SymbolInformation( + name="volume", + kind=types.SymbolKind.Function, + location=types.Location(uri="", range=range_from_str("5:3-5:9")), + ), + types.SymbolInformation( + name="rect", + kind=types.SymbolKind.Variable, + location=types.Location(uri="", range=range_from_str("5:10-5:14")), + container_name="volume", + ), + types.SymbolInformation( + name="length", + kind=types.SymbolKind.Variable, + location=types.Location(uri="", range=range_from_str("5:27-5:33")), + container_name="volume", + ), + ], + ), + ( + "x", + [ + types.SymbolInformation( + name="x", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("0:15-0:16")), + container_name="Rectangle", + ), + types.SymbolInformation( + name="x", + kind=types.SymbolKind.Field, + location=types.Location(uri="", range=range_from_str("1:12-1:13")), + container_name="Square", + ), + ], + ), + ], +) +@pytest.mark.asyncio(scope="module") +async def test_workspace_symbols( + symbols: Tuple[BaseLanguageClient, types.InitializeResult], + uri_for, + path_for, + query, + expected, +): + """Ensure that the example symbols server is working as expected.""" + client, initialize_result = symbols + + document_symbols_options = initialize_result.capabilities.document_symbol_provider + assert document_symbols_options is True + + test_uri = uri_for("code.txt") + test_path = path_for("code.txt") + + # Needed so that the server parses the document + client.text_document_did_open( + types.DidOpenTextDocumentParams( + types.TextDocumentItem( + uri=test_uri, + language_id="plaintext", + version=0, + text=test_path.read_text(), + ) + ) + ) + + response = await client.workspace_symbol_async( + types.WorkspaceSymbolParams(query=query), + ) + + assert len(response) == len(expected) + for expected_symbol, actual_symbol in zip(expected, response): + expected_symbol.location.uri = test_uri + + assert expected_symbol == actual_symbol + + +def check_document_symbol(actual: types.DocumentSymbol, expected: types.DocumentSymbol): + """Ensure that the given ``DocumentSymbols`` are equivalent.""" + + assert isinstance(actual, types.DocumentSymbol) + + assert actual.name == expected.name + assert actual.kind == expected.kind + assert actual.range == expected.range + assert actual.selection_range == expected.selection_range + + if expected.children is None: + assert actual.children is None + return + + assert actual.children is not None + assert len(actual.children) == len( + expected.children + ), f"Children mismatch in symbol '{actual.name}'" + + for actual_child, expected_child in zip(actual.children, expected.children): + check_document_symbol(actual_child, expected_child) diff --git a/tests/lsp/test_document_symbol.py b/tests/lsp/test_document_symbol.py deleted file mode 100644 index 251c8fb8..00000000 --- a/tests/lsp/test_document_symbol.py +++ /dev/null @@ -1,168 +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 List, Union - -from lsprotocol.types import TEXT_DOCUMENT_DOCUMENT_SYMBOL -from lsprotocol.types import ( - DocumentSymbol, - DocumentSymbolOptions, - DocumentSymbolParams, - Location, - Position, - Range, - SymbolInformation, - SymbolKind, - TextDocumentIdentifier, -) - -from ..conftest import ClientServer - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature( - TEXT_DOCUMENT_DOCUMENT_SYMBOL, - DocumentSymbolOptions(), - ) - def f( - params: DocumentSymbolParams, - ) -> Union[List[SymbolInformation], List[DocumentSymbol]]: - symbol_info = SymbolInformation( - name="symbol", - kind=SymbolKind.Namespace, - location=Location( - uri="uri", - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - ), - container_name="container", - deprecated=False, - ) - - document_symbol_inner = DocumentSymbol( - name="inner_symbol", - kind=SymbolKind.Number, - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - selection_range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - ) - - document_symbol = DocumentSymbol( - name="symbol", - kind=SymbolKind.Object, - range=Range( - start=Position(line=0, character=0), - end=Position(line=10, character=10), - ), - selection_range=Range( - start=Position(line=0, character=0), - end=Position(line=10, character=10), - ), - detail="detail", - children=[document_symbol_inner], - deprecated=True, - ) - - return { # type: ignore - "file://return.symbol_information_list": [symbol_info], - "file://return.document_symbol_list": [document_symbol], - }.get(params.text_document.uri, None) - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - _, server = client_server - capabilities = server.server_capabilities - - assert capabilities.document_symbol_provider - - -@ConfiguredLS.decorate() -def test_document_symbol_return_symbol_information_list(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_DOCUMENT_SYMBOL, - DocumentSymbolParams( - text_document=TextDocumentIdentifier( - uri="file://return.symbol_information_list" - ), - ), - ).result() - - assert response - - assert response[0].name == "symbol" - assert response[0].kind == SymbolKind.Namespace - assert response[0].location.uri == "uri" - assert response[0].location.range.start.line == 0 - assert response[0].location.range.start.character == 0 - assert response[0].location.range.end.line == 1 - assert response[0].location.range.end.character == 1 - assert response[0].container_name == "container" - assert not response[0].deprecated - - -@ConfiguredLS.decorate() -def test_document_symbol_return_document_symbol_list(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_DOCUMENT_SYMBOL, - DocumentSymbolParams( - text_document=TextDocumentIdentifier( - uri="file://return.document_symbol_list" - ), - ), - ).result() - - assert response - - assert response[0].name == "symbol" - assert response[0].kind == SymbolKind.Object - assert response[0].range.start.line == 0 - assert response[0].range.start.character == 0 - assert response[0].range.end.line == 10 - assert response[0].range.end.character == 10 - assert response[0].selection_range.start.line == 0 - assert response[0].selection_range.start.character == 0 - assert response[0].selection_range.end.line == 10 - assert response[0].selection_range.end.character == 10 - assert response[0].detail == "detail" - assert response[0].deprecated - - assert response[0].children[0].name == "inner_symbol" - assert response[0].children[0].kind == SymbolKind.Number - assert response[0].children[0].range.start.line == 0 - assert response[0].children[0].range.start.character == 0 - assert response[0].children[0].range.end.line == 1 - assert response[0].children[0].range.end.character == 1 - range = response[0].children[0].selection_range - assert range.start.line == 0 - assert range.start.character == 0 - assert range.end.line == 1 - assert range.end.character == 1 - - assert response[0].children[0].children is None