ripperdoc 0.2.9__py3-none-any.whl → 0.3.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 (76) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +379 -51
  3. ripperdoc/cli/commands/__init__.py +6 -0
  4. ripperdoc/cli/commands/agents_cmd.py +128 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  7. ripperdoc/cli/commands/exit_cmd.py +1 -0
  8. ripperdoc/cli/commands/memory_cmd.py +2 -1
  9. ripperdoc/cli/commands/models_cmd.py +63 -7
  10. ripperdoc/cli/commands/resume_cmd.py +5 -0
  11. ripperdoc/cli/commands/skills_cmd.py +103 -0
  12. ripperdoc/cli/commands/stats_cmd.py +244 -0
  13. ripperdoc/cli/commands/status_cmd.py +10 -0
  14. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  15. ripperdoc/cli/commands/themes_cmd.py +139 -0
  16. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  17. ripperdoc/cli/ui/helpers.py +6 -3
  18. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  19. ripperdoc/cli/ui/panels.py +14 -8
  20. ripperdoc/cli/ui/rich_ui.py +737 -47
  21. ripperdoc/cli/ui/spinner.py +93 -18
  22. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  23. ripperdoc/cli/ui/tool_renderers.py +10 -9
  24. ripperdoc/cli/ui/wizard.py +24 -19
  25. ripperdoc/core/agents.py +14 -3
  26. ripperdoc/core/config.py +238 -6
  27. ripperdoc/core/default_tools.py +91 -10
  28. ripperdoc/core/hooks/events.py +4 -0
  29. ripperdoc/core/hooks/llm_callback.py +58 -0
  30. ripperdoc/core/hooks/manager.py +6 -0
  31. ripperdoc/core/permissions.py +160 -9
  32. ripperdoc/core/providers/openai.py +84 -28
  33. ripperdoc/core/query.py +489 -87
  34. ripperdoc/core/query_utils.py +17 -14
  35. ripperdoc/core/skills.py +1 -0
  36. ripperdoc/core/theme.py +298 -0
  37. ripperdoc/core/tool.py +15 -5
  38. ripperdoc/protocol/__init__.py +14 -0
  39. ripperdoc/protocol/models.py +300 -0
  40. ripperdoc/protocol/stdio.py +1453 -0
  41. ripperdoc/tools/background_shell.py +354 -139
  42. ripperdoc/tools/bash_tool.py +117 -22
  43. ripperdoc/tools/file_edit_tool.py +228 -50
  44. ripperdoc/tools/file_read_tool.py +154 -3
  45. ripperdoc/tools/file_write_tool.py +53 -11
  46. ripperdoc/tools/grep_tool.py +98 -8
  47. ripperdoc/tools/lsp_tool.py +609 -0
  48. ripperdoc/tools/multi_edit_tool.py +26 -3
  49. ripperdoc/tools/skill_tool.py +52 -1
  50. ripperdoc/tools/task_tool.py +539 -65
  51. ripperdoc/utils/conversation_compaction.py +1 -1
  52. ripperdoc/utils/file_watch.py +216 -7
  53. ripperdoc/utils/image_utils.py +125 -0
  54. ripperdoc/utils/log.py +30 -3
  55. ripperdoc/utils/lsp.py +812 -0
  56. ripperdoc/utils/mcp.py +80 -18
  57. ripperdoc/utils/message_formatting.py +7 -4
  58. ripperdoc/utils/messages.py +198 -33
  59. ripperdoc/utils/pending_messages.py +50 -0
  60. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  61. ripperdoc/utils/permissions/tool_permission_utils.py +180 -15
  62. ripperdoc/utils/platform.py +198 -0
  63. ripperdoc/utils/session_heatmap.py +242 -0
  64. ripperdoc/utils/session_history.py +2 -2
  65. ripperdoc/utils/session_stats.py +294 -0
  66. ripperdoc/utils/shell_utils.py +8 -5
  67. ripperdoc/utils/todo.py +0 -6
  68. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +55 -17
  69. ripperdoc-0.3.0.dist-info/RECORD +136 -0
  70. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  71. ripperdoc/sdk/__init__.py +0 -9
  72. ripperdoc/sdk/client.py +0 -333
  73. ripperdoc-0.2.9.dist-info/RECORD +0 -123
  74. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.9.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
ripperdoc/utils/lsp.py ADDED
@@ -0,0 +1,812 @@
1
+ """LSP configuration loader and client manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextvars
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+ from urllib.parse import unquote, urlparse
15
+
16
+ from ripperdoc.utils.git_utils import get_git_root, is_git_repository
17
+ from ripperdoc.utils.log import get_logger
18
+ from ripperdoc.utils.platform import is_windows
19
+
20
+
21
+ logger = get_logger()
22
+
23
+
24
+ def _float_env(name: str, default: str) -> float:
25
+ value = os.getenv(name, default)
26
+ try:
27
+ return float(value)
28
+ except (TypeError, ValueError):
29
+ return float(default)
30
+
31
+
32
+ DEFAULT_REQUEST_TIMEOUT = _float_env("RIPPERDOC_LSP_TIMEOUT", "20")
33
+
34
+ LANGUAGE_ID_BY_EXT: Dict[str, str] = {
35
+ ".py": "python",
36
+ ".js": "javascript",
37
+ ".mjs": "javascript",
38
+ ".cjs": "javascript",
39
+ ".jsx": "javascriptreact",
40
+ ".ts": "typescript",
41
+ ".tsx": "typescriptreact",
42
+ ".json": "json",
43
+ ".jsonc": "jsonc",
44
+ ".yaml": "yaml",
45
+ ".yml": "yaml",
46
+ ".toml": "toml",
47
+ ".md": "markdown",
48
+ ".rst": "restructuredtext",
49
+ ".html": "html",
50
+ ".htm": "html",
51
+ ".css": "css",
52
+ ".scss": "scss",
53
+ ".less": "less",
54
+ ".go": "go",
55
+ ".rs": "rust",
56
+ ".java": "java",
57
+ ".kt": "kotlin",
58
+ ".kts": "kotlin",
59
+ ".swift": "swift",
60
+ ".c": "c",
61
+ ".h": "c",
62
+ ".cc": "cpp",
63
+ ".cpp": "cpp",
64
+ ".cxx": "cpp",
65
+ ".hpp": "cpp",
66
+ ".hh": "cpp",
67
+ ".cs": "csharp",
68
+ ".php": "php",
69
+ ".rb": "ruby",
70
+ ".lua": "lua",
71
+ ".sql": "sql",
72
+ ".sh": "shellscript",
73
+ ".bash": "shellscript",
74
+ ".zsh": "shellscript",
75
+ ".ps1": "powershell",
76
+ ".dart": "dart",
77
+ ".vue": "vue",
78
+ ".svelte": "svelte",
79
+ ".ex": "elixir",
80
+ ".exs": "elixir",
81
+ ".erl": "erlang",
82
+ ".hs": "haskell",
83
+ ".ml": "ocaml",
84
+ ".mli": "ocaml",
85
+ ".fs": "fsharp",
86
+ ".r": "r",
87
+ }
88
+
89
+
90
+ def guess_language_id(file_path: Path) -> Optional[str]:
91
+ """Guess LSP language id from file extension."""
92
+ return LANGUAGE_ID_BY_EXT.get(file_path.suffix.lower())
93
+
94
+
95
+ def file_path_to_uri(file_path: Path) -> str:
96
+ """Convert a filesystem path to file:// URI."""
97
+ return file_path.resolve().as_uri()
98
+
99
+
100
+ def uri_to_path(uri: str) -> Optional[Path]:
101
+ """Convert a file:// URI to a filesystem path."""
102
+ parsed = urlparse(uri)
103
+ if parsed.scheme and parsed.scheme != "file":
104
+ return None
105
+ path = unquote(parsed.path)
106
+ if is_windows() and re.match(r"^/[A-Za-z]:", path):
107
+ path = path[1:]
108
+ return Path(path)
109
+
110
+
111
+ def _load_json_file(path: Path) -> Dict[str, Any]:
112
+ if not path.exists():
113
+ return {}
114
+ try:
115
+ data = json.loads(path.read_text())
116
+ if isinstance(data, dict):
117
+ return data
118
+ return {}
119
+ except (OSError, json.JSONDecodeError):
120
+ logger.exception("Failed to load JSON", extra={"path": str(path)})
121
+ return {}
122
+
123
+
124
+ def _normalize_command(raw_command: Any, raw_args: Any) -> Tuple[Optional[str], List[str]]:
125
+ """Normalize LSP server command/args."""
126
+ args: List[str] = []
127
+ if isinstance(raw_args, list):
128
+ args = [str(a) for a in raw_args]
129
+
130
+ if isinstance(raw_command, list):
131
+ tokens = [str(t) for t in raw_command if str(t)]
132
+ if not tokens:
133
+ return None, args
134
+ return tokens[0], tokens[1:] + args
135
+
136
+ if not isinstance(raw_command, str):
137
+ return None, args
138
+
139
+ command_str = raw_command.strip()
140
+ if not command_str:
141
+ return None, args
142
+
143
+ if not args and (" " in command_str or "\t" in command_str):
144
+ try:
145
+ tokens = shlex.split(command_str)
146
+ except ValueError:
147
+ tokens = [command_str]
148
+ if tokens:
149
+ return tokens[0], tokens[1:]
150
+
151
+ return command_str, args
152
+
153
+
154
+ def _normalize_str_list(raw: Any) -> List[str]:
155
+ if raw is None:
156
+ return []
157
+ if isinstance(raw, str):
158
+ raw = [raw]
159
+ if not isinstance(raw, list):
160
+ return []
161
+ return [str(item).strip() for item in raw if str(item).strip()]
162
+
163
+
164
+ def _normalize_extensions(raw: Any) -> List[str]:
165
+ extensions = _normalize_str_list(raw)
166
+ normalized: List[str] = []
167
+ for ext in extensions:
168
+ if not ext:
169
+ continue
170
+ ext = ext.strip()
171
+ if not ext.startswith("."):
172
+ ext = f".{ext}"
173
+ normalized.append(ext.lower())
174
+ return normalized
175
+
176
+
177
+ def _normalize_extension_language_map(raw: Any) -> Dict[str, str]:
178
+ if not isinstance(raw, dict):
179
+ return {}
180
+ result: Dict[str, str] = {}
181
+ for raw_ext, raw_lang in raw.items():
182
+ ext = str(raw_ext).strip()
183
+ lang = str(raw_lang).strip()
184
+ if not ext or not lang:
185
+ continue
186
+ if not ext.startswith("."):
187
+ ext = f".{ext}"
188
+ result[ext.lower()] = lang.lower()
189
+ return result
190
+
191
+
192
+ @dataclass
193
+ class LspServerConfig:
194
+ name: str
195
+ command: Optional[str]
196
+ args: List[str] = field(default_factory=list)
197
+ languages: List[str] = field(default_factory=list)
198
+ extensions: List[str] = field(default_factory=list)
199
+ extension_language_map: Dict[str, str] = field(default_factory=dict)
200
+ root_patterns: List[str] = field(default_factory=list)
201
+ env: Dict[str, str] = field(default_factory=dict)
202
+ initialization_options: Dict[str, Any] = field(default_factory=dict)
203
+ settings: Dict[str, Any] = field(default_factory=dict)
204
+
205
+
206
+ def _parse_server_config(name: str, raw: Dict[str, Any]) -> Optional[LspServerConfig]:
207
+ command, args = _normalize_command(raw.get("command"), raw.get("args"))
208
+ if not command:
209
+ return None
210
+
211
+ languages = _normalize_str_list(
212
+ raw.get("languages")
213
+ or raw.get("languageIds")
214
+ or raw.get("languageId")
215
+ or raw.get("language")
216
+ )
217
+ extensions = _normalize_extensions(
218
+ raw.get("extensions") or raw.get("fileExtensions") or raw.get("file_extensions")
219
+ )
220
+ extension_language_map = _normalize_extension_language_map(
221
+ raw.get("extensionToLanguage") or raw.get("extension_to_language")
222
+ )
223
+ root_patterns = _normalize_str_list(
224
+ raw.get("rootPatterns") or raw.get("root_patterns") or raw.get("rootPattern")
225
+ )
226
+
227
+ env = raw.get("env") if isinstance(raw.get("env"), dict) else {}
228
+ env = {str(k): str(v) for k, v in env.items()} if env else {}
229
+
230
+ initialization_options = (
231
+ raw.get("initializationOptions")
232
+ if isinstance(raw.get("initializationOptions"), dict)
233
+ else {}
234
+ )
235
+ settings = raw.get("settings") if isinstance(raw.get("settings"), dict) else {}
236
+
237
+ if extension_language_map:
238
+ for ext in extension_language_map.keys():
239
+ if ext not in extensions:
240
+ extensions.append(ext)
241
+ if not languages:
242
+ languages = list({lang for lang in extension_language_map.values() if lang})
243
+
244
+ if not languages and not extensions and not extension_language_map:
245
+ name_hint = str(name).strip().lower()
246
+ if name_hint:
247
+ languages = [name_hint]
248
+
249
+ return LspServerConfig(
250
+ name=name,
251
+ command=command,
252
+ args=[str(a) for a in args] if args else [],
253
+ languages=[lang.lower() for lang in languages],
254
+ extensions=extensions,
255
+ extension_language_map=extension_language_map,
256
+ root_patterns=root_patterns,
257
+ env=env,
258
+ initialization_options=initialization_options or {},
259
+ settings=settings or {},
260
+ )
261
+
262
+
263
+ def _parse_servers(data: Dict[str, Any]) -> Dict[str, LspServerConfig]:
264
+ servers: Dict[str, LspServerConfig] = {}
265
+ for key in ("servers", "lspServers"):
266
+ raw_servers = data.get(key)
267
+ if not isinstance(raw_servers, dict):
268
+ continue
269
+ for name, raw in raw_servers.items():
270
+ if not isinstance(raw, dict):
271
+ continue
272
+ server_name = str(name).strip()
273
+ if not server_name:
274
+ continue
275
+ parsed = _parse_server_config(server_name, raw)
276
+ if parsed:
277
+ servers[server_name] = parsed
278
+ if servers:
279
+ return servers
280
+
281
+ for name, raw in data.items():
282
+ if not isinstance(raw, dict):
283
+ continue
284
+ if not any(
285
+ key in raw
286
+ for key in (
287
+ "command",
288
+ "args",
289
+ "extensionToLanguage",
290
+ "extension_to_language",
291
+ "languages",
292
+ "languageIds",
293
+ "languageId",
294
+ "language",
295
+ "extensions",
296
+ "fileExtensions",
297
+ "file_extensions",
298
+ )
299
+ ):
300
+ continue
301
+ server_name = str(name).strip()
302
+ if not server_name:
303
+ continue
304
+ parsed = _parse_server_config(server_name, raw)
305
+ if parsed:
306
+ servers[server_name] = parsed
307
+ return servers
308
+
309
+
310
+ def load_lsp_server_configs(project_path: Optional[Path] = None) -> Dict[str, LspServerConfig]:
311
+ project_path = project_path or Path.cwd()
312
+ candidates = [
313
+ Path.home() / ".ripperdoc" / "lsp.json",
314
+ Path.home() / ".lsp.json",
315
+ project_path / ".ripperdoc" / "lsp.json",
316
+ project_path / ".lsp.json",
317
+ ]
318
+
319
+ merged: Dict[str, LspServerConfig] = {}
320
+ for path in candidates:
321
+ data = _load_json_file(path)
322
+ merged.update(_parse_servers(data))
323
+
324
+ logger.debug(
325
+ "[lsp] Loaded LSP server configs",
326
+ extra={
327
+ "project_path": str(project_path),
328
+ "server_count": len(merged),
329
+ "candidates": [str(path) for path in candidates],
330
+ },
331
+ )
332
+ return merged
333
+
334
+
335
+ def _resolve_workspace_root(file_path: Path, root_patterns: List[str]) -> Path:
336
+ if root_patterns:
337
+ for parent in [file_path.parent] + list(file_path.parents):
338
+ for pattern in root_patterns:
339
+ if not pattern:
340
+ continue
341
+ candidate = parent / pattern
342
+ if any(ch in pattern for ch in "*?[]"):
343
+ matches = list(parent.glob(pattern))
344
+ if matches:
345
+ return parent
346
+ continue
347
+ if candidate.exists():
348
+ return parent
349
+
350
+ if is_git_repository(file_path.parent):
351
+ git_root = get_git_root(file_path.parent)
352
+ if git_root:
353
+ return git_root
354
+
355
+ return file_path.parent
356
+
357
+
358
+ class LspProtocolError(RuntimeError):
359
+ """Protocol-level error from the LSP client."""
360
+
361
+
362
+ class LspRequestError(RuntimeError):
363
+ """Error returned by LSP server for a request."""
364
+
365
+
366
+ class LspLaunchError(RuntimeError):
367
+ """Error launching an LSP server process."""
368
+
369
+
370
+ @dataclass
371
+ class LspDocumentState:
372
+ version: int
373
+ text: str
374
+
375
+
376
+ class LspServer:
377
+ """Manage a single LSP server process."""
378
+
379
+ def __init__(self, config: LspServerConfig, workspace_root: Path) -> None:
380
+ self.config = config
381
+ self.workspace_root = workspace_root
382
+ self._process: Optional[asyncio.subprocess.Process] = None
383
+ self._reader_task: Optional[asyncio.Task[None]] = None
384
+ self._stderr_task: Optional[asyncio.Task[None]] = None
385
+ self._pending: Dict[int, asyncio.Future[Any]] = {}
386
+ self._next_id = 1
387
+ self._send_lock = asyncio.Lock()
388
+ self._init_lock = asyncio.Lock()
389
+ self._initialized = False
390
+ self._closed = False
391
+ self._open_docs: Dict[str, LspDocumentState] = {}
392
+ self._workspace_folders = [
393
+ {"uri": file_path_to_uri(self.workspace_root), "name": self.workspace_root.name}
394
+ ]
395
+
396
+ @property
397
+ def is_closed(self) -> bool:
398
+ return self._closed
399
+
400
+ async def _start_process(self) -> None:
401
+ if self._process and self._process.returncode is None:
402
+ return
403
+
404
+ env = os.environ.copy()
405
+ env.update(self.config.env)
406
+
407
+ if not self.config.command:
408
+ raise ValueError(f"LSP server '{self.config.name}' has no command configured")
409
+
410
+ try:
411
+ self._process = await asyncio.create_subprocess_exec(
412
+ self.config.command,
413
+ *self.config.args,
414
+ stdin=asyncio.subprocess.PIPE,
415
+ stdout=asyncio.subprocess.PIPE,
416
+ stderr=asyncio.subprocess.PIPE,
417
+ cwd=str(self.workspace_root),
418
+ env=env,
419
+ )
420
+ except (FileNotFoundError, PermissionError, OSError) as exc:
421
+ raise LspLaunchError(
422
+ f"Failed to launch LSP server '{self.config.name}': {exc}"
423
+ ) from exc
424
+
425
+ self._reader_task = asyncio.create_task(self._read_messages())
426
+ if self._process.stderr:
427
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
428
+
429
+ async def _drain_stderr(self) -> None:
430
+ assert self._process and self._process.stderr
431
+ try:
432
+ while True:
433
+ line = await self._process.stderr.readline()
434
+ if not line:
435
+ break
436
+ logger.debug(
437
+ "[lsp] stderr",
438
+ extra={
439
+ "server": self.config.name,
440
+ "line": line.decode(errors="replace").strip(),
441
+ },
442
+ )
443
+ except (asyncio.CancelledError, RuntimeError):
444
+ return
445
+
446
+ async def _read_messages(self) -> None:
447
+ assert self._process and self._process.stdout
448
+ try:
449
+ while True:
450
+ message = await self._read_message()
451
+ if message is None:
452
+ break
453
+ await self._handle_message(message)
454
+ except (asyncio.CancelledError, RuntimeError, OSError):
455
+ pass
456
+ finally:
457
+ self._closed = True
458
+ self._fail_pending("LSP server disconnected")
459
+
460
+ async def _read_message(self) -> Optional[Dict[str, Any]]:
461
+ assert self._process and self._process.stdout
462
+ headers: Dict[str, str] = {}
463
+ while True:
464
+ line = await self._process.stdout.readline()
465
+ if not line:
466
+ return None
467
+ decoded = line.decode("utf-8", errors="replace").strip()
468
+ if not decoded:
469
+ break
470
+ if ":" in decoded:
471
+ key, value = decoded.split(":", 1)
472
+ headers[key.strip().lower()] = value.strip()
473
+
474
+ length_str = headers.get("content-length")
475
+ if not length_str:
476
+ return None
477
+ try:
478
+ length = int(length_str)
479
+ except ValueError:
480
+ raise LspProtocolError(f"Invalid Content-Length header: {length_str}")
481
+
482
+ body = await self._process.stdout.readexactly(length)
483
+ try:
484
+ payload = json.loads(body.decode("utf-8", errors="replace"))
485
+ except json.JSONDecodeError as exc:
486
+ raise LspProtocolError(f"Invalid JSON payload: {exc}") from exc
487
+ if not isinstance(payload, dict):
488
+ raise LspProtocolError("LSP payload is not an object")
489
+ return payload
490
+
491
+ async def _handle_message(self, message: Dict[str, Any]) -> None:
492
+ if "id" in message and ("result" in message or "error" in message):
493
+ request_id = message.get("id")
494
+ if not isinstance(request_id, int):
495
+ return
496
+ future = self._pending.pop(request_id, None)
497
+ if future:
498
+ if "error" in message:
499
+ error = message.get("error")
500
+ future.set_exception(LspRequestError(str(error)))
501
+ else:
502
+ future.set_result(message.get("result"))
503
+ return
504
+
505
+ if "id" in message and "method" in message:
506
+ response = await self._handle_server_request(message)
507
+ if response is not None:
508
+ await self._send_payload(response)
509
+ return
510
+
511
+ # Notifications are ignored.
512
+
513
+ async def _handle_server_request(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
514
+ request_id = message.get("id")
515
+ method = message.get("method")
516
+ params = message.get("params") or {}
517
+
518
+ result: Any = None
519
+ if method == "workspace/configuration":
520
+ items = params.get("items") if isinstance(params, dict) else None
521
+ if isinstance(items, list):
522
+ result = [self.config.settings or {} for _ in items]
523
+ else:
524
+ result = [self.config.settings or {}]
525
+ elif method == "workspace/workspaceFolders":
526
+ result = self._workspace_folders
527
+ elif method in ("client/registerCapability", "client/unregisterCapability"):
528
+ result = None
529
+ elif method == "workspace/applyEdit":
530
+ result = {"applied": False}
531
+ elif method == "window/showMessageRequest":
532
+ result = None
533
+ else:
534
+ result = None
535
+
536
+ return {"jsonrpc": "2.0", "id": request_id, "result": result}
537
+
538
+ async def _send_payload(self, payload: Dict[str, Any]) -> None:
539
+ if not self._process or not self._process.stdin:
540
+ raise LspProtocolError("LSP server process is not running")
541
+
542
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
543
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
544
+ async with self._send_lock:
545
+ try:
546
+ self._process.stdin.write(header + body)
547
+ await self._process.stdin.drain()
548
+ except (ConnectionError, BrokenPipeError, OSError) as exc:
549
+ raise LspProtocolError(f"Failed to write to LSP server: {exc}") from exc
550
+
551
+ async def _send_request(self, method: str, params: Optional[Dict[str, Any]]) -> Any:
552
+ await self._start_process()
553
+ request_id = self._next_id
554
+ self._next_id += 1
555
+
556
+ loop = asyncio.get_running_loop()
557
+ future: asyncio.Future[Any] = loop.create_future()
558
+ self._pending[request_id] = future
559
+
560
+ payload: Dict[str, Any] = {
561
+ "jsonrpc": "2.0",
562
+ "id": request_id,
563
+ "method": method,
564
+ }
565
+ if params is not None:
566
+ payload["params"] = params
567
+
568
+ await self._send_payload(payload)
569
+
570
+ try:
571
+ return await asyncio.wait_for(future, timeout=DEFAULT_REQUEST_TIMEOUT)
572
+ except asyncio.TimeoutError as exc:
573
+ self._pending.pop(request_id, None)
574
+ raise LspProtocolError(f"LSP request timed out: {method}") from exc
575
+
576
+ async def _send_notification(self, method: str, params: Optional[Dict[str, Any]]) -> None:
577
+ await self._start_process()
578
+ payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method}
579
+ if params is not None:
580
+ payload["params"] = params
581
+ await self._send_payload(payload)
582
+
583
+ def _fail_pending(self, message: str) -> None:
584
+ for future in self._pending.values():
585
+ if not future.done():
586
+ future.set_exception(LspProtocolError(message))
587
+ self._pending.clear()
588
+
589
+ async def ensure_initialized(self) -> None:
590
+ if self._initialized:
591
+ return
592
+ async with self._init_lock:
593
+ if self._initialized:
594
+ return
595
+ params = {
596
+ "processId": os.getpid(),
597
+ "rootUri": file_path_to_uri(self.workspace_root),
598
+ "workspaceFolders": self._workspace_folders,
599
+ "capabilities": {
600
+ "textDocument": {
601
+ "definition": {"dynamicRegistration": False},
602
+ "references": {"dynamicRegistration": False},
603
+ "hover": {"dynamicRegistration": False},
604
+ "documentSymbol": {"dynamicRegistration": False},
605
+ "implementation": {"dynamicRegistration": False},
606
+ },
607
+ "workspace": {"symbol": {"dynamicRegistration": False}},
608
+ },
609
+ "initializationOptions": self.config.initialization_options or {},
610
+ }
611
+
612
+ await self._send_request("initialize", params)
613
+ await self._send_notification("initialized", {})
614
+ if self.config.settings:
615
+ await self._send_notification(
616
+ "workspace/didChangeConfiguration", {"settings": self.config.settings}
617
+ )
618
+ self._initialized = True
619
+
620
+ async def ensure_document_open(self, file_path: Path, text: str, language_id: str) -> None:
621
+ uri = file_path_to_uri(file_path)
622
+ state = self._open_docs.get(uri)
623
+
624
+ if state is None:
625
+ self._open_docs[uri] = LspDocumentState(version=1, text=text)
626
+ await self._send_notification(
627
+ "textDocument/didOpen",
628
+ {
629
+ "textDocument": {
630
+ "uri": uri,
631
+ "languageId": language_id,
632
+ "version": 1,
633
+ "text": text,
634
+ }
635
+ },
636
+ )
637
+ return
638
+
639
+ if state.text != text:
640
+ state.version += 1
641
+ state.text = text
642
+ await self._send_notification(
643
+ "textDocument/didChange",
644
+ {
645
+ "textDocument": {"uri": uri, "version": state.version},
646
+ "contentChanges": [{"text": text}],
647
+ },
648
+ )
649
+
650
+ async def request(self, method: str, params: Optional[Dict[str, Any]]) -> Any:
651
+ return await self._send_request(method, params)
652
+
653
+ async def notify(self, method: str, params: Optional[Dict[str, Any]]) -> None:
654
+ await self._send_notification(method, params)
655
+
656
+ async def shutdown(self) -> None:
657
+ if self._closed:
658
+ return
659
+ self._closed = True
660
+ try:
661
+ if self._process and self._process.returncode is None:
662
+ try:
663
+ await self._send_request("shutdown", None)
664
+ await self._send_notification("exit", None)
665
+ except (LspProtocolError, LspRequestError):
666
+ pass
667
+ self._process.terminate()
668
+ try:
669
+ await asyncio.wait_for(self._process.wait(), timeout=2)
670
+ except asyncio.TimeoutError:
671
+ self._process.kill()
672
+ finally:
673
+ self._fail_pending("LSP server shut down")
674
+ if self._reader_task:
675
+ self._reader_task.cancel()
676
+ if self._stderr_task:
677
+ self._stderr_task.cancel()
678
+
679
+
680
+ class LspManager:
681
+ """Track configured LSP servers and route requests."""
682
+
683
+ def __init__(self, project_path: Path) -> None:
684
+ self.project_path = project_path
685
+ self.configs = load_lsp_server_configs(project_path)
686
+ self._servers: Dict[Tuple[str, Path], LspServer] = {}
687
+ self._closed = False
688
+
689
+ def _match_config(self, file_path: Path) -> Optional[LspServerConfig]:
690
+ if not self.configs:
691
+ return None
692
+ ext = file_path.suffix.lower()
693
+ language_id = guess_language_id(file_path)
694
+
695
+ matches_by_extension: List[LspServerConfig] = []
696
+ matches_by_language: List[LspServerConfig] = []
697
+
698
+ for config in self.configs.values():
699
+ if config.extension_language_map and ext in config.extension_language_map:
700
+ matches_by_extension.append(config)
701
+ elif config.extensions and ext in config.extensions:
702
+ matches_by_extension.append(config)
703
+ elif config.languages and language_id and language_id in config.languages:
704
+ matches_by_language.append(config)
705
+
706
+ if matches_by_extension:
707
+ return matches_by_extension[0]
708
+ if matches_by_language:
709
+ return matches_by_language[0]
710
+ if len(self.configs) == 1:
711
+ return next(iter(self.configs.values()))
712
+ return None
713
+
714
+ def _language_id_for(self, file_path: Path, config: LspServerConfig) -> str:
715
+ ext = file_path.suffix.lower()
716
+ if config.extension_language_map and ext in config.extension_language_map:
717
+ return config.extension_language_map[ext]
718
+
719
+ guessed = guess_language_id(file_path)
720
+ if guessed:
721
+ return guessed
722
+ if config.languages:
723
+ return config.languages[0]
724
+ return "plaintext"
725
+
726
+ async def server_for_path(
727
+ self, file_path: Path
728
+ ) -> Optional[Tuple[LspServer, LspServerConfig, str]]:
729
+ if self._closed:
730
+ return None
731
+
732
+ config = self._match_config(file_path)
733
+ if not config:
734
+ return None
735
+
736
+ workspace_root = _resolve_workspace_root(file_path, config.root_patterns)
737
+ key = (config.name, workspace_root)
738
+ server = self._servers.get(key)
739
+ if not server or server.is_closed:
740
+ server = LspServer(config, workspace_root)
741
+ self._servers[key] = server
742
+
743
+ language_id = self._language_id_for(file_path, config)
744
+ return server, config, language_id
745
+
746
+ async def shutdown(self) -> None:
747
+ if self._closed:
748
+ return
749
+ self._closed = True
750
+ servers = list(self._servers.values())
751
+ self._servers.clear()
752
+ for server in servers:
753
+ try:
754
+ await server.shutdown()
755
+ except (RuntimeError, OSError, LspProtocolError):
756
+ logger.warning(
757
+ "[lsp] Failed to shutdown LSP server",
758
+ extra={"server": getattr(server.config, "name", "unknown")},
759
+ )
760
+
761
+
762
+ _runtime_var: contextvars.ContextVar[Optional[LspManager]] = contextvars.ContextVar(
763
+ "ripperdoc_lsp_runtime", default=None
764
+ )
765
+ _global_runtime: Optional[LspManager] = None
766
+
767
+
768
+ def get_existing_lsp_manager() -> Optional[LspManager]:
769
+ runtime = _runtime_var.get()
770
+ return runtime or _global_runtime
771
+
772
+
773
+ async def ensure_lsp_manager(project_path: Optional[Path] = None) -> LspManager:
774
+ runtime = get_existing_lsp_manager()
775
+ if runtime and not runtime._closed:
776
+ return runtime
777
+
778
+ project_path = project_path or Path.cwd()
779
+ runtime = LspManager(project_path)
780
+ _runtime_var.set(runtime)
781
+ global _global_runtime
782
+ _global_runtime = runtime
783
+ return runtime
784
+
785
+
786
+ async def shutdown_lsp_manager() -> None:
787
+ runtime = get_existing_lsp_manager()
788
+ if not runtime:
789
+ return
790
+ try:
791
+ await runtime.shutdown()
792
+ finally:
793
+ _runtime_var.set(None)
794
+ global _global_runtime
795
+ _global_runtime = None
796
+
797
+
798
+ __all__ = [
799
+ "LspManager",
800
+ "LspServer",
801
+ "LspServerConfig",
802
+ "LspLaunchError",
803
+ "LspProtocolError",
804
+ "LspRequestError",
805
+ "ensure_lsp_manager",
806
+ "get_existing_lsp_manager",
807
+ "shutdown_lsp_manager",
808
+ "guess_language_id",
809
+ "file_path_to_uri",
810
+ "uri_to_path",
811
+ "load_lsp_server_configs",
812
+ ]