local-coding-agent 1.1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""LspClient: Stdio JSON-RPC Client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from ._types import (
|
|
14
|
+
EXTENSION_TO_LANGUAGE,
|
|
15
|
+
LspConnectionError,
|
|
16
|
+
LspError,
|
|
17
|
+
LspHoverResult,
|
|
18
|
+
LspLocation,
|
|
19
|
+
LspResponseError,
|
|
20
|
+
LspSymbol,
|
|
21
|
+
LspTimeoutError,
|
|
22
|
+
MessageDecoder,
|
|
23
|
+
encode_message,
|
|
24
|
+
path_to_uri,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LspClient:
|
|
29
|
+
"""JSON-RPC Language Server client over stdio."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
command: Sequence[str],
|
|
34
|
+
workspace_root: str | Path | None = None,
|
|
35
|
+
*,
|
|
36
|
+
timeout: float = 10.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.command = list(command)
|
|
39
|
+
self.workspace_root = str(Path(workspace_root).resolve()) if workspace_root else os.getcwd()
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self.process: subprocess.Popen[bytes] | None = None
|
|
42
|
+
self._next_id = 1
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._pending: dict[int, concurrent.futures.Future[dict[str, Any]]] = {}
|
|
45
|
+
self._reader_thread: threading.Thread | None = None
|
|
46
|
+
self._stopped = threading.Event()
|
|
47
|
+
self.server_capabilities: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
def start(self) -> None:
|
|
50
|
+
"""Start language server child process and communication threads."""
|
|
51
|
+
if self.process is not None:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
self.process = subprocess.Popen(
|
|
55
|
+
self.command,
|
|
56
|
+
cwd=self.workspace_root,
|
|
57
|
+
stdin=subprocess.PIPE,
|
|
58
|
+
stdout=subprocess.PIPE,
|
|
59
|
+
stderr=subprocess.DEVNULL,
|
|
60
|
+
)
|
|
61
|
+
except OSError as e:
|
|
62
|
+
raise LspConnectionError(f"Failed to spawn LSP server {self.command!r}: {e}") from e
|
|
63
|
+
|
|
64
|
+
self._stopped.clear()
|
|
65
|
+
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
66
|
+
self._reader_thread.start()
|
|
67
|
+
|
|
68
|
+
def _read_loop(self) -> None:
|
|
69
|
+
assert self.process is not None and self.process.stdout is not None
|
|
70
|
+
decoder = MessageDecoder()
|
|
71
|
+
read_fn = getattr(self.process.stdout, "read1", self.process.stdout.read)
|
|
72
|
+
try:
|
|
73
|
+
while not self._stopped.is_set():
|
|
74
|
+
chunk = read_fn(4096)
|
|
75
|
+
if not chunk:
|
|
76
|
+
break
|
|
77
|
+
messages = decoder.push(chunk)
|
|
78
|
+
for msg in messages:
|
|
79
|
+
self._handle_incoming(msg)
|
|
80
|
+
except (OSError, ValueError):
|
|
81
|
+
pass
|
|
82
|
+
finally:
|
|
83
|
+
self._fail_all_pending("LSP server connection closed")
|
|
84
|
+
|
|
85
|
+
def _handle_incoming(self, message: dict[str, Any]) -> None:
|
|
86
|
+
if "id" in message and message["id"] is not None:
|
|
87
|
+
msg_id = message["id"]
|
|
88
|
+
with self._lock:
|
|
89
|
+
future = self._pending.pop(msg_id, None)
|
|
90
|
+
if future is not None and not future.done():
|
|
91
|
+
if "error" in message and message["error"] is not None:
|
|
92
|
+
err = message["error"]
|
|
93
|
+
future.set_exception(
|
|
94
|
+
LspResponseError(
|
|
95
|
+
code=err.get("code", -1),
|
|
96
|
+
message=err.get("message", "Unknown LSP error"),
|
|
97
|
+
data=err.get("data"),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
future.set_result(message)
|
|
102
|
+
|
|
103
|
+
def _fail_all_pending(self, reason: str) -> None:
|
|
104
|
+
with self._lock:
|
|
105
|
+
pending = list(self._pending.values())
|
|
106
|
+
self._pending.clear()
|
|
107
|
+
for fut in pending:
|
|
108
|
+
if not fut.done():
|
|
109
|
+
fut.set_exception(LspConnectionError(reason))
|
|
110
|
+
|
|
111
|
+
def send_request(
|
|
112
|
+
self,
|
|
113
|
+
method: str,
|
|
114
|
+
params: dict[str, Any] | None = None,
|
|
115
|
+
*,
|
|
116
|
+
timeout: float | None = None,
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""Send a JSON-RPC request and wait for the response."""
|
|
119
|
+
if self.process is None or self.process.poll() is not None:
|
|
120
|
+
raise LspConnectionError("LSP server process is not running")
|
|
121
|
+
|
|
122
|
+
with self._lock:
|
|
123
|
+
req_id = self._next_id
|
|
124
|
+
self._next_id += 1
|
|
125
|
+
future: concurrent.futures.Future[dict[str, Any]] = concurrent.futures.Future()
|
|
126
|
+
self._pending[req_id] = future
|
|
127
|
+
|
|
128
|
+
request_obj = {
|
|
129
|
+
"jsonrpc": "2.0",
|
|
130
|
+
"id": req_id,
|
|
131
|
+
"method": method,
|
|
132
|
+
"params": params if params is not None else {},
|
|
133
|
+
}
|
|
134
|
+
data = encode_message(request_obj)
|
|
135
|
+
try:
|
|
136
|
+
assert self.process.stdin is not None
|
|
137
|
+
self.process.stdin.write(data)
|
|
138
|
+
self.process.stdin.flush()
|
|
139
|
+
except OSError as e:
|
|
140
|
+
with self._lock:
|
|
141
|
+
self._pending.pop(req_id, None)
|
|
142
|
+
raise LspConnectionError(f"Failed to write to LSP server stdin: {e}") from e
|
|
143
|
+
|
|
144
|
+
effective_timeout = timeout if timeout is not None else self.timeout
|
|
145
|
+
try:
|
|
146
|
+
response = future.result(timeout=effective_timeout)
|
|
147
|
+
return response.get("result")
|
|
148
|
+
except concurrent.futures.TimeoutError as e:
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._pending.pop(req_id, None)
|
|
151
|
+
raise LspTimeoutError(f"LSP request '{method}' timed out after {effective_timeout}s") from e
|
|
152
|
+
|
|
153
|
+
def send_notification(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
154
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
155
|
+
if self.process is None or self.process.poll() is not None:
|
|
156
|
+
return
|
|
157
|
+
notification = {
|
|
158
|
+
"jsonrpc": "2.0",
|
|
159
|
+
"method": method,
|
|
160
|
+
"params": params if params is not None else {},
|
|
161
|
+
}
|
|
162
|
+
data = encode_message(notification)
|
|
163
|
+
try:
|
|
164
|
+
assert self.process.stdin is not None
|
|
165
|
+
self.process.stdin.write(data)
|
|
166
|
+
self.process.stdin.flush()
|
|
167
|
+
except OSError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
def initialize(self, workspace_root: str | Path | None = None) -> dict[str, Any]:
|
|
171
|
+
"""Perform standard LSP initialize handshake."""
|
|
172
|
+
root = str(Path(workspace_root).resolve()) if workspace_root else self.workspace_root
|
|
173
|
+
root_uri = path_to_uri(root)
|
|
174
|
+
params = {
|
|
175
|
+
"processId": os.getpid(),
|
|
176
|
+
"rootUri": root_uri,
|
|
177
|
+
"rootPath": root,
|
|
178
|
+
"capabilities": {
|
|
179
|
+
"textDocument": {
|
|
180
|
+
"definition": {"dynamicRegistration": False, "linkSupport": True},
|
|
181
|
+
"references": {"dynamicRegistration": False},
|
|
182
|
+
"hover": {"contentFormat": ["markdown", "plaintext"]},
|
|
183
|
+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
|
|
184
|
+
},
|
|
185
|
+
"workspace": {
|
|
186
|
+
"workspaceFolders": True,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"workspaceFolders": [
|
|
190
|
+
{"uri": root_uri, "name": Path(root).name}
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
res = self.send_request("initialize", params)
|
|
194
|
+
if isinstance(res, Mapping):
|
|
195
|
+
self.server_capabilities = dict(res.get("capabilities", {}))
|
|
196
|
+
self.send_notification("initialized", {})
|
|
197
|
+
return self.server_capabilities
|
|
198
|
+
|
|
199
|
+
def did_open(
|
|
200
|
+
self,
|
|
201
|
+
file_path: str | Path,
|
|
202
|
+
language_id: str | None = None,
|
|
203
|
+
content: str | None = None,
|
|
204
|
+
version: int = 1,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Send textDocument/didOpen notification."""
|
|
207
|
+
path = Path(file_path).resolve()
|
|
208
|
+
uri = path_to_uri(path)
|
|
209
|
+
if content is None:
|
|
210
|
+
try:
|
|
211
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
212
|
+
except OSError:
|
|
213
|
+
content = ""
|
|
214
|
+
lang = language_id or EXTENSION_TO_LANGUAGE.get(path.suffix.lower(), "plaintext")
|
|
215
|
+
self.send_notification(
|
|
216
|
+
"textDocument/didOpen",
|
|
217
|
+
{
|
|
218
|
+
"textDocument": {
|
|
219
|
+
"uri": uri,
|
|
220
|
+
"languageId": lang,
|
|
221
|
+
"version": version,
|
|
222
|
+
"text": content,
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def did_close(self, file_path: str | Path) -> None:
|
|
228
|
+
"""Send textDocument/didClose notification."""
|
|
229
|
+
uri = path_to_uri(Path(file_path).resolve())
|
|
230
|
+
self.send_notification("textDocument/didClose", {"textDocument": {"uri": uri}})
|
|
231
|
+
|
|
232
|
+
def definition(self, file_path: str | Path, line: int, character: int) -> list[LspLocation]:
|
|
233
|
+
"""Query textDocument/definition for symbol locations."""
|
|
234
|
+
uri = path_to_uri(Path(file_path).resolve())
|
|
235
|
+
params = {
|
|
236
|
+
"textDocument": {"uri": uri},
|
|
237
|
+
"position": {"line": line, "character": character},
|
|
238
|
+
}
|
|
239
|
+
res = self.send_request("textDocument/definition", params)
|
|
240
|
+
if res is None:
|
|
241
|
+
return []
|
|
242
|
+
if isinstance(res, Mapping):
|
|
243
|
+
return [LspLocation.from_wire(res)]
|
|
244
|
+
if isinstance(res, Sequence):
|
|
245
|
+
return [LspLocation.from_wire(item) for item in res if isinstance(item, Mapping)]
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
def references(
|
|
249
|
+
self,
|
|
250
|
+
file_path: str | Path,
|
|
251
|
+
line: int,
|
|
252
|
+
character: int,
|
|
253
|
+
include_declaration: bool = True,
|
|
254
|
+
) -> list[LspLocation]:
|
|
255
|
+
"""Query textDocument/references for symbol occurrences."""
|
|
256
|
+
uri = path_to_uri(Path(file_path).resolve())
|
|
257
|
+
params = {
|
|
258
|
+
"textDocument": {"uri": uri},
|
|
259
|
+
"position": {"line": line, "character": character},
|
|
260
|
+
"context": {"includeDeclaration": include_declaration},
|
|
261
|
+
}
|
|
262
|
+
res = self.send_request("textDocument/references", params)
|
|
263
|
+
if not res or not isinstance(res, Sequence):
|
|
264
|
+
return []
|
|
265
|
+
return [LspLocation.from_wire(item) for item in res if isinstance(item, Mapping)]
|
|
266
|
+
|
|
267
|
+
def hover(self, file_path: str | Path, line: int, character: int) -> LspHoverResult | None:
|
|
268
|
+
"""Query textDocument/hover for symbol type and docstring information."""
|
|
269
|
+
uri = path_to_uri(Path(file_path).resolve())
|
|
270
|
+
params = {
|
|
271
|
+
"textDocument": {"uri": uri},
|
|
272
|
+
"position": {"line": line, "character": character},
|
|
273
|
+
}
|
|
274
|
+
res = self.send_request("textDocument/hover", params)
|
|
275
|
+
return LspHoverResult.from_wire(res)
|
|
276
|
+
|
|
277
|
+
def document_symbols(self, file_path: str | Path) -> list[LspSymbol]:
|
|
278
|
+
"""Query textDocument/documentSymbol for file symbol outline."""
|
|
279
|
+
uri = path_to_uri(Path(file_path).resolve())
|
|
280
|
+
params = {"textDocument": {"uri": uri}}
|
|
281
|
+
res = self.send_request("textDocument/documentSymbol", params)
|
|
282
|
+
return LspSymbol.from_wire(res, file_uri=uri)
|
|
283
|
+
|
|
284
|
+
def shutdown(self) -> None:
|
|
285
|
+
"""Gracefully shut down the language server."""
|
|
286
|
+
if self.process is None or self.process.poll() is not None:
|
|
287
|
+
return
|
|
288
|
+
try:
|
|
289
|
+
self.send_request("shutdown", None, timeout=3.0)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
self.send_notification("exit", None)
|
|
293
|
+
|
|
294
|
+
def stop(self) -> None:
|
|
295
|
+
"""Terminate process and release reader resources."""
|
|
296
|
+
self._stopped.set()
|
|
297
|
+
if self.process is not None:
|
|
298
|
+
try:
|
|
299
|
+
self.shutdown()
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
try:
|
|
303
|
+
if self.process.poll() is None:
|
|
304
|
+
self.process.terminate()
|
|
305
|
+
self.process.wait(timeout=2.0)
|
|
306
|
+
except Exception:
|
|
307
|
+
try:
|
|
308
|
+
self.process.kill()
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
self.process = None
|
|
312
|
+
if self._reader_thread is not None:
|
|
313
|
+
self._reader_thread.join(timeout=1.0)
|
|
314
|
+
self._reader_thread = None
|
|
315
|
+
|
|
316
|
+
def __enter__(self) -> LspClient:
|
|
317
|
+
self.start()
|
|
318
|
+
self.initialize()
|
|
319
|
+
return self
|
|
320
|
+
|
|
321
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
322
|
+
self.stop()
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Fallback AST & Regex Code Intelligence Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._types import LspHoverResult, LspLocation, LspPosition, LspRange, LspSymbol, path_to_uri
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FallbackLspEngine:
|
|
14
|
+
"""Built-in code intelligence engine for offline environments & test runs."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def _get_word_at_pos(content: str, line: int, character: int) -> tuple[str, LspRange] | None:
|
|
18
|
+
lines = content.splitlines()
|
|
19
|
+
if line < 0 or line >= len(lines):
|
|
20
|
+
return None
|
|
21
|
+
line_text = lines[line]
|
|
22
|
+
if character < 0 or character > len(line_text):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
# Find word boundaries
|
|
26
|
+
start = character
|
|
27
|
+
while start > 0 and (line_text[start - 1].isalnum() or line_text[start - 1] == "_"):
|
|
28
|
+
start -= 1
|
|
29
|
+
end = character
|
|
30
|
+
while end < len(line_text) and (line_text[end].isalnum() or line_text[end] == "_"):
|
|
31
|
+
end += 1
|
|
32
|
+
|
|
33
|
+
if start >= end:
|
|
34
|
+
return None
|
|
35
|
+
word = line_text[start:end]
|
|
36
|
+
word_range = LspRange(LspPosition(line, start), LspPosition(line, end))
|
|
37
|
+
return word, word_range
|
|
38
|
+
|
|
39
|
+
def document_symbols(self, file_path: str | Path) -> list[LspSymbol]:
|
|
40
|
+
"""Extract symbol outline using AST (for Python) or regex (for others)."""
|
|
41
|
+
path = Path(file_path).resolve()
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
return []
|
|
44
|
+
try:
|
|
45
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
46
|
+
except OSError:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
uri = path_to_uri(path)
|
|
50
|
+
if path.suffix.lower() in {".py", ".pyi"}:
|
|
51
|
+
return self._document_symbols_python(content, uri, str(path))
|
|
52
|
+
return self._document_symbols_regex(content, uri, str(path))
|
|
53
|
+
|
|
54
|
+
def _document_symbols_python(self, content: str, uri: str, file_path: str) -> list[LspSymbol]:
|
|
55
|
+
try:
|
|
56
|
+
tree = ast.parse(content)
|
|
57
|
+
except SyntaxError:
|
|
58
|
+
return self._document_symbols_regex(content, uri, file_path)
|
|
59
|
+
|
|
60
|
+
symbols: list[LspSymbol] = []
|
|
61
|
+
|
|
62
|
+
def visit_node(node: ast.AST, parent_kind: int | None = None) -> None:
|
|
63
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
64
|
+
kind = 6 if parent_kind == 5 else 12
|
|
65
|
+
kind_name = "Method" if kind == 6 else "Function"
|
|
66
|
+
start_line = max(0, getattr(node, "lineno", 1) - 1)
|
|
67
|
+
start_char = getattr(node, "col_offset", 0)
|
|
68
|
+
end_line = max(start_line, getattr(node, "end_lineno", start_line + 1) - 1)
|
|
69
|
+
end_char = getattr(node, "end_col_offset", start_char + len(node.name))
|
|
70
|
+
loc = LspLocation(
|
|
71
|
+
uri=uri,
|
|
72
|
+
range=LspRange(LspPosition(start_line, start_char), LspPosition(end_line, end_char)),
|
|
73
|
+
file_path=file_path,
|
|
74
|
+
)
|
|
75
|
+
symbols.append(LspSymbol(name=node.name, kind=kind, kind_name=kind_name, location=loc))
|
|
76
|
+
for child in node.body:
|
|
77
|
+
visit_node(child, parent_kind=kind)
|
|
78
|
+
elif isinstance(node, ast.ClassDef):
|
|
79
|
+
kind = 5
|
|
80
|
+
kind_name = "Class"
|
|
81
|
+
start_line = max(0, getattr(node, "lineno", 1) - 1)
|
|
82
|
+
start_char = getattr(node, "col_offset", 0)
|
|
83
|
+
end_line = max(start_line, getattr(node, "end_lineno", start_line + 1) - 1)
|
|
84
|
+
end_char = getattr(node, "end_col_offset", start_char + len(node.name))
|
|
85
|
+
loc = LspLocation(
|
|
86
|
+
uri=uri,
|
|
87
|
+
range=LspRange(LspPosition(start_line, start_char), LspPosition(end_line, end_char)),
|
|
88
|
+
file_path=file_path,
|
|
89
|
+
)
|
|
90
|
+
symbols.append(LspSymbol(name=node.name, kind=kind, kind_name=kind_name, location=loc))
|
|
91
|
+
for child in node.body:
|
|
92
|
+
visit_node(child, parent_kind=kind)
|
|
93
|
+
elif isinstance(node, ast.Assign):
|
|
94
|
+
for target in node.targets:
|
|
95
|
+
if isinstance(target, ast.Name):
|
|
96
|
+
name = target.id
|
|
97
|
+
is_const = name.isupper()
|
|
98
|
+
kind = 14 if is_const else 13
|
|
99
|
+
kind_name = "Constant" if is_const else "Variable"
|
|
100
|
+
start_line = max(0, getattr(target, "lineno", 1) - 1)
|
|
101
|
+
start_char = getattr(target, "col_offset", 0)
|
|
102
|
+
end_char = start_char + len(name)
|
|
103
|
+
loc = LspLocation(
|
|
104
|
+
uri=uri,
|
|
105
|
+
range=LspRange(LspPosition(start_line, start_char), LspPosition(start_line, end_char)),
|
|
106
|
+
file_path=file_path,
|
|
107
|
+
)
|
|
108
|
+
symbols.append(LspSymbol(name=name, kind=kind, kind_name=kind_name, location=loc))
|
|
109
|
+
|
|
110
|
+
for item in tree.body:
|
|
111
|
+
visit_node(item)
|
|
112
|
+
return symbols
|
|
113
|
+
|
|
114
|
+
def _document_symbols_regex(self, content: str, uri: str, file_path: str) -> list[LspSymbol]:
|
|
115
|
+
symbols: list[LspSymbol] = []
|
|
116
|
+
patterns = [
|
|
117
|
+
(re.compile(r"^\s*(?:async\s+)?def\s+([a-zA-Z_][a-zA-Z0-9_]*)"), 12, "Function"),
|
|
118
|
+
(re.compile(r"^\s*class\s+([a-zA-Z_][a-zA-Z0-9_]*)"), 5, "Class"),
|
|
119
|
+
(re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_][a-zA-Z0-9_]*)"), 12, "Function"),
|
|
120
|
+
(re.compile(r"^\s*(?:export\s+)?(?:class|interface|type)\s+([a-zA-Z_][a-zA-Z0-9_]*)"), 5, "Class"),
|
|
121
|
+
(re.compile(r"^\s*(?:pub\s+)?fn\s+([a-zA-Z_][a-zA-Z0-9_]*)"), 12, "Function"),
|
|
122
|
+
(re.compile(r"^\s*(?:pub\s+)?(?:struct|enum|trait)\s+([a-zA-Z_][a-zA-Z0-9_]*)"), 23, "Struct"),
|
|
123
|
+
(re.compile(r"^\s*func\s+(?:\([^)]+\)\s+)?([a-zA-Z_][a-zA-Z0-9_]*)"), 12, "Function"),
|
|
124
|
+
(re.compile(r"^\s*type\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+struct"), 23, "Struct"),
|
|
125
|
+
]
|
|
126
|
+
for line_num, line_text in enumerate(content.splitlines()):
|
|
127
|
+
for pat, kind, kind_name in patterns:
|
|
128
|
+
m = pat.match(line_text)
|
|
129
|
+
if m:
|
|
130
|
+
name = m.group(1)
|
|
131
|
+
start_char = line_text.find(name)
|
|
132
|
+
end_char = start_char + len(name)
|
|
133
|
+
loc = LspLocation(
|
|
134
|
+
uri=uri,
|
|
135
|
+
range=LspRange(LspPosition(line_num, start_char), LspPosition(line_num, end_char)),
|
|
136
|
+
file_path=file_path,
|
|
137
|
+
)
|
|
138
|
+
symbols.append(LspSymbol(name=name, kind=kind, kind_name=kind_name, location=loc))
|
|
139
|
+
break
|
|
140
|
+
return symbols
|
|
141
|
+
|
|
142
|
+
def go_to_definition(
|
|
143
|
+
self,
|
|
144
|
+
file_path: str | Path,
|
|
145
|
+
line: int,
|
|
146
|
+
character: int,
|
|
147
|
+
workspace_root: str | Path | None = None,
|
|
148
|
+
) -> list[LspLocation]:
|
|
149
|
+
"""Navigate to symbol definition in file or workspace."""
|
|
150
|
+
path = Path(file_path).resolve()
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
return []
|
|
153
|
+
try:
|
|
154
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
155
|
+
except OSError:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
word_info = self._get_word_at_pos(content, line, character)
|
|
159
|
+
if not word_info:
|
|
160
|
+
return []
|
|
161
|
+
word, _ = word_info
|
|
162
|
+
|
|
163
|
+
# 1. Search in current file first
|
|
164
|
+
current_symbols = self.document_symbols(path)
|
|
165
|
+
for sym in current_symbols:
|
|
166
|
+
if sym.name == word:
|
|
167
|
+
return [sym.location]
|
|
168
|
+
|
|
169
|
+
# 2. Search in workspace files
|
|
170
|
+
root = Path(workspace_root).resolve() if workspace_root else path.parent
|
|
171
|
+
locations: list[LspLocation] = []
|
|
172
|
+
for file in root.rglob(f"*{path.suffix}"):
|
|
173
|
+
if file == path or not file.is_file():
|
|
174
|
+
continue
|
|
175
|
+
if any(part.startswith(".") or part in {"node_modules", "__pycache__", "venv", ".venv"} for part in file.parts):
|
|
176
|
+
continue
|
|
177
|
+
symbols = self.document_symbols(file)
|
|
178
|
+
for sym in symbols:
|
|
179
|
+
if sym.name == word:
|
|
180
|
+
locations.append(sym.location)
|
|
181
|
+
if len(locations) >= 5:
|
|
182
|
+
return locations
|
|
183
|
+
return locations
|
|
184
|
+
|
|
185
|
+
def find_references(
|
|
186
|
+
self,
|
|
187
|
+
file_path: str | Path,
|
|
188
|
+
line: int,
|
|
189
|
+
character: int,
|
|
190
|
+
workspace_root: str | Path | None = None,
|
|
191
|
+
include_declaration: bool = True,
|
|
192
|
+
) -> list[LspLocation]:
|
|
193
|
+
"""Find occurrences and references of the symbol across workspace."""
|
|
194
|
+
path = Path(file_path).resolve()
|
|
195
|
+
if not path.is_file():
|
|
196
|
+
return []
|
|
197
|
+
try:
|
|
198
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
199
|
+
except OSError:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
word_info = self._get_word_at_pos(content, line, character)
|
|
203
|
+
if not word_info:
|
|
204
|
+
return []
|
|
205
|
+
word, _ = word_info
|
|
206
|
+
|
|
207
|
+
root = Path(workspace_root).resolve() if workspace_root else path.parent
|
|
208
|
+
target_files = [path]
|
|
209
|
+
for f in root.rglob(f"*{path.suffix}"):
|
|
210
|
+
if f != path and f.is_file() and not any(part.startswith(".") or part in {"node_modules", "__pycache__", "venv", ".venv"} for part in f.parts):
|
|
211
|
+
target_files.append(f)
|
|
212
|
+
|
|
213
|
+
references: list[LspLocation] = []
|
|
214
|
+
word_regex = re.compile(rf"\b{re.escape(word)}\b")
|
|
215
|
+
|
|
216
|
+
for f in target_files:
|
|
217
|
+
try:
|
|
218
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
219
|
+
except OSError:
|
|
220
|
+
continue
|
|
221
|
+
file_uri = path_to_uri(f)
|
|
222
|
+
for line_idx, line_text in enumerate(text.splitlines()):
|
|
223
|
+
for match in word_regex.finditer(line_text):
|
|
224
|
+
col_start = match.start()
|
|
225
|
+
col_end = match.end()
|
|
226
|
+
loc = LspLocation(
|
|
227
|
+
uri=file_uri,
|
|
228
|
+
range=LspRange(LspPosition(line_idx, col_start), LspPosition(line_idx, col_end)),
|
|
229
|
+
file_path=str(f),
|
|
230
|
+
)
|
|
231
|
+
references.append(loc)
|
|
232
|
+
if len(references) >= 100:
|
|
233
|
+
return references
|
|
234
|
+
return references
|
|
235
|
+
|
|
236
|
+
def hover(
|
|
237
|
+
self,
|
|
238
|
+
file_path: str | Path,
|
|
239
|
+
line: int,
|
|
240
|
+
character: int,
|
|
241
|
+
workspace_root: str | Path | None = None,
|
|
242
|
+
) -> LspHoverResult | None:
|
|
243
|
+
"""Extract signature or docstring preview for the symbol under cursor."""
|
|
244
|
+
path = Path(file_path).resolve()
|
|
245
|
+
if not path.is_file():
|
|
246
|
+
return None
|
|
247
|
+
try:
|
|
248
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
249
|
+
except OSError:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
word_info = self._get_word_at_pos(content, line, character)
|
|
253
|
+
if not word_info:
|
|
254
|
+
return None
|
|
255
|
+
word, word_range = word_info
|
|
256
|
+
|
|
257
|
+
# Check Python AST
|
|
258
|
+
if path.suffix.lower() in {".py", ".pyi"}:
|
|
259
|
+
try:
|
|
260
|
+
tree = ast.parse(content)
|
|
261
|
+
for node in ast.walk(tree):
|
|
262
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == word:
|
|
263
|
+
args = [a.arg for a in node.args.args]
|
|
264
|
+
sig = f"def {node.name}({', '.join(args)})"
|
|
265
|
+
doc = ast.get_docstring(node) or ""
|
|
266
|
+
body = f"```python\n{sig}\n```"
|
|
267
|
+
if doc:
|
|
268
|
+
body += f"\n\n{doc}"
|
|
269
|
+
return LspHoverResult(contents=body, range=word_range)
|
|
270
|
+
elif isinstance(node, ast.ClassDef) and node.name == word:
|
|
271
|
+
bases = [getattr(b, "id", "object") for b in node.bases if hasattr(b, "id")]
|
|
272
|
+
sig = f"class {node.name}({', '.join(bases)})" if bases else f"class {node.name}"
|
|
273
|
+
doc = ast.get_docstring(node) or ""
|
|
274
|
+
body = f"```python\n{sig}\n```"
|
|
275
|
+
if doc:
|
|
276
|
+
body += f"\n\n{doc}"
|
|
277
|
+
return LspHoverResult(contents=body, range=word_range)
|
|
278
|
+
except SyntaxError:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
# Regex fallback hover
|
|
282
|
+
lines = content.splitlines()
|
|
283
|
+
for idx, line_text in enumerate(lines):
|
|
284
|
+
if re.search(rf"\b(?:def|class|function|fn|func|struct)\s+{re.escape(word)}\b", line_text):
|
|
285
|
+
doc_lines = []
|
|
286
|
+
for prev_idx in range(idx - 1, max(-1, idx - 6), -1):
|
|
287
|
+
prev = lines[prev_idx].strip()
|
|
288
|
+
if prev.startswith(("#", "//", "/*", "*")):
|
|
289
|
+
doc_lines.insert(0, prev)
|
|
290
|
+
else:
|
|
291
|
+
break
|
|
292
|
+
body = f"```\n{line_text.strip()}\n```"
|
|
293
|
+
if doc_lines:
|
|
294
|
+
body += f"\n\n" + "\n".join(doc_lines)
|
|
295
|
+
return LspHoverResult(contents=body, range=word_range)
|
|
296
|
+
|
|
297
|
+
return LspHoverResult(contents=f"Symbol: `{word}`", range=word_range)
|