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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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"
|