crabcode 0.1.6__py3-none-win_amd64.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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,786 @@
1
+ """LSP client — connects to language servers via stdio and manages document sync + diagnostics.
2
+
3
+ Modeled after the OpenCode TypeScript LSP client. Implements:
4
+ - Lifecycle: ``initialize`` request → ``initialized`` notification → ``shutdown`` → ``exit``
5
+ - Document sync: ``textDocument/didOpen`` / ``textDocument/didChange`` (incremental)
6
+ - Diagnostics: listens for ``textDocument/publishDiagnostics``
7
+ - Reverse requests: ``workspace/configuration``, ``client/registerCapability``
8
+ - File version tracking with per-URI counters
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import uuid
17
+ from pathlib import Path, PureWindowsPath
18
+ from typing import Any
19
+
20
+ from crabcode_core.logging_utils import get_logger
21
+ from crabcode_core.subprocess_utils import (
22
+ resolve_executable_command,
23
+ managed_process_command,
24
+ subprocess_group_options,
25
+ terminate_process_tree,
26
+ )
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # JSON-RPC helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ _JSONRPC_VERSION = "2.0"
35
+
36
+
37
+ class JsonRpcError(Exception):
38
+ """Wraps a JSON-RPC error response."""
39
+
40
+ def __init__(self, code: int, message: str, data: Any = None) -> None:
41
+ super().__init__(f"JSON-RPC error {code}: {message}")
42
+ self.code = code
43
+ self.message = message
44
+ self.data = data
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # LSP Client
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class LSPClient:
53
+ """Async LSP client that communicates over stdio with a language server.
54
+
55
+ Usage::
56
+
57
+ client = LSPClient(command=["pyright-langserver", "--stdio"], root_uri=root_uri)
58
+ await client.create()
59
+ await client.touch_file(file_path)
60
+ diag = await client.wait_for_diagnostics(file_path)
61
+ await client.shutdown()
62
+ """
63
+
64
+ # -----------------------------------------------------------------------
65
+ # Construction
66
+ # -----------------------------------------------------------------------
67
+
68
+ def __init__(
69
+ self,
70
+ command: list[str],
71
+ root_uri: str,
72
+ *,
73
+ env: dict[str, str] | None = None,
74
+ initialization_options: dict[str, Any] | None = None,
75
+ trace: str | None = None,
76
+ ) -> None:
77
+ self._command = command
78
+ self._root_uri = root_uri
79
+ self._env = {**os.environ, **(env or {})}
80
+ if os.name == "nt":
81
+ self._env = {key.upper(): value for key, value in self._env.items()}
82
+ self._initialization_options = initialization_options
83
+ self._trace = trace
84
+
85
+ # Process + I/O
86
+ self._process: asyncio.subprocess.Process | None = None
87
+ self._reader_task: asyncio.Task | None = None # type: ignore[type-arg]
88
+ self._writer_lock = asyncio.Lock()
89
+
90
+ # JSON-RPC state
91
+ self._request_id: int = 0
92
+ self._pending_requests: dict[int, asyncio.Future[dict[str, Any]]] = {}
93
+
94
+ # Document version tracking {uri: version}
95
+ self._file_versions: dict[str, int] = {}
96
+
97
+ # Open documents {uri: text}
98
+ self._open_documents: dict[str, str] = {}
99
+
100
+ # Diagnostics {uri: list[dict]}
101
+ self.diagnostics: dict[str, list[dict[str, Any]]] = {}
102
+
103
+ # Event fired when diagnostics arrive for a given URI
104
+ self._diagnostic_events: dict[str, asyncio.Event] = {}
105
+
106
+ # Reverse-request handlers
107
+ self._registered_capabilities: dict[str, dict[str, Any]] = {}
108
+
109
+ # Capabilities returned by the server
110
+ self._server_capabilities: dict[str, Any] = {}
111
+
112
+ # Shutdown flag
113
+ self._shutting_down = False
114
+
115
+ # -----------------------------------------------------------------------
116
+ # Public API
117
+ # -----------------------------------------------------------------------
118
+
119
+ async def create(self) -> dict[str, Any]:
120
+ """Start the language server process, send ``initialize`` + ``initialized``.
121
+
122
+ Returns the ``initialize`` result (server capabilities, etc.).
123
+ """
124
+ if self._process is not None:
125
+ raise RuntimeError("LSP client already initialised")
126
+
127
+ # Launch the server process
128
+ launch_command = resolve_executable_command(self._command, env=self._env)
129
+ self._process = await asyncio.create_subprocess_exec(
130
+ *managed_process_command(launch_command),
131
+ stdin=asyncio.subprocess.PIPE,
132
+ stdout=asyncio.subprocess.PIPE,
133
+ stderr=asyncio.subprocess.PIPE,
134
+ env=self._env,
135
+ **subprocess_group_options(),
136
+ )
137
+
138
+ # Start the reader loop in the background
139
+ self._reader_task = asyncio.create_task(self._reader_loop())
140
+
141
+ # Also drain stderr for debugging
142
+ asyncio.create_task(self._stderr_drain())
143
+
144
+ # Send the initialize request
145
+ init_params: dict[str, Any] = {
146
+ "processId": os.getpid(),
147
+ "rootUri": self._root_uri,
148
+ "rootPath": _uri_to_path(self._root_uri),
149
+ "capabilities": self._client_capabilities(),
150
+ "trace": self._trace or "off",
151
+ }
152
+ if self._initialization_options is not None:
153
+ init_params["initializationOptions"] = self._initialization_options
154
+
155
+ result = await self._send_request("initialize", init_params)
156
+ self._server_capabilities = result.get("capabilities", {})
157
+
158
+ # Send the initialized notification (no params required by spec)
159
+ await self._send_notification("initialized", {})
160
+
161
+ logger.info(
162
+ "LSP server initialised: %s", self._command[0],
163
+ )
164
+ return result
165
+
166
+ async def touch_file(
167
+ self,
168
+ file_path: str | Path,
169
+ text: str | None = None,
170
+ ) -> None:
171
+ """Open or update a file in the language server.
172
+
173
+ If the file has not been opened yet, sends ``textDocument/didOpen``.
174
+ Otherwise sends ``textDocument/didChange`` with the full text.
175
+ """
176
+ uri = _path_to_uri(str(file_path))
177
+
178
+ if text is None:
179
+ try:
180
+ from crabcode_core.io_worker import run_io
181
+ text = await run_io("crabcode_core.filesystem", "read_text_file",
182
+ str(file_path), timeout=5)
183
+ except OSError:
184
+ logger.warning("Cannot read file for LSP sync: %s", file_path)
185
+ return
186
+
187
+ if uri in self._open_documents:
188
+ # Increment version and send didChange
189
+ version = self._file_versions.get(uri, 0) + 1
190
+ self._file_versions[uri] = version
191
+ self._open_documents[uri] = text
192
+
193
+ await self._send_notification(
194
+ "textDocument/didChange",
195
+ {
196
+ "textDocument": {
197
+ "uri": uri,
198
+ "version": version,
199
+ },
200
+ "contentChanges": [
201
+ {"text": text},
202
+ ],
203
+ },
204
+ )
205
+ else:
206
+ # First open — didOpen
207
+ version = 1
208
+ self._file_versions[uri] = version
209
+ self._open_documents[uri] = text
210
+
211
+ # Determine language id from file extension
212
+ language_id = _language_id(str(file_path))
213
+
214
+ await self._send_notification(
215
+ "textDocument/didOpen",
216
+ {
217
+ "textDocument": {
218
+ "uri": uri,
219
+ "languageId": language_id,
220
+ "version": version,
221
+ "text": text,
222
+ },
223
+ },
224
+ )
225
+
226
+ async def close_file(self, file_path: str | Path) -> None:
227
+ """Send ``textDocument/didClose`` for a tracked file."""
228
+ uri = _path_to_uri(str(file_path))
229
+ if uri not in self._open_documents:
230
+ return
231
+
232
+ await self._send_notification(
233
+ "textDocument/didClose",
234
+ {
235
+ "textDocument": {
236
+ "uri": uri,
237
+ },
238
+ },
239
+ )
240
+ self._open_documents.pop(uri, None)
241
+ self._file_versions.pop(uri, None)
242
+
243
+ async def wait_for_diagnostics(
244
+ self,
245
+ file_path: str | Path,
246
+ *,
247
+ timeout: float = 30.0,
248
+ debounce: float = 0.5,
249
+ ) -> list[dict[str, Any]]:
250
+ """Wait for diagnostics for *file_path*, with debounce.
251
+
252
+ The LSP server may send multiple ``publishDiagnostics`` notifications
253
+ in rapid succession (e.g. as it re-analyses). We debounce by waiting
254
+ *debounce* seconds after the **last** notification before returning.
255
+
256
+ Returns the list of diagnostic dicts for the file.
257
+ """
258
+ uri = _diagnostic_uri_key(_path_to_uri(str(file_path)))
259
+
260
+ # If we already have diagnostics, return them immediately (caller can
261
+ # still choose to wait again after touch_file).
262
+ event = self._diagnostic_events.get(uri)
263
+ if event is None:
264
+ event = asyncio.Event()
265
+ self._diagnostic_events[uri] = event
266
+
267
+ # Wait for the first notification (or use existing data)
268
+ if uri not in self.diagnostics:
269
+ try:
270
+ await asyncio.wait_for(event.wait(), timeout=timeout)
271
+ except asyncio.TimeoutError:
272
+ logger.warning("Timed out waiting for diagnostics: %s", uri)
273
+ return self.diagnostics.get(uri, [])
274
+
275
+ # Debounce: keep waiting as long as new notifications arrive within
276
+ # the debounce window.
277
+ while True:
278
+ event.clear()
279
+ try:
280
+ await asyncio.wait_for(event.wait(), timeout=debounce)
281
+ # A new notification arrived — keep debouncing.
282
+ except asyncio.TimeoutError:
283
+ # No new notification within the debounce window.
284
+ break
285
+
286
+ return self.diagnostics.get(uri, [])
287
+
288
+ async def send_request(
289
+ self,
290
+ method: str,
291
+ params: dict[str, Any] | None,
292
+ ) -> dict[str, Any]:
293
+ """Send an arbitrary LSP request and return the result.
294
+
295
+ This is the public counterpart to ``_send_request`` so that
296
+ :class:`LSPManager` (and other consumers) can issue requests like
297
+ ``textDocument/hover``, ``textDocument/definition``, etc. without
298
+ reaching into the private API.
299
+ """
300
+ return await self._send_request(method, params)
301
+
302
+ async def shutdown(self) -> None:
303
+ """Send ``shutdown`` request followed by ``exit`` notification and terminate."""
304
+ if self._shutting_down:
305
+ return
306
+ self._shutting_down = True
307
+
308
+ try:
309
+ await self._send_request("shutdown", None)
310
+ except (JsonRpcError, Exception):
311
+ logger.debug("shutdown request failed (server may have exited)")
312
+
313
+ try:
314
+ await self._send_notification("exit", None)
315
+ except Exception:
316
+ logger.debug("exit notification failed")
317
+
318
+ # Kill the process if still running
319
+ if self._process is not None and self._process.returncode is None:
320
+ await terminate_process_tree(self._process, timeout=5.0)
321
+
322
+ # Cancel the reader task
323
+ if self._reader_task is not None and not self._reader_task.done():
324
+ self._reader_task.cancel()
325
+ try:
326
+ await self._reader_task
327
+ except asyncio.CancelledError:
328
+ pass
329
+
330
+ self._process = None
331
+ self._reader_task = None
332
+ logger.info("LSP server shut down: %s", self._command[0])
333
+
334
+ # -----------------------------------------------------------------------
335
+ # Properties
336
+ # -----------------------------------------------------------------------
337
+
338
+ @property
339
+ def server_capabilities(self) -> dict[str, Any]:
340
+ """Capabilities returned by the server in the ``initialize`` response."""
341
+ return self._server_capabilities
342
+
343
+ @property
344
+ def file_versions(self) -> dict[str, int]:
345
+ """Read-only view of current file version counters."""
346
+ return dict(self._file_versions)
347
+
348
+ @property
349
+ def open_documents(self) -> dict[str, str]:
350
+ """Read-only view of currently open document texts."""
351
+ return dict(self._open_documents)
352
+
353
+ # -----------------------------------------------------------------------
354
+ # JSON-RPC transport
355
+ # -----------------------------------------------------------------------
356
+
357
+ async def _send_request(
358
+ self,
359
+ method: str,
360
+ params: dict[str, Any] | None,
361
+ ) -> dict[str, Any]:
362
+ """Send a JSON-RPC request and wait for the response."""
363
+ self._request_id += 1
364
+ request_id = self._request_id
365
+
366
+ message: dict[str, Any] = {
367
+ "jsonrpc": _JSONRPC_VERSION,
368
+ "id": request_id,
369
+ "method": method,
370
+ }
371
+ if params is not None:
372
+ message["params"] = params
373
+
374
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_event_loop().create_future()
375
+ self._pending_requests[request_id] = future
376
+
377
+ await self._write_message(message)
378
+
379
+ try:
380
+ result = await future
381
+ finally:
382
+ self._pending_requests.pop(request_id, None)
383
+
384
+ return result
385
+
386
+ async def _send_notification(
387
+ self,
388
+ method: str,
389
+ params: dict[str, Any] | None,
390
+ ) -> None:
391
+ """Send a JSON-RPC notification (no response expected)."""
392
+ message: dict[str, Any] = {
393
+ "jsonrpc": _JSONRPC_VERSION,
394
+ "method": method,
395
+ }
396
+ if params is not None:
397
+ message["params"] = params
398
+
399
+ await self._write_message(message)
400
+
401
+ async def _send_response(
402
+ self,
403
+ request_id: int | str,
404
+ result: Any = None,
405
+ error: dict[str, Any] | None = None,
406
+ ) -> None:
407
+ """Send a JSON-RPC response (for reverse requests from the server)."""
408
+ message: dict[str, Any] = {
409
+ "jsonrpc": _JSONRPC_VERSION,
410
+ "id": request_id,
411
+ }
412
+ if error is not None:
413
+ message["error"] = error
414
+ else:
415
+ message["result"] = result
416
+
417
+ await self._write_message(message)
418
+
419
+ async def _write_message(self, message: dict[str, Any]) -> None:
420
+ """Serialise and write a JSON-RPC message over stdin (Content-Length header)."""
421
+ if self._process is None or self._process.stdin is None:
422
+ raise RuntimeError("LSP server process not running")
423
+
424
+ body = json.dumps(message, ensure_ascii=False)
425
+ header = f"Content-Length: {len(body.encode('utf-8'))}\r\n\r\n"
426
+
427
+ async with self._writer_lock:
428
+ self._process.stdin.write(header.encode("utf-8") + body.encode("utf-8"))
429
+ await self._process.stdin.drain()
430
+
431
+ logger.debug("LSP → %s", message.get("method", f"response:{message.get('id')}"))
432
+
433
+ # -----------------------------------------------------------------------
434
+ # Reader loop
435
+ # -----------------------------------------------------------------------
436
+
437
+ async def _reader_loop(self) -> None:
438
+ """Background task: read JSON-RPC messages from the server stdout."""
439
+ try:
440
+ while self._process is not None and self._process.stdout is not None:
441
+ message = await self._read_message()
442
+ if message is None:
443
+ break
444
+ asyncio.create_task(self._handle_message(message))
445
+ except asyncio.CancelledError:
446
+ pass
447
+ except Exception:
448
+ logger.exception("LSP reader loop crashed")
449
+
450
+ async def _read_message(self) -> dict[str, Any] | None:
451
+ """Read a single JSON-RPC message (Content-Length header + body)."""
452
+ if self._process is None or self._process.stdout is None:
453
+ return None
454
+
455
+ # Read headers until empty line
456
+ content_length: int | None = None
457
+ while True:
458
+ line_bytes = await self._process.stdout.readline()
459
+ if not line_bytes:
460
+ return None # EOF
461
+ line = line_bytes.decode("utf-8").strip()
462
+ if not line:
463
+ break # End of headers
464
+ if line.lower().startswith("content-length:"):
465
+ content_length = int(line.split(":", 1)[1].strip())
466
+
467
+ if content_length is None:
468
+ logger.warning("LSP message missing Content-Length header")
469
+ return None
470
+
471
+ body_bytes = await self._process.stdout.readexactly(content_length)
472
+ return json.loads(body_bytes.decode("utf-8"))
473
+
474
+ async def _handle_message(self, message: dict[str, Any]) -> None:
475
+ """Dispatch an incoming JSON-RPC message."""
476
+ if "id" in message and "method" in message:
477
+ # Reverse request from the server
478
+ await self._handle_reverse_request(message)
479
+ elif "id" in message:
480
+ # Response to one of our requests
481
+ self._handle_response(message)
482
+ elif "method" in message:
483
+ # Notification from the server
484
+ await self._handle_notification(message)
485
+ else:
486
+ logger.warning("Unknown LSP message format: %s", message)
487
+
488
+ def _handle_response(self, message: dict[str, Any]) -> None:
489
+ """Resolve a pending request future with the server response."""
490
+ request_id = message["id"]
491
+ future = self._pending_requests.get(request_id)
492
+ if future is None:
493
+ logger.warning("LSP response for unknown request id: %s", request_id)
494
+ return
495
+
496
+ if "error" in message:
497
+ error = message["error"]
498
+ future.set_exception(
499
+ JsonRpcError(
500
+ code=error.get("code", -1),
501
+ message=error.get("message", "Unknown error"),
502
+ data=error.get("data"),
503
+ ),
504
+ )
505
+ else:
506
+ future.set_result(message.get("result", {}))
507
+
508
+ async def _handle_notification(self, message: dict[str, Any]) -> None:
509
+ """Handle a server-initiated notification."""
510
+ method = message.get("method", "")
511
+ params = message.get("params", {})
512
+
513
+ logger.debug("LSP ← notification %s", method)
514
+
515
+ if method == "textDocument/publishDiagnostics":
516
+ uri = _diagnostic_uri_key(params.get("uri", ""))
517
+ diags = params.get("diagnostics", [])
518
+ self.diagnostics[uri] = diags
519
+
520
+ # Signal any waiters
521
+ event = self._diagnostic_events.get(uri)
522
+ if event is None:
523
+ event = asyncio.Event()
524
+ self._diagnostic_events[uri] = event
525
+ event.set()
526
+
527
+ logger.debug(
528
+ "LSP diagnostics for %s: %d items", uri, len(diags),
529
+ )
530
+ elif method == "window/logMessage":
531
+ log_type = params.get("type", 4)
532
+ log_msg = params.get("message", "")
533
+ _log_level = {1: logger.error, 2: logger.warning, 3: logger.info, 4: logger.debug}
534
+ _log_level.get(log_type, logger.debug)("LSP server: %s", log_msg)
535
+ elif method == "window/showMessage":
536
+ logger.info("LSP server message: %s", params.get("message", ""))
537
+ else:
538
+ logger.debug("LSP unhandled notification: %s", method)
539
+
540
+ async def _handle_reverse_request(self, message: dict[str, Any]) -> None:
541
+ """Handle a server-initiated request (reverse request)."""
542
+ method = message.get("method", "")
543
+ request_id = message["id"]
544
+ params = message.get("params", {})
545
+
546
+ logger.debug("LSP ← reverse request %s (id=%s)", method, request_id)
547
+
548
+ if method == "workspace/configuration":
549
+ result = await self._handle_workspace_configuration(params)
550
+ await self._send_response(request_id, result=result)
551
+ elif method == "client/registerCapability":
552
+ result = await self._handle_register_capability(params)
553
+ await self._send_response(request_id, result=result)
554
+ elif method == "client/unregisterCapability":
555
+ result = await self._handle_unregister_capability(params)
556
+ await self._send_response(request_id, result=result)
557
+ else:
558
+ logger.warning("LSP unhandled reverse request: %s", method)
559
+ await self._send_response(
560
+ request_id,
561
+ error={
562
+ "code": -32601,
563
+ "message": f"Method not found: {method}",
564
+ },
565
+ )
566
+
567
+ # -----------------------------------------------------------------------
568
+ # Reverse-request handlers
569
+ # -----------------------------------------------------------------------
570
+
571
+ async def _handle_workspace_configuration(
572
+ self,
573
+ params: dict[str, Any],
574
+ ) -> list[Any]:
575
+ """Respond to ``workspace/configuration`` requests.
576
+
577
+ The server asks for configuration items identified by section names.
578
+ We return an empty configuration for each requested section —
579
+ subclasses or callers can override this to provide real settings.
580
+ """
581
+ items = params.get("items", [])
582
+ results: list[Any] = []
583
+ for item in items:
584
+ section = item.get("section")
585
+ # Return empty config by default; callers can subclass to override
586
+ logger.debug("LSP workspace/configuration request for section: %s", section)
587
+ results.append({})
588
+ return results
589
+
590
+ async def _handle_register_capability(
591
+ self,
592
+ params: dict[str, Any],
593
+ ) -> None:
594
+ """Handle ``client/registerCapability`` — store the registration."""
595
+ registrations = params.get("registrations", [])
596
+ for reg in registrations:
597
+ reg_id = reg.get("id", str(uuid.uuid4()))
598
+ method = reg.get("method", "")
599
+ register_options = reg.get("registerOptions", {})
600
+ self._registered_capabilities[reg_id] = {
601
+ "method": method,
602
+ "registerOptions": register_options,
603
+ }
604
+ logger.info("LSP registered capability: %s (%s)", method, reg_id)
605
+ return None
606
+
607
+ async def _handle_unregister_capability(
608
+ self,
609
+ params: dict[str, Any],
610
+ ) -> None:
611
+ """Handle ``client/unregisterCapability`` — remove the registration."""
612
+ unregistrations = params.get("unregistrations", [])
613
+ for unreg in unregistrations:
614
+ reg_id = unreg.get("id", "")
615
+ self._registered_capabilities.pop(reg_id, None)
616
+ logger.info("LSP unregistered capability: %s", reg_id)
617
+ return None
618
+
619
+ # -----------------------------------------------------------------------
620
+ # Client capabilities
621
+ # -----------------------------------------------------------------------
622
+
623
+ @staticmethod
624
+ def _client_capabilities() -> dict[str, Any]:
625
+ """Return the client capabilities dict sent in the ``initialize`` request."""
626
+ return {
627
+ "workspace": {
628
+ "applyEdit": True,
629
+ "configuration": True,
630
+ "didChangeConfiguration": {
631
+ "dynamicRegistration": True,
632
+ },
633
+ "workspaceFolders": True,
634
+ "symbol": {
635
+ "dynamicRegistration": True,
636
+ },
637
+ },
638
+ "textDocument": {
639
+ "synchronization": {
640
+ "dynamicRegistration": True,
641
+ "willSave": False,
642
+ "willSaveWaitUntil": False,
643
+ "didSave": True,
644
+ },
645
+ "completion": {
646
+ "dynamicRegistration": True,
647
+ "completionItem": {
648
+ "snippetSupport": False,
649
+ },
650
+ },
651
+ "hover": {
652
+ "dynamicRegistration": True,
653
+ },
654
+ "publishDiagnostics": {
655
+ "relatedInformation": True,
656
+ "versionSupport": True,
657
+ },
658
+ },
659
+ }
660
+
661
+ # -----------------------------------------------------------------------
662
+ # Helpers
663
+ # -----------------------------------------------------------------------
664
+
665
+ async def _stderr_drain(self) -> None:
666
+ """Consume stderr from the server process and log it."""
667
+ if self._process is None or self._process.stderr is None:
668
+ return
669
+ try:
670
+ while True:
671
+ line = await self._process.stderr.readline()
672
+ if not line:
673
+ break
674
+ logger.debug("LSP stderr: %s", line.decode("utf-8", errors="replace").rstrip())
675
+ except asyncio.CancelledError:
676
+ pass
677
+
678
+
679
+ # ---------------------------------------------------------------------------
680
+ # Utility functions
681
+ # ---------------------------------------------------------------------------
682
+
683
+
684
+ def _diagnostic_uri_key(uri: str) -> str:
685
+ if uri.lower().startswith("file:"):
686
+ return Path(os.path.normcase(os.path.abspath(_uri_to_path(uri)))).as_uri()
687
+ return uri
688
+
689
+
690
+ def _path_to_uri(path: str) -> str:
691
+ """Convert a filesystem path to a file:// URI."""
692
+ # URI encoding is lexical: resolving symlinks here can block the event
693
+ # loop on a network mount. Match _diagnostic_uri_key's normalization.
694
+ return Path(os.path.abspath(path)).as_uri()
695
+
696
+
697
+ def _uri_to_path(uri: str) -> str:
698
+ """Convert a file:// URI back to a filesystem path."""
699
+ from urllib.parse import unquote, urlparse
700
+ from urllib.request import url2pathname
701
+
702
+ parsed = urlparse(uri)
703
+ if parsed.scheme != "file":
704
+ return uri
705
+
706
+ if os.name == "nt":
707
+ # Python 3.10–3.12 url2pathname detects a drive before unquoting,
708
+ # so the common LSP form /c%3A/path becomes a rooted, drive-less path.
709
+ path = unquote(parsed.path).replace("/", "\\")
710
+ if len(path) >= 3 and path[0] == "\\" and path[1].isalpha() and path[2] == ":":
711
+ path = path[1:]
712
+ else:
713
+ path = url2pathname(parsed.path)
714
+ host = unquote(parsed.netloc)
715
+ if host and host.lower() != "localhost":
716
+ if os.name == "nt":
717
+ tail = path.lstrip("/\\")
718
+ return str(PureWindowsPath(f"//{host}/{tail}"))
719
+ return f"//{host}{path}"
720
+ return path
721
+
722
+
723
+ # Map of common file extensions to LSP language identifiers
724
+ _LANGUAGE_MAP: dict[str, str] = {
725
+ ".py": "python",
726
+ ".js": "javascript",
727
+ ".jsx": "javascriptreact",
728
+ ".ts": "typescript",
729
+ ".tsx": "typescriptreact",
730
+ ".go": "go",
731
+ ".rs": "rust",
732
+ ".java": "java",
733
+ ".kt": "kotlin",
734
+ ".c": "c",
735
+ ".cpp": "cpp",
736
+ ".h": "c",
737
+ ".hpp": "cpp",
738
+ ".cs": "csharp",
739
+ ".rb": "ruby",
740
+ ".php": "php",
741
+ ".swift": "swift",
742
+ ".lua": "lua",
743
+ ".r": "r",
744
+ ".sql": "sql",
745
+ ".sh": "shellscript",
746
+ ".bash": "shellscript",
747
+ ".json": "json",
748
+ ".yaml": "yaml",
749
+ ".yml": "yaml",
750
+ ".xml": "xml",
751
+ ".html": "html",
752
+ ".css": "css",
753
+ ".scss": "scss",
754
+ ".less": "less",
755
+ ".md": "markdown",
756
+ ".dart": "dart",
757
+ ".elixir": "elixir",
758
+ ".ex": "elixir",
759
+ ".exs": "elixir",
760
+ ".erl": "erlang",
761
+ ".hrl": "erlang",
762
+ ".scala": "scala",
763
+ ".clj": "clojure",
764
+ ".cljs": "clojure",
765
+ ".hs": "haskell",
766
+ ".ml": "fsharp",
767
+ ".fs": "fsharp",
768
+ ".fsi": "fsharp",
769
+ ".vim": "vim",
770
+ ".zig": "zig",
771
+ ".toml": "toml",
772
+ ".ini": "ini",
773
+ ".dockerfile": "dockerfile",
774
+ }
775
+
776
+
777
+ def _language_id(file_path: str) -> str:
778
+ """Return the LSP language identifier for a file path."""
779
+ suffix = Path(file_path).suffix.lower()
780
+ if suffix in _LANGUAGE_MAP:
781
+ return _LANGUAGE_MAP[suffix]
782
+ # Special case: Dockerfile
783
+ name = Path(file_path).name.lower()
784
+ if name == "dockerfile" or name.startswith("dockerfile."):
785
+ return "dockerfile"
786
+ return suffix.lstrip(".") if suffix else "plaintext"