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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +257 -123
- ripperdoc/cli/commands/__init__.py +2 -1
- ripperdoc/cli/commands/agents_cmd.py +138 -8
- ripperdoc/cli/commands/clear_cmd.py +9 -4
- ripperdoc/cli/commands/config_cmd.py +1 -1
- ripperdoc/cli/commands/context_cmd.py +3 -2
- ripperdoc/cli/commands/doctor_cmd.py +18 -4
- ripperdoc/cli/commands/exit_cmd.py +1 -0
- ripperdoc/cli/commands/hooks_cmd.py +27 -53
- ripperdoc/cli/commands/models_cmd.py +27 -10
- ripperdoc/cli/commands/permissions_cmd.py +27 -9
- ripperdoc/cli/commands/resume_cmd.py +9 -3
- ripperdoc/cli/commands/stats_cmd.py +244 -0
- ripperdoc/cli/commands/status_cmd.py +4 -4
- ripperdoc/cli/commands/tasks_cmd.py +8 -4
- ripperdoc/cli/ui/file_mention_completer.py +2 -1
- ripperdoc/cli/ui/interrupt_handler.py +2 -3
- ripperdoc/cli/ui/message_display.py +4 -2
- ripperdoc/cli/ui/panels.py +1 -0
- ripperdoc/cli/ui/provider_options.py +247 -0
- ripperdoc/cli/ui/rich_ui.py +403 -81
- ripperdoc/cli/ui/spinner.py +54 -18
- ripperdoc/cli/ui/thinking_spinner.py +1 -2
- ripperdoc/cli/ui/tool_renderers.py +8 -2
- ripperdoc/cli/ui/wizard.py +213 -0
- ripperdoc/core/agents.py +19 -6
- ripperdoc/core/config.py +51 -17
- ripperdoc/core/custom_commands.py +7 -6
- ripperdoc/core/default_tools.py +101 -12
- ripperdoc/core/hooks/config.py +1 -3
- ripperdoc/core/hooks/events.py +27 -28
- ripperdoc/core/hooks/executor.py +4 -6
- ripperdoc/core/hooks/integration.py +12 -21
- ripperdoc/core/hooks/llm_callback.py +59 -0
- ripperdoc/core/hooks/manager.py +40 -15
- ripperdoc/core/permissions.py +118 -12
- ripperdoc/core/providers/anthropic.py +109 -36
- ripperdoc/core/providers/gemini.py +70 -5
- ripperdoc/core/providers/openai.py +89 -24
- ripperdoc/core/query.py +273 -68
- ripperdoc/core/query_utils.py +2 -0
- ripperdoc/core/skills.py +9 -3
- ripperdoc/core/system_prompt.py +4 -2
- ripperdoc/core/tool.py +17 -8
- ripperdoc/sdk/client.py +79 -4
- ripperdoc/tools/ask_user_question_tool.py +5 -3
- ripperdoc/tools/background_shell.py +307 -135
- ripperdoc/tools/bash_output_tool.py +1 -1
- ripperdoc/tools/bash_tool.py +63 -24
- ripperdoc/tools/dynamic_mcp_tool.py +29 -8
- ripperdoc/tools/enter_plan_mode_tool.py +1 -1
- ripperdoc/tools/exit_plan_mode_tool.py +1 -1
- ripperdoc/tools/file_edit_tool.py +167 -54
- ripperdoc/tools/file_read_tool.py +28 -4
- ripperdoc/tools/file_write_tool.py +13 -10
- ripperdoc/tools/glob_tool.py +3 -2
- ripperdoc/tools/grep_tool.py +3 -2
- ripperdoc/tools/kill_bash_tool.py +1 -1
- ripperdoc/tools/ls_tool.py +1 -1
- ripperdoc/tools/lsp_tool.py +615 -0
- ripperdoc/tools/mcp_tools.py +13 -10
- ripperdoc/tools/multi_edit_tool.py +8 -7
- ripperdoc/tools/notebook_edit_tool.py +7 -4
- ripperdoc/tools/skill_tool.py +1 -1
- ripperdoc/tools/task_tool.py +519 -69
- ripperdoc/tools/todo_tool.py +2 -2
- ripperdoc/tools/tool_search_tool.py +3 -2
- ripperdoc/utils/conversation_compaction.py +9 -5
- ripperdoc/utils/file_watch.py +214 -5
- ripperdoc/utils/json_utils.py +2 -1
- ripperdoc/utils/lsp.py +806 -0
- ripperdoc/utils/mcp.py +11 -3
- ripperdoc/utils/memory.py +4 -2
- ripperdoc/utils/message_compaction.py +21 -7
- ripperdoc/utils/message_formatting.py +14 -7
- ripperdoc/utils/messages.py +126 -67
- ripperdoc/utils/path_ignore.py +35 -8
- ripperdoc/utils/permissions/path_validation_utils.py +2 -1
- ripperdoc/utils/permissions/shell_command_validation.py +427 -91
- ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
- ripperdoc/utils/safe_get_cwd.py +2 -1
- ripperdoc/utils/session_heatmap.py +244 -0
- ripperdoc/utils/session_history.py +13 -6
- ripperdoc/utils/session_stats.py +293 -0
- ripperdoc/utils/todo.py +2 -1
- ripperdoc/utils/token_estimation.py +6 -1
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
- ripperdoc-0.2.10.dist-info/RECORD +129 -0
- ripperdoc-0.2.8.dist-info/RECORD +0 -121
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
]
|