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/facade.py ADDED
@@ -0,0 +1,402 @@
1
+ """AuroraLSP - High-level facade for LSP integration.
2
+
3
+ Provides a simple, synchronous API for Aurora CLI and MCP.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from aurora_lsp.analysis import CodeAnalyzer
14
+ from aurora_lsp.client import AuroraLSPClient
15
+ from aurora_lsp.diagnostics import DiagnosticsFormatter
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class AuroraLSP:
22
+ """High-level LSP interface for Aurora.
23
+
24
+ Provides synchronous methods that wrap async LSP operations.
25
+ Designed for use in CLI commands and MCP tools.
26
+
27
+ Example:
28
+ lsp = AuroraLSP("/path/to/workspace")
29
+
30
+ # Find usages (excluding imports)
31
+ result = lsp.find_usages("src/main.py", 10, 5)
32
+ print(f"Found {result['total_usages']} usages")
33
+
34
+ # Find dead code
35
+ dead = lsp.find_dead_code()
36
+ for item in dead:
37
+ print(f"Unused: {item['name']} in {item['file']}")
38
+
39
+ # Get linting diagnostics
40
+ diags = lsp.lint("src/")
41
+ print(f"Found {diags['total_errors']} errors")
42
+
43
+ lsp.close()
44
+ """
45
+
46
+ def __init__(self, workspace: str | Path | None = None):
47
+ """Initialize AuroraLSP.
48
+
49
+ Args:
50
+ workspace: Workspace root directory. Defaults to current directory.
51
+ """
52
+ self.workspace = Path(workspace or Path.cwd()).resolve()
53
+ self._client: AuroraLSPClient | None = None
54
+ self._analyzer: CodeAnalyzer | None = None
55
+ self._diagnostics: DiagnosticsFormatter | None = None
56
+ self._loop: asyncio.AbstractEventLoop | None = None
57
+
58
+ @property
59
+ def client(self) -> AuroraLSPClient:
60
+ """Get or create LSP client."""
61
+ if self._client is None:
62
+ self._client = AuroraLSPClient(self.workspace)
63
+ return self._client
64
+
65
+ @property
66
+ def analyzer(self) -> CodeAnalyzer:
67
+ """Get or create code analyzer."""
68
+ if self._analyzer is None:
69
+ self._analyzer = CodeAnalyzer(self.client, self.workspace)
70
+ return self._analyzer
71
+
72
+ @property
73
+ def diagnostics(self) -> DiagnosticsFormatter:
74
+ """Get or create diagnostics formatter."""
75
+ if self._diagnostics is None:
76
+ self._diagnostics = DiagnosticsFormatter(self.client, self.workspace)
77
+ return self._diagnostics
78
+
79
+ def _run_async(self, coro) -> Any:
80
+ """Run an async coroutine synchronously."""
81
+ try:
82
+ loop = asyncio.get_event_loop()
83
+ except RuntimeError:
84
+ loop = asyncio.new_event_loop()
85
+ asyncio.set_event_loop(loop)
86
+
87
+ return loop.run_until_complete(coro)
88
+
89
+ # =========================================================================
90
+ # VITAL: Import vs Usage
91
+ # =========================================================================
92
+
93
+ def find_usages(
94
+ self,
95
+ file_path: str | Path,
96
+ line: int,
97
+ col: int = 0,
98
+ include_imports: bool = False,
99
+ ) -> dict:
100
+ """Find usages of a symbol (excluding imports by default).
101
+
102
+ Args:
103
+ file_path: Path to file containing the symbol.
104
+ line: Line number (0-indexed).
105
+ col: Column number (0-indexed).
106
+ include_imports: Whether to include import statements.
107
+
108
+ Returns:
109
+ Dict with:
110
+ - usages: List of usage locations with context
111
+ - imports: List of import locations with context
112
+ - total_usages: Count of actual usages
113
+ - total_imports: Count of import statements
114
+ """
115
+ return self._run_async(
116
+ self.analyzer.find_usages(file_path, line, col, include_imports)
117
+ )
118
+
119
+ def get_usage_summary(
120
+ self,
121
+ file_path: str | Path,
122
+ line: int,
123
+ col: int = 0,
124
+ symbol_name: str | None = None,
125
+ ) -> dict:
126
+ """Get comprehensive usage summary for a symbol.
127
+
128
+ Args:
129
+ file_path: Path to file containing the symbol.
130
+ line: Line number (0-indexed).
131
+ col: Column number (0-indexed).
132
+ symbol_name: Optional symbol name for display.
133
+
134
+ Returns:
135
+ Dict with:
136
+ - symbol: Symbol name
137
+ - total_usages: Count of actual usages
138
+ - total_imports: Count of imports
139
+ - impact: 'low', 'medium', or 'high'
140
+ - files_affected: Number of files with usages
141
+ - usages_by_file: Usages grouped by file
142
+ - usages: Top 20 usage locations
143
+ - imports: All import locations
144
+ """
145
+ return self._run_async(
146
+ self.analyzer.get_usage_summary(file_path, line, col, symbol_name)
147
+ )
148
+
149
+ # =========================================================================
150
+ # IMPORTANT: Dead Code Detection
151
+ # =========================================================================
152
+
153
+ def find_dead_code(
154
+ self,
155
+ path: str | Path | None = None,
156
+ include_private: bool = False,
157
+ ) -> list[dict]:
158
+ """Find functions/classes with 0 usages.
159
+
160
+ Args:
161
+ path: Directory or file to analyze. Defaults to workspace.
162
+ include_private: Whether to include private symbols (_name).
163
+
164
+ Returns:
165
+ List of dead code items, each with:
166
+ - file: File path
167
+ - line: Line number
168
+ - name: Symbol name
169
+ - kind: 'function', 'class', or 'method'
170
+ - imports: Number of times imported (but never used)
171
+ """
172
+ return self._run_async(
173
+ self.analyzer.find_dead_code(path, include_private)
174
+ )
175
+
176
+ # =========================================================================
177
+ # IMPORTANT: Linting
178
+ # =========================================================================
179
+
180
+ def lint(
181
+ self,
182
+ path: str | Path | None = None,
183
+ severity_filter: int | None = None,
184
+ ) -> dict:
185
+ """Get linting diagnostics for file(s).
186
+
187
+ Args:
188
+ path: File or directory to lint. Defaults to workspace.
189
+ severity_filter: Minimum severity (1=error, 2=warning, etc.).
190
+
191
+ Returns:
192
+ Dict with:
193
+ - errors: List of errors
194
+ - warnings: List of warnings
195
+ - hints: List of hints
196
+ - total_errors: Error count
197
+ - total_warnings: Warning count
198
+ - total_hints: Hint count
199
+ """
200
+ return self._run_async(
201
+ self.diagnostics.get_all_diagnostics(path, severity_filter)
202
+ )
203
+
204
+ def lint_file(self, file_path: str | Path) -> dict:
205
+ """Get linting diagnostics for a single file.
206
+
207
+ Args:
208
+ file_path: Path to file.
209
+
210
+ Returns:
211
+ Dict with errors, warnings, hints lists.
212
+ """
213
+ return self._run_async(
214
+ self.diagnostics.get_file_diagnostics(file_path)
215
+ )
216
+
217
+ # =========================================================================
218
+ # OPTIONAL: Call Hierarchy (callers/callees)
219
+ # =========================================================================
220
+
221
+ def get_callers(
222
+ self,
223
+ file_path: str | Path,
224
+ line: int,
225
+ col: int = 0,
226
+ ) -> list[dict]:
227
+ """Find functions that call this symbol.
228
+
229
+ Args:
230
+ file_path: Path to file containing the symbol.
231
+ line: Line number (0-indexed).
232
+ col: Column number (0-indexed).
233
+
234
+ Returns:
235
+ List of caller functions, each with:
236
+ - file: File path
237
+ - line: Line number
238
+ - name: Function name
239
+ - kind: 'function' or 'method'
240
+ """
241
+ return self._run_async(
242
+ self.analyzer.get_callers(file_path, line, col)
243
+ )
244
+
245
+ def get_callees(
246
+ self,
247
+ file_path: str | Path,
248
+ line: int,
249
+ col: int = 0,
250
+ ) -> list[dict]:
251
+ """Find functions called by this symbol.
252
+
253
+ Note: Limited support. May return empty list.
254
+
255
+ Args:
256
+ file_path: Path to file containing the symbol.
257
+ line: Line number (0-indexed).
258
+ col: Column number (0-indexed).
259
+
260
+ Returns:
261
+ List of called functions (may be empty).
262
+ """
263
+ return self._run_async(
264
+ self.analyzer.get_callees(file_path, line, col)
265
+ )
266
+
267
+ # =========================================================================
268
+ # Utility Methods
269
+ # =========================================================================
270
+
271
+ def find_symbol(self, name: str, path: str | Path | None = None) -> dict | None:
272
+ """Find a symbol by name in the workspace.
273
+
274
+ Args:
275
+ name: Symbol name to find.
276
+ path: Limit search to this path.
277
+
278
+ Returns:
279
+ Dict with file, line, col if found, or None.
280
+ """
281
+ # Get all source files
282
+ target = Path(path) if path else self.workspace
283
+ if target.is_file():
284
+ files = [target]
285
+ else:
286
+ extensions = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java"}
287
+ files = []
288
+ for ext in extensions:
289
+ files.extend(target.rglob(f"*{ext}"))
290
+
291
+ # Search for symbol
292
+ for file_path in files:
293
+ try:
294
+ symbols = self._run_async(
295
+ self.client.request_document_symbols(file_path)
296
+ )
297
+ for symbol in self._flatten_symbols(symbols or []):
298
+ if symbol.get("name") == name:
299
+ range_info = symbol.get("range", {})
300
+ start = range_info.get("start", {})
301
+ return {
302
+ "file": str(file_path),
303
+ "line": start.get("line", 0),
304
+ "col": start.get("character", 0),
305
+ "kind": symbol.get("kind"),
306
+ }
307
+ except Exception:
308
+ continue
309
+
310
+ return None
311
+
312
+ def _flatten_symbols(self, symbols: list) -> list[dict]:
313
+ """Flatten nested symbol tree."""
314
+ result = []
315
+ for s in symbols:
316
+ result.append(s)
317
+ children = s.get("children", [])
318
+ if children:
319
+ result.extend(self._flatten_symbols(children))
320
+ return result
321
+
322
+ def close(self) -> None:
323
+ """Close all language server connections."""
324
+ if self._client:
325
+ self._run_async(self._client.close())
326
+ self._client = None
327
+ self._analyzer = None
328
+ self._diagnostics = None
329
+
330
+ def __enter__(self):
331
+ return self
332
+
333
+ def __exit__(self, exc_type, exc_val, exc_tb):
334
+ self.close()
335
+
336
+
337
+ # =========================================================================
338
+ # Convenience Functions
339
+ # =========================================================================
340
+
341
+ def find_usages(
342
+ file_path: str | Path,
343
+ line: int,
344
+ col: int = 0,
345
+ workspace: str | Path | None = None,
346
+ ) -> dict:
347
+ """Find usages of a symbol (excluding imports).
348
+
349
+ Convenience function that creates a temporary AuroraLSP instance.
350
+
351
+ Args:
352
+ file_path: Path to file containing the symbol.
353
+ line: Line number (0-indexed).
354
+ col: Column number (0-indexed).
355
+ workspace: Workspace root. Defaults to file's parent.
356
+
357
+ Returns:
358
+ Dict with usages, imports, and counts.
359
+ """
360
+ ws = workspace or Path(file_path).parent
361
+ with AuroraLSP(ws) as lsp:
362
+ return lsp.find_usages(file_path, line, col)
363
+
364
+
365
+ def find_dead_code(
366
+ path: str | Path | None = None,
367
+ workspace: str | Path | None = None,
368
+ ) -> list[dict]:
369
+ """Find dead code in a directory.
370
+
371
+ Convenience function that creates a temporary AuroraLSP instance.
372
+
373
+ Args:
374
+ path: Directory to analyze.
375
+ workspace: Workspace root. Defaults to path.
376
+
377
+ Returns:
378
+ List of dead code items.
379
+ """
380
+ ws = workspace or path or Path.cwd()
381
+ with AuroraLSP(ws) as lsp:
382
+ return lsp.find_dead_code(path)
383
+
384
+
385
+ def lint(
386
+ path: str | Path | None = None,
387
+ workspace: str | Path | None = None,
388
+ ) -> dict:
389
+ """Lint a directory.
390
+
391
+ Convenience function that creates a temporary AuroraLSP instance.
392
+
393
+ Args:
394
+ path: Directory to lint.
395
+ workspace: Workspace root. Defaults to path.
396
+
397
+ Returns:
398
+ Dict with errors, warnings, hints.
399
+ """
400
+ ws = workspace or path or Path.cwd()
401
+ with AuroraLSP(ws) as lsp:
402
+ return lsp.lint(path)
aurora_lsp/filters.py ADDED
@@ -0,0 +1,195 @@
1
+ """Import filtering for LSP references.
2
+
3
+ Distinguishes actual usages from import statements across multiple languages.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Awaitable, Callable
11
+
12
+
13
+ class ImportFilter:
14
+ """Filter import statements from LSP references.
15
+
16
+ LSP returns ALL references including imports. This class distinguishes
17
+ actual usages from import statements using language-specific patterns.
18
+ """
19
+
20
+ # Language-specific import statement patterns
21
+ IMPORT_PATTERNS: dict[str, list[str]] = {
22
+ "python": [
23
+ r"^\s*import\s+",
24
+ r"^\s*from\s+[\w.]+\s+import\s+",
25
+ ],
26
+ "javascript": [
27
+ r"^\s*import\s+",
28
+ r"^\s*import\s*\{",
29
+ r"^\s*import\s+\*\s+as\s+",
30
+ r"^\s*(const|let|var)\s+.*\s*=\s*require\s*\(",
31
+ ],
32
+ "typescript": [
33
+ r"^\s*import\s+",
34
+ r"^\s*import\s*\{",
35
+ r"^\s*import\s+\*\s+as\s+",
36
+ r"^\s*import\s+type\s+",
37
+ r"^\s*(const|let|var)\s+.*\s*=\s*require\s*\(",
38
+ ],
39
+ "go": [
40
+ r"^\s*import\s+",
41
+ r'^\s*import\s*\(',
42
+ r'^\s*"[\w/.-]+"', # Inside import block
43
+ ],
44
+ "rust": [
45
+ r"^\s*use\s+",
46
+ r"^\s*extern\s+crate\s+",
47
+ ],
48
+ "java": [
49
+ r"^\s*import\s+",
50
+ r"^\s*import\s+static\s+",
51
+ ],
52
+ "ruby": [
53
+ r"^\s*require\s+",
54
+ r"^\s*require_relative\s+",
55
+ r"^\s*load\s+",
56
+ r"^\s*autoload\s+",
57
+ ],
58
+ "csharp": [
59
+ r"^\s*using\s+",
60
+ r"^\s*using\s+static\s+",
61
+ ],
62
+ "dart": [
63
+ r"^\s*import\s+",
64
+ r"^\s*export\s+",
65
+ r"^\s*part\s+",
66
+ ],
67
+ "kotlin": [
68
+ r"^\s*import\s+",
69
+ ],
70
+ }
71
+
72
+ def __init__(self, language: str):
73
+ """Initialize filter for a specific language.
74
+
75
+ Args:
76
+ language: Language identifier (e.g., 'python', 'typescript').
77
+ """
78
+ self.language = language
79
+ patterns = self.IMPORT_PATTERNS.get(language, [])
80
+ self.patterns = [re.compile(p) for p in patterns]
81
+
82
+ def is_import_line(self, line_content: str) -> bool:
83
+ """Check if a line is an import statement.
84
+
85
+ Args:
86
+ line_content: The source code line to check.
87
+
88
+ Returns:
89
+ True if the line is an import statement.
90
+ """
91
+ for pattern in self.patterns:
92
+ if pattern.match(line_content):
93
+ return True
94
+ return False
95
+
96
+ async def filter_references(
97
+ self,
98
+ refs: list[dict],
99
+ file_reader: Callable[[str, int], Awaitable[str]],
100
+ ) -> tuple[list[dict], list[dict]]:
101
+ """Split references into usages and imports.
102
+
103
+ Args:
104
+ refs: List of reference locations from LSP.
105
+ file_reader: Async function to read a line from a file.
106
+ Signature: (file_path, line_number) -> line_content
107
+
108
+ Returns:
109
+ Tuple of (usages, imports) where each is a list of references.
110
+ """
111
+ usages = []
112
+ imports = []
113
+
114
+ for ref in refs:
115
+ file_path = ref.get("file", "")
116
+ line_num = ref.get("line", 0)
117
+
118
+ try:
119
+ line_content = await file_reader(file_path, line_num)
120
+ ref_with_context = {**ref, "context": line_content.strip()}
121
+
122
+ if self.is_import_line(line_content):
123
+ imports.append(ref_with_context)
124
+ else:
125
+ usages.append(ref_with_context)
126
+ except Exception:
127
+ # If we can't read the line, assume it's a usage
128
+ usages.append(ref)
129
+
130
+ return usages, imports
131
+
132
+ def filter_references_sync(
133
+ self,
134
+ refs: list[dict],
135
+ file_reader: Callable[[str, int], str],
136
+ ) -> tuple[list[dict], list[dict]]:
137
+ """Synchronous version of filter_references.
138
+
139
+ Args:
140
+ refs: List of reference locations from LSP.
141
+ file_reader: Sync function to read a line from a file.
142
+
143
+ Returns:
144
+ Tuple of (usages, imports).
145
+ """
146
+ usages = []
147
+ imports = []
148
+
149
+ for ref in refs:
150
+ file_path = ref.get("file", "")
151
+ line_num = ref.get("line", 0)
152
+
153
+ try:
154
+ line_content = file_reader(file_path, line_num)
155
+ ref_with_context = {**ref, "context": line_content.strip()}
156
+
157
+ if self.is_import_line(line_content):
158
+ imports.append(ref_with_context)
159
+ else:
160
+ usages.append(ref_with_context)
161
+ except Exception:
162
+ usages.append(ref)
163
+
164
+ return usages, imports
165
+
166
+
167
+ def get_filter_for_file(file_path: str | Path) -> ImportFilter:
168
+ """Get appropriate ImportFilter for a file based on extension.
169
+
170
+ Args:
171
+ file_path: Path to file.
172
+
173
+ Returns:
174
+ ImportFilter for the file's language.
175
+ """
176
+ ext_to_lang = {
177
+ ".py": "python",
178
+ ".pyi": "python",
179
+ ".js": "javascript",
180
+ ".jsx": "javascript",
181
+ ".ts": "typescript",
182
+ ".tsx": "typescript",
183
+ ".go": "go",
184
+ ".rs": "rust",
185
+ ".java": "java",
186
+ ".rb": "ruby",
187
+ ".cs": "csharp",
188
+ ".dart": "dart",
189
+ ".kt": "kotlin",
190
+ ".kts": "kotlin",
191
+ }
192
+
193
+ ext = Path(file_path).suffix.lower()
194
+ lang = ext_to_lang.get(ext, "python") # Default to Python patterns
195
+ return ImportFilter(lang)