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.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. 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")