429 lines
12 KiB
Python
429 lines
12 KiB
Python
|
"""Utility functions to help with testing Language Server features."""
|
||
|
import logging
|
||
|
import pathlib
|
||
|
import re
|
||
|
from typing import List
|
||
|
from typing import Optional
|
||
|
from typing import Union
|
||
|
|
||
|
import pygls.uris as Uri
|
||
|
from lsprotocol.types import ClientCapabilities
|
||
|
from lsprotocol.types import CompletionItem
|
||
|
from lsprotocol.types import CompletionList
|
||
|
from lsprotocol.types import CompletionParams
|
||
|
from lsprotocol.types import DidChangeTextDocumentParams
|
||
|
from lsprotocol.types import DidCloseTextDocumentParams
|
||
|
from lsprotocol.types import DidOpenTextDocumentParams
|
||
|
from lsprotocol.types import Hover
|
||
|
from lsprotocol.types import HoverParams
|
||
|
from lsprotocol.types import Position
|
||
|
from lsprotocol.types import Range
|
||
|
from lsprotocol.types import TextDocumentContentChangeEvent_Type1
|
||
|
from lsprotocol.types import TextDocumentIdentifier
|
||
|
from lsprotocol.types import TextDocumentItem
|
||
|
from lsprotocol.types import VersionedTextDocumentIdentifier
|
||
|
from pygls.workspace import Document
|
||
|
from pytest_lsp import LanguageClient
|
||
|
from pytest_lsp import make_test_lsp_client
|
||
|
from sphinx import __version__ as __sphinx_version__
|
||
|
|
||
|
from esbonio.lsp import CompletionContext
|
||
|
from esbonio.lsp.rst.config import ServerCompletionConfig
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
def _noop(*args, **kwargs):
|
||
|
...
|
||
|
|
||
|
|
||
|
def make_esbonio_client(*args, **kwargs) -> LanguageClient:
|
||
|
"""Construct a pytest-lsp client that is aware of esbonio specific messages"""
|
||
|
client = make_test_lsp_client(*args, **kwargs)
|
||
|
client.feature("esbonio/buildStart")(_noop)
|
||
|
client.feature("esbonio/buildComplete")(_noop)
|
||
|
|
||
|
return client
|
||
|
|
||
|
|
||
|
def sphinx_version(
|
||
|
eq: Optional[int] = None,
|
||
|
lt: Optional[int] = None,
|
||
|
lte: Optional[int] = None,
|
||
|
gt: Optional[int] = None,
|
||
|
gte: Optional[int] = None,
|
||
|
) -> bool:
|
||
|
"""Helper function for determining which version of Sphinx we are
|
||
|
testing with.
|
||
|
|
||
|
.. note::
|
||
|
|
||
|
Currently this function only considers the major version number.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
eq
|
||
|
When set, this function returns ``True`` if Sphinx's version is exactly
|
||
|
what's given.
|
||
|
|
||
|
gt
|
||
|
When set, this function returns ``True`` if Sphinx's version is strictly
|
||
|
greater than what's given
|
||
|
|
||
|
gte
|
||
|
When set, this function returns ``True`` if Sphinx's version is greater than
|
||
|
or equal to what's given
|
||
|
|
||
|
lt
|
||
|
When set, this function returns ``True`` if Sphinx's version is strictly
|
||
|
less than what's given
|
||
|
|
||
|
lte
|
||
|
When set, this function returns ``True`` if Sphinx's version is less than
|
||
|
or equal to what's given
|
||
|
|
||
|
"""
|
||
|
|
||
|
major, _, _ = (int(v) for v in __sphinx_version__.split("."))
|
||
|
|
||
|
if eq is not None:
|
||
|
return major == eq
|
||
|
|
||
|
if gt is not None:
|
||
|
return major > gt
|
||
|
|
||
|
if gte is not None:
|
||
|
return major >= gte
|
||
|
|
||
|
if lt is not None:
|
||
|
return major < lt
|
||
|
|
||
|
if lte is not None:
|
||
|
return major <= lte
|
||
|
|
||
|
return False
|
||
|
|
||
|
|
||
|
def range_from_str(spec: str) -> Range:
|
||
|
"""Create a range from the given string ``a:b-x:y``"""
|
||
|
start, end = spec.split("-")
|
||
|
sl, sc = start.split(":")
|
||
|
el, ec = end.split(":")
|
||
|
|
||
|
return Range(
|
||
|
start=Position(line=int(sl), character=int(sc)),
|
||
|
end=Position(line=int(el), character=int(ec)),
|
||
|
)
|
||
|
|
||
|
|
||
|
def make_completion_context(
|
||
|
pattern: re.Pattern,
|
||
|
text: str,
|
||
|
*,
|
||
|
character: int = -1,
|
||
|
prefer_insert: bool = False,
|
||
|
) -> CompletionContext:
|
||
|
"""Helper for making test completion context instances.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
pattern
|
||
|
The regular expression pattern that corresponds to the completion request.
|
||
|
|
||
|
text
|
||
|
The text that "triggered" the completion request
|
||
|
|
||
|
character
|
||
|
The character column at which the request is being made.
|
||
|
If ``-1`` (the default), it will be assumed that the request is being made at
|
||
|
the end of ``text``.
|
||
|
|
||
|
prefer_insert
|
||
|
Flag to indicate if the ``preferred_insert_behavior`` option should be set to
|
||
|
``insert``
|
||
|
"""
|
||
|
|
||
|
match = pattern.match(text)
|
||
|
if not match:
|
||
|
raise ValueError(f"'{text}' is not valid in this completion context")
|
||
|
|
||
|
line = 0
|
||
|
character = len(text) if character == -1 else character
|
||
|
|
||
|
return CompletionContext(
|
||
|
doc=Document(uri="file:///test.txt"),
|
||
|
location="rst",
|
||
|
match=match,
|
||
|
position=Position(line=line, character=character),
|
||
|
config=ServerCompletionConfig(
|
||
|
preferred_insert_behavior="insert" if prefer_insert else "replace"
|
||
|
),
|
||
|
capabilities=ClientCapabilities(),
|
||
|
)
|
||
|
|
||
|
|
||
|
def directive_argument_patterns(name: str, partial: str = "") -> List[str]:
|
||
|
"""Return a number of example directive argument patterns.
|
||
|
|
||
|
These correspond to test cases where directive argument suggestions should be
|
||
|
generated.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name:
|
||
|
The name of the directive to generate suggestions for.
|
||
|
partial:
|
||
|
The partial argument that the user has already entered.
|
||
|
"""
|
||
|
return [s.format(name, partial) for s in [".. {}:: {}", " .. {}:: {}"]]
|
||
|
|
||
|
|
||
|
def role_patterns(partial: str = "") -> List[str]:
|
||
|
"""Return a number of example role patterns.
|
||
|
|
||
|
These correspond to when role suggestions should be generated.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
partial:
|
||
|
The partial role name that the user has already entered
|
||
|
"""
|
||
|
return [
|
||
|
s.format(partial)
|
||
|
for s in [
|
||
|
"{}",
|
||
|
"({}",
|
||
|
"- {}",
|
||
|
" {}",
|
||
|
" ({}",
|
||
|
" - {}",
|
||
|
"some text {}",
|
||
|
"some text ({}",
|
||
|
" some text {}",
|
||
|
" some text ({}",
|
||
|
]
|
||
|
]
|
||
|
|
||
|
|
||
|
def role_target_patterns(
|
||
|
name: str, partial: str = "", include_modifiers: bool = True
|
||
|
) -> List[str]:
|
||
|
"""Return a number of example role target patterns.
|
||
|
|
||
|
These correspond to test cases where role target suggestions should be generated.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name:
|
||
|
The name of the role to generate suggestions for.
|
||
|
partial:
|
||
|
The partial target that the user as already entered.
|
||
|
include_modifiers:
|
||
|
A flag to indicate if additional modifiers like ``!`` and ``~`` should be
|
||
|
included in the generated patterns.
|
||
|
"""
|
||
|
|
||
|
patterns = [
|
||
|
":{}:`{}",
|
||
|
"(:{}:`{}",
|
||
|
"- :{}:`{}",
|
||
|
":{}:`More Info <{}",
|
||
|
"(:{}:`More Info <{}",
|
||
|
" :{}:`{}",
|
||
|
" (:{}:`{}",
|
||
|
" - :{}:`{}",
|
||
|
" :{}:`Some Label <{}",
|
||
|
" (:{}:`Some Label <{}",
|
||
|
]
|
||
|
|
||
|
test_cases = [p.format(name, partial) for p in patterns]
|
||
|
|
||
|
if include_modifiers:
|
||
|
test_cases += [p.format(name, "!" + partial) for p in patterns]
|
||
|
test_cases += [p.format(name, "~" + partial) for p in patterns]
|
||
|
|
||
|
return test_cases
|
||
|
|
||
|
|
||
|
def intersphinx_target_patterns(name: str, project: str) -> List[str]:
|
||
|
"""Return a number of example intersphinx target patterns.
|
||
|
|
||
|
These correspond to cases where target completions may be generated
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
name: str
|
||
|
The name of the role to generate examples for
|
||
|
project: str
|
||
|
The name of the project to generate examples for
|
||
|
"""
|
||
|
return [
|
||
|
s.format(name, project)
|
||
|
for s in [
|
||
|
":{}:`{}:",
|
||
|
"(:{}:`{}:",
|
||
|
":{}:`More Info <{}:",
|
||
|
"(:{}:`More Info <{}:",
|
||
|
" :{}:`{}:",
|
||
|
" (:{}:`{}:",
|
||
|
" :{}:`Some Label <{}:",
|
||
|
" (:{}:`Some Label <{}:",
|
||
|
]
|
||
|
]
|
||
|
|
||
|
|
||
|
async def completion_request(
|
||
|
client: LanguageClient, test_uri: str, text: str, character: Optional[int] = None
|
||
|
) -> Union[CompletionList, List[CompletionItem], None]:
|
||
|
"""Make a completion request to a language server.
|
||
|
|
||
|
Intended for use within test cases, this function simulates the opening of a
|
||
|
document, inserting some text, triggering a completion request and closing it
|
||
|
again.
|
||
|
|
||
|
The file referenced by ``test_uri`` does not have to exist.
|
||
|
|
||
|
The text to be inserted is specified through the ``text`` parameter. By default
|
||
|
it's assumed that the ``text`` parameter consists of a single line of text, in fact
|
||
|
this function will error if that is not the case.
|
||
|
|
||
|
If your request requires additional context (such as directive option completions)
|
||
|
it can be included but it must be delimited with a ``\\f`` character. For example,
|
||
|
to represent the following scenario::
|
||
|
|
||
|
.. image:: filename.png
|
||
|
:align: center
|
||
|
:
|
||
|
^
|
||
|
|
||
|
where ``^`` represents the position from which we trigger the completion request.
|
||
|
We would set ``text`` to the following
|
||
|
``.. image:: filename.png\\n :align: center\\n\\f :``
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
test:
|
||
|
The client used to make the request.
|
||
|
test_uri:
|
||
|
The uri the completion request should be made within.
|
||
|
text
|
||
|
The text that provides the context for the completion request.
|
||
|
character:
|
||
|
The character index at which to make the completion request from.
|
||
|
If ``None``, it will default to the end of the inserted text.
|
||
|
"""
|
||
|
|
||
|
if "\f" in text:
|
||
|
contents, text = text.split("\f")
|
||
|
else:
|
||
|
contents = ""
|
||
|
|
||
|
logger.debug("Context text: '%s'", contents)
|
||
|
logger.debug("Insertion text: '%s'", text)
|
||
|
assert "\n" not in text, "Insertion text cannot contain newlines"
|
||
|
|
||
|
ext = pathlib.Path(Uri.to_fs_path(test_uri)).suffix
|
||
|
lang_id = "python" if ext == ".py" else "rst"
|
||
|
|
||
|
client.text_document_did_open(
|
||
|
DidOpenTextDocumentParams(
|
||
|
text_document=TextDocumentItem(
|
||
|
uri=test_uri, language_id=lang_id, version=1, text=contents
|
||
|
)
|
||
|
)
|
||
|
)
|
||
|
|
||
|
lines = contents.split("\n")
|
||
|
line = len(lines) - 1
|
||
|
insertion_point = len(lines[-1])
|
||
|
|
||
|
new_lines = text.split("\n")
|
||
|
num_new_lines = len(new_lines) - 1
|
||
|
num_new_chars = len(new_lines[-1])
|
||
|
|
||
|
if num_new_lines > 0:
|
||
|
end_char = num_new_chars
|
||
|
else:
|
||
|
end_char = insertion_point + num_new_chars
|
||
|
|
||
|
client.text_document_did_change(
|
||
|
DidChangeTextDocumentParams(
|
||
|
text_document=VersionedTextDocumentIdentifier(uri=test_uri, version=2),
|
||
|
content_changes=[
|
||
|
TextDocumentContentChangeEvent_Type1(
|
||
|
text=text,
|
||
|
range=Range(
|
||
|
start=Position(line=line, character=insertion_point),
|
||
|
end=Position(line=line + num_new_lines, character=end_char),
|
||
|
),
|
||
|
)
|
||
|
],
|
||
|
)
|
||
|
)
|
||
|
|
||
|
character = character or insertion_point + len(text)
|
||
|
response = await client.text_document_completion_async(
|
||
|
CompletionParams(
|
||
|
text_document=TextDocumentIdentifier(uri=test_uri),
|
||
|
position=Position(line=line, character=character),
|
||
|
)
|
||
|
)
|
||
|
|
||
|
client.text_document_did_close(
|
||
|
DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=test_uri))
|
||
|
)
|
||
|
|
||
|
return response
|
||
|
|
||
|
|
||
|
async def hover_request(
|
||
|
client: LanguageClient, test_uri: str, text: str, line: int, character: int
|
||
|
) -> Optional[Hover]:
|
||
|
"""Make a hover request to a language server.
|
||
|
|
||
|
Intended for use within test cases, this function simulates the opening of a
|
||
|
document containing some text, triggering a hover request and closing it again.
|
||
|
|
||
|
The file referenced by ``test_uri`` does not have to exist.
|
||
|
|
||
|
Parameters
|
||
|
----------
|
||
|
test
|
||
|
The client used to make the request.
|
||
|
|
||
|
test_uri
|
||
|
The uri the completion request should be made within.
|
||
|
|
||
|
text
|
||
|
The text that provides the context for the hover request.
|
||
|
|
||
|
line
|
||
|
The line number to make the hover request from
|
||
|
|
||
|
character
|
||
|
The column number to make the hover request from
|
||
|
"""
|
||
|
ext = pathlib.Path(Uri.to_fs_path(test_uri)).suffix
|
||
|
lang_id = "python" if ext == ".py" else "rst"
|
||
|
|
||
|
client.text_document_did_open(
|
||
|
DidOpenTextDocumentParams(
|
||
|
text_document=TextDocumentItem(
|
||
|
uri=test_uri, language_id=lang_id, version=1, text=text
|
||
|
)
|
||
|
)
|
||
|
)
|
||
|
|
||
|
response = await client.text_document_hover_async(
|
||
|
HoverParams(
|
||
|
text_document=TextDocumentIdentifier(uri=test_uri),
|
||
|
position=Position(line=line, character=character),
|
||
|
)
|
||
|
)
|
||
|
|
||
|
client.text_document_did_close(
|
||
|
DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=test_uri))
|
||
|
)
|
||
|
|
||
|
return response
|