fcp-python 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,196 @@
1
+ """LSP client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ from .transport import LspWriter, read_loop
11
+ from .types import InitializeResult, JsonRpcNotification, ServerCapabilities
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class LspError(Exception):
17
+ """Error from LSP server."""
18
+
19
+ def __init__(self, code: int, message: str) -> None:
20
+ self.code = code
21
+ super().__init__(f"LSP error {code}: {message}")
22
+
23
+
24
+ class LspClient:
25
+ """JSON-RPC client for communicating with an LSP server subprocess."""
26
+
27
+ def __init__(
28
+ self,
29
+ process: asyncio.subprocess.Process,
30
+ writer: LspWriter,
31
+ pending: dict[str, asyncio.Future],
32
+ pending_lock: asyncio.Lock,
33
+ notification_queue: asyncio.Queue[JsonRpcNotification],
34
+ read_task: asyncio.Task,
35
+ server_capabilities: ServerCapabilities | None = None,
36
+ ) -> None:
37
+ self._process = process
38
+ self._writer = writer
39
+ self._pending = pending
40
+ self._pending_lock = pending_lock
41
+ self._notification_queue = notification_queue
42
+ self._read_task = read_task
43
+ self._next_id = 1
44
+ self.server_capabilities = server_capabilities
45
+
46
+ @classmethod
47
+ async def spawn(cls, command: str, args: list[str], root_uri: str) -> LspClient:
48
+ """Spawn an LSP server process and perform the initialize handshake."""
49
+ process = await asyncio.create_subprocess_exec(
50
+ command,
51
+ *args,
52
+ stdin=asyncio.subprocess.PIPE,
53
+ stdout=asyncio.subprocess.PIPE,
54
+ stderr=asyncio.subprocess.DEVNULL,
55
+ )
56
+
57
+ assert process.stdin is not None
58
+ assert process.stdout is not None
59
+
60
+ writer = LspWriter(process.stdin)
61
+ pending: dict[str, asyncio.Future] = {}
62
+ pending_lock = asyncio.Lock()
63
+ notification_queue: asyncio.Queue[JsonRpcNotification] = asyncio.Queue(maxsize=64)
64
+
65
+ reader = process.stdout
66
+ read_task = asyncio.create_task(
67
+ read_loop(reader, pending, notification_queue, pending_lock)
68
+ )
69
+
70
+ client = cls(
71
+ process=process,
72
+ writer=writer,
73
+ pending=pending,
74
+ pending_lock=pending_lock,
75
+ notification_queue=notification_queue,
76
+ read_task=read_task,
77
+ )
78
+
79
+ # Initialize handshake
80
+ caps = await client._initialize(root_uri)
81
+ client.server_capabilities = caps
82
+
83
+ # Send initialized notification
84
+ await client.notify("initialized", {})
85
+
86
+ return client
87
+
88
+ async def _initialize(self, root_uri: str) -> ServerCapabilities:
89
+ params = {
90
+ "processId": os.getpid(),
91
+ "rootUri": root_uri,
92
+ "capabilities": {
93
+ "textDocument": {
94
+ "codeAction": {
95
+ "codeActionLiteralSupport": {
96
+ "codeActionKind": {
97
+ "valueSet": [
98
+ "quickfix",
99
+ "refactor",
100
+ "refactor.extract",
101
+ "refactor.inline",
102
+ "refactor.rewrite",
103
+ "source",
104
+ "source.organizeImports",
105
+ ]
106
+ }
107
+ }
108
+ },
109
+ "rename": {"prepareSupport": False},
110
+ },
111
+ "workspace": {
112
+ "applyEdit": True,
113
+ "workspaceEdit": {
114
+ "documentChanges": True,
115
+ "resourceOperations": ["create", "rename", "delete"],
116
+ },
117
+ },
118
+ },
119
+ }
120
+
121
+ result = await self.request("initialize", params)
122
+ init_result = InitializeResult.from_dict(result)
123
+ return init_result.capabilities
124
+
125
+ async def request(self, method: str, params: Any) -> Any:
126
+ """Send a JSON-RPC request and await the response."""
127
+ req_id = self._next_id
128
+ self._next_id += 1
129
+
130
+ loop = asyncio.get_running_loop()
131
+ fut: asyncio.Future = loop.create_future()
132
+
133
+ async with self._pending_lock:
134
+ self._pending[str(req_id)] = fut
135
+
136
+ await self._writer.send_request(req_id, method, params)
137
+
138
+ resp = await fut
139
+
140
+ if resp.error is not None:
141
+ raise LspError(resp.error.code, resp.error.message)
142
+
143
+ return resp.result
144
+
145
+ async def notify(self, method: str, params: Any) -> None:
146
+ """Send a JSON-RPC notification (no response expected)."""
147
+ await self._writer.send_notification(method, params)
148
+
149
+ async def did_open(self, uri: str, text: str) -> None:
150
+ """Send textDocument/didOpen notification."""
151
+ params = {
152
+ "textDocument": {
153
+ "uri": uri,
154
+ "languageId": "python",
155
+ "version": 1,
156
+ "text": text,
157
+ }
158
+ }
159
+ await self.notify("textDocument/didOpen", params)
160
+
161
+ async def did_change(self, uri: str, version: int, text: str) -> None:
162
+ """Send textDocument/didChange notification (full sync)."""
163
+ params = {
164
+ "textDocument": {"uri": uri, "version": version},
165
+ "contentChanges": [{"text": text}],
166
+ }
167
+ await self.notify("textDocument/didChange", params)
168
+
169
+ async def did_close(self, uri: str) -> None:
170
+ """Send textDocument/didClose notification."""
171
+ params = {"textDocument": {"uri": uri}}
172
+ await self.notify("textDocument/didClose", params)
173
+
174
+ async def shutdown(self) -> None:
175
+ """Send shutdown request and exit notification, then wait for process."""
176
+ try:
177
+ await self.request("shutdown", None)
178
+ except Exception:
179
+ pass
180
+ try:
181
+ await self.notify("exit", None)
182
+ except Exception:
183
+ pass
184
+ try:
185
+ await self._process.wait()
186
+ except Exception:
187
+ pass
188
+ self._read_task.cancel()
189
+ try:
190
+ await self._read_task
191
+ except asyncio.CancelledError:
192
+ pass
193
+
194
+ @property
195
+ def notification_queue(self) -> asyncio.Queue[JsonRpcNotification]:
196
+ return self._notification_queue
@@ -0,0 +1,89 @@
1
+ """LSP server lifecycle manager with crash recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from enum import Enum, auto
8
+
9
+ from .client import LspClient
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ServerStatus(Enum):
15
+ NotStarted = auto()
16
+ Starting = auto()
17
+ Ready = auto()
18
+ Indexing = auto()
19
+ Crashed = auto()
20
+ Stopped = auto()
21
+
22
+
23
+ class LifecycleManager:
24
+ """Manages LSP server lifecycle including crash recovery and document replay."""
25
+
26
+ def __init__(
27
+ self,
28
+ command: str,
29
+ args: list[str],
30
+ root_uri: str,
31
+ max_restarts: int = 3,
32
+ ) -> None:
33
+ self._command = command
34
+ self._args = args
35
+ self._root_uri = root_uri
36
+ self._client: LspClient | None = None
37
+ self._status = ServerStatus.NotStarted
38
+ self._restart_count = 0
39
+ self._max_restarts = max_restarts
40
+ self._last_restart: float | None = None
41
+ self._tracked_documents: dict[str, str] = {}
42
+
43
+ @property
44
+ def status(self) -> ServerStatus:
45
+ return self._status
46
+
47
+ async def ensure_client(self) -> LspClient:
48
+ """Ensure the LSP client is running. Starts or restarts if needed."""
49
+ if self._client is not None and self._status == ServerStatus.Ready:
50
+ return self._client
51
+
52
+ if self._status == ServerStatus.Crashed and self._restart_count >= self._max_restarts:
53
+ raise RuntimeError("max restarts exceeded")
54
+
55
+ self._status = ServerStatus.Starting
56
+
57
+ try:
58
+ client = await LspClient.spawn(self._command, self._args, self._root_uri)
59
+ self._client = client
60
+ self._status = ServerStatus.Ready
61
+ self._last_restart = time.monotonic()
62
+
63
+ # Replay tracked documents
64
+ for uri, text in list(self._tracked_documents.items()):
65
+ try:
66
+ await client.did_open(uri, text)
67
+ except Exception:
68
+ logger.warning("failed to replay document: %s", uri)
69
+
70
+ return client
71
+ except Exception as e:
72
+ self._status = ServerStatus.Crashed
73
+ self._restart_count += 1
74
+ raise RuntimeError(f"failed to start LSP server: {e}") from e
75
+
76
+ def track_document(self, uri: str, text: str) -> None:
77
+ """Track a document for replay on restart."""
78
+ self._tracked_documents[uri] = text
79
+
80
+ def untrack_document(self, uri: str) -> None:
81
+ """Untrack a document."""
82
+ self._tracked_documents.pop(uri, None)
83
+
84
+ async def shutdown(self) -> None:
85
+ """Shutdown the LSP server gracefully."""
86
+ if self._client is not None:
87
+ await self._client.shutdown()
88
+ self._client = None
89
+ self._status = ServerStatus.Stopped
@@ -0,0 +1,105 @@
1
+ """Content-Length framed LSP transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ from typing import Any
9
+
10
+ from .types import JsonRpcNotification, JsonRpcResponse
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def encode_message(body: bytes) -> bytes:
16
+ """Prepend Content-Length header to a message body."""
17
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
18
+ return header + body
19
+
20
+
21
+ async def decode_message(reader: asyncio.StreamReader) -> dict:
22
+ """Read a Content-Length framed message and parse as JSON."""
23
+ content_length: int | None = None
24
+
25
+ # Read headers line by line
26
+ while True:
27
+ line = await reader.readline()
28
+ if not line:
29
+ raise ConnectionError("unexpected EOF reading headers")
30
+
31
+ line_str = line.decode("ascii", errors="replace").strip()
32
+ if not line_str:
33
+ # Empty line = end of headers
34
+ break
35
+
36
+ if line_str.lower().startswith("content-length:"):
37
+ value = line_str.split(":", 1)[1].strip()
38
+ try:
39
+ content_length = int(value)
40
+ except ValueError:
41
+ raise ValueError(f"invalid Content-Length: {value}")
42
+
43
+ if content_length is None:
44
+ raise ValueError("missing Content-Length header")
45
+
46
+ body = await reader.readexactly(content_length)
47
+ return json.loads(body)
48
+
49
+
50
+ class LspWriter:
51
+ """Writer wrapper for sending LSP messages with Content-Length framing."""
52
+
53
+ def __init__(self, writer: asyncio.StreamWriter) -> None:
54
+ self._writer = writer
55
+ self._lock = asyncio.Lock()
56
+
57
+ async def send_request(self, id: Any, method: str, params: Any) -> None:
58
+ msg = {"jsonrpc": "2.0", "id": id, "method": method, "params": params}
59
+ body = json.dumps(msg).encode("utf-8")
60
+ frame = encode_message(body)
61
+ async with self._lock:
62
+ self._writer.write(frame)
63
+ await self._writer.drain()
64
+
65
+ async def send_notification(self, method: str, params: Any) -> None:
66
+ msg = {"jsonrpc": "2.0", "method": method, "params": params}
67
+ body = json.dumps(msg).encode("utf-8")
68
+ frame = encode_message(body)
69
+ async with self._lock:
70
+ self._writer.write(frame)
71
+ await self._writer.drain()
72
+
73
+
74
+ async def read_loop(
75
+ reader: asyncio.StreamReader,
76
+ pending: dict[str, asyncio.Future],
77
+ notification_queue: asyncio.Queue[JsonRpcNotification],
78
+ pending_lock: asyncio.Lock,
79
+ ) -> None:
80
+ """Dispatch incoming messages to pending futures or notification queue."""
81
+ while True:
82
+ try:
83
+ msg = await decode_message(reader)
84
+ except (ConnectionError, asyncio.IncompleteReadError):
85
+ return # EOF or read error
86
+ except Exception:
87
+ logger.debug("read_loop: error decoding message", exc_info=True)
88
+ return
89
+
90
+ # Response: has "id" and no "method"
91
+ if "id" in msg and "method" not in msg:
92
+ resp = JsonRpcResponse.from_dict(msg)
93
+ id_str = str(resp.id)
94
+ async with pending_lock:
95
+ fut = pending.pop(id_str, None)
96
+ if fut is not None and not fut.done():
97
+ fut.set_result(resp)
98
+ elif "method" in msg and "id" not in msg:
99
+ # Notification
100
+ notif = JsonRpcNotification.from_dict(msg)
101
+ try:
102
+ notification_queue.put_nowait(notif)
103
+ except asyncio.QueueFull:
104
+ logger.warning("notification queue full, dropping: %s", notif.method)
105
+ # Server requests (both id and method) are ignored for now