kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Minimal LSP client over a stdio subprocess.
|
|
2
|
+
|
|
3
|
+
One background reader thread decodes framed JSON-RPC and fans it out: responses
|
|
4
|
+
to per-request queues (matched by id), ``publishDiagnostics`` notifications to a
|
|
5
|
+
condition-guarded latest-wins map, and server-to-client requests to a benign
|
|
6
|
+
reply so a server waiting on ``workspace/configuration`` cannot stall. Every
|
|
7
|
+
wait is bounded, and teardown never raises.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import queue
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Sequence
|
|
19
|
+
from urllib.parse import unquote, urlparse
|
|
20
|
+
from urllib.request import url2pathname
|
|
21
|
+
|
|
22
|
+
from kiwimatecoder import __version__
|
|
23
|
+
from kiwimatecoder.lsp.protocol import LspProtocolError, decode_messages, encode_message
|
|
24
|
+
|
|
25
|
+
CLIENT_INFO = {"name": "kiwimatecoder", "version": __version__}
|
|
26
|
+
SHUTDOWN_TIMEOUT = 2.0
|
|
27
|
+
CLOSE_TIMEOUT = 2.0
|
|
28
|
+
|
|
29
|
+
LANGUAGE_IDS = {
|
|
30
|
+
".py": "python",
|
|
31
|
+
".pyi": "python",
|
|
32
|
+
".ts": "typescript",
|
|
33
|
+
".tsx": "typescriptreact",
|
|
34
|
+
".js": "javascript",
|
|
35
|
+
".jsx": "javascriptreact",
|
|
36
|
+
".mjs": "javascript",
|
|
37
|
+
".cjs": "javascript",
|
|
38
|
+
".go": "go",
|
|
39
|
+
".rs": "rust",
|
|
40
|
+
".c": "c",
|
|
41
|
+
".h": "c",
|
|
42
|
+
".cc": "cpp",
|
|
43
|
+
".hh": "cpp",
|
|
44
|
+
".cpp": "cpp",
|
|
45
|
+
".hpp": "cpp",
|
|
46
|
+
".cxx": "cpp",
|
|
47
|
+
".java": "java",
|
|
48
|
+
".rb": "ruby",
|
|
49
|
+
".php": "php",
|
|
50
|
+
".cs": "csharp",
|
|
51
|
+
".swift": "swift",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
SEVERITY_NAMES = {1: "error", 2: "warning", 3: "information", 4: "hint"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LspError(RuntimeError):
|
|
58
|
+
"""A language-server operation failed or is unavailable."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LspTimeout(LspError):
|
|
62
|
+
"""A language-server operation exceeded its time budget."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def path_to_uri(path: str | Path) -> str:
|
|
66
|
+
"""Return the ``file://`` URI for ``path``."""
|
|
67
|
+
return Path(path).expanduser().resolve().as_uri()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def uri_to_path(uri: str) -> str:
|
|
71
|
+
"""Convert a ``file://`` URI to a filesystem path (other URIs unchanged)."""
|
|
72
|
+
try:
|
|
73
|
+
parsed = urlparse(uri)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return uri
|
|
76
|
+
if parsed.scheme != "file":
|
|
77
|
+
return uri
|
|
78
|
+
path = url2pathname(unquote(parsed.path))
|
|
79
|
+
if parsed.netloc and parsed.netloc not in ("", "localhost"):
|
|
80
|
+
path = f"//{parsed.netloc}{path}"
|
|
81
|
+
return path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def language_id_for(path: str | Path) -> str:
|
|
85
|
+
"""Guess an LSP ``languageId`` from a file suffix."""
|
|
86
|
+
suffix = Path(path).suffix.lower()
|
|
87
|
+
return LANGUAGE_IDS.get(suffix, suffix.lstrip(".") or "plaintext")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _point(value: Any) -> tuple[int, int]:
|
|
91
|
+
"""Return a non-negative (line, character) pair from an LSP position."""
|
|
92
|
+
if not isinstance(value, dict):
|
|
93
|
+
return 0, 0
|
|
94
|
+
try:
|
|
95
|
+
line = int(value.get("line", 0))
|
|
96
|
+
character = int(value.get("character", 0))
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return 0, 0
|
|
99
|
+
return max(0, line), max(0, character)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _range_start(value: Any) -> Any:
|
|
103
|
+
if not isinstance(value, dict):
|
|
104
|
+
return None
|
|
105
|
+
return value.get("start")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def normalize_diagnostics(params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
109
|
+
"""Normalize a ``publishDiagnostics`` payload into display-ready dicts.
|
|
110
|
+
|
|
111
|
+
Lines and characters become 1-based; severity becomes a lowercase name.
|
|
112
|
+
"""
|
|
113
|
+
raw = params.get("diagnostics")
|
|
114
|
+
if not isinstance(raw, list):
|
|
115
|
+
return []
|
|
116
|
+
normalized: list[dict[str, Any]] = []
|
|
117
|
+
for diagnostic in raw:
|
|
118
|
+
if not isinstance(diagnostic, dict):
|
|
119
|
+
continue
|
|
120
|
+
line, character = _point(_range_start(diagnostic.get("range")))
|
|
121
|
+
severity = diagnostic.get("severity")
|
|
122
|
+
normalized.append(
|
|
123
|
+
{
|
|
124
|
+
"line": line + 1,
|
|
125
|
+
"character": character + 1,
|
|
126
|
+
"severity": (
|
|
127
|
+
SEVERITY_NAMES.get(severity, "error")
|
|
128
|
+
if isinstance(severity, int)
|
|
129
|
+
else "error"
|
|
130
|
+
),
|
|
131
|
+
"message": str(diagnostic.get("message") or ""),
|
|
132
|
+
"source": str(diagnostic.get("source") or ""),
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
return normalized
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def normalize_locations(result: Any) -> list[dict[str, Any]]:
|
|
139
|
+
"""Normalize definition/references results into ``{path, line, character}``.
|
|
140
|
+
|
|
141
|
+
Accepts a single Location, a list of Locations, or LocationLinks. Lines and
|
|
142
|
+
characters become 1-based.
|
|
143
|
+
"""
|
|
144
|
+
if result is None:
|
|
145
|
+
return []
|
|
146
|
+
items = result if isinstance(result, list) else [result]
|
|
147
|
+
locations: list[dict[str, Any]] = []
|
|
148
|
+
for item in items:
|
|
149
|
+
if not isinstance(item, dict):
|
|
150
|
+
continue
|
|
151
|
+
uri = item.get("uri") or item.get("targetUri")
|
|
152
|
+
if not isinstance(uri, str) or not uri:
|
|
153
|
+
continue
|
|
154
|
+
start = _range_start(
|
|
155
|
+
item.get("range")
|
|
156
|
+
or item.get("targetSelectionRange")
|
|
157
|
+
or item.get("targetRange")
|
|
158
|
+
)
|
|
159
|
+
line, character = _point(start)
|
|
160
|
+
locations.append(
|
|
161
|
+
{
|
|
162
|
+
"path": uri_to_path(uri),
|
|
163
|
+
"line": line + 1,
|
|
164
|
+
"character": character + 1,
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
return locations
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class LspClient:
|
|
171
|
+
"""A live language server and its bounded request/diagnostics API."""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
command: str,
|
|
176
|
+
args: Sequence[str] | None = None,
|
|
177
|
+
*,
|
|
178
|
+
root: str | Path | None = None,
|
|
179
|
+
initialize_timeout: float = 10.0,
|
|
180
|
+
request_timeout: float = 10.0,
|
|
181
|
+
) -> None:
|
|
182
|
+
self.command = command
|
|
183
|
+
self.args = [str(arg) for arg in (args or [])]
|
|
184
|
+
self.root = Path(root).expanduser().resolve() if root is not None else None
|
|
185
|
+
self.initialize_timeout = float(initialize_timeout)
|
|
186
|
+
self.request_timeout = float(request_timeout)
|
|
187
|
+
|
|
188
|
+
self._next_id = 0
|
|
189
|
+
self._responses: dict[Any, queue.Queue[dict[str, Any] | None]] = {}
|
|
190
|
+
self._lock = threading.Lock()
|
|
191
|
+
self._write_lock = threading.Lock()
|
|
192
|
+
self._doc_lock = threading.Lock()
|
|
193
|
+
self._cond = threading.Condition()
|
|
194
|
+
self._diagnostics: dict[str, dict[str, Any]] = {}
|
|
195
|
+
self._dirty: set[str] = set()
|
|
196
|
+
self._versions: dict[str, int] = {}
|
|
197
|
+
self._texts: dict[str, str] = {}
|
|
198
|
+
self._opened: set[str] = set()
|
|
199
|
+
self._closed = False
|
|
200
|
+
self._shutdown_done = False
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
self._process: subprocess.Popen[bytes] = subprocess.Popen(
|
|
204
|
+
[self.command, *self.args],
|
|
205
|
+
stdin=subprocess.PIPE,
|
|
206
|
+
stdout=subprocess.PIPE,
|
|
207
|
+
stderr=subprocess.DEVNULL,
|
|
208
|
+
bufsize=0,
|
|
209
|
+
)
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise LspError(f"could not start {self.command!r}: {exc}") from exc
|
|
212
|
+
self._reader = threading.Thread(
|
|
213
|
+
target=self._read_loop,
|
|
214
|
+
name=f"lsp-{Path(self.command).name}",
|
|
215
|
+
daemon=True,
|
|
216
|
+
)
|
|
217
|
+
self._reader.start()
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def running(self) -> bool:
|
|
221
|
+
"""Whether the server process is still alive."""
|
|
222
|
+
return self._process.poll() is None
|
|
223
|
+
|
|
224
|
+
def initialize(self) -> dict[str, Any]:
|
|
225
|
+
"""Perform the LSP handshake; returns the server capabilities."""
|
|
226
|
+
params: dict[str, Any] = {
|
|
227
|
+
"processId": os.getpid(),
|
|
228
|
+
"clientInfo": CLIENT_INFO,
|
|
229
|
+
"capabilities": {
|
|
230
|
+
"textDocument": {
|
|
231
|
+
"synchronization": {"dynamicRegistration": False},
|
|
232
|
+
"publishDiagnostics": {"relatedInformation": True},
|
|
233
|
+
"definition": {"linkSupport": True},
|
|
234
|
+
"references": {},
|
|
235
|
+
},
|
|
236
|
+
"workspace": {"workspaceFolders": True},
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
if self.root is not None:
|
|
240
|
+
root_uri = path_to_uri(self.root)
|
|
241
|
+
params["rootUri"] = root_uri
|
|
242
|
+
params["workspaceFolders"] = [
|
|
243
|
+
{"uri": root_uri, "name": self.root.name}
|
|
244
|
+
]
|
|
245
|
+
result = self._request("initialize", params, self.initialize_timeout)
|
|
246
|
+
self._notify("initialized", {})
|
|
247
|
+
return result if isinstance(result, dict) else {}
|
|
248
|
+
|
|
249
|
+
def is_open(self, path: str | Path) -> bool:
|
|
250
|
+
"""Whether the document has been opened on this server."""
|
|
251
|
+
with self._doc_lock:
|
|
252
|
+
return path_to_uri(path) in self._opened
|
|
253
|
+
|
|
254
|
+
def did_open(self, path: str | Path, text: str) -> None:
|
|
255
|
+
"""Notify the server a document was opened (Full sync, version 1)."""
|
|
256
|
+
uri = path_to_uri(path)
|
|
257
|
+
with self._doc_lock:
|
|
258
|
+
self._opened.add(uri)
|
|
259
|
+
self._versions[uri] = 1
|
|
260
|
+
self._texts[uri] = text
|
|
261
|
+
self._mark_dirty(uri)
|
|
262
|
+
self._notify(
|
|
263
|
+
"textDocument/didOpen",
|
|
264
|
+
{
|
|
265
|
+
"textDocument": {
|
|
266
|
+
"uri": uri,
|
|
267
|
+
"languageId": language_id_for(path),
|
|
268
|
+
"version": 1,
|
|
269
|
+
"text": text,
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def did_change(self, path: str | Path, text: str) -> None:
|
|
275
|
+
"""Notify the server of new full document content."""
|
|
276
|
+
uri = path_to_uri(path)
|
|
277
|
+
with self._doc_lock:
|
|
278
|
+
if uri not in self._opened:
|
|
279
|
+
opened = False
|
|
280
|
+
version = 1
|
|
281
|
+
else:
|
|
282
|
+
opened = True
|
|
283
|
+
version = self._versions.get(uri, 1) + 1
|
|
284
|
+
self._versions[uri] = version
|
|
285
|
+
self._texts[uri] = text
|
|
286
|
+
if not opened:
|
|
287
|
+
self.did_open(path, text)
|
|
288
|
+
return
|
|
289
|
+
self._mark_dirty(uri)
|
|
290
|
+
self._notify(
|
|
291
|
+
"textDocument/didChange",
|
|
292
|
+
{
|
|
293
|
+
"textDocument": {"uri": uri, "version": version},
|
|
294
|
+
"contentChanges": [{"text": text}],
|
|
295
|
+
},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def sync(self, path: str | Path, text: str) -> None:
|
|
299
|
+
"""Open the document, or send a change only when its text differs."""
|
|
300
|
+
uri = path_to_uri(path)
|
|
301
|
+
with self._doc_lock:
|
|
302
|
+
opened = uri in self._opened
|
|
303
|
+
unchanged = opened and self._texts.get(uri) == text
|
|
304
|
+
if not opened:
|
|
305
|
+
self.did_open(path, text)
|
|
306
|
+
elif not unchanged:
|
|
307
|
+
self.did_change(path, text)
|
|
308
|
+
|
|
309
|
+
def diagnostics(
|
|
310
|
+
self, path: str | Path, timeout: float | None = None
|
|
311
|
+
) -> list[dict[str, Any]]:
|
|
312
|
+
"""Return the latest diagnostics for ``path``.
|
|
313
|
+
|
|
314
|
+
Waits up to ``timeout`` for a publish that reflects the most recent
|
|
315
|
+
open/change notification. Raises :class:`LspTimeout` when none arrives.
|
|
316
|
+
"""
|
|
317
|
+
uri = path_to_uri(path)
|
|
318
|
+
budget = self.request_timeout if timeout is None else float(timeout)
|
|
319
|
+
deadline = time.monotonic() + max(0.0, budget)
|
|
320
|
+
with self._cond:
|
|
321
|
+
while True:
|
|
322
|
+
stored = self._diagnostics.get(uri)
|
|
323
|
+
if stored is not None and uri not in self._dirty:
|
|
324
|
+
return normalize_diagnostics(stored)
|
|
325
|
+
if self._closed:
|
|
326
|
+
raise LspError("language server closed before diagnostics arrived")
|
|
327
|
+
remaining = deadline - time.monotonic()
|
|
328
|
+
if remaining <= 0:
|
|
329
|
+
if stored is not None:
|
|
330
|
+
return normalize_diagnostics(stored)
|
|
331
|
+
raise LspTimeout(
|
|
332
|
+
f"no diagnostics for {path} within {budget:g}s"
|
|
333
|
+
)
|
|
334
|
+
self._cond.wait(remaining)
|
|
335
|
+
|
|
336
|
+
def definition(
|
|
337
|
+
self,
|
|
338
|
+
path: str | Path,
|
|
339
|
+
line: int,
|
|
340
|
+
character: int,
|
|
341
|
+
timeout: float | None = None,
|
|
342
|
+
) -> list[dict[str, Any]]:
|
|
343
|
+
"""Request ``textDocument/definition`` (0-based position)."""
|
|
344
|
+
result = self._position_request(
|
|
345
|
+
"textDocument/definition", path, line, character, None, timeout
|
|
346
|
+
)
|
|
347
|
+
return normalize_locations(result)
|
|
348
|
+
|
|
349
|
+
def references(
|
|
350
|
+
self,
|
|
351
|
+
path: str | Path,
|
|
352
|
+
line: int,
|
|
353
|
+
character: int,
|
|
354
|
+
include_declaration: bool = True,
|
|
355
|
+
timeout: float | None = None,
|
|
356
|
+
) -> list[dict[str, Any]]:
|
|
357
|
+
"""Request ``textDocument/references`` (0-based position)."""
|
|
358
|
+
context = {"includeDeclaration": bool(include_declaration)}
|
|
359
|
+
result = self._position_request(
|
|
360
|
+
"textDocument/references", path, line, character, context, timeout
|
|
361
|
+
)
|
|
362
|
+
return normalize_locations(result)
|
|
363
|
+
|
|
364
|
+
def shutdown(self) -> None:
|
|
365
|
+
"""Best-effort graceful shutdown (shutdown/exit, then terminate)."""
|
|
366
|
+
if self._shutdown_done:
|
|
367
|
+
return
|
|
368
|
+
self._shutdown_done = True
|
|
369
|
+
if not self._closed:
|
|
370
|
+
try:
|
|
371
|
+
self._request("shutdown", None, timeout=SHUTDOWN_TIMEOUT)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass
|
|
374
|
+
try:
|
|
375
|
+
self._notify("exit", None)
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
self.close()
|
|
379
|
+
|
|
380
|
+
def close(self) -> None:
|
|
381
|
+
"""Terminate the server process; never raises."""
|
|
382
|
+
self._closed = True
|
|
383
|
+
process = self._process
|
|
384
|
+
try:
|
|
385
|
+
if process.stdin is not None:
|
|
386
|
+
try:
|
|
387
|
+
process.stdin.close()
|
|
388
|
+
except OSError:
|
|
389
|
+
pass
|
|
390
|
+
if process.poll() is None:
|
|
391
|
+
process.terminate()
|
|
392
|
+
try:
|
|
393
|
+
process.wait(timeout=CLOSE_TIMEOUT)
|
|
394
|
+
except subprocess.TimeoutExpired:
|
|
395
|
+
process.kill()
|
|
396
|
+
try:
|
|
397
|
+
process.wait(timeout=CLOSE_TIMEOUT)
|
|
398
|
+
except subprocess.TimeoutExpired:
|
|
399
|
+
pass
|
|
400
|
+
except OSError:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
# -- internals ----------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def _mark_dirty(self, uri: str) -> None:
|
|
406
|
+
with self._cond:
|
|
407
|
+
self._dirty.add(uri)
|
|
408
|
+
self._cond.notify_all()
|
|
409
|
+
|
|
410
|
+
def _read_loop(self) -> None:
|
|
411
|
+
buffer = b""
|
|
412
|
+
stream = self._process.stdout
|
|
413
|
+
error: str | None = None
|
|
414
|
+
if stream is not None:
|
|
415
|
+
try:
|
|
416
|
+
while True:
|
|
417
|
+
try:
|
|
418
|
+
chunk = stream.read(4096)
|
|
419
|
+
except (OSError, ValueError):
|
|
420
|
+
break
|
|
421
|
+
if not chunk:
|
|
422
|
+
break
|
|
423
|
+
buffer += chunk
|
|
424
|
+
try:
|
|
425
|
+
messages, buffer = decode_messages(buffer)
|
|
426
|
+
except LspProtocolError as exc:
|
|
427
|
+
error = f"protocol error: {exc}"
|
|
428
|
+
break
|
|
429
|
+
for message in messages:
|
|
430
|
+
self._dispatch(message)
|
|
431
|
+
finally:
|
|
432
|
+
self._fail_waiters(error)
|
|
433
|
+
|
|
434
|
+
def _dispatch(self, message: dict[str, Any]) -> None:
|
|
435
|
+
request_id = message.get("id")
|
|
436
|
+
if request_id is not None and ("result" in message or "error" in message):
|
|
437
|
+
with self._lock:
|
|
438
|
+
waiter = self._responses.get(request_id)
|
|
439
|
+
if waiter is not None:
|
|
440
|
+
waiter.put(message)
|
|
441
|
+
return
|
|
442
|
+
method = message.get("method")
|
|
443
|
+
if method == "textDocument/publishDiagnostics":
|
|
444
|
+
params = message.get("params")
|
|
445
|
+
if isinstance(params, dict) and isinstance(params.get("uri"), str):
|
|
446
|
+
uri = str(params["uri"])
|
|
447
|
+
with self._cond:
|
|
448
|
+
self._diagnostics[uri] = params
|
|
449
|
+
self._dirty.discard(uri)
|
|
450
|
+
self._cond.notify_all()
|
|
451
|
+
return
|
|
452
|
+
if request_id is not None and method is not None:
|
|
453
|
+
# Server -> client request. Answer benignly so servers that block
|
|
454
|
+
# on configuration (clangd, pyright) can continue.
|
|
455
|
+
result: Any = [] if method == "workspace/configuration" else None
|
|
456
|
+
try:
|
|
457
|
+
self._write(
|
|
458
|
+
{"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
459
|
+
)
|
|
460
|
+
except LspError:
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
def _fail_waiters(self, reason: str | None) -> None:
|
|
464
|
+
with self._lock:
|
|
465
|
+
self._closed = True
|
|
466
|
+
waiters = list(self._responses.values())
|
|
467
|
+
self._responses.clear()
|
|
468
|
+
for waiter in waiters:
|
|
469
|
+
waiter.put(None)
|
|
470
|
+
with self._cond:
|
|
471
|
+
self._cond.notify_all()
|
|
472
|
+
|
|
473
|
+
def _write(self, message: dict[str, Any]) -> None:
|
|
474
|
+
with self._write_lock:
|
|
475
|
+
process = self._process
|
|
476
|
+
if self._closed or process.poll() is not None:
|
|
477
|
+
raise LspError("language server is not running")
|
|
478
|
+
if process.stdin is None:
|
|
479
|
+
raise LspError("language server has no stdin")
|
|
480
|
+
try:
|
|
481
|
+
process.stdin.write(encode_message(message))
|
|
482
|
+
process.stdin.flush()
|
|
483
|
+
except (OSError, ValueError) as exc:
|
|
484
|
+
raise LspError(
|
|
485
|
+
f"could not write to language server: {exc}"
|
|
486
|
+
) from exc
|
|
487
|
+
|
|
488
|
+
def _request(
|
|
489
|
+
self, method: str, params: Any, timeout: float
|
|
490
|
+
) -> Any:
|
|
491
|
+
with self._lock:
|
|
492
|
+
self._next_id += 1
|
|
493
|
+
request_id = self._next_id
|
|
494
|
+
waiter: queue.Queue[dict[str, Any] | None] = queue.Queue()
|
|
495
|
+
self._responses[request_id] = waiter
|
|
496
|
+
try:
|
|
497
|
+
self._write(
|
|
498
|
+
{
|
|
499
|
+
"jsonrpc": "2.0",
|
|
500
|
+
"id": request_id,
|
|
501
|
+
"method": method,
|
|
502
|
+
"params": params,
|
|
503
|
+
}
|
|
504
|
+
)
|
|
505
|
+
try:
|
|
506
|
+
response = waiter.get(timeout=max(0.05, float(timeout)))
|
|
507
|
+
except queue.Empty:
|
|
508
|
+
raise LspTimeout(
|
|
509
|
+
f"{method} timed out after {timeout:g}s"
|
|
510
|
+
) from None
|
|
511
|
+
finally:
|
|
512
|
+
with self._lock:
|
|
513
|
+
self._responses.pop(request_id, None)
|
|
514
|
+
if response is None:
|
|
515
|
+
raise LspError(f"{method}: language server closed the connection")
|
|
516
|
+
error = response.get("error")
|
|
517
|
+
if error:
|
|
518
|
+
detail = (
|
|
519
|
+
str(error.get("message") or error)
|
|
520
|
+
if isinstance(error, dict)
|
|
521
|
+
else str(error)
|
|
522
|
+
)
|
|
523
|
+
raise LspError(f"{method} failed: {detail}")
|
|
524
|
+
return response.get("result")
|
|
525
|
+
|
|
526
|
+
def _notify(self, method: str, params: Any) -> None:
|
|
527
|
+
self._write({"jsonrpc": "2.0", "method": method, "params": params})
|
|
528
|
+
|
|
529
|
+
def _position_request(
|
|
530
|
+
self,
|
|
531
|
+
method: str,
|
|
532
|
+
path: str | Path,
|
|
533
|
+
line: int,
|
|
534
|
+
character: int,
|
|
535
|
+
context: dict[str, Any] | None,
|
|
536
|
+
timeout: float | None,
|
|
537
|
+
) -> Any:
|
|
538
|
+
params: dict[str, Any] = {
|
|
539
|
+
"textDocument": {"uri": path_to_uri(path)},
|
|
540
|
+
"position": {
|
|
541
|
+
"line": max(0, int(line)),
|
|
542
|
+
"character": max(0, int(character)),
|
|
543
|
+
},
|
|
544
|
+
}
|
|
545
|
+
if context is not None:
|
|
546
|
+
params["context"] = context
|
|
547
|
+
budget = self.request_timeout if timeout is None else float(timeout)
|
|
548
|
+
return self._request(method, params, budget)
|