diff --git a/docs/source/examples/links.rst b/docs/source/examples/links.rst new file mode 100644 index 00000000..e31b4190 --- /dev/null +++ b/docs/source/examples/links.rst @@ -0,0 +1,5 @@ +Document Links +============== + +.. example-server:: links.py + :start-at: import logging diff --git a/docs/source/getting-started.rst b/docs/source/getting-started.rst index e5d91f92..14646e29 100644 --- a/docs/source/getting-started.rst +++ b/docs/source/getting-started.rst @@ -70,6 +70,13 @@ Each of the following example servers are focused on implementing a particular s :octicon:`info` + .. grid-item-card:: Links + :link: /examples/links + :link-type: doc + :text-align: center + + :octicon:`link` + .. grid-item-card:: Publish Diagnostics :link: /examples/publish-diagnostics :link-type: doc @@ -110,4 +117,3 @@ Tutorial .. note:: Coming soon\ :sup:`TM` - diff --git a/examples/servers/README.md b/examples/servers/README.md index a7f6da6a..845a7648 100644 --- a/examples/servers/README.md +++ b/examples/servers/README.md @@ -9,6 +9,7 @@ | `goto.py` | `code.txt` | Implements the various "Goto X" requests in the specification | | `hover.py` | `dates.txt` | Opens a popup showing the date underneath the cursor in multiple formats | | `inlay_hints.py` | `sums.txt` | Use inlay hints to show the binary representation of numbers in the file | +| `links.py` | `links.txt` | Implements `textDocument/documentLink` | | `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 | diff --git a/examples/servers/links.py b/examples/servers/links.py new file mode 100644 index 00000000..8857d196 --- /dev/null +++ b/examples/servers/links.py @@ -0,0 +1,94 @@ +############################################################################ +# 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/documentLink` and :lsp:`documentLink/resolve` +requests. + +These allow you to add support for custom link syntax to your language. +In editors like VSCode, links will often be underlined and can be opened with a +:kbd:`Ctrl+Click`. + +This server scans the document given to ``textDocument/documentLink`` for the +syntax ```` and returns a document link desribing its location. +While we could easily compute the ``target`` and ``tooltip`` fields in the same +method, this example demonstrates how the ``documentLink/resolve`` method can be used +to defer this until it is actually necessary +""" + +import logging +import re + +from lsprotocol import types + +from pygls.server import LanguageServer + +LINK = re.compile(r"<(\w+):([^>]+)>") +server = LanguageServer("links-server", "v1") + + +@server.feature( + types.TEXT_DOCUMENT_DOCUMENT_LINK, +) +def document_links(params: types.DocumentLinkParams): + """Return a list of links contained in the document.""" + items = [] + document_uri = params.text_document.uri + document = server.workspace.get_text_document(document_uri) + + for linum, line in enumerate(document.lines): + for match in LINK.finditer(line): + start_char, end_char = match.span() + items.append( + types.DocumentLink( + range=types.Range( + start=types.Position(line=linum, character=start_char), + end=types.Position(line=linum, character=end_char), + ), + data={"type": match.group(1), "target": match.group(2)}, + ), + ) + + return items + + +LINK_TYPES = { + "github": ("https://github.com/{}", "Github - {}"), + "pypi": ("https://pypi.org/project/{}", "PyPi - {}"), +} + + +@server.feature(types.DOCUMENT_LINK_RESOLVE) +def document_link_resolve(link: types.DocumentLink): + """Given a link, fill in additional information about it""" + logging.info("resolving link: %s", link) + + link_type = link.data.get("type", "") + link_target = link.data.get("target", "") + + if (link_info := LINK_TYPES.get(link_type, None)) is None: + logging.error("Unknown link type: '%s'", link_type) + return link + + url, tooltip = link_info + link.target = url.format(link_target) + link.tooltip = tooltip.format(link_target) + + return link + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(message)s") + server.start_io() diff --git a/examples/servers/workspace/links.txt b/examples/servers/workspace/links.txt new file mode 100644 index 00000000..2214b71c --- /dev/null +++ b/examples/servers/workspace/links.txt @@ -0,0 +1,2 @@ +pygls is a framework for writing language servers in Python! +It can be installed from PyPi , it depends on the lsprotocol package diff --git a/pygls/capabilities.py b/pygls/capabilities.py index 48aece7e..ae3d7f7e 100644 --- a/pygls/capabilities.py +++ b/pygls/capabilities.py @@ -216,6 +216,7 @@ def _with_document_link(self): types.TEXT_DOCUMENT_DOCUMENT_LINK, default=types.DocumentLinkOptions() ) if value is not None: + value.resolve_provider = types.DOCUMENT_LINK_RESOLVE in self.features self.server_cap.document_link_provider = value return self diff --git a/tests/e2e/test_links.py b/tests/e2e/test_links.py new file mode 100644 index 00000000..9c7e71db --- /dev/null +++ b/tests/e2e/test_links.py @@ -0,0 +1,100 @@ +############################################################################ +# 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 links(get_client_for): + async for result in get_client_for("links.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_link( + links: Tuple[BaseLanguageClient, types.InitializeResult], uri_for +): + """Ensure that the example links server is working as expected.""" + client, initialize_result = links + + document_link_options = initialize_result.capabilities.document_link_provider + assert document_link_options.resolve_provider is True + + test_uri = uri_for("links.txt") + response = await client.text_document_document_link_async( + types.DocumentLinkParams( + text_document=types.TextDocumentIdentifier(uri=test_uri) + ) + ) + + assert response == [ + types.DocumentLink( + range=range_from_str("0:6-0:35"), + data=dict(type="github", target="openlawlibrary/pygls"), + ), + types.DocumentLink( + range=range_from_str("1:30-1:42"), + data=dict(type="pypi", target="pygls"), + ), + types.DocumentLink( + range=range_from_str("1:73-1:90"), + data=dict(type="pypi", target="lsprotocol"), + ), + ] + + +@pytest.mark.asyncio(scope="module") +async def test_document_link_resolve( + links: Tuple[BaseLanguageClient, types.InitializeResult], uri_for +): + """Ensure that the server can resolve document links correctly.""" + + client, _ = links + link = types.DocumentLink( + range=range_from_str("0:6-0:35"), + data=dict(type="github", target="openlawlibrary/pygls"), + ) + + response = await client.document_link_resolve_async(link) + + assert response == types.DocumentLink( + range=range_from_str("0:6-0:35"), + target="https://github.com/openlawlibrary/pygls", + tooltip="Github - openlawlibrary/pygls", + data=dict(type="github", target="openlawlibrary/pygls"), + ) diff --git a/tests/lsp/test_document_link.py b/tests/lsp/test_document_link.py deleted file mode 100644 index 0602773d..00000000 --- a/tests/lsp/test_document_link.py +++ /dev/null @@ -1,98 +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, Optional - -from lsprotocol.types import TEXT_DOCUMENT_DOCUMENT_LINK -from lsprotocol.types import ( - DocumentLink, - DocumentLinkOptions, - DocumentLinkParams, - Position, - Range, - TextDocumentIdentifier, -) - -from ..conftest import ClientServer - - -class ConfiguredLS(ClientServer): - def __init__(self): - super().__init__() - - @self.server.feature( - TEXT_DOCUMENT_DOCUMENT_LINK, - DocumentLinkOptions(resolve_provider=True), - ) - def f(params: DocumentLinkParams) -> Optional[List[DocumentLink]]: - if params.text_document.uri == "file://return.list": - return [ - DocumentLink( - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=1), - ), - target="target", - tooltip="tooltip", - data="data", - ), - ] - else: - return None - - -@ConfiguredLS.decorate() -def test_capabilities(client_server): - _, server = client_server - capabilities = server.server_capabilities - - assert capabilities.document_link_provider - assert capabilities.document_link_provider.resolve_provider - - -@ConfiguredLS.decorate() -def test_document_link_return_list(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_DOCUMENT_LINK, - DocumentLinkParams( - text_document=TextDocumentIdentifier(uri="file://return.list"), - ), - ).result() - - assert response - - assert response[0].range.start.line == 0 - assert response[0].range.start.character == 0 - assert response[0].range.end.line == 1 - assert response[0].range.end.character == 1 - assert response[0].target == "target" - assert response[0].tooltip == "tooltip" - assert response[0].data == "data" - - -@ConfiguredLS.decorate() -def test_document_link_return_none(client_server): - client, _ = client_server - response = client.lsp.send_request( - TEXT_DOCUMENT_DOCUMENT_LINK, - DocumentLinkParams( - text_document=TextDocumentIdentifier(uri="file://return.none"), - ), - ).result() - - assert response is None