lspleanklib 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lspleanklib-0.1.1/.gitignore +5 -0
- lspleanklib-0.1.1/LICENSE +21 -0
- lspleanklib-0.1.1/PKG-INFO +34 -0
- lspleanklib-0.1.1/justfile +19 -0
- lspleanklib-0.1.1/lspleanklib/__init__.py +40 -0
- lspleanklib-0.1.1/lspleanklib/__main__.py +3 -0
- lspleanklib-0.1.1/lspleanklib/aio.py +100 -0
- lspleanklib-0.1.1/lspleanklib/cli.py +25 -0
- lspleanklib-0.1.1/lspleanklib/jsonrpc.py +332 -0
- lspleanklib-0.1.1/lspleanklib/lake.py +87 -0
- lspleanklib-0.1.1/lspleanklib/lakelspout.py +135 -0
- lspleanklib-0.1.1/lspleanklib/lspleank.py +306 -0
- lspleanklib-0.1.1/lspleanklib/py.typed +0 -0
- lspleanklib-0.1.1/lspleanklib/server.py +345 -0
- lspleanklib-0.1.1/lspleanklib/util.py +54 -0
- lspleanklib-0.1.1/lspleanklib.egg-info/PKG-INFO +34 -0
- lspleanklib-0.1.1/lspleanklib.egg-info/SOURCES.txt +37 -0
- lspleanklib-0.1.1/lspleanklib.egg-info/dependency_links.txt +1 -0
- lspleanklib-0.1.1/lspleanklib.egg-info/entry_points.txt +3 -0
- lspleanklib-0.1.1/lspleanklib.egg-info/requires.txt +3 -0
- lspleanklib-0.1.1/lspleanklib.egg-info/top_level.txt +1 -0
- lspleanklib-0.1.1/pyproject.toml +47 -0
- lspleanklib-0.1.1/setup.cfg +4 -0
- lspleanklib-0.1.1/tests/cases/alt_import/Main.lean +2 -0
- lspleanklib-0.1.1/tests/cases/alt_import/Min/Sub.lean +1 -0
- lspleanklib-0.1.1/tests/cases/alt_import/Min.lean +3 -0
- lspleanklib-0.1.1/tests/cases/alt_import/lake-manifest.json +5 -0
- lspleanklib-0.1.1/tests/cases/alt_import/lakefile.toml +4 -0
- lspleanklib-0.1.1/tests/cases/alt_import/lean-toolchain +1 -0
- lspleanklib-0.1.1/tests/cases/min_import/Main.lean +2 -0
- lspleanklib-0.1.1/tests/cases/min_import/Min.lean +1 -0
- lspleanklib-0.1.1/tests/cases/min_import/lake-manifest.json +5 -0
- lspleanklib-0.1.1/tests/cases/min_import/lakefile.toml +4 -0
- lspleanklib-0.1.1/tests/cases/min_import/lean-toolchain +1 -0
- lspleanklib-0.1.1/tests/cases/vim9-lsp-init.txt +3 -0
- lspleanklib-0.1.1/tests/test_lake.py +123 -0
- lspleanklib-0.1.1/tests/test_rpc.py +74 -0
- lspleanklib-0.1.1/tests/test_server.py +285 -0
- lspleanklib-0.1.1/tests/util.py +94 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 E. Castedo Ellerman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be
|
|
13
|
+
included in all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
20
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
21
|
+
DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: lspleanklib
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: LSP proxy and library to link LSP-enabled editors to Lake LSP servers
|
|
5
|
+
Author-email: Castedo Ellerman <castedo@castedo.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 E. Castedo Ellerman
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be
|
|
18
|
+
included in all copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
25
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
26
|
+
DEALINGS IN THE SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Keywords: lsp,language-server-protocol,jsonrpc
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Operating System :: POSIX
|
|
31
|
+
Requires-Python: >=3.12
|
|
32
|
+
License-File: LICENSE
|
|
33
|
+
Provides-Extra: crossplatform
|
|
34
|
+
Requires-Dist: platformdirs; extra == "crossplatform"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env -S just --justfile
|
|
2
|
+
|
|
3
|
+
default:
|
|
4
|
+
just --list
|
|
5
|
+
|
|
6
|
+
test:
|
|
7
|
+
ruff check lspleanklib || true
|
|
8
|
+
mypy --strict lspleanklib
|
|
9
|
+
mypy tests --cache-dir tests/.mypy_cache
|
|
10
|
+
pytest -vv tests --timeout=2 \
|
|
11
|
+
# -m 'not slow' \
|
|
12
|
+
# --durations=3 \
|
|
13
|
+
# --log-cli-level=DEBUG \
|
|
14
|
+
|
|
15
|
+
clean:
|
|
16
|
+
rm -rf dist
|
|
17
|
+
rm -rf build
|
|
18
|
+
rm -rf *.egg-info
|
|
19
|
+
rm -f _version.py
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from .jsonrpc import (
|
|
2
|
+
ErrorCode,
|
|
3
|
+
JsonRpcMsg,
|
|
4
|
+
MethodCall,
|
|
5
|
+
Response,
|
|
6
|
+
RpcChannel,
|
|
7
|
+
RpcInterface,
|
|
8
|
+
RpcMsgChannel,
|
|
9
|
+
RpcMsgConnection,
|
|
10
|
+
awaitable_error,
|
|
11
|
+
json_rpc_channel,
|
|
12
|
+
)
|
|
13
|
+
from .lspleank import lspleank_connect_main
|
|
14
|
+
from .server import (
|
|
15
|
+
RpcDirChannelFactory,
|
|
16
|
+
RpcSubprocessFactory,
|
|
17
|
+
channel_lsp_server,
|
|
18
|
+
get_user_socket_path,
|
|
19
|
+
)
|
|
20
|
+
from .util import LspAny, LspObject
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
'ErrorCode',
|
|
24
|
+
'JsonRpcMsg',
|
|
25
|
+
'LspAny',
|
|
26
|
+
'LspObject',
|
|
27
|
+
'MethodCall',
|
|
28
|
+
'Response',
|
|
29
|
+
'RpcChannel',
|
|
30
|
+
'RpcDirChannelFactory',
|
|
31
|
+
'RpcInterface',
|
|
32
|
+
'RpcMsgChannel',
|
|
33
|
+
'RpcMsgConnection',
|
|
34
|
+
'RpcSubprocessFactory',
|
|
35
|
+
'awaitable_error',
|
|
36
|
+
'channel_lsp_server',
|
|
37
|
+
'get_user_socket_path',
|
|
38
|
+
'json_rpc_channel',
|
|
39
|
+
'lspleank_connect_main',
|
|
40
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Asynchronous IO
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio, io, os, typing
|
|
6
|
+
from asyncio import AbstractEventLoop, Future
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from typing import BinaryIO
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MinimalWriter(typing.Protocol):
|
|
13
|
+
def write(self, data: bytes) -> None: ...
|
|
14
|
+
async def drain(self) -> None: ...
|
|
15
|
+
def close(self) -> None: ...
|
|
16
|
+
async def wait_closed(self) -> None: ...
|
|
17
|
+
def is_closing(self) -> bool: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MinimalReader(typing.Protocol):
|
|
21
|
+
async def readuntil(self) -> bytes: ... # read until and return with '\n'
|
|
22
|
+
async def readexactly(self, n: int) -> bytes: ...
|
|
23
|
+
async def read(self) -> bytes: ...
|
|
24
|
+
def at_eof(self) -> bool: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DuplexStream:
|
|
28
|
+
def __init__(self, ain: MinimalReader, aout: MinimalWriter):
|
|
29
|
+
self.ain = ain
|
|
30
|
+
self.aout = aout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WriterFileAdapter(MinimalWriter):
|
|
34
|
+
def __init__(self, fout: BinaryIO, *, loop: AbstractEventLoop):
|
|
35
|
+
self._fout = fout
|
|
36
|
+
self._loop = loop
|
|
37
|
+
self._pool = ThreadPoolExecutor(1, thread_name_prefix="blocking_write")
|
|
38
|
+
self._buf: list[bytes] = []
|
|
39
|
+
self._closing: Future[None] | None = None
|
|
40
|
+
|
|
41
|
+
def write(self, data: bytes) -> None:
|
|
42
|
+
if self._closing is not None:
|
|
43
|
+
raise ValueError('write to closed file')
|
|
44
|
+
if data:
|
|
45
|
+
self._buf.append(data)
|
|
46
|
+
|
|
47
|
+
async def drain(self) -> None:
|
|
48
|
+
if self._buf:
|
|
49
|
+
todo = tuple(self._buf)
|
|
50
|
+
self._buf.clear()
|
|
51
|
+
await self._loop.run_in_executor(self._pool, self._drain, todo)
|
|
52
|
+
|
|
53
|
+
def _drain(self, todo: tuple[bytes, ...]) -> None:
|
|
54
|
+
for data in todo:
|
|
55
|
+
self._fout.write(data)
|
|
56
|
+
self._fout.flush()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if self._closing is None:
|
|
60
|
+
self._closing = self._loop.run_in_executor(self._pool, self._fout.close)
|
|
61
|
+
|
|
62
|
+
async def wait_closed(self) -> None:
|
|
63
|
+
if self._closing is None:
|
|
64
|
+
raise ValueError('file not closed')
|
|
65
|
+
await self._closing
|
|
66
|
+
|
|
67
|
+
def is_closing(self) -> bool:
|
|
68
|
+
return self._closing is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ReadFilePump:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
fd: int,
|
|
75
|
+
*,
|
|
76
|
+
loop: AbstractEventLoop,
|
|
77
|
+
on_eof: Callable[[], None] = lambda: None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._fd = fd
|
|
80
|
+
self._loop = loop
|
|
81
|
+
self.stream = asyncio.StreamReader(loop=loop)
|
|
82
|
+
self._on_eof = on_eof
|
|
83
|
+
|
|
84
|
+
def run(self) -> None:
|
|
85
|
+
try:
|
|
86
|
+
while True:
|
|
87
|
+
try:
|
|
88
|
+
data = os.read(self._fd, io.DEFAULT_BUFFER_SIZE)
|
|
89
|
+
except KeyboardInterrupt as ex:
|
|
90
|
+
nex = IOError('file read interrupted')
|
|
91
|
+
nex.__cause__ = ex
|
|
92
|
+
self._loop.call_soon_threadsafe(self.stream.set_exception, nex)
|
|
93
|
+
return
|
|
94
|
+
if data:
|
|
95
|
+
self._loop.call_soon_threadsafe(self.stream.feed_data, data)
|
|
96
|
+
else:
|
|
97
|
+
self._loop.call_soon_threadsafe(self.stream.feed_eof)
|
|
98
|
+
return
|
|
99
|
+
finally:
|
|
100
|
+
self._loop.call_soon_threadsafe(self._on_eof)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI common code
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def version() -> str:
|
|
7
|
+
try:
|
|
8
|
+
from ._version import version # type: ignore[import-not-found, import-untyped, unused-ignore]
|
|
9
|
+
|
|
10
|
+
return str(version)
|
|
11
|
+
except ImportError:
|
|
12
|
+
return '0.0.0'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def split_cmd_line(
|
|
16
|
+
cmd_line_args: list[str], default_extra_args: list[str] | None = None
|
|
17
|
+
) -> tuple[list[str], list[str]]:
|
|
18
|
+
try:
|
|
19
|
+
cut = cmd_line_args.index('--')
|
|
20
|
+
except ValueError:
|
|
21
|
+
cut = len(cmd_line_args)
|
|
22
|
+
extra_args = cmd_line_args[cut + 1 :]
|
|
23
|
+
if default_extra_args is None:
|
|
24
|
+
default_extra_args = []
|
|
25
|
+
return (cmd_line_args[:cut], extra_args or default_extra_args)
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JSON-RPC for Language Server Protocol
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import asyncio, copy, enum, json, typing
|
|
7
|
+
from asyncio import AbstractEventLoop, Future, TaskGroup
|
|
8
|
+
from collections.abc import Awaitable, Mapping, Sequence
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from typing import Any, TypeAlias
|
|
11
|
+
from warnings import warn
|
|
12
|
+
|
|
13
|
+
from .aio import DuplexStream, MinimalReader, MinimalWriter
|
|
14
|
+
from .util import LspAny, LspObject, log
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ErrorCode(enum.IntEnum):
|
|
18
|
+
UnknownErrorCode = -32001
|
|
19
|
+
ServerNotInitialized = -32002
|
|
20
|
+
InvalidRequest = -32600
|
|
21
|
+
MethodNotFound = -32601
|
|
22
|
+
InvalidParams = -32602
|
|
23
|
+
InternalError = -32603
|
|
24
|
+
RequestFailed = -32803
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ErrorCodes = ErrorCode # in the LSP specification the name is unpythonic plural
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def write_message(stream: MinimalWriter, msg: Mapping[str, Any]) -> None:
|
|
31
|
+
body = json.dumps(msg, separators=(',', ':')).encode()
|
|
32
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode()
|
|
33
|
+
stream.write(header)
|
|
34
|
+
stream.write(body)
|
|
35
|
+
await stream.drain()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def read_message(stream: MinimalReader) -> dict[str, Any] | None:
|
|
39
|
+
body_length = 0
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
line = await stream.readuntil()
|
|
43
|
+
key_val = line.split(b':', 2)
|
|
44
|
+
if len(key_val) == 2:
|
|
45
|
+
if key_val[0].lower() == b'content-length':
|
|
46
|
+
body_length = int(key_val[1].strip())
|
|
47
|
+
else:
|
|
48
|
+
# end of header (empty line if correct LSP)
|
|
49
|
+
break
|
|
50
|
+
body = await stream.readexactly(body_length)
|
|
51
|
+
ret = json.loads(body)
|
|
52
|
+
if not isinstance(ret, dict):
|
|
53
|
+
raise ValueError("expecting JSON-RPC message to be JSON object")
|
|
54
|
+
return ret
|
|
55
|
+
except asyncio.exceptions.IncompleteReadError as ex:
|
|
56
|
+
if ex.partial:
|
|
57
|
+
raise ValueError("truncated stream input") from ex
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
MsgParams: TypeAlias = Sequence[LspAny] | Mapping[str, LspAny]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class MethodCall:
|
|
66
|
+
method: str
|
|
67
|
+
params: MsgParams | None = None
|
|
68
|
+
|
|
69
|
+
def to_lsp_obj(self) -> LspObject:
|
|
70
|
+
lobj: dict[str, LspAny] = {'method': self.method}
|
|
71
|
+
if self.params is not None:
|
|
72
|
+
lobj['params'] = copy.deepcopy(self.params)
|
|
73
|
+
return lobj
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ResponseError:
|
|
78
|
+
code: int
|
|
79
|
+
message: str
|
|
80
|
+
data: LspAny | None = None
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def from_lsp_obj(msg: LspObject) -> ResponseError:
|
|
84
|
+
code = msg.get('code')
|
|
85
|
+
if not isinstance(code, int):
|
|
86
|
+
code = ErrorCode.UnknownErrorCode
|
|
87
|
+
return ResponseError(code, str(msg.get('message')), msg.get('data'))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def awaitable_error(ec: ErrorCode) -> Response:
|
|
91
|
+
return Response.from_error_code(ec)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Response:
|
|
96
|
+
result: LspAny
|
|
97
|
+
error: ResponseError | None = None
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def from_lsp_obj(msg: LspObject) -> Response:
|
|
101
|
+
error = msg.get('error')
|
|
102
|
+
if error is None:
|
|
103
|
+
return Response(msg.get('result'))
|
|
104
|
+
elif not isinstance(error, dict):
|
|
105
|
+
raise ValueError('LSP errors must be JSON objects')
|
|
106
|
+
else:
|
|
107
|
+
return Response(None, ResponseError.from_lsp_obj(error))
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def from_error_code(ec: ErrorCode) -> Response:
|
|
111
|
+
return Response(None, ResponseError(ec, ec.name))
|
|
112
|
+
|
|
113
|
+
def to_lsp_obj(self) -> LspObject:
|
|
114
|
+
if self.error:
|
|
115
|
+
return {'error': asdict(self.error)}
|
|
116
|
+
else:
|
|
117
|
+
return {'result': self.result}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class JsonRpcMsg:
|
|
122
|
+
payload: MethodCall | Response
|
|
123
|
+
id: int | str | None = None
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def from_jsonrpc(msg: LspObject) -> JsonRpcMsg:
|
|
127
|
+
if msg.get('jsonrpc') != '2.0':
|
|
128
|
+
raise ValueError('JSON object is not JSON-RPC 2.0 message')
|
|
129
|
+
msg_id = typing.cast(int | str | None, msg.get('id'))
|
|
130
|
+
method = msg.get('method')
|
|
131
|
+
if method is None:
|
|
132
|
+
return JsonRpcMsg(Response.from_lsp_obj(msg), msg_id)
|
|
133
|
+
elif not isinstance(method, str):
|
|
134
|
+
raise ValueError('LSP method names must be strings')
|
|
135
|
+
else:
|
|
136
|
+
params = typing.cast(MsgParams | None, msg.get('params'))
|
|
137
|
+
return JsonRpcMsg(MethodCall(method, params), msg_id)
|
|
138
|
+
|
|
139
|
+
def to_lsp_obj(self) -> LspObject:
|
|
140
|
+
ret: dict[str, LspAny] = {} if self.id is None else {'id': self.id}
|
|
141
|
+
ret.update(self.payload.to_lsp_obj())
|
|
142
|
+
ret['jsonrpc'] = '2.0'
|
|
143
|
+
return ret
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class IncommingResponses:
|
|
147
|
+
def __init__(self, loop: AbstractEventLoop) -> None:
|
|
148
|
+
self._loop = loop
|
|
149
|
+
self._todo: dict[int | str | None, Future[Response]] = {}
|
|
150
|
+
self.next_id = 1
|
|
151
|
+
|
|
152
|
+
def __bool__(self) -> bool:
|
|
153
|
+
return bool(self._todo)
|
|
154
|
+
|
|
155
|
+
def cancel_all(self) -> None:
|
|
156
|
+
while self._todo:
|
|
157
|
+
msg_id, expect = self._todo.popitem()
|
|
158
|
+
expect.cancel()
|
|
159
|
+
|
|
160
|
+
def prepare(self, fixed_id: str | None) -> tuple[int | str, Future[Response]]:
|
|
161
|
+
msg_id = self.next_id if fixed_id is None else fixed_id
|
|
162
|
+
if fixed_id is None:
|
|
163
|
+
self.next_id += 1
|
|
164
|
+
stale = self._todo.pop(msg_id, None)
|
|
165
|
+
if stale is not None:
|
|
166
|
+
warn(f"Response abandoned due to id reuse by new request: {msg_id}")
|
|
167
|
+
stale.cancel()
|
|
168
|
+
expect: Future[Response] = self._loop.create_future()
|
|
169
|
+
self._todo[msg_id] = expect
|
|
170
|
+
return (msg_id, expect)
|
|
171
|
+
|
|
172
|
+
def got_response(self, response: Response, msg_id: int | str | None) -> None:
|
|
173
|
+
expect = self._todo.pop(msg_id, None)
|
|
174
|
+
if expect:
|
|
175
|
+
expect.set_result(response)
|
|
176
|
+
else:
|
|
177
|
+
warn(f"Unexpected response with id: {msg_id}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class RpcMsgConnection(typing.Protocol):
|
|
181
|
+
async def close_and_wait(self) -> None: ...
|
|
182
|
+
async def write(self, msg: JsonRpcMsg) -> None: ...
|
|
183
|
+
async def read(self) -> JsonRpcMsg | None: ...
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class JsonRpcMsgStream(RpcMsgConnection):
|
|
187
|
+
def __init__(self, aio: DuplexStream):
|
|
188
|
+
self._aio = aio
|
|
189
|
+
|
|
190
|
+
async def close_and_wait(self) -> None:
|
|
191
|
+
self._aio.aout.close()
|
|
192
|
+
await self._aio.aout.wait_closed()
|
|
193
|
+
|
|
194
|
+
async def write(self, msg: JsonRpcMsg) -> None:
|
|
195
|
+
await write_message(self._aio.aout, msg.to_lsp_obj())
|
|
196
|
+
|
|
197
|
+
async def read(self) -> JsonRpcMsg | None:
|
|
198
|
+
while (msg := await read_message(self._aio.ain)) is not None:
|
|
199
|
+
try:
|
|
200
|
+
return JsonRpcMsg.from_jsonrpc(msg)
|
|
201
|
+
except ValueError as ex:
|
|
202
|
+
log.exception(ex)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class RpcInterface(typing.Protocol):
|
|
207
|
+
async def close_and_wait(self) -> None: ...
|
|
208
|
+
async def notify(self, mc: MethodCall) -> None: ...
|
|
209
|
+
async def request(
|
|
210
|
+
self, mc: MethodCall, fix_id: str | None = None
|
|
211
|
+
) -> Awaitable[Response]: ...
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class RemoteRpcProxy(RpcInterface):
|
|
215
|
+
def __init__(self, conn: RpcMsgConnection, *, loop: AbstractEventLoop):
|
|
216
|
+
self._conn = conn
|
|
217
|
+
self._expecting: IncommingResponses | None = IncommingResponses(loop)
|
|
218
|
+
|
|
219
|
+
async def close_and_wait(self) -> None:
|
|
220
|
+
log.debug(f"closing {self.__class__.__name__}")
|
|
221
|
+
await self._conn.close_and_wait()
|
|
222
|
+
|
|
223
|
+
async def notify(self, mc: MethodCall) -> None:
|
|
224
|
+
msg = JsonRpcMsg(mc)
|
|
225
|
+
try:
|
|
226
|
+
await self._conn.write(msg)
|
|
227
|
+
except RuntimeError:
|
|
228
|
+
log.exception(f"Write failed for RPC call '{mc.method}'")
|
|
229
|
+
|
|
230
|
+
async def request(
|
|
231
|
+
self, mc: MethodCall, fix_id: str | None = None
|
|
232
|
+
) -> Awaitable[Response]:
|
|
233
|
+
if self._expecting is None:
|
|
234
|
+
warn('request called on closed or closing RPC connection')
|
|
235
|
+
return awaitable_error(ErrorCode.InternalError)
|
|
236
|
+
(msg_id, expect) = self._expecting.prepare(fix_id)
|
|
237
|
+
try:
|
|
238
|
+
await self._conn.write(JsonRpcMsg(mc, msg_id))
|
|
239
|
+
except RuntimeError:
|
|
240
|
+
error_response = Response.from_error_code(ErrorCode.InternalError)
|
|
241
|
+
self._expecting.got_response(error_response, msg_id)
|
|
242
|
+
log.exception(f"Write failed for RPC call '{mc.method}'")
|
|
243
|
+
return expect
|
|
244
|
+
|
|
245
|
+
def got_response(self, response: Response, msg_id: int | str | None) -> None:
|
|
246
|
+
if self._expecting is None:
|
|
247
|
+
warn('RemoteRpcProxy.got_response called after end_of_incoming_responses')
|
|
248
|
+
else:
|
|
249
|
+
self._expecting.got_response(response, msg_id)
|
|
250
|
+
|
|
251
|
+
def end_of_incomming_responses(self) -> None:
|
|
252
|
+
if self._expecting:
|
|
253
|
+
self._expecting.cancel_all()
|
|
254
|
+
log.info("orphaned incoming responses cancelled")
|
|
255
|
+
self._expecting = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class RpcChannel(typing.Protocol):
|
|
259
|
+
@property
|
|
260
|
+
def proxy(self) -> RpcInterface: ...
|
|
261
|
+
|
|
262
|
+
async def pump(self, impl: RpcInterface | None = None) -> None: ...
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class NoClient(RpcInterface):
|
|
266
|
+
async def notify(self, mc: MethodCall) -> None:
|
|
267
|
+
warn(f"No client RPC implementation for '{mc.method}' notification")
|
|
268
|
+
|
|
269
|
+
async def request(
|
|
270
|
+
self, mc: MethodCall, fix_id: str | None = None
|
|
271
|
+
) -> Awaitable[Response]:
|
|
272
|
+
warn(f"No client RPC implementation for '{mc.method}' request")
|
|
273
|
+
return awaitable_error(ErrorCode.MethodNotFound)
|
|
274
|
+
|
|
275
|
+
async def close_and_wait(self) -> None:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def await_send_response(
|
|
280
|
+
conn: RpcMsgConnection, tbd: Awaitable[Response], msg_id: int | str | None
|
|
281
|
+
) -> None:
|
|
282
|
+
try:
|
|
283
|
+
response = await tbd
|
|
284
|
+
await conn.write(JsonRpcMsg(response, msg_id))
|
|
285
|
+
except (asyncio.CancelledError, ValueError) as ex:
|
|
286
|
+
# writing to closed aout stream raises ValueError
|
|
287
|
+
log.exception(ex)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class RpcMsgChannel(RpcChannel):
|
|
291
|
+
def __init__(self, conn: RpcMsgConnection, *, name: str, loop: AbstractEventLoop):
|
|
292
|
+
self._conn = conn
|
|
293
|
+
self._proxy = RemoteRpcProxy(conn, loop=loop)
|
|
294
|
+
self.name = name
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def proxy(self) -> RpcInterface:
|
|
298
|
+
return self._proxy
|
|
299
|
+
|
|
300
|
+
async def pump(self, impl: RpcInterface | None = None) -> None:
|
|
301
|
+
"""Listen for JSONRPC message on connection input until stream EOF.
|
|
302
|
+
|
|
303
|
+
impl: implements the methods for RPC calls received on stream input
|
|
304
|
+
"""
|
|
305
|
+
impl = impl or NoClient()
|
|
306
|
+
try:
|
|
307
|
+
async with TaskGroup() as response_tasks:
|
|
308
|
+
try:
|
|
309
|
+
while (msg := await self._conn.read()) is not None:
|
|
310
|
+
if isinstance(msg.payload, Response):
|
|
311
|
+
self._proxy.got_response(msg.payload, msg.id)
|
|
312
|
+
elif msg.id is None:
|
|
313
|
+
await impl.notify(msg.payload)
|
|
314
|
+
else:
|
|
315
|
+
fix_id = msg.id if isinstance(msg.id, str) else None
|
|
316
|
+
tbd = await impl.request(msg.payload, fix_id)
|
|
317
|
+
coro = await_send_response(self._conn, tbd, msg.id)
|
|
318
|
+
response_tasks.create_task(coro)
|
|
319
|
+
finally:
|
|
320
|
+
await impl.close_and_wait()
|
|
321
|
+
self._proxy.end_of_incomming_responses()
|
|
322
|
+
log.debug(f"{self.name} pump done reading responses")
|
|
323
|
+
finally:
|
|
324
|
+
log.debug(f"{self.name} pump closing connection")
|
|
325
|
+
await self._conn.close_and_wait()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def json_rpc_channel(
|
|
329
|
+
ain: MinimalReader, aout: MinimalWriter, *, name: str, loop: AbstractEventLoop
|
|
330
|
+
) -> RpcMsgChannel:
|
|
331
|
+
stream = JsonRpcMsgStream(DuplexStream(ain, aout))
|
|
332
|
+
return RpcMsgChannel(stream, name=name, loop=loop)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code for the Lake LSP server
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .jsonrpc import (
|
|
9
|
+
ErrorCode,
|
|
10
|
+
MethodCall,
|
|
11
|
+
Response,
|
|
12
|
+
RpcChannel,
|
|
13
|
+
RpcInterface,
|
|
14
|
+
awaitable_error,
|
|
15
|
+
)
|
|
16
|
+
from .server import (
|
|
17
|
+
RpcDirChannelFactory,
|
|
18
|
+
leank_init_call,
|
|
19
|
+
leank_init_response,
|
|
20
|
+
text_doc_caps,
|
|
21
|
+
)
|
|
22
|
+
from .util import awaitable, get_uri_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LakeClient(RpcInterface):
|
|
26
|
+
def __init__(self, leank_client: RpcInterface):
|
|
27
|
+
self.client = leank_client
|
|
28
|
+
|
|
29
|
+
async def close_and_wait(self) -> None:
|
|
30
|
+
await self.client.close_and_wait()
|
|
31
|
+
|
|
32
|
+
async def notify(self, mc: MethodCall) -> None:
|
|
33
|
+
await self.client.notify(mc)
|
|
34
|
+
|
|
35
|
+
async def request(
|
|
36
|
+
self, mc: MethodCall, fix_id: str | None = None
|
|
37
|
+
) -> Awaitable[Response]:
|
|
38
|
+
if mc.method == "client/registerCapability":
|
|
39
|
+
return awaitable_error(ErrorCode.MethodNotFound)
|
|
40
|
+
return await self.client.request(mc, fix_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LeankServer(RpcInterface):
|
|
44
|
+
def __init__(self, lake_server: RpcInterface):
|
|
45
|
+
self._lake_server = lake_server
|
|
46
|
+
|
|
47
|
+
async def close_and_wait(self) -> None:
|
|
48
|
+
await self._lake_server.close_and_wait()
|
|
49
|
+
|
|
50
|
+
async def notify(self, mc: MethodCall) -> None:
|
|
51
|
+
await self._lake_server.notify(mc)
|
|
52
|
+
|
|
53
|
+
async def request(
|
|
54
|
+
self, mc: MethodCall, fix_id: str | None = None
|
|
55
|
+
) -> Awaitable[Response]:
|
|
56
|
+
if mc.method != "initialized":
|
|
57
|
+
return await self._lake_server.request(mc)
|
|
58
|
+
else:
|
|
59
|
+
init_params = mc.params if isinstance(mc.params, dict) else {}
|
|
60
|
+
lsp_root = get_uri_path(init_params, 'rootUri')
|
|
61
|
+
init_call = leank_init_call(lsp_root, text_doc_caps(init_params))
|
|
62
|
+
aw_response = await self._lake_server.request(init_call)
|
|
63
|
+
response = await aw_response
|
|
64
|
+
return awaitable(leank_init_response(response))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LeankLakeChannel(RpcChannel):
|
|
68
|
+
def __init__(self, lake_channel: RpcChannel):
|
|
69
|
+
self._lake_channel = lake_channel
|
|
70
|
+
self._leank_server = LeankServer(lake_channel.proxy)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def proxy(self) -> RpcInterface:
|
|
74
|
+
return self._leank_server
|
|
75
|
+
|
|
76
|
+
async def pump(self, leank_client: RpcInterface | None = None) -> None:
|
|
77
|
+
impl = None if leank_client is None else LakeClient(leank_client)
|
|
78
|
+
await self._lake_channel.pump(impl)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LeankLakeFactory(RpcDirChannelFactory):
|
|
82
|
+
def __init__(self, lake_factory: RpcDirChannelFactory):
|
|
83
|
+
self._lake_factory = lake_factory
|
|
84
|
+
|
|
85
|
+
async def anew(self, work_root: Path) -> RpcChannel:
|
|
86
|
+
lake_channel = await self._lake_factory.anew(work_root)
|
|
87
|
+
return LeankLakeChannel(lake_channel)
|