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