cortexcode 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.
@@ -0,0 +1,315 @@
1
+ """LSP Server — Language Server Protocol support for CortexCode.
2
+
3
+ Provides hover, go-to-definition, and document symbols via LSP,
4
+ so any LSP-compatible editor can use CortexCode index data.
5
+
6
+ Usage:
7
+ cortexcode lsp # Start LSP server on stdin/stdout
8
+ """
9
+
10
+ import json
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ def _read_message(stream) -> dict | None:
18
+ """Read an LSP message from stream (Content-Length header + JSON body)."""
19
+ headers = {}
20
+ while True:
21
+ line = stream.readline()
22
+ if not line:
23
+ return None
24
+ line = line.strip()
25
+ if not line:
26
+ break
27
+ if ":" in line:
28
+ key, value = line.split(":", 1)
29
+ headers[key.strip().lower()] = value.strip()
30
+
31
+ content_length = int(headers.get("content-length", 0))
32
+ if content_length == 0:
33
+ return None
34
+
35
+ body = stream.read(content_length)
36
+ return json.loads(body)
37
+
38
+
39
+ def _write_message(stream, msg: dict) -> None:
40
+ """Write an LSP message to stream."""
41
+ body = json.dumps(msg)
42
+ header = f"Content-Length: {len(body)}\r\n\r\n"
43
+ stream.write(header + body)
44
+ stream.flush()
45
+
46
+
47
+ def _make_response(req_id: Any, result: Any) -> dict:
48
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
49
+
50
+
51
+ class CortexCodeLSP:
52
+ """Minimal LSP server backed by CortexCode index."""
53
+
54
+ def __init__(self):
55
+ self.index: dict | None = None
56
+ self.index_path: Path | None = None
57
+ self.root_path: str = ""
58
+ self._symbol_cache: dict[str, list[dict]] = {} # file -> symbols
59
+
60
+ def _load_index(self):
61
+ """Find and load the CortexCode index."""
62
+ if self.root_path:
63
+ candidate = Path(self.root_path) / ".cortexcode" / "index.json"
64
+ if candidate.exists():
65
+ self.index_path = candidate
66
+ try:
67
+ self.index = json.loads(candidate.read_text(encoding="utf-8"))
68
+ self._build_symbol_cache()
69
+ except (json.JSONDecodeError, OSError):
70
+ self.index = None
71
+
72
+ def _build_symbol_cache(self):
73
+ """Build a lookup from file path to symbols."""
74
+ self._symbol_cache = {}
75
+ if not self.index:
76
+ return
77
+ root = self.index.get("project_root", "")
78
+ for rel_path, file_data in self.index.get("files", {}).items():
79
+ if not isinstance(file_data, dict):
80
+ continue
81
+ # Store by both relative and absolute path
82
+ abs_path = str(Path(root) / rel_path).replace("\\", "/")
83
+ self._symbol_cache[abs_path] = file_data.get("symbols", [])
84
+ self._symbol_cache[rel_path.replace("\\", "/")] = file_data.get("symbols", [])
85
+
86
+ def _find_symbol_at(self, uri: str, line: int, col: int) -> dict | None:
87
+ """Find a symbol at a given position."""
88
+ file_path = _uri_to_path(uri)
89
+
90
+ # Try to read the word at position from the file
91
+ word = self._get_word_at_position(file_path, line, col)
92
+ if not word:
93
+ return None
94
+
95
+ # Search all files for this symbol
96
+ if self.index:
97
+ for rel_path, file_data in self.index.get("files", {}).items():
98
+ if not isinstance(file_data, dict):
99
+ continue
100
+ for sym in file_data.get("symbols", []):
101
+ if sym.get("name") == word:
102
+ return {**sym, "file": rel_path}
103
+ # Check methods
104
+ for m in sym.get("methods", []):
105
+ if m.get("name") == word:
106
+ return {**m, "file": rel_path}
107
+ return None
108
+
109
+ def _get_word_at_position(self, file_path: str, line: int, col: int) -> str | None:
110
+ """Extract the word under the cursor."""
111
+ try:
112
+ path = Path(file_path)
113
+ if not path.exists():
114
+ return None
115
+ lines = path.read_text(encoding="utf-8", errors="ignore").split("\n")
116
+ if line >= len(lines):
117
+ return None
118
+ text = lines[line]
119
+ # Find word boundaries
120
+ start = col
121
+ while start > 0 and (text[start - 1].isalnum() or text[start - 1] == "_"):
122
+ start -= 1
123
+ end = col
124
+ while end < len(text) and (text[end].isalnum() or text[end] == "_"):
125
+ end += 1
126
+ word = text[start:end]
127
+ return word if word else None
128
+ except (OSError, IndexError):
129
+ return None
130
+
131
+ def handle(self, msg: dict) -> dict | None:
132
+ """Handle an LSP request/notification."""
133
+ method = msg.get("method", "")
134
+ params = msg.get("params", {})
135
+ req_id = msg.get("id")
136
+
137
+ if method == "initialize":
138
+ self.root_path = params.get("rootPath", "") or ""
139
+ root_uri = params.get("rootUri", "")
140
+ if root_uri and not self.root_path:
141
+ self.root_path = _uri_to_path(root_uri)
142
+ self._load_index()
143
+
144
+ return _make_response(req_id, {
145
+ "capabilities": {
146
+ "hoverProvider": True,
147
+ "definitionProvider": True,
148
+ "documentSymbolProvider": True,
149
+ "textDocumentSync": 1,
150
+ },
151
+ "serverInfo": {
152
+ "name": "cortexcode-lsp",
153
+ "version": "0.1.0",
154
+ },
155
+ })
156
+
157
+ elif method == "initialized":
158
+ return None
159
+
160
+ elif method == "shutdown":
161
+ return _make_response(req_id, None)
162
+
163
+ elif method == "exit":
164
+ sys.exit(0)
165
+
166
+ elif method == "textDocument/hover":
167
+ return self._handle_hover(req_id, params)
168
+
169
+ elif method == "textDocument/definition":
170
+ return self._handle_definition(req_id, params)
171
+
172
+ elif method == "textDocument/documentSymbol":
173
+ return self._handle_document_symbols(req_id, params)
174
+
175
+ elif req_id is not None:
176
+ return _make_response(req_id, None)
177
+
178
+ return None
179
+
180
+ def _handle_hover(self, req_id, params: dict) -> dict:
181
+ """Handle textDocument/hover."""
182
+ uri = params.get("textDocument", {}).get("uri", "")
183
+ pos = params.get("position", {})
184
+ line = pos.get("line", 0)
185
+ col = pos.get("character", 0)
186
+
187
+ sym = self._find_symbol_at(uri, line, col)
188
+ if not sym:
189
+ return _make_response(req_id, None)
190
+
191
+ # Build hover content
192
+ parts = [f"**{sym.get('name')}** ({sym.get('type', 'symbol')})"]
193
+ if sym.get("params"):
194
+ parts.append(f"Parameters: `{', '.join(sym['params'])}`")
195
+ if sym.get("return_type"):
196
+ parts.append(f"Returns: `{sym['return_type']}`")
197
+ if sym.get("file"):
198
+ parts.append(f"Defined in: `{sym['file']}:{sym.get('line', '?')}`")
199
+ if sym.get("calls"):
200
+ parts.append(f"Calls: {', '.join(f'`{c}`' for c in sym['calls'][:5])}")
201
+ if sym.get("doc"):
202
+ parts.append(f"\n{sym['doc']}")
203
+ if sym.get("framework"):
204
+ parts.append(f"Framework: {sym['framework']}")
205
+
206
+ content = "\n\n".join(parts)
207
+
208
+ return _make_response(req_id, {
209
+ "contents": {"kind": "markdown", "value": content},
210
+ })
211
+
212
+ def _handle_definition(self, req_id, params: dict) -> dict:
213
+ """Handle textDocument/definition."""
214
+ uri = params.get("textDocument", {}).get("uri", "")
215
+ pos = params.get("position", {})
216
+ line = pos.get("line", 0)
217
+ col = pos.get("character", 0)
218
+
219
+ sym = self._find_symbol_at(uri, line, col)
220
+ if not sym or not sym.get("file"):
221
+ return _make_response(req_id, None)
222
+
223
+ root = self.index.get("project_root", "") if self.index else ""
224
+ target_path = str(Path(root) / sym["file"])
225
+ target_uri = _path_to_uri(target_path)
226
+ target_line = max(0, sym.get("line", 1) - 1)
227
+
228
+ return _make_response(req_id, {
229
+ "uri": target_uri,
230
+ "range": {
231
+ "start": {"line": target_line, "character": 0},
232
+ "end": {"line": target_line, "character": 0},
233
+ },
234
+ })
235
+
236
+ def _handle_document_symbols(self, req_id, params: dict) -> dict:
237
+ """Handle textDocument/documentSymbol."""
238
+ uri = params.get("textDocument", {}).get("uri", "")
239
+ file_path = _uri_to_path(uri).replace("\\", "/")
240
+
241
+ symbols = []
242
+
243
+ # Try to find symbols for this file
244
+ found_syms = self._symbol_cache.get(file_path, [])
245
+ if not found_syms:
246
+ # Try matching by filename
247
+ for key, syms in self._symbol_cache.items():
248
+ if file_path.endswith(key) or key.endswith(file_path.split("/")[-1]):
249
+ found_syms = syms
250
+ break
251
+
252
+ symbol_kind_map = {
253
+ "function": 12, # Function
254
+ "method": 6, # Method
255
+ "class": 5, # Class
256
+ "interface": 11, # Interface
257
+ "type": 26, # TypeParameter
258
+ "enum": 10, # Enum
259
+ }
260
+
261
+ for sym in found_syms:
262
+ line = max(0, sym.get("line", 1) - 1)
263
+ kind = symbol_kind_map.get(sym.get("type", ""), 13) # 13 = Variable
264
+
265
+ symbols.append({
266
+ "name": sym.get("name", "?"),
267
+ "kind": kind,
268
+ "range": {
269
+ "start": {"line": line, "character": 0},
270
+ "end": {"line": line, "character": 0},
271
+ },
272
+ "selectionRange": {
273
+ "start": {"line": line, "character": 0},
274
+ "end": {"line": line, "character": 0},
275
+ },
276
+ })
277
+
278
+ return _make_response(req_id, symbols)
279
+
280
+
281
+ def _uri_to_path(uri: str) -> str:
282
+ """Convert file URI to path."""
283
+ if uri.startswith("file:///"):
284
+ path = uri[8:] # Remove file:///
285
+ # Handle Windows drive letters
286
+ if len(path) > 2 and path[1] == ":" or (path[0] == "/" and len(path) > 3 and path[2] == ":"):
287
+ path = path.lstrip("/")
288
+ return path.replace("/", "\\") if "\\" in path or ":" in path[:3] else path
289
+ return uri
290
+
291
+
292
+ def _path_to_uri(path: str) -> str:
293
+ """Convert path to file URI."""
294
+ path = path.replace("\\", "/")
295
+ if not path.startswith("/"):
296
+ path = "/" + path
297
+ return "file://" + path
298
+
299
+
300
+ def run_lsp_server():
301
+ """Run the LSP server on stdin/stdout."""
302
+ server = CortexCodeLSP()
303
+
304
+ # Use binary mode for reading
305
+ stdin = sys.stdin
306
+ stdout = sys.stdout
307
+
308
+ while True:
309
+ msg = _read_message(stdin)
310
+ if msg is None:
311
+ break
312
+
313
+ response = server.handle(msg)
314
+ if response is not None:
315
+ _write_message(stdout, response)