Skip to content

Commit 2debb04

Browse files
committed
Merge branch 'main' into feat/cli
2 parents d6ab576 + e6b95a9 commit 2debb04

File tree

21 files changed

+438
-357
lines changed

21 files changed

+438
-357
lines changed

container/ty/ContainerFile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# ty has built-in LSP
2-
ARG VERSION=latest
2+
ARG VERSION=0.0.5
33
FROM ghcr.io/astral-sh/uv:python3.11-trixie-slim AS builder
44
ARG VERSION
55
RUN uv tool install ty@${VERSION}

examples/protocol.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from typing import Protocol, runtime_checkable
1010

11-
from lsp_client import LSPClient
11+
from lsp_client import Client
1212
from lsp_client.capability.request import (
1313
WithRequestCallHierarchy,
1414
WithRequestDefinition,
@@ -32,7 +32,7 @@ class ExpectClientProtocol(
3232

3333

3434
class BadClient(
35-
LSPClient,
35+
Client,
3636
WithRequestDocumentSymbol,
3737
):
3838
"""Client that fails to meet protocol requirements.
@@ -44,7 +44,7 @@ class BadClient(
4444

4545

4646
class GoodClient(
47-
LSPClient,
47+
Client,
4848
WithRequestReferences,
4949
WithRequestDefinition,
5050
WithRequestCallHierarchy,

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ dev = [
3636
"pytest-asyncio>=1.0.0",
3737
"pytest-cov>=6.0.0",
3838
"ruff>=0.8.0",
39-
"mypy>=1.13.0",
4039
"httpx>=0.28.1",
4140
"aiofiles>=24.1.0",
4241
"pdoc>=16.0.0",

src/lsp_client/__init__.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,8 @@ async def main():
3535

3636
from loguru import logger
3737

38-
from .client.abc import LSPClient
39-
from .clients import PythonClient, RustClient, TypeScriptClient
40-
from .clients.pyrefly import PyreflyClient
41-
from .server.abc import LSPServer
38+
from .client.abc import Client
39+
from .server.abc import Server
4240
from .server.container import ContainerServer
4341
from .server.local import LocalServer
4442
from .utils.types import * # noqa: F403
@@ -61,14 +59,10 @@ def disable_logging() -> None:
6159
}
6260

6361
__all__ = [
62+
"Client",
6463
"ContainerServer",
65-
"LSPClient",
66-
"LSPServer",
6764
"LocalServer",
68-
"PyreflyClient",
69-
"PythonClient",
70-
"RustClient",
71-
"TypeScriptClient",
65+
"Server",
7266
"disable_logging",
7367
"enable_logging",
7468
]

src/lsp_client/client/abc.py

Lines changed: 52 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import os
44
from abc import ABC, abstractmethod
5-
from collections.abc import AsyncGenerator
5+
from collections.abc import AsyncGenerator, Iterable
66
from contextlib import asynccontextmanager
77
from pathlib import Path
88
from typing import Any, Self, override
@@ -30,75 +30,83 @@
3030
CapabilityClientProtocol,
3131
CapabilityProtocol,
3232
)
33-
from lsp_client.server import LocalServer
34-
from lsp_client.server.abc import LSPServer
33+
from lsp_client.server import DefaultServers, Server, ServerRuntimeError
3534
from lsp_client.server.types import ServerRequest
3635
from lsp_client.utils.channel import Receiver, channel
3736
from lsp_client.utils.types import AnyPath, Notification, Request, Response, lsp_type
3837
from lsp_client.utils.workspace import (
3938
DEFAULT_WORKSPACE_DIR,
4039
RawWorkspace,
4140
Workspace,
42-
WorkspaceFolder,
41+
format_workspace,
4342
)
4443

4544

4645
@define
47-
class LSPClient(
46+
class Client(
4847
# text sync support is mandatory
4948
WithNotifyTextDocumentSynchronize,
5049
CapabilityClientProtocol,
5150
AsyncContextManagerMixin,
5251
ABC,
5352
):
54-
_server: LSPServer | None = field(alias="server", default=None)
55-
_workspace: RawWorkspace = field(alias="workspace", factory=Path.cwd)
53+
_server_arg: Server | None = field(alias="server", default=None)
54+
_workspace_arg: RawWorkspace = field(alias="workspace", factory=Path.cwd)
5655

5756
sync_file: bool = True
5857
request_timeout: float = 5.0
5958

59+
_server: Server = field(init=False)
60+
_workspace: Workspace = field(init=False)
6061
_buffer: LSPFileBuffer = field(factory=LSPFileBuffer, init=False)
6162

62-
def get_server(self) -> LSPServer:
63-
return self._server or self.create_default_server()
63+
def _iter_candidate_servers(self) -> Iterable[Server]:
64+
"""
65+
Server candidates in order of priority:
66+
1. User-provided server
67+
2. Containerized server
68+
3. Local server (maybe with auto-installation)
69+
"""
70+
71+
if self._server_arg:
72+
yield self._server_arg
73+
defaults = self.create_default_servers()
74+
yield defaults.container
75+
yield defaults.local
76+
77+
@asynccontextmanager
78+
async def _run_server(
79+
self,
80+
) -> AsyncGenerator[tuple[Server, Receiver[ServerRequest]]]:
81+
async with channel[ServerRequest].create() as (sender, receiver):
82+
errors: list[ServerRuntimeError] = []
83+
for server in self._iter_candidate_servers():
84+
try:
85+
async with server.run(self.get_workspace(), sender=sender) as s: # ty: ignore[invalid-argument-type]
86+
yield s, receiver
87+
return
88+
except ServerRuntimeError as e:
89+
logger.debug("Failed to start server {}: {}", server, e)
90+
errors.append(e)
91+
92+
raise ExceptionGroup(
93+
f"All servers failed to start for {type(self).__name__}", errors
94+
)
6495

6596
@override
6697
def get_workspace(self) -> Workspace:
67-
match self._workspace:
68-
case str() | os.PathLike() as root_folder_path:
69-
return Workspace(
70-
{
71-
DEFAULT_WORKSPACE_DIR: WorkspaceFolder(
72-
uri=Path(root_folder_path).as_uri(),
73-
name="root",
74-
)
75-
}
76-
)
77-
case Workspace() as ws:
78-
return ws
79-
case _ as mapping:
80-
return Workspace(
81-
{
82-
name: WorkspaceFolder(uri=Path(path).as_uri(), name=name)
83-
for name, path in mapping.items()
84-
}
85-
)
98+
return self._workspace
99+
100+
def get_server(self) -> Server:
101+
return self._server
86102

87103
@abstractmethod
88104
def get_language_id(self) -> lsp_type.LanguageKind:
89105
"""The language ID of the client."""
90106

91107
@abstractmethod
92-
def create_default_server(self) -> LSPServer:
93-
"""Create the default server for this client."""
94-
95-
@abstractmethod
96-
async def ensure_installed(self) -> None:
97-
"""
98-
Check and install the server if necessary.
99-
100-
Note: For local runtime only.
101-
"""
108+
def create_default_servers(self) -> DefaultServers:
109+
"""Create default servers for this client."""
102110

103111
@abstractmethod
104112
def create_initialization_options(self) -> dict[str, Any]:
@@ -230,21 +238,19 @@ async def _exit(self) -> None:
230238
@asynccontextmanager
231239
@logger.catch(reraise=True)
232240
async def __asynccontextmanager__(self) -> AsyncGenerator[Self]:
233-
if isinstance(self._server, LocalServer):
234-
await self.ensure_installed()
235-
236-
self._hook = build_server_request_hooks(self)
237-
client_capabilities = build_client_capabilities(self.__class__)
241+
self._workspace = format_workspace(self._workspace_arg)
238242

239243
async with (
240244
asyncer.create_task_group() as tg,
241-
channel[ServerRequest].create() as (sender, receiver),
242-
self.get_server().serve(workspace=self.get_workspace(), sender=sender),
245+
self._run_server() as (server, receiver), # ty: ignore[invalid-argument-type]
243246
):
247+
self._server = server
248+
244249
# start to receive server requests here,
245250
# since server notification can be sent before `initialize`
246-
tg.soonify(self._dispatch_server_requests)(receiver)
251+
tg.soonify(self._dispatch_server_requests)(receiver) # ty: ignore[invalid-argument-type]
247252

253+
client_capabilities = build_client_capabilities(self.__class__)
248254
root_workspace = self.get_workspace().get(DEFAULT_WORKSPACE_DIR)
249255
root_path = root_workspace.path.as_posix() if root_workspace else None
250256
root_uri = root_workspace.uri if root_workspace else None

src/lsp_client/clients/deno/client.py

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
WithReceivePublishDiagnostics,
2424
)
2525
from lsp_client.capability.server_notification.log_message import WithReceiveLogMessage
26-
from lsp_client.client.abc import LSPClient
27-
from lsp_client.server.abc import LSPServer
26+
from lsp_client.client.abc import Client
27+
from lsp_client.server import DefaultServers
2828
from lsp_client.server.container import ContainerServer
2929
from lsp_client.server.local import LocalServer
3030
from lsp_client.utils.types import lsp_type
@@ -48,9 +48,38 @@
4848
)
4949

5050

51+
async def ensure_deno_installed() -> None:
52+
if shutil.which("deno"):
53+
return
54+
55+
logger.warning("deno not found, attempting to install...")
56+
57+
try:
58+
# Use shell to execute the piped command
59+
await anyio.run_process(
60+
["sh", "-c", "curl -fsSL https://deno.land/install.sh | sh"]
61+
)
62+
logger.info("Successfully installed deno via shell script")
63+
return
64+
except CalledProcessError as e:
65+
raise RuntimeError(
66+
"Could not install deno. Please install it manually with:\n"
67+
"curl -fsSL https://deno.land/install.sh | sh\n\n"
68+
"See https://deno.land/ for more information."
69+
) from e
70+
71+
72+
DenoLocalServer = partial(
73+
LocalServer,
74+
program="deno",
75+
args=["lsp"],
76+
ensure_installed=ensure_deno_installed,
77+
)
78+
79+
5180
@define
5281
class DenoClient(
53-
LSPClient,
82+
Client,
5483
WithRequestHover,
5584
WithRequestDefinition,
5685
WithRequestReferences,
@@ -108,8 +137,11 @@ def get_language_id(self) -> lsp_type.LanguageKind:
108137
return lsp_type.LanguageKind.TypeScript
109138

110139
@override
111-
def create_default_server(self) -> LSPServer:
112-
return LocalServer(command=["deno", "lsp"])
140+
def create_default_servers(self) -> DefaultServers:
141+
return DefaultServers(
142+
local=DenoLocalServer(),
143+
container=DenoContainerServer(),
144+
)
113145

114146
@override
115147
def create_initialization_options(self) -> dict[str, Any]:
@@ -152,24 +184,3 @@ def create_initialization_options(self) -> dict[str, Any]:
152184
@override
153185
def check_server_compatibility(self, info: lsp_type.ServerInfo | None) -> None:
154186
return
155-
156-
@override
157-
async def ensure_installed(self) -> None:
158-
if shutil.which("deno"):
159-
return
160-
161-
logger.warning("deno not found, attempting to install...")
162-
163-
try:
164-
# Use shell to execute the piped command
165-
await anyio.run_process(
166-
["sh", "-c", "curl -fsSL https://deno.land/install.sh | sh"]
167-
)
168-
logger.info("Successfully installed deno via shell script")
169-
return
170-
except CalledProcessError as e:
171-
raise RuntimeError(
172-
"Could not install deno. Please install it manually with:\n"
173-
"curl -fsSL https://deno.land/install.sh | sh\n\n"
174-
"See https://deno.land/ for more information."
175-
) from e

0 commit comments

Comments
 (0)