aurora-lsp 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.
aurora_lsp/client.py ADDED
@@ -0,0 +1,305 @@
1
+ """Low-level multilspy wrapper for Aurora.
2
+
3
+ Manages language server instances and provides async LSP operations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+
14
+ if TYPE_CHECKING:
15
+ from multilspy.language_server import LanguageServer
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Import multilspy components
20
+ try:
21
+ from multilspy.language_server import LanguageServer
22
+ from multilspy.multilspy_config import Language, MultilspyConfig
23
+ from multilspy.multilspy_logger import MultilspyLogger
24
+ MULTILSPY_AVAILABLE = True
25
+ except ImportError:
26
+ MULTILSPY_AVAILABLE = False
27
+ Language = None # type: ignore
28
+
29
+
30
+ class AuroraLSPClient:
31
+ """Low-level multilspy wrapper.
32
+
33
+ Manages language server instances per language, lazily initialized.
34
+ Provides async methods for common LSP operations.
35
+
36
+ Must be used within the server context:
37
+
38
+ client = AuroraLSPClient(workspace)
39
+ async with client.start():
40
+ refs = await client.request_references(file, line, col)
41
+ """
42
+
43
+ # Map file extensions to Language enum values
44
+ LANGUAGE_MAP: dict[str, Any] = {}
45
+
46
+ def __init__(self, workspace: Path | str):
47
+ """Initialize LSP client for a workspace.
48
+
49
+ Args:
50
+ workspace: Root directory of the project to analyze.
51
+ """
52
+ self.workspace = Path(workspace).resolve()
53
+ self._servers: dict[str, Any] = {} # Language -> server
54
+ self._contexts: dict[str, Any] = {} # Language -> context manager
55
+ self._open_files: set[str] = set() # Tracks opened files
56
+ self._lock = asyncio.Lock()
57
+ self._logger: Any = None
58
+ self._started = False
59
+
60
+ # Initialize language map if multilspy is available
61
+ if MULTILSPY_AVAILABLE and Language is not None:
62
+ self.LANGUAGE_MAP = {
63
+ ".py": Language.PYTHON,
64
+ ".pyi": Language.PYTHON,
65
+ ".rs": Language.RUST,
66
+ ".go": Language.GO,
67
+ ".js": Language.JAVASCRIPT,
68
+ ".jsx": Language.JAVASCRIPT,
69
+ ".ts": Language.TYPESCRIPT,
70
+ ".tsx": Language.TYPESCRIPT,
71
+ ".java": Language.JAVA,
72
+ ".rb": Language.RUBY,
73
+ ".cs": Language.CSHARP,
74
+ ".dart": Language.DART,
75
+ ".kt": Language.KOTLIN,
76
+ ".kts": Language.KOTLIN,
77
+ }
78
+
79
+ def get_language(self, file_path: str | Path) -> Any:
80
+ """Get language enum value for a file."""
81
+ ext = Path(file_path).suffix.lower()
82
+ return self.LANGUAGE_MAP.get(ext)
83
+
84
+ async def _ensure_server(self, file_path: str | Path) -> Any:
85
+ """Ensure server is started for file's language.
86
+
87
+ Args:
88
+ file_path: Path to file (used to determine language).
89
+
90
+ Returns:
91
+ Started LanguageServer instance.
92
+ """
93
+ if not MULTILSPY_AVAILABLE:
94
+ raise ImportError("multilspy not installed. Install with: pip install multilspy")
95
+
96
+ lang = self.get_language(file_path)
97
+ if not lang:
98
+ raise ValueError(f"Unsupported file type: {Path(file_path).suffix}")
99
+
100
+ lang_key = lang.name if hasattr(lang, 'name') else str(lang)
101
+
102
+ async with self._lock:
103
+ if lang_key not in self._servers:
104
+ # Create logger if not exists
105
+ if self._logger is None:
106
+ self._logger = MultilspyLogger()
107
+
108
+ # Create config
109
+ config = MultilspyConfig(code_language=lang)
110
+
111
+ logger.info(f"Starting {lang_key} language server for {self.workspace}")
112
+
113
+ # Create server (sync)
114
+ server = LanguageServer.create(config, self._logger, str(self.workspace))
115
+
116
+ # Start server (async context manager)
117
+ ctx = server.start_server()
118
+ await ctx.__aenter__()
119
+
120
+ self._servers[lang_key] = server
121
+ self._contexts[lang_key] = ctx
122
+
123
+ return self._servers[lang_key]
124
+
125
+ def _ensure_file_open(self, server: Any, file_path: str) -> None:
126
+ """Ensure file is opened in the server."""
127
+ rel_path = self._to_relative(file_path)
128
+ if rel_path not in self._open_files:
129
+ server.open_file(rel_path)
130
+ self._open_files.add(rel_path)
131
+
132
+ async def request_references(
133
+ self,
134
+ file_path: str | Path,
135
+ line: int,
136
+ col: int,
137
+ ) -> list[dict]:
138
+ """Find all references to a symbol.
139
+
140
+ Args:
141
+ file_path: Path to file containing the symbol.
142
+ line: Line number (0-indexed).
143
+ col: Column number (0-indexed).
144
+
145
+ Returns:
146
+ List of reference locations, each with 'file', 'line', 'col' keys.
147
+ """
148
+ server = await self._ensure_server(file_path)
149
+ rel_path = self._to_relative(file_path)
150
+ self._ensure_file_open(server, file_path)
151
+
152
+ try:
153
+ refs = await server.request_references(rel_path, line, col)
154
+ return self._normalize_locations(refs)
155
+ except Exception as e:
156
+ logger.warning(f"request_references failed: {e}")
157
+ return []
158
+
159
+ async def request_definition(
160
+ self,
161
+ file_path: str | Path,
162
+ line: int,
163
+ col: int,
164
+ ) -> list[dict]:
165
+ """Find definition of a symbol."""
166
+ server = await self._ensure_server(file_path)
167
+ rel_path = self._to_relative(file_path)
168
+ self._ensure_file_open(server, file_path)
169
+
170
+ try:
171
+ defs = await server.request_definition(rel_path, line, col)
172
+ return self._normalize_locations(defs)
173
+ except Exception as e:
174
+ logger.warning(f"request_definition failed: {e}")
175
+ return []
176
+
177
+ async def request_document_symbols(
178
+ self,
179
+ file_path: str | Path,
180
+ ) -> list[dict]:
181
+ """Get all symbols defined in a file."""
182
+ server = await self._ensure_server(file_path)
183
+ rel_path = self._to_relative(file_path)
184
+ self._ensure_file_open(server, file_path)
185
+
186
+ try:
187
+ result = await server.request_document_symbols(rel_path)
188
+ # multilspy returns tuple (symbols_list, extra_info) - extract first element
189
+ if isinstance(result, tuple):
190
+ symbols = result[0]
191
+ else:
192
+ symbols = result
193
+ return symbols or []
194
+ except Exception as e:
195
+ logger.warning(f"request_document_symbols failed: {e}")
196
+ return []
197
+
198
+ async def request_hover(
199
+ self,
200
+ file_path: str | Path,
201
+ line: int,
202
+ col: int,
203
+ ) -> dict | None:
204
+ """Get hover information for a symbol."""
205
+ server = await self._ensure_server(file_path)
206
+ rel_path = self._to_relative(file_path)
207
+ self._ensure_file_open(server, file_path)
208
+
209
+ try:
210
+ return await server.request_hover(rel_path, line, col)
211
+ except Exception as e:
212
+ logger.warning(f"request_hover failed: {e}")
213
+ return None
214
+
215
+ async def request_diagnostics(
216
+ self,
217
+ file_path: str | Path,
218
+ ) -> list[dict]:
219
+ """Get diagnostics (errors, warnings) for a file.
220
+
221
+ Args:
222
+ file_path: Path to file to check.
223
+
224
+ Returns:
225
+ List of diagnostic dicts with severity, message, range.
226
+ """
227
+ server = await self._ensure_server(file_path)
228
+ rel_path = self._to_relative(file_path)
229
+ self._ensure_file_open(server, file_path)
230
+
231
+ try:
232
+ # multilspy may return diagnostics via different methods
233
+ # Try the standard approach first
234
+ if hasattr(server, 'request_diagnostics'):
235
+ return await server.request_diagnostics(rel_path) or []
236
+ # Some servers publish diagnostics automatically after open_file
237
+ # Check if server has cached diagnostics
238
+ if hasattr(server, 'get_diagnostics'):
239
+ return server.get_diagnostics(rel_path) or []
240
+ # Fallback: diagnostics may be in server state
241
+ logger.debug(f"Diagnostics not directly supported for {file_path}")
242
+ return []
243
+ except Exception as e:
244
+ logger.warning(f"request_diagnostics failed: {e}")
245
+ return []
246
+
247
+ async def close(self) -> None:
248
+ """Close all language server connections."""
249
+ async with self._lock:
250
+ # Exit context managers
251
+ for lang_key, ctx in self._contexts.items():
252
+ try:
253
+ logger.info(f"Stopping {lang_key} language server")
254
+ await ctx.__aexit__(None, None, None)
255
+ except Exception as e:
256
+ logger.warning(f"Error stopping {lang_key} server: {e}")
257
+
258
+ self._servers.clear()
259
+ self._contexts.clear()
260
+ self._open_files.clear()
261
+
262
+ def _to_relative(self, file_path: str | Path) -> str:
263
+ """Convert absolute path to workspace-relative path."""
264
+ path = Path(file_path)
265
+ if path.is_absolute():
266
+ try:
267
+ return str(path.relative_to(self.workspace))
268
+ except ValueError:
269
+ return str(path)
270
+ return str(path)
271
+
272
+ def _normalize_locations(self, locations: list | None) -> list[dict]:
273
+ """Normalize LSP location responses to consistent format."""
274
+ if not locations:
275
+ return []
276
+
277
+ result = []
278
+ for loc in locations:
279
+ if isinstance(loc, dict):
280
+ # Handle different LSP location formats
281
+ if "absolutePath" in loc:
282
+ file_path = loc["absolutePath"]
283
+ elif "uri" in loc:
284
+ file_path = loc["uri"].replace("file://", "")
285
+ elif "targetUri" in loc:
286
+ file_path = loc["targetUri"].replace("file://", "")
287
+ else:
288
+ file_path = loc.get("file", loc.get("relativePath", ""))
289
+
290
+ range_info = loc.get("range") or loc.get("targetRange", {})
291
+ start = range_info.get("start", {})
292
+
293
+ result.append({
294
+ "file": file_path,
295
+ "line": start.get("line", 0),
296
+ "col": start.get("character", 0),
297
+ })
298
+
299
+ return result
300
+
301
+ async def __aenter__(self):
302
+ return self
303
+
304
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
305
+ await self.close()
@@ -0,0 +1,207 @@
1
+ """Diagnostics (linting) wrapper for LSP.
2
+
3
+ Formats LSP diagnostics into Aurora-friendly output.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from enum import IntEnum
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from aurora_lsp.client import AuroraLSPClient
15
+
16
+
17
+ class DiagnosticSeverity(IntEnum):
18
+ """LSP DiagnosticSeverity values."""
19
+
20
+ ERROR = 1
21
+ WARNING = 2
22
+ INFORMATION = 3
23
+ HINT = 4
24
+
25
+
26
+ class DiagnosticsFormatter:
27
+ """Format and filter LSP diagnostics."""
28
+
29
+ SEVERITY_NAMES = {
30
+ DiagnosticSeverity.ERROR: "error",
31
+ DiagnosticSeverity.WARNING: "warning",
32
+ DiagnosticSeverity.INFORMATION: "info",
33
+ DiagnosticSeverity.HINT: "hint",
34
+ }
35
+
36
+ def __init__(self, client: AuroraLSPClient, workspace: Path | str):
37
+ """Initialize diagnostics formatter.
38
+
39
+ Args:
40
+ client: LSP client for making requests.
41
+ workspace: Workspace root directory.
42
+ """
43
+ self.client = client
44
+ self.workspace = Path(workspace).resolve()
45
+
46
+ async def get_file_diagnostics(self, file_path: str | Path) -> dict:
47
+ """Get diagnostics for a single file.
48
+
49
+ Args:
50
+ file_path: Path to file.
51
+
52
+ Returns:
53
+ Dict with errors, warnings, hints lists.
54
+ """
55
+ diags = await self.client.request_diagnostics(file_path)
56
+ return self._format_diagnostics(diags, file_path)
57
+
58
+ async def get_all_diagnostics(
59
+ self,
60
+ path: str | Path | None = None,
61
+ severity_filter: int | None = None,
62
+ ) -> dict:
63
+ """Get diagnostics for all files in a directory.
64
+
65
+ Args:
66
+ path: Directory to scan. Defaults to workspace.
67
+ severity_filter: Minimum severity (1=error, 2=warning, etc.).
68
+
69
+ Returns:
70
+ Dict with errors, warnings, hints lists and summary.
71
+ """
72
+ target = Path(path) if path else self.workspace
73
+
74
+ if target.is_file():
75
+ files = [target]
76
+ else:
77
+ # Get all source files
78
+ extensions = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java"}
79
+ files = []
80
+ for ext in extensions:
81
+ files.extend(target.rglob(f"*{ext}"))
82
+
83
+ # Filter out common non-source directories
84
+ exclude_dirs = {"node_modules", ".git", "__pycache__", ".venv", "venv"}
85
+ files = [f for f in files if not any(d in f.parts for d in exclude_dirs)]
86
+
87
+ all_errors = []
88
+ all_warnings = []
89
+ all_hints = []
90
+
91
+ for file_path in sorted(files):
92
+ try:
93
+ diags = await self.client.request_diagnostics(file_path)
94
+ formatted = self._format_diagnostics(diags, file_path)
95
+
96
+ all_errors.extend(formatted["errors"])
97
+ all_warnings.extend(formatted["warnings"])
98
+ all_hints.extend(formatted["hints"])
99
+ except Exception:
100
+ continue
101
+
102
+ # Apply severity filter
103
+ if severity_filter:
104
+ if severity_filter > DiagnosticSeverity.ERROR:
105
+ all_errors = []
106
+ if severity_filter > DiagnosticSeverity.WARNING:
107
+ all_warnings = []
108
+ if severity_filter > DiagnosticSeverity.INFORMATION:
109
+ all_hints = []
110
+
111
+ return {
112
+ "errors": all_errors,
113
+ "warnings": all_warnings,
114
+ "hints": all_hints,
115
+ "total_errors": len(all_errors),
116
+ "total_warnings": len(all_warnings),
117
+ "total_hints": len(all_hints),
118
+ }
119
+
120
+ def _format_diagnostics(
121
+ self,
122
+ diags: list[dict],
123
+ file_path: str | Path,
124
+ ) -> dict:
125
+ """Format raw LSP diagnostics.
126
+
127
+ Args:
128
+ diags: Raw diagnostics from LSP.
129
+ file_path: Source file path.
130
+
131
+ Returns:
132
+ Dict with errors, warnings, hints lists.
133
+ """
134
+ errors = []
135
+ warnings = []
136
+ hints = []
137
+
138
+ # Make path relative for display
139
+ try:
140
+ rel_path = Path(file_path).relative_to(self.workspace)
141
+ except ValueError:
142
+ rel_path = Path(file_path)
143
+
144
+ for d in diags:
145
+ severity = d.get("severity", DiagnosticSeverity.HINT)
146
+ range_info = d.get("range", {})
147
+ start = range_info.get("start", {})
148
+
149
+ entry = {
150
+ "file": str(rel_path),
151
+ "line": start.get("line", 0) + 1, # Convert to 1-indexed
152
+ "col": start.get("character", 0) + 1,
153
+ "message": d.get("message", ""),
154
+ "code": d.get("code", ""),
155
+ "source": d.get("source", ""),
156
+ "severity": self.SEVERITY_NAMES.get(severity, "unknown"),
157
+ }
158
+
159
+ if severity == DiagnosticSeverity.ERROR:
160
+ errors.append(entry)
161
+ elif severity == DiagnosticSeverity.WARNING:
162
+ warnings.append(entry)
163
+ else:
164
+ hints.append(entry)
165
+
166
+ return {
167
+ "errors": errors,
168
+ "warnings": warnings,
169
+ "hints": hints,
170
+ }
171
+
172
+ def format_for_display(self, diagnostics: dict, max_items: int = 10) -> str:
173
+ """Format diagnostics for CLI display.
174
+
175
+ Args:
176
+ diagnostics: Diagnostics dict from get_all_diagnostics.
177
+ max_items: Maximum items per category to show.
178
+
179
+ Returns:
180
+ Formatted string for display.
181
+ """
182
+ lines = []
183
+
184
+ total_errors = diagnostics.get("total_errors", len(diagnostics.get("errors", [])))
185
+ total_warnings = diagnostics.get("total_warnings", len(diagnostics.get("warnings", [])))
186
+
187
+ lines.append(f"{total_errors} errors, {total_warnings} warnings")
188
+ lines.append("")
189
+
190
+ errors = diagnostics.get("errors", [])
191
+ if errors:
192
+ lines.append("Errors:")
193
+ for e in errors[:max_items]:
194
+ lines.append(f" {e['file']}:{e['line']} {e['message']}")
195
+ if len(errors) > max_items:
196
+ lines.append(f" ... ({len(errors) - max_items} more)")
197
+ lines.append("")
198
+
199
+ warnings = diagnostics.get("warnings", [])
200
+ if warnings:
201
+ lines.append("Warnings:")
202
+ for w in warnings[:max_items]:
203
+ lines.append(f" {w['file']}:{w['line']} {w['message']}")
204
+ if len(warnings) > max_items:
205
+ lines.append(f" ... ({len(warnings) - max_items} more)")
206
+
207
+ return "\n".join(lines)