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.
Files changed (101) hide show
  1. local_coding_agent/__init__.py +314 -0
  2. local_coding_agent/__main__.py +5 -0
  3. local_coding_agent/acp_server/__init__.py +38 -0
  4. local_coding_agent/acp_server/_codec.py +95 -0
  5. local_coding_agent/acp_server/_protocol.py +13 -0
  6. local_coding_agent/acp_server/_server.py +663 -0
  7. local_coding_agent/acp_server/_session.py +24 -0
  8. local_coding_agent/acp_server/_tools.py +126 -0
  9. local_coding_agent/ast_compactor.py +130 -0
  10. local_coding_agent/atomizer.py +77 -0
  11. local_coding_agent/benchmark/__init__.py +57 -0
  12. local_coding_agent/benchmark/_cases.py +307 -0
  13. local_coding_agent/benchmark/_judge.py +175 -0
  14. local_coding_agent/benchmark/_oracles.py +157 -0
  15. local_coding_agent/benchmark/_runner.py +259 -0
  16. local_coding_agent/benchmark/_stats.py +22 -0
  17. local_coding_agent/benchmark/_summary.py +143 -0
  18. local_coding_agent/benchmark_oracle_worker.py +169 -0
  19. local_coding_agent/calibration.py +154 -0
  20. local_coding_agent/capability.py +450 -0
  21. local_coding_agent/chat_compaction.py +276 -0
  22. local_coding_agent/cli/__init__.py +12 -0
  23. local_coding_agent/cli/_dispatch.py +377 -0
  24. local_coding_agent/cli/_handlers.py +320 -0
  25. local_coding_agent/cli/_handlers2.py +725 -0
  26. local_coding_agent/cli/_input.py +61 -0
  27. local_coding_agent/cli/_main.py +159 -0
  28. local_coding_agent/cli/_parser.py +327 -0
  29. local_coding_agent/context_manager.py +155 -0
  30. local_coding_agent/controller/__init__.py +18 -0
  31. local_coding_agent/controller/_constants.py +108 -0
  32. local_coding_agent/controller/_controller.py +1008 -0
  33. local_coding_agent/controller/_post_apply.py +216 -0
  34. local_coding_agent/delegator.py +180 -0
  35. local_coding_agent/desktop/__init__.py +8 -0
  36. local_coding_agent/desktop/app.py +100 -0
  37. local_coding_agent/desktop/assets/lucide.min.js +12 -0
  38. local_coding_agent/desktop/assets/tailwind.css +1 -0
  39. local_coding_agent/desktop/client_js.py +1271 -0
  40. local_coding_agent/desktop/components.py +555 -0
  41. local_coding_agent/desktop/server/__init__.py +37 -0
  42. local_coding_agent/desktop/server/_handlers.py +2178 -0
  43. local_coding_agent/desktop/server/_models.py +204 -0
  44. local_coding_agent/desktop/server/_server.py +347 -0
  45. local_coding_agent/desktop/server/_telemetry.py +87 -0
  46. local_coding_agent/desktop/styles.py +76 -0
  47. local_coding_agent/desktop/ui.py +53 -0
  48. local_coding_agent/doctor.py +413 -0
  49. local_coding_agent/hooks/__init__.py +19 -0
  50. local_coding_agent/hooks/_adapters.py +458 -0
  51. local_coding_agent/hooks/_bridge.py +346 -0
  52. local_coding_agent/hooks/_decision.py +26 -0
  53. local_coding_agent/lsp/__init__.py +60 -0
  54. local_coding_agent/lsp/_client.py +322 -0
  55. local_coding_agent/lsp/_fallback.py +297 -0
  56. local_coding_agent/lsp/_manager.py +173 -0
  57. local_coding_agent/lsp/_mock.py +200 -0
  58. local_coding_agent/lsp/_types.py +398 -0
  59. local_coding_agent/mcp_config.py +398 -0
  60. local_coding_agent/mcp_server.py +377 -0
  61. local_coding_agent/memory.py +140 -0
  62. local_coding_agent/mode_router.py +200 -0
  63. local_coding_agent/model_scanner.py +525 -0
  64. local_coding_agent/monitor.py +635 -0
  65. local_coding_agent/observation_policy.py +184 -0
  66. local_coding_agent/ollama_adapter.py +736 -0
  67. local_coding_agent/plan_mode.py +653 -0
  68. local_coding_agent/prescriptions.py +278 -0
  69. local_coding_agent/profiles.py +239 -0
  70. local_coding_agent/repository_tools.py +782 -0
  71. local_coding_agent/ripgrep.py +326 -0
  72. local_coding_agent/semantic_linter.py +122 -0
  73. local_coding_agent/service.py +405 -0
  74. local_coding_agent/session_events.py +684 -0
  75. local_coding_agent/session_query.py +578 -0
  76. local_coding_agent/skill_config.py +426 -0
  77. local_coding_agent/smoke.py +268 -0
  78. local_coding_agent/speculative_racing.py +85 -0
  79. local_coding_agent/spill.py +258 -0
  80. local_coding_agent/stats.py +255 -0
  81. local_coding_agent/stdio.py +132 -0
  82. local_coding_agent/subagent.py +516 -0
  83. local_coding_agent/task.py +90 -0
  84. local_coding_agent/task_store.py +142 -0
  85. local_coding_agent/tasks.py +240 -0
  86. local_coding_agent/terminal/__init__.py +35 -0
  87. local_coding_agent/terminal/_manager.py +156 -0
  88. local_coding_agent/terminal/_process.py +148 -0
  89. local_coding_agent/terminal/_session.py +452 -0
  90. local_coding_agent/terminal/_tools.py +233 -0
  91. local_coding_agent/terminal/_types.py +60 -0
  92. local_coding_agent/tool_call_parser.py +178 -0
  93. local_coding_agent/validators.py +563 -0
  94. local_coding_agent/vram_fit.py +240 -0
  95. local_coding_agent/worker_pool.py +516 -0
  96. local_coding_agent-1.1.0.dist-info/METADATA +202 -0
  97. local_coding_agent-1.1.0.dist-info/RECORD +101 -0
  98. local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
  99. local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
  100. local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
  101. 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)