voidx 1.0.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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/lsp/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""LSP error types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LspError(Exception):
|
|
7
|
+
"""Base class for LSP failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LspConnectionError(LspError):
|
|
11
|
+
"""Raised when a language server process cannot be used."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LspRequestError(LspError):
|
|
15
|
+
"""Raised when a server returns a JSON-RPC error response."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LspServerUnavailable(LspError):
|
|
19
|
+
"""Raised when no enabled server can handle a file."""
|
voidx/lsp/manager.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""LSP server lifecycle and document operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from voidx.lsp.client import LspClient
|
|
11
|
+
from voidx.lsp.config import install_hint_for, language_for_path, load_lsp_servers
|
|
12
|
+
from voidx.lsp.errors import LspConnectionError, LspServerUnavailable
|
|
13
|
+
from voidx.lsp.schema import (
|
|
14
|
+
LspDiagnostic,
|
|
15
|
+
LspDoctorCheck,
|
|
16
|
+
LspLocation,
|
|
17
|
+
LspRuntimeStatus,
|
|
18
|
+
LspServerConfig,
|
|
19
|
+
LspSymbol,
|
|
20
|
+
file_uri,
|
|
21
|
+
parse_document_symbols,
|
|
22
|
+
parse_locations,
|
|
23
|
+
)
|
|
24
|
+
from voidx.tools.base import resolve_safe
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LspManager:
|
|
28
|
+
def __init__(self, workspace: str) -> None:
|
|
29
|
+
self.workspace = str(Path(workspace).resolve())
|
|
30
|
+
self._servers = load_lsp_servers(self.workspace)
|
|
31
|
+
self._clients: dict[str, LspClient] = {}
|
|
32
|
+
self._errors: dict[str, str] = {}
|
|
33
|
+
self._open_docs: dict[str, tuple[str, int, str]] = {}
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def servers(self) -> dict[str, LspServerConfig]:
|
|
37
|
+
return self._servers
|
|
38
|
+
|
|
39
|
+
async def stop_all(self) -> None:
|
|
40
|
+
clients = list(self._clients.values())
|
|
41
|
+
self._clients.clear()
|
|
42
|
+
self._open_docs.clear()
|
|
43
|
+
await asyncio.gather(*(client.stop() for client in clients), return_exceptions=True)
|
|
44
|
+
|
|
45
|
+
async def restart(self, language: str | None = None) -> None:
|
|
46
|
+
if language:
|
|
47
|
+
client = self._clients.pop(language, None)
|
|
48
|
+
if client is not None:
|
|
49
|
+
await client.stop()
|
|
50
|
+
self._errors.pop(language, None)
|
|
51
|
+
for uri, (doc_language, _, _) in list(self._open_docs.items()):
|
|
52
|
+
if doc_language == language:
|
|
53
|
+
self._open_docs.pop(uri, None)
|
|
54
|
+
return
|
|
55
|
+
await self.stop_all()
|
|
56
|
+
self._errors.clear()
|
|
57
|
+
self._servers = load_lsp_servers(self.workspace)
|
|
58
|
+
|
|
59
|
+
def statuses(self) -> list[LspRuntimeStatus]:
|
|
60
|
+
result: list[LspRuntimeStatus] = []
|
|
61
|
+
for language, config in self._servers.items():
|
|
62
|
+
client = self._clients.get(language)
|
|
63
|
+
open_docs = sum(1 for doc_language, _, _ in self._open_docs.values() if doc_language == language)
|
|
64
|
+
if not config.enabled:
|
|
65
|
+
status = "disabled"
|
|
66
|
+
elif language in self._errors:
|
|
67
|
+
status = "error"
|
|
68
|
+
elif client is not None and client.connected:
|
|
69
|
+
status = "connected"
|
|
70
|
+
else:
|
|
71
|
+
status = "disconnected"
|
|
72
|
+
result.append(LspRuntimeStatus(
|
|
73
|
+
language=language,
|
|
74
|
+
command=" ".join([config.command, *config.args]).strip(),
|
|
75
|
+
status=status,
|
|
76
|
+
pid=client.pid if client is not None else None,
|
|
77
|
+
open_documents=open_docs,
|
|
78
|
+
error_message=self._errors.get(language, "") or (client.error_message if client else ""),
|
|
79
|
+
))
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
def doctor(self) -> list[LspDoctorCheck]:
|
|
83
|
+
checks: list[LspDoctorCheck] = []
|
|
84
|
+
for language, config in self._servers.items():
|
|
85
|
+
resolved = config.resolved_command or _resolve_command(config.command)
|
|
86
|
+
available = bool(resolved)
|
|
87
|
+
error = ""
|
|
88
|
+
if not config.enabled:
|
|
89
|
+
error = "Server disabled in config."
|
|
90
|
+
elif not available:
|
|
91
|
+
error = f"Command not found: {config.command}"
|
|
92
|
+
checks.append(LspDoctorCheck(
|
|
93
|
+
language=language,
|
|
94
|
+
command=" ".join([config.command, *config.args]).strip(),
|
|
95
|
+
enabled=config.enabled,
|
|
96
|
+
available=available,
|
|
97
|
+
resolved_path=resolved,
|
|
98
|
+
install_hint=install_hint_for(language) if not available else "",
|
|
99
|
+
error_message=error,
|
|
100
|
+
detected_source=config.detected_source,
|
|
101
|
+
))
|
|
102
|
+
return checks
|
|
103
|
+
|
|
104
|
+
async def diagnostics(self, file_path: str | None = None, *, wait: float = 0.35) -> list[LspDiagnostic]:
|
|
105
|
+
if file_path:
|
|
106
|
+
client, uri = await self.open_document(file_path)
|
|
107
|
+
if wait > 0:
|
|
108
|
+
await asyncio.sleep(wait)
|
|
109
|
+
return client.diagnostics_for(uri)
|
|
110
|
+
result: list[LspDiagnostic] = []
|
|
111
|
+
for client in self._clients.values():
|
|
112
|
+
result.extend(client.all_diagnostics())
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
async def document_symbols(self, file_path: str) -> list[LspSymbol]:
|
|
116
|
+
client, uri = await self.open_document(file_path)
|
|
117
|
+
value = await client.request("textDocument/documentSymbol", {
|
|
118
|
+
"textDocument": {"uri": uri},
|
|
119
|
+
})
|
|
120
|
+
return parse_document_symbols(uri, value)
|
|
121
|
+
|
|
122
|
+
async def workspace_symbols(self, query: str) -> list[LspSymbol]:
|
|
123
|
+
symbols: list[LspSymbol] = []
|
|
124
|
+
for language in self._servers:
|
|
125
|
+
try:
|
|
126
|
+
client = await self._ensure_client(language)
|
|
127
|
+
except LspServerUnavailable:
|
|
128
|
+
continue
|
|
129
|
+
value = await client.request("workspace/symbol", {"query": query})
|
|
130
|
+
symbols.extend(parse_document_symbols(file_uri(self.workspace), value))
|
|
131
|
+
return symbols
|
|
132
|
+
|
|
133
|
+
async def definition(self, file_path: str, line: int, character: int) -> list[LspLocation]:
|
|
134
|
+
client, uri = await self.open_document(file_path)
|
|
135
|
+
value = await client.request("textDocument/definition", {
|
|
136
|
+
"textDocument": {"uri": uri},
|
|
137
|
+
"position": _position(line, character),
|
|
138
|
+
})
|
|
139
|
+
return parse_locations(value)
|
|
140
|
+
|
|
141
|
+
async def references(
|
|
142
|
+
self,
|
|
143
|
+
file_path: str,
|
|
144
|
+
line: int,
|
|
145
|
+
character: int,
|
|
146
|
+
*,
|
|
147
|
+
include_declaration: bool = True,
|
|
148
|
+
) -> list[LspLocation]:
|
|
149
|
+
client, uri = await self.open_document(file_path)
|
|
150
|
+
value = await client.request("textDocument/references", {
|
|
151
|
+
"textDocument": {"uri": uri},
|
|
152
|
+
"position": _position(line, character),
|
|
153
|
+
"context": {"includeDeclaration": include_declaration},
|
|
154
|
+
})
|
|
155
|
+
return parse_locations(value)
|
|
156
|
+
|
|
157
|
+
async def format_document(self, file_path: str) -> tuple[bool, str, str]:
|
|
158
|
+
path = self._resolve_path(file_path)
|
|
159
|
+
old_text = path.read_text(encoding="utf-8", errors="replace")
|
|
160
|
+
client, uri = await self.open_document(file_path)
|
|
161
|
+
edits = await client.request("textDocument/formatting", {
|
|
162
|
+
"textDocument": {"uri": uri},
|
|
163
|
+
"options": {"tabSize": 4, "insertSpaces": True},
|
|
164
|
+
})
|
|
165
|
+
new_text = apply_text_edits(old_text, edits if isinstance(edits, list) else [])
|
|
166
|
+
if new_text == old_text:
|
|
167
|
+
return False, old_text, old_text
|
|
168
|
+
path.write_text(new_text, encoding="utf-8")
|
|
169
|
+
await self.open_document(file_path)
|
|
170
|
+
return True, old_text, new_text
|
|
171
|
+
|
|
172
|
+
async def open_document(self, file_path: str) -> tuple[LspClient, str]:
|
|
173
|
+
path = self._resolve_path(file_path)
|
|
174
|
+
language = self._language_for(path)
|
|
175
|
+
client = await self._ensure_client(language)
|
|
176
|
+
uri = file_uri(path)
|
|
177
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
178
|
+
_, version, previous = self._open_docs.get(uri, (language, 0, ""))
|
|
179
|
+
if version == 0:
|
|
180
|
+
version = 1
|
|
181
|
+
await client.notify("textDocument/didOpen", {
|
|
182
|
+
"textDocument": {
|
|
183
|
+
"uri": uri,
|
|
184
|
+
"languageId": language,
|
|
185
|
+
"version": version,
|
|
186
|
+
"text": text,
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
elif previous != text:
|
|
190
|
+
version += 1
|
|
191
|
+
await client.notify("textDocument/didChange", {
|
|
192
|
+
"textDocument": {"uri": uri, "version": version},
|
|
193
|
+
"contentChanges": [{"text": text}],
|
|
194
|
+
})
|
|
195
|
+
self._open_docs[uri] = (language, version, text)
|
|
196
|
+
return client, uri
|
|
197
|
+
|
|
198
|
+
async def _ensure_client(self, language: str) -> LspClient:
|
|
199
|
+
config = self._servers.get(language)
|
|
200
|
+
if config is None or not config.enabled:
|
|
201
|
+
raise LspServerUnavailable(f"No enabled LSP server for language: {language}")
|
|
202
|
+
client = self._clients.get(language)
|
|
203
|
+
if client is not None and client.connected:
|
|
204
|
+
return client
|
|
205
|
+
self._check_command(config)
|
|
206
|
+
client = LspClient(config, cwd=self.workspace)
|
|
207
|
+
try:
|
|
208
|
+
await client.start(root_uri=file_uri(self.workspace))
|
|
209
|
+
except LspConnectionError as exc:
|
|
210
|
+
self._errors[language] = str(exc)
|
|
211
|
+
raise
|
|
212
|
+
self._errors.pop(language, None)
|
|
213
|
+
self._clients[language] = client
|
|
214
|
+
return client
|
|
215
|
+
|
|
216
|
+
def _resolve_path(self, file_path: str) -> Path:
|
|
217
|
+
path = resolve_safe(self.workspace, file_path)
|
|
218
|
+
if path is None:
|
|
219
|
+
raise LspServerUnavailable(f"Path traversal blocked: {file_path}")
|
|
220
|
+
if not path.exists():
|
|
221
|
+
raise LspServerUnavailable(f"File not found: {file_path}")
|
|
222
|
+
if path.is_dir():
|
|
223
|
+
raise LspServerUnavailable(f"Path is a directory: {file_path}")
|
|
224
|
+
return path
|
|
225
|
+
|
|
226
|
+
def _language_for(self, path: Path) -> str:
|
|
227
|
+
language = language_for_path(path, self._servers)
|
|
228
|
+
if language is None:
|
|
229
|
+
raise LspServerUnavailable(f"No LSP server configured for file type: {path.suffix or path.name}")
|
|
230
|
+
return language
|
|
231
|
+
|
|
232
|
+
def _check_command(self, config: LspServerConfig) -> None:
|
|
233
|
+
resolved = config.resolved_command or _resolve_command(config.command)
|
|
234
|
+
if resolved:
|
|
235
|
+
return
|
|
236
|
+
message = f"Command not found for {config.language} LSP: {config.command}"
|
|
237
|
+
self._errors[config.language] = message
|
|
238
|
+
raise LspServerUnavailable(message)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _position(line: int, character: int) -> dict[str, int]:
|
|
242
|
+
return {"line": max(line - 1, 0), "character": max(character, 0)}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _resolve_command(command: str) -> str:
|
|
246
|
+
path = Path(command)
|
|
247
|
+
if path.is_absolute() and path.exists():
|
|
248
|
+
return str(path)
|
|
249
|
+
resolved = shutil.which(command)
|
|
250
|
+
return resolved or ""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def apply_text_edits(text: str, edits: list[Any]) -> str:
|
|
254
|
+
parsed: list[tuple[int, int, str]] = []
|
|
255
|
+
for edit in edits:
|
|
256
|
+
if not isinstance(edit, dict) or "range" not in edit:
|
|
257
|
+
continue
|
|
258
|
+
range_data = edit["range"]
|
|
259
|
+
if not isinstance(range_data, dict):
|
|
260
|
+
continue
|
|
261
|
+
start = range_data.get("start", {})
|
|
262
|
+
end = range_data.get("end", {})
|
|
263
|
+
if not isinstance(start, dict) or not isinstance(end, dict):
|
|
264
|
+
continue
|
|
265
|
+
start_offset = _offset_for_position(text, int(start.get("line", 0)), int(start.get("character", 0)))
|
|
266
|
+
end_offset = _offset_for_position(text, int(end.get("line", 0)), int(end.get("character", 0)))
|
|
267
|
+
parsed.append((start_offset, end_offset, str(edit.get("newText", ""))))
|
|
268
|
+
result = text
|
|
269
|
+
for start, end, new_text in sorted(parsed, key=lambda item: item[0], reverse=True):
|
|
270
|
+
result = result[:start] + new_text + result[end:]
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _offset_for_position(text: str, line: int, character: int) -> int:
|
|
275
|
+
lines = text.splitlines(keepends=True)
|
|
276
|
+
if line <= 0:
|
|
277
|
+
return min(character, len(lines[0]) if lines else len(text))
|
|
278
|
+
if line >= len(lines):
|
|
279
|
+
return len(text)
|
|
280
|
+
return sum(len(part) for part in lines[:line]) + min(character, len(lines[line]))
|
voidx/lsp/schema.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Pydantic models for Language Server Protocol data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
from urllib.parse import unquote, urlparse
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LspStatus = Literal["disabled", "disconnected", "connected", "error"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LspPosition(BaseModel):
|
|
16
|
+
line: int = Field(ge=0)
|
|
17
|
+
character: int = Field(ge=0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LspRange(BaseModel):
|
|
21
|
+
start: LspPosition
|
|
22
|
+
end: LspPosition
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LspLocation(BaseModel):
|
|
26
|
+
uri: str
|
|
27
|
+
path: str
|
|
28
|
+
range: LspRange | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LspDiagnostic(BaseModel):
|
|
32
|
+
uri: str
|
|
33
|
+
path: str
|
|
34
|
+
range: LspRange
|
|
35
|
+
severity: int | None = None
|
|
36
|
+
source: str = ""
|
|
37
|
+
code: str = ""
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LspSymbol(BaseModel):
|
|
42
|
+
name: str
|
|
43
|
+
kind: int | None = None
|
|
44
|
+
path: str = ""
|
|
45
|
+
range: LspRange | None = None
|
|
46
|
+
selection_range: LspRange | None = None
|
|
47
|
+
container_name: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LspServerConfig(BaseModel):
|
|
51
|
+
language: str
|
|
52
|
+
command: str
|
|
53
|
+
args: list[str] = Field(default_factory=list)
|
|
54
|
+
extensions: list[str] = Field(default_factory=list)
|
|
55
|
+
enabled: bool = True
|
|
56
|
+
# Set after auto-detection: actual binary path found
|
|
57
|
+
resolved_command: str = ""
|
|
58
|
+
# Human-readable source of detection, e.g. "CursorPyright (Cursor ext)"
|
|
59
|
+
detected_source: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LspRuntimeStatus(BaseModel):
|
|
63
|
+
language: str
|
|
64
|
+
command: str = ""
|
|
65
|
+
status: LspStatus
|
|
66
|
+
pid: int | None = None
|
|
67
|
+
open_documents: int = 0
|
|
68
|
+
error_message: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LspDoctorCheck(BaseModel):
|
|
72
|
+
language: str
|
|
73
|
+
command: str
|
|
74
|
+
enabled: bool = True
|
|
75
|
+
available: bool = False
|
|
76
|
+
resolved_path: str = ""
|
|
77
|
+
install_hint: str = ""
|
|
78
|
+
error_message: str = ""
|
|
79
|
+
detected_source: str = ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def file_uri(path: str | Path) -> str:
|
|
83
|
+
return Path(path).resolve().as_uri()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def path_from_uri(uri: str) -> str:
|
|
87
|
+
parsed = urlparse(uri)
|
|
88
|
+
if parsed.scheme != "file":
|
|
89
|
+
return uri
|
|
90
|
+
return unquote(parsed.path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_range(data: dict[str, Any] | None) -> LspRange | None:
|
|
94
|
+
if not isinstance(data, dict):
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return LspRange.model_validate(data)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_location(data: dict[str, Any]) -> LspLocation | None:
|
|
103
|
+
uri = data.get("uri") or data.get("targetUri")
|
|
104
|
+
if not isinstance(uri, str):
|
|
105
|
+
return None
|
|
106
|
+
range_data = data.get("range") or data.get("targetSelectionRange") or data.get("targetRange")
|
|
107
|
+
return LspLocation(
|
|
108
|
+
uri=uri,
|
|
109
|
+
path=path_from_uri(uri),
|
|
110
|
+
range=parse_range(range_data),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_locations(value: Any) -> list[LspLocation]:
|
|
115
|
+
if value is None:
|
|
116
|
+
return []
|
|
117
|
+
items = value if isinstance(value, list) else [value]
|
|
118
|
+
result: list[LspLocation] = []
|
|
119
|
+
for item in items:
|
|
120
|
+
if isinstance(item, dict):
|
|
121
|
+
location = parse_location(item)
|
|
122
|
+
if location is not None:
|
|
123
|
+
result.append(location)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_diagnostics(uri: str, diagnostics: Any) -> list[LspDiagnostic]:
|
|
128
|
+
if not isinstance(diagnostics, list):
|
|
129
|
+
return []
|
|
130
|
+
result: list[LspDiagnostic] = []
|
|
131
|
+
for item in diagnostics:
|
|
132
|
+
if not isinstance(item, dict) or not isinstance(item.get("message"), str):
|
|
133
|
+
continue
|
|
134
|
+
range_data = parse_range(item.get("range"))
|
|
135
|
+
if range_data is None:
|
|
136
|
+
continue
|
|
137
|
+
code = item.get("code", "")
|
|
138
|
+
result.append(LspDiagnostic(
|
|
139
|
+
uri=uri,
|
|
140
|
+
path=path_from_uri(uri),
|
|
141
|
+
range=range_data,
|
|
142
|
+
severity=item.get("severity") if isinstance(item.get("severity"), int) else None,
|
|
143
|
+
source=str(item.get("source", "")),
|
|
144
|
+
code=str(code) if code is not None else "",
|
|
145
|
+
message=item["message"],
|
|
146
|
+
))
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_document_symbols(uri: str, value: Any) -> list[LspSymbol]:
|
|
151
|
+
if not isinstance(value, list):
|
|
152
|
+
return []
|
|
153
|
+
result: list[LspSymbol] = []
|
|
154
|
+
|
|
155
|
+
def visit(items: list[Any], container: str = "") -> None:
|
|
156
|
+
for item in items:
|
|
157
|
+
if not isinstance(item, dict) or not isinstance(item.get("name"), str):
|
|
158
|
+
continue
|
|
159
|
+
location = item.get("location")
|
|
160
|
+
symbol_uri = uri
|
|
161
|
+
range_data = item.get("range")
|
|
162
|
+
selection_range = item.get("selectionRange")
|
|
163
|
+
if isinstance(location, dict):
|
|
164
|
+
symbol_uri = location.get("uri") or uri
|
|
165
|
+
range_data = location.get("range") or range_data
|
|
166
|
+
result.append(LspSymbol(
|
|
167
|
+
name=item["name"],
|
|
168
|
+
kind=item.get("kind") if isinstance(item.get("kind"), int) else None,
|
|
169
|
+
path=path_from_uri(symbol_uri),
|
|
170
|
+
range=parse_range(range_data),
|
|
171
|
+
selection_range=parse_range(selection_range),
|
|
172
|
+
container_name=str(item.get("containerName") or container or ""),
|
|
173
|
+
))
|
|
174
|
+
children = item.get("children")
|
|
175
|
+
if isinstance(children, list):
|
|
176
|
+
visit(children, item["name"])
|
|
177
|
+
|
|
178
|
+
visit(value)
|
|
179
|
+
return result
|
voidx/lsp/service.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""High-level LSP operations for tools and slash commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from voidx.lsp.manager import LspManager
|
|
8
|
+
from voidx.lsp.schema import LspDiagnostic, LspLocation, LspSymbol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LspService:
|
|
12
|
+
def __init__(self, manager: LspManager) -> None:
|
|
13
|
+
self._manager = manager
|
|
14
|
+
|
|
15
|
+
async def diagnostics(self, file_path: str | None = None) -> str:
|
|
16
|
+
diagnostics = await self._manager.diagnostics(file_path)
|
|
17
|
+
if not diagnostics:
|
|
18
|
+
target = file_path or "opened files"
|
|
19
|
+
return f"No LSP diagnostics for {target}."
|
|
20
|
+
return "\n".join(_format_diagnostic(item, self._manager.workspace) for item in diagnostics)
|
|
21
|
+
|
|
22
|
+
async def symbols(self, file_path: str | None = None, query: str = "") -> str:
|
|
23
|
+
if file_path:
|
|
24
|
+
symbols = await self._manager.document_symbols(file_path)
|
|
25
|
+
elif query:
|
|
26
|
+
symbols = await self._manager.workspace_symbols(query)
|
|
27
|
+
else:
|
|
28
|
+
return "Provide file_path for document symbols or query for workspace symbols."
|
|
29
|
+
if not symbols:
|
|
30
|
+
return "No LSP symbols found."
|
|
31
|
+
return "\n".join(_format_symbol(item, self._manager.workspace) for item in symbols[:200])
|
|
32
|
+
|
|
33
|
+
async def definition(self, file_path: str, line: int, character: int) -> str:
|
|
34
|
+
locations = await self._manager.definition(file_path, line, character)
|
|
35
|
+
if not locations:
|
|
36
|
+
return "No definition found."
|
|
37
|
+
return "\n".join(_format_location(item, self._manager.workspace) for item in locations)
|
|
38
|
+
|
|
39
|
+
async def references(
|
|
40
|
+
self,
|
|
41
|
+
file_path: str,
|
|
42
|
+
line: int,
|
|
43
|
+
character: int,
|
|
44
|
+
*,
|
|
45
|
+
include_declaration: bool = True,
|
|
46
|
+
) -> str:
|
|
47
|
+
locations = await self._manager.references(
|
|
48
|
+
file_path,
|
|
49
|
+
line,
|
|
50
|
+
character,
|
|
51
|
+
include_declaration=include_declaration,
|
|
52
|
+
)
|
|
53
|
+
if not locations:
|
|
54
|
+
return "No references found."
|
|
55
|
+
return "\n".join(_format_location(item, self._manager.workspace) for item in locations[:200])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _format_diagnostic(diagnostic: LspDiagnostic, workspace: str) -> str:
|
|
59
|
+
level = _severity_label(diagnostic.severity)
|
|
60
|
+
loc = _format_range_location(diagnostic.path, diagnostic.range, workspace)
|
|
61
|
+
source = f" [{diagnostic.source}]" if diagnostic.source else ""
|
|
62
|
+
code = f" {diagnostic.code}" if diagnostic.code else ""
|
|
63
|
+
return f"{loc}: {level}{source}{code}: {diagnostic.message}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_symbol(symbol: LspSymbol, workspace: str) -> str:
|
|
67
|
+
path = _rel(symbol.path, workspace) if symbol.path else ""
|
|
68
|
+
if symbol.selection_range is not None:
|
|
69
|
+
path = _format_range_location(symbol.path, symbol.selection_range, workspace)
|
|
70
|
+
name = symbol.name
|
|
71
|
+
if symbol.container_name:
|
|
72
|
+
name = f"{symbol.container_name}.{name}"
|
|
73
|
+
kind = f"kind={symbol.kind}" if symbol.kind is not None else "symbol"
|
|
74
|
+
return f"{path}: {name} ({kind})" if path else f"{name} ({kind})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_location(location: LspLocation, workspace: str) -> str:
|
|
78
|
+
if location.range is None:
|
|
79
|
+
return _rel(location.path, workspace)
|
|
80
|
+
return _format_range_location(location.path, location.range, workspace)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _format_range_location(path: str, range_data, workspace: str) -> str:
|
|
84
|
+
rel = _rel(path, workspace)
|
|
85
|
+
line = range_data.start.line + 1
|
|
86
|
+
character = range_data.start.character
|
|
87
|
+
return f"{rel}:{line}:{character}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _rel(path: str, workspace: str) -> str:
|
|
91
|
+
try:
|
|
92
|
+
return str(Path(path).resolve().relative_to(Path(workspace).resolve())).replace("\\", "/")
|
|
93
|
+
except ValueError:
|
|
94
|
+
return path
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _severity_label(severity: int | None) -> str:
|
|
98
|
+
return {
|
|
99
|
+
1: "error",
|
|
100
|
+
2: "warning",
|
|
101
|
+
3: "info",
|
|
102
|
+
4: "hint",
|
|
103
|
+
}.get(severity, "diagnostic")
|