chad-code 0.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.
chad/lsp.py ADDED
@@ -0,0 +1,323 @@
1
+ """Precise cross-file code intelligence via an in-process language server.
2
+
3
+ Phase 2 of the symbolic stack. Tree-sitter (`repomap.py`) gives fast, language-
4
+ agnostic STRUCTURE; this adds semantic PRECISION that name-matching cannot:
5
+ true go-to-definition resolution and cross-file find-all-references that follow
6
+ imports, respect scoping, and don't confuse a method with an unrelated function of
7
+ the same name.
8
+
9
+ We drive `solidlsp` (Serena's synchronous LSP core) directly — no MCP server, no
10
+ Serena agent. For Python it spawns pyright on a worker thread (≈0.3s warm; a
11
+ one-time pyright download on a cold machine). The server is started LAZILY on first
12
+ use (so sessions that never ask for references pay nothing), bound to one project
13
+ root, and reaped on rebind / process exit.
14
+
15
+ Everything degrades gracefully: if the language server can't start (no Node,
16
+ offline, unsupported language) callers fall back to the tree-sitter backend. The
17
+ LSP is a precision upgrade, never a hard dependency of the harness.
18
+
19
+ Operational guardrails (all measured on an 11k-file repo before they existed):
20
+ * every request carries a timeout (default 5s; one uncapped find-references ran 18s),
21
+ * C/C++ needs a compile database before clangd is started at all — without one
22
+ clangd returns clean *empty* results and "[no references]" would be a lie,
23
+ * a server whose process tree outgrows CHAD_LSP_MAX_RSS_MB is recycled after the
24
+ request (pyright hit 4 GB on hot symbols — on a machine wiring ~12 GB of model
25
+ weights that's the same Metal-OOM crash class the repo map fix removed), and
26
+ * decorative callers (disambiguation hints) use `references_decorative`, which
27
+ never *starts* a server except the proven-cheap Python backend.
28
+ """
29
+
30
+ import atexit
31
+ import os
32
+ import subprocess
33
+ import threading
34
+ from collections import defaultdict
35
+
36
+ from . import config
37
+ from .diag import log
38
+
39
+ # solidlsp pulls a fair bit of machinery; import lazily inside _server() so a normal
40
+ # session / cold start doesn't pay for it unless references are actually requested.
41
+ # It ships in the optional `lsp` extra (serena-agent) — absent, we log once and every
42
+ # caller degrades to the tree-sitter backend.
43
+
44
+ _MISSING_LOGGED = False
45
+
46
+ _IGNORED = ["__pycache__", ".git", ".venv", "venv", "node_modules", ".mypy_cache",
47
+ ".pytest_cache", "dist", "build", ".cache", "models"]
48
+
49
+ # Extension -> solidlsp Language enum name. Python is the proven backend (pyright);
50
+ # the others light up automatically once their language server is available.
51
+ _LANG_BY_EXT = {
52
+ ".py": "PYTHON", ".pyi": "PYTHON",
53
+ ".ts": "TYPESCRIPT", ".tsx": "TYPESCRIPT", ".js": "TYPESCRIPT", ".jsx": "TYPESCRIPT",
54
+ ".go": "GO", ".rs": "RUST", ".java": "JAVA", ".rb": "RUBY",
55
+ ".cs": "CSHARP", ".php": "PHP", ".kt": "KOTLIN", ".cpp": "CPP", ".cc": "CPP",
56
+ ".c": "CPP", ".h": "CPP", ".hpp": "CPP",
57
+ }
58
+
59
+
60
+ def lang_for(path: str):
61
+ return _LANG_BY_EXT.get(os.path.splitext(path)[1].lower())
62
+
63
+
64
+ _REQUEST_TIMEOUT_S = config.env_float("CHAD_LSP_TIMEOUT", 5.0)
65
+ _MAX_RSS_MB = config.env_int("CHAD_LSP_MAX_RSS_MB", 1536)
66
+
67
+ # Languages worth a server *start* for a decorative annotation. Python's pyright is
68
+ # proven lazy and cheap (~0.9s spawn, flat across repo sizes); other servers can
69
+ # gradle-sync, download binaries, or chew a file for ~10s (measured: clangd), which
70
+ # is never worth a "used by …" hint.
71
+ _DECOR_SPAWN = frozenset({"PYTHON"})
72
+
73
+
74
+ def _has_compile_db(root: str) -> bool:
75
+ """clangd is only precise with a compilation database; without one it silently
76
+ analyzes each file in isolation and returns clean empty cross-file results."""
77
+ return any(os.path.exists(os.path.join(root, p))
78
+ for p in ("compile_commands.json",
79
+ os.path.join("build", "compile_commands.json"),
80
+ ".clangd"))
81
+
82
+
83
+ _QUIET_HOOK_INSTALLED = False
84
+
85
+
86
+ def _install_quiet_shutdown_hook():
87
+ """solidlsp sends the LSP shutdown request from a helper thread; when the server
88
+ is busy (typically mid-request after we timed one out) that request itself times
89
+ out and the default threading excepthook dumps a full traceback over the TUI.
90
+ Filter exactly that case — solidlsp still reaps the process — and pass every
91
+ other thread exception through untouched."""
92
+ global _QUIET_HOOK_INSTALLED
93
+ if _QUIET_HOOK_INSTALLED:
94
+ return
95
+ _QUIET_HOOK_INSTALLED = True
96
+ prev = threading.excepthook
97
+
98
+ def hook(args):
99
+ if (args.exc_type is TimeoutError
100
+ and "_send_shutdown" in getattr(args.thread, "name", "")):
101
+ log.info("lsp: shutdown request timed out (server busy); reaped anyway")
102
+ return
103
+ prev(args)
104
+
105
+ threading.excepthook = hook
106
+
107
+
108
+ def _tree_rss_mb(pid: int) -> int:
109
+ """Resident set of `pid` plus all descendants, in MB (pyright-langserver forks
110
+ node children; the parent alone under-reports)."""
111
+ try:
112
+ out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss="],
113
+ capture_output=True, text=True, timeout=5).stdout
114
+ except Exception: # noqa: BLE001 - a failed sample must never break a request
115
+ return 0
116
+ kids, rss = defaultdict(list), {}
117
+ for ln in out.splitlines():
118
+ try:
119
+ p, pp, r = ln.split()
120
+ kids[int(pp)].append(int(p))
121
+ rss[int(p)] = int(r)
122
+ except ValueError:
123
+ continue
124
+ total, stack, seen = 0, [pid], set()
125
+ while stack:
126
+ cur = stack.pop()
127
+ if cur in seen:
128
+ continue
129
+ seen.add(cur)
130
+ total += rss.get(cur, 0)
131
+ stack.extend(kids.get(cur, ()))
132
+ return total // 1024
133
+
134
+
135
+ class LspService:
136
+ """One language server per (root, language), started lazily, reaped on exit."""
137
+
138
+ def __init__(self, root: str = "."):
139
+ self.root = os.path.abspath(root)
140
+ self._servers = {} # lang -> SolidLanguageServer | None (None = tried & failed)
141
+ self._lock = threading.Lock()
142
+
143
+ def _server(self, lang: str):
144
+ """Lazily create + start the language server for `lang`; None if unavailable.
145
+ A failed start is remembered (cached None) so we don't retry on every call."""
146
+ if lang in self._servers:
147
+ return self._servers[lang]
148
+ with self._lock:
149
+ if lang in self._servers:
150
+ return self._servers[lang]
151
+ if lang == "CPP" and not _has_compile_db(self.root):
152
+ # Without a compile database clangd "works" but sees each file in
153
+ # isolation: cross-file references come back a clean, WRONG empty.
154
+ # Refusing to start keeps callers on the tree-sitter fallback, which
155
+ # labels itself NAME-MATCH ONLY instead of lying with confidence.
156
+ log.info("lsp: no compile_commands.json under %s; not starting clangd "
157
+ "(tree-sitter fallback)", self.root)
158
+ self._servers[lang] = None
159
+ return None
160
+ srv = None
161
+ try:
162
+ from solidlsp import SolidLanguageServer
163
+ from solidlsp.ls_config import Language, LanguageServerConfig
164
+ from solidlsp.settings import SolidLSPSettings
165
+ except ImportError:
166
+ global _MISSING_LOGGED
167
+ if not _MISSING_LOGGED:
168
+ _MISSING_LOGGED = True
169
+ log.info("lsp: solidlsp not installed (install the 'lsp' extra for "
170
+ "precise refs/rename); using tree-sitter fallback")
171
+ self._servers[lang] = None
172
+ return None
173
+ try:
174
+ _install_quiet_shutdown_hook()
175
+ cfg = LanguageServerConfig(code_language=Language[lang],
176
+ ignored_paths=list(_IGNORED))
177
+ srv = SolidLanguageServer.create(cfg, self.root,
178
+ solidlsp_settings=SolidLSPSettings())
179
+ srv.start()
180
+ # A hung/slow request must degrade to the tree-sitter fallback, not
181
+ # stall the agent loop (measured 18s for one uncapped find-references).
182
+ try:
183
+ srv.set_request_timeout(_REQUEST_TIMEOUT_S)
184
+ except Exception: # noqa: BLE001 - older solidlsp without the knob
185
+ pass
186
+ except Exception:
187
+ srv = None
188
+ self._servers[lang] = srv
189
+ return srv
190
+
191
+ def _recycle_if_bloated(self, lang: str, srv):
192
+ """Stop and forget a server whose process tree outgrew the RSS cap. The next
193
+ request simply starts a fresh one (pyright re-warms in ~2s) — unbounded
194
+ analysis memory next to wired model weights is how the process dies."""
195
+ pid = getattr(getattr(getattr(srv, "server", None), "process", None), "pid", None)
196
+ if not pid:
197
+ return
198
+ mb = _tree_rss_mb(pid)
199
+ if mb > _MAX_RSS_MB:
200
+ log.info("lsp: %s server tree at %d MB (cap %d); recycling", lang, mb, _MAX_RSS_MB)
201
+ try:
202
+ srv.stop()
203
+ except Exception: # noqa: BLE001 - reaping is best-effort
204
+ pass
205
+ with self._lock:
206
+ if self._servers.get(lang) is srv:
207
+ del self._servers[lang]
208
+
209
+ def stop(self):
210
+ for srv in self._servers.values():
211
+ if srv is not None:
212
+ try:
213
+ srv.stop()
214
+ except Exception:
215
+ pass
216
+ self._servers.clear()
217
+
218
+ def available(self, path: str) -> bool:
219
+ lang = lang_for(path)
220
+ return bool(lang) and self._server(lang) is not None
221
+
222
+ def references(self, rel_path: str, name_row: int, name_col: int, timeout=None):
223
+ """Precise references to the symbol whose identifier sits at the 0-based
224
+ (name_row, name_col) in rel_path. Returns a list of (rel, line1, col1) or
225
+ None if the language server is unavailable / errored / timed out (caller
226
+ should fall back to tree-sitter). An empty list means "ran, found none".
227
+ `timeout` tightens the request deadline below the default for callers with
228
+ their own budget (the disambiguation pass); never loosens it."""
229
+ lang = lang_for(rel_path)
230
+ if not lang:
231
+ return None
232
+ srv = self._server(lang)
233
+ if srv is None:
234
+ return None
235
+ set_to = getattr(srv, "set_request_timeout", None)
236
+ if timeout is not None and set_to is not None:
237
+ try:
238
+ set_to(min(timeout, _REQUEST_TIMEOUT_S))
239
+ except Exception: # noqa: BLE001 - keep the default timeout
240
+ set_to = None
241
+ try:
242
+ locs = srv.request_references(rel_path, name_row, name_col)
243
+ except Exception:
244
+ return None
245
+ finally:
246
+ if timeout is not None and set_to is not None:
247
+ try:
248
+ set_to(_REQUEST_TIMEOUT_S)
249
+ except Exception: # noqa: BLE001
250
+ pass
251
+ self._recycle_if_bloated(lang, srv)
252
+ out = []
253
+ for loc in locs or []:
254
+ rel = loc.get("relativePath")
255
+ if not rel:
256
+ uri = loc.get("uri", "")
257
+ rel = self._rel(uri[7:]) if uri.startswith("file://") else uri
258
+ start = loc.get("range", {}).get("start", {})
259
+ out.append((rel, start.get("line", 0) + 1, start.get("character", 0) + 1))
260
+ return out
261
+
262
+ def references_decorative(self, rel_path: str, name_row: int, name_col: int,
263
+ timeout=None):
264
+ """`references()` for decoration (the disambiguation "used by …" hints):
265
+ never pays a server *start* for it unless the language is the proven-cheap
266
+ Python backend — other languages are used only if already running. Measured
267
+ before this existed: one view_symbol('main') on a mixed repo spawned clangd
268
+ per C++ candidate and took 86s."""
269
+ lang = lang_for(rel_path)
270
+ if not lang:
271
+ return None
272
+ if lang not in _DECOR_SPAWN and self._servers.get(lang) is None:
273
+ return None # absent (never started) and tried-but-failed both refuse
274
+ return self.references(rel_path, name_row, name_col, timeout=timeout)
275
+
276
+ def definition(self, rel_path: str, row: int, col: int):
277
+ """Precise go-to-definition for the symbol at 0-based (row, col). Returns a
278
+ list of (rel, line1) or None if unavailable."""
279
+ lang = lang_for(rel_path)
280
+ if not lang:
281
+ return None
282
+ srv = self._server(lang)
283
+ if srv is None:
284
+ return None
285
+ try:
286
+ locs = srv.request_definition(rel_path, row, col)
287
+ except Exception:
288
+ return None
289
+ finally:
290
+ self._recycle_if_bloated(lang, srv)
291
+ out = []
292
+ for loc in locs or []:
293
+ rel = loc.get("relativePath") or loc.get("uri", "")
294
+ start = loc.get("range", {}).get("start", {})
295
+ out.append((rel, start.get("line", 0) + 1))
296
+ return out
297
+
298
+ def _rel(self, p):
299
+ try:
300
+ return os.path.relpath(p, self.root)
301
+ except ValueError:
302
+ return p
303
+
304
+
305
+ _SERVICE = None
306
+
307
+
308
+ def service() -> LspService:
309
+ """Lazily-bound, cwd-rooted service. Rebinding to a new project root (e.g. each
310
+ eval task) stops the previous language server so pyright processes don't leak."""
311
+ global _SERVICE
312
+ cwd = os.path.abspath(os.getcwd())
313
+ if _SERVICE is None or _SERVICE.root != cwd:
314
+ if _SERVICE is not None:
315
+ _SERVICE.stop()
316
+ _SERVICE = LspService(cwd)
317
+ return _SERVICE
318
+
319
+
320
+ @atexit.register
321
+ def _shutdown():
322
+ if _SERVICE is not None:
323
+ _SERVICE.stop()