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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. 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)