exolot 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.
exolot/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Run SonarQube for IDE's language server from the command line."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("exolot")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
exolot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
exolot/analyze.py ADDED
@@ -0,0 +1,340 @@
1
+ """Run the language server headlessly over a set of files and collect issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from . import __version__
16
+ from .lsp import LspClient
17
+ from .server import Server
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # VS Code language ids, which is what the server expects in didOpen.
22
+ LANGUAGE_IDS = {
23
+ ".py": "python",
24
+ ".pyi": "python",
25
+ ".js": "javascript",
26
+ ".mjs": "javascript",
27
+ ".cjs": "javascript",
28
+ ".jsx": "javascriptreact",
29
+ ".ts": "typescript",
30
+ ".mts": "typescript",
31
+ ".cts": "typescript",
32
+ ".tsx": "typescriptreact",
33
+ ".vue": "vue",
34
+ ".css": "css",
35
+ ".scss": "scss",
36
+ ".less": "less",
37
+ ".html": "html",
38
+ ".htm": "html",
39
+ ".xml": "xml",
40
+ ".java": "java",
41
+ ".php": "php",
42
+ ".go": "go",
43
+ ".cs": "csharp",
44
+ ".c": "c",
45
+ ".h": "c",
46
+ ".cpp": "cpp",
47
+ ".cc": "cpp",
48
+ ".cxx": "cpp",
49
+ ".hpp": "cpp",
50
+ ".yaml": "yaml",
51
+ ".yml": "yaml",
52
+ ".json": "json",
53
+ ".tf": "terraform",
54
+ ".bicep": "bicep",
55
+ ".sh": "shellscript",
56
+ ".bash": "shellscript",
57
+ ".properties": "properties",
58
+ }
59
+
60
+ SKIP_DIRS = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", ".tox", ".mypy_cache"}
61
+ MAX_FILE_SIZE = 2 * 1024 * 1024
62
+ SEVERITY_NAMES = {1: "error", 2: "warning", 3: "info", 4: "hint"}
63
+
64
+
65
+ @dataclass
66
+ class Issue:
67
+ path: Path
68
+ line: int # 1-based
69
+ column: int # 1-based
70
+ end_line: int
71
+ end_column: int
72
+ rule: str
73
+ message: str
74
+ severity: str
75
+ raw: dict[str, Any] = field(repr=False)
76
+
77
+
78
+ def language_id(path: Path) -> str:
79
+ if path.name == "Dockerfile" or path.name.startswith("Dockerfile."):
80
+ return "dockerfile"
81
+ return LANGUAGE_IDS.get(path.suffix.lower(), "plaintext")
82
+
83
+
84
+ def collect_files(paths: list[Path]) -> list[Path]:
85
+ """Expand directories into the files under them, skipping ignored and binary files."""
86
+ result: list[Path] = []
87
+ seen: set[Path] = set()
88
+ for path in paths:
89
+ if not path.exists():
90
+ raise FileNotFoundError(f"no such file or directory: {path}")
91
+ candidates = [path] if path.is_file() else _list_dir(path)
92
+ for f in candidates:
93
+ f = f.resolve()
94
+ if f not in seen and _is_analyzable(f):
95
+ seen.add(f)
96
+ result.append(f)
97
+ return result
98
+
99
+
100
+ def _list_dir(directory: Path) -> list[Path]:
101
+ git = shutil.which("git")
102
+ if git:
103
+ proc = subprocess.run(
104
+ [git, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
105
+ cwd=directory,
106
+ capture_output=True,
107
+ )
108
+ if proc.returncode == 0:
109
+ return [directory / name for name in proc.stdout.decode().split("\0") if name]
110
+ files = []
111
+ for dirpath, dirnames, filenames in os.walk(directory):
112
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
113
+ files.extend(Path(dirpath) / name for name in filenames)
114
+ return files
115
+
116
+
117
+ def _is_analyzable(path: Path) -> bool:
118
+ try:
119
+ if not path.is_file() or path.stat().st_size > MAX_FILE_SIZE:
120
+ return False
121
+ with open(path, "rb") as f:
122
+ return b"\0" not in f.read(8192)
123
+ except OSError:
124
+ return False
125
+
126
+
127
+ class AnalysisSession:
128
+ """One language server process analyzing one workspace folder."""
129
+
130
+ def __init__(
131
+ self,
132
+ server: Server,
133
+ workspace: Path,
134
+ rules: dict[str, Any] | None = None,
135
+ node: str | None = None,
136
+ jvm_args: list[str] | None = None,
137
+ verbose: bool = False,
138
+ ):
139
+ self.server = server
140
+ self.workspace = workspace.resolve()
141
+ self.rules = rules or {}
142
+ self.node = node
143
+ self.jvm_args = jvm_args
144
+ self.verbose = verbose
145
+ self._cond = threading.Condition()
146
+ self._published: dict[str, list[dict[str, Any]]] = {}
147
+ self._active_progress: set[Any] = set()
148
+ self._last_event = time.monotonic()
149
+ self._folder_configured = threading.Event()
150
+ self._proc: subprocess.Popen[bytes] | None = None
151
+ self._client: LspClient | None = None
152
+
153
+ def __enter__(self) -> AnalysisSession:
154
+ try:
155
+ self._start()
156
+ except BaseException:
157
+ self._kill()
158
+ raise
159
+ return self
160
+
161
+ def __exit__(self, *exc: object) -> None:
162
+ self.close()
163
+
164
+ def _start(self) -> None:
165
+ cmd = self.server.command(self.jvm_args)
166
+ log.debug("starting %s", cmd)
167
+ self._proc = subprocess.Popen(
168
+ cmd,
169
+ stdin=subprocess.PIPE,
170
+ stdout=subprocess.PIPE,
171
+ stderr=None if self.verbose else subprocess.DEVNULL,
172
+ )
173
+ client = self._client = LspClient(self._proc)
174
+ client.on_notification("textDocument/publishDiagnostics", self._on_diagnostics)
175
+ client.on_notification("$/progress", self._on_progress)
176
+ client.on_notification("window/logMessage", self._on_log)
177
+ client.on_request("workspace/configuration", self._on_configuration)
178
+ client.on_request("window/workDoneProgress/create", lambda params: None)
179
+ client.on_request("sonarlint/isOpenInEditor", lambda uri: True)
180
+ client.on_request("sonarlint/isIgnoredByScm", lambda uri: False)
181
+ client.on_request("sonarlint/shouldAnalyseFile", lambda params: {"shouldBeAnalysed": True})
182
+ client.on_request("sonarlint/filterOutExcludedFiles", lambda params: params)
183
+ client.on_request("sonarlint/listFilesInFolder", lambda params: {"foundFiles": []})
184
+ client.on_request("sonarlint/canShowMissingRequirementsNotification", lambda params: "error_only")
185
+ client.on_request("sonarlint/hasJoinedIdeLabs", lambda params: False)
186
+ client.start()
187
+
188
+ root_uri = self.workspace.as_uri()
189
+ client.request(
190
+ "initialize",
191
+ {
192
+ "processId": os.getpid(),
193
+ "rootUri": root_uri,
194
+ "workspaceFolders": [{"uri": root_uri, "name": self.workspace.name}],
195
+ "capabilities": {
196
+ "workspace": {"configuration": True, "workspaceFolders": True},
197
+ "window": {"workDoneProgress": True},
198
+ },
199
+ "initializationOptions": {
200
+ "productKey": "vscode",
201
+ "productName": "exolot",
202
+ "productVersion": __version__,
203
+ "workspaceName": self.workspace.name,
204
+ "showVerboseLogs": self.verbose,
205
+ "firstSecretDetected": True,
206
+ "enableNotebooks": False,
207
+ "clientNodePath": self.node,
208
+ "connections": {"sonarqube": [], "sonarcloud": []},
209
+ "rules": self.rules,
210
+ "focusOnNewCode": False,
211
+ "automaticAnalysis": True,
212
+ "additionalAttributes": {},
213
+ },
214
+ },
215
+ ).result(timeout=300)
216
+ client.notify("initialized", {})
217
+ # Files opened before the server has fetched the folder's settings get
218
+ # analyzed twice, so wait for that first.
219
+ if not self._folder_configured.wait(timeout=30):
220
+ log.warning("server did not ask for folder settings; continuing anyway")
221
+
222
+ def _settings(self) -> dict[str, Any]:
223
+ return {
224
+ "rules": self.rules,
225
+ "disableTelemetry": True,
226
+ "pathToNodeExecutable": self.node,
227
+ "automaticAnalysis": True,
228
+ "focusOnNewCode": False,
229
+ "output": {"showVerboseLogs": self.verbose},
230
+ }
231
+
232
+ def _on_configuration(self, params: dict[str, Any]) -> list[Any]:
233
+ if any(item.get("scopeUri") for item in params["items"]):
234
+ self._folder_configured.set()
235
+ return [self._settings() if item.get("section") == "sonarlint" else None for item in params["items"]]
236
+
237
+ def _touch(self) -> None:
238
+ self._last_event = time.monotonic()
239
+ self._cond.notify_all()
240
+
241
+ def _on_diagnostics(self, params: dict[str, Any]) -> None:
242
+ with self._cond:
243
+ self._published[params["uri"]] = params["diagnostics"]
244
+ self._touch()
245
+
246
+ def _on_progress(self, params: dict[str, Any]) -> None:
247
+ kind = params.get("value", {}).get("kind")
248
+ with self._cond:
249
+ if kind == "begin":
250
+ self._active_progress.add(params["token"])
251
+ elif kind == "end":
252
+ self._active_progress.discard(params["token"])
253
+ self._touch()
254
+
255
+ def _on_log(self, params: dict[str, Any]) -> None:
256
+ if self.verbose:
257
+ log.info("server: %s", params.get("message", "").rstrip())
258
+
259
+ def analyze(
260
+ self,
261
+ files: list[Path],
262
+ settle: float = 1.0,
263
+ stall: float = 30.0,
264
+ timeout: float | None = None,
265
+ ) -> list[Issue]:
266
+ """Open every file, then wait for the server to finish analyzing them.
267
+
268
+ The server publishes diagnostics asynchronously and may publish a file
269
+ more than once (e.g. an empty list before the real results). Analysis
270
+ runs are bracketed by $/progress begin/end, so we treat the session as
271
+ done once every file has been published, no progress is active, and
272
+ nothing has arrived for ``settle`` seconds. If some files are never
273
+ published after ``stall`` quiet seconds, the analysis is incomplete and
274
+ we raise rather than report a partial result as clean.
275
+ """
276
+ assert self._client is not None
277
+ deadline = None if timeout is None else time.monotonic() + timeout
278
+ uris = {f.as_uri(): f for f in files}
279
+ with self._cond:
280
+ for uri in uris:
281
+ self._published.pop(uri, None)
282
+ for uri, path in uris.items():
283
+ if deadline is not None and time.monotonic() >= deadline:
284
+ raise TimeoutError("analysis did not finish in time")
285
+ text = path.read_text(encoding="utf-8", errors="replace")
286
+ self._client.notify(
287
+ "textDocument/didOpen",
288
+ {"textDocument": {"uri": uri, "languageId": language_id(path), "version": 1, "text": text}},
289
+ )
290
+ with self._cond:
291
+ self._touch()
292
+ while True:
293
+ if self._client.closed.is_set():
294
+ raise RuntimeError("language server exited unexpectedly")
295
+ now = time.monotonic()
296
+ quiet = now - self._last_event
297
+ missing = [u for u in uris if u not in self._published]
298
+ if not self._active_progress:
299
+ if not missing and quiet >= settle:
300
+ break
301
+ if missing and quiet >= stall:
302
+ names = ", ".join(str(uris[u]) for u in missing[:5])
303
+ more = f" and {len(missing) - 5} more" if len(missing) > 5 else ""
304
+ raise RuntimeError(f"the server never reported results for {names}{more}")
305
+ if deadline is not None and now >= deadline:
306
+ raise TimeoutError("analysis did not finish in time")
307
+ self._cond.wait(timeout=0.2)
308
+ published = {uri: self._published.get(uri, []) for uri in uris}
309
+ return [_to_issue(uris[uri], d) for uri, diags in published.items() for d in diags]
310
+
311
+ def close(self) -> None:
312
+ if self._client is not None and self._proc is not None and self._proc.poll() is None:
313
+ try:
314
+ self._client.request("shutdown").result(timeout=30)
315
+ self._client.notify("exit")
316
+ self._proc.wait(timeout=30)
317
+ except Exception:
318
+ log.debug("clean shutdown failed; killing server", exc_info=True)
319
+ self._kill()
320
+
321
+ def _kill(self) -> None:
322
+ if self._proc is not None and self._proc.poll() is None:
323
+ self._proc.kill()
324
+ self._proc.wait()
325
+
326
+
327
+ def _to_issue(path: Path, diagnostic: dict[str, Any]) -> Issue:
328
+ start = diagnostic["range"]["start"]
329
+ end = diagnostic["range"]["end"]
330
+ return Issue(
331
+ path=path,
332
+ line=start["line"] + 1,
333
+ column=start["character"] + 1,
334
+ end_line=end["line"] + 1,
335
+ end_column=end["character"] + 1,
336
+ rule=str(diagnostic.get("code", "")),
337
+ message=diagnostic.get("message", ""),
338
+ severity=SEVERITY_NAMES.get(diagnostic.get("severity", 2), "warning"),
339
+ raw=diagnostic,
340
+ )
exolot/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .analyze import AnalysisSession, Issue, collect_files
12
+ from .server import DEFAULT_SERVER_VERSION, ServerError, cache_dir, detect_platform, ensure_server
13
+
14
+ COMMANDS = ("analyze", "install", "cache-dir")
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="exolot",
20
+ description="Analyze code locally with the SonarQube for IDE language server, no account needed.",
21
+ )
22
+ parser.add_argument("--version", action="version", version=f"exolot {__version__}")
23
+ sub = parser.add_subparsers(dest="command")
24
+
25
+ server_opts = argparse.ArgumentParser(add_help=False)
26
+ server_opts.add_argument(
27
+ "--server-version",
28
+ default=DEFAULT_SERVER_VERSION,
29
+ help="sonarlint-vscode release tag to use (default: %(default)s)",
30
+ )
31
+ server_opts.add_argument(
32
+ "--platform",
33
+ default=None,
34
+ help="VSIX platform, e.g. linux-x64, or 'universal' (default: detected)",
35
+ )
36
+ server_opts.add_argument("--java", help="Java executable to use instead of the bundled one")
37
+ server_opts.add_argument("-v", "--verbose", action="store_true", help="show server logs")
38
+
39
+ analyze = sub.add_parser("analyze", parents=[server_opts], help="analyze files (the default command)")
40
+ analyze.add_argument("paths", nargs="*", type=Path, default=[Path(".")], help="files or directories")
41
+ analyze.add_argument("--format", choices=("text", "json"), default="text")
42
+ analyze.add_argument(
43
+ "--rules",
44
+ type=Path,
45
+ help="JSON file of rule settings, in the same format as VS Code's sonarlint.rules setting",
46
+ )
47
+ analyze.add_argument("--node", help="Node.js executable for JavaScript/TypeScript analysis")
48
+ analyze.add_argument("--workspace", type=Path, help="workspace folder (default: current directory)")
49
+ analyze.add_argument("--timeout", type=float, default=None, help="give up after this many seconds")
50
+
51
+ sub.add_parser("install", parents=[server_opts], help="download and unpack the language server")
52
+ sub.add_parser("cache-dir", help="print the cache directory")
53
+ return parser
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ argv = list(sys.argv[1:] if argv is None else argv)
58
+ if not argv or (argv[0] not in COMMANDS and argv[0] not in ("-h", "--help", "--version")):
59
+ argv.insert(0, "analyze")
60
+ args = build_parser().parse_args(argv)
61
+
62
+ logging.basicConfig(
63
+ level=logging.INFO if getattr(args, "verbose", False) else logging.WARNING,
64
+ format="exolot: %(message)s",
65
+ )
66
+
67
+ if args.command == "cache-dir":
68
+ print(cache_dir())
69
+ return 0
70
+
71
+ plat = args.platform
72
+ if plat == "universal":
73
+ plat = ""
74
+
75
+ if args.command == "analyze":
76
+ # Check the inputs before possibly downloading the server.
77
+ try:
78
+ files = collect_files(args.paths)
79
+ rules = json.loads(args.rules.read_text()) if args.rules else None
80
+ except (OSError, ValueError) as e:
81
+ print(f"exolot: {e}", file=sys.stderr)
82
+ return 2
83
+ if not files:
84
+ print("exolot: no files to analyze", file=sys.stderr)
85
+ return 0
86
+
87
+ try:
88
+ server = ensure_server(args.server_version, plat, args.java)
89
+ except (ServerError, OSError) as e:
90
+ print(f"exolot: {e}", file=sys.stderr)
91
+ return 2
92
+
93
+ if args.command == "install":
94
+ shown = plat if plat is not None else detect_platform()
95
+ print(f"Language server {args.server_version} ({shown or 'universal'})")
96
+ print(f" installed in {server.root}")
97
+ print(f" using Java {server.java}")
98
+ return 0
99
+
100
+ workspace = args.workspace or Path.cwd()
101
+
102
+ try:
103
+ with AnalysisSession(server, workspace, rules=rules, node=args.node, verbose=args.verbose) as session:
104
+ issues = session.analyze(files, timeout=args.timeout)
105
+ # Report before the server shuts down, which can take a few seconds.
106
+ _report(issues, len(files), args.format)
107
+ except (OSError, RuntimeError, TimeoutError) as e:
108
+ print(f"exolot: {e}", file=sys.stderr)
109
+ return 2
110
+ return 1 if issues else 0
111
+
112
+
113
+ def _report(issues: list[Issue], file_count: int, fmt: str) -> None:
114
+ issues = sorted(issues, key=lambda i: (str(i.path), i.line, i.column, i.rule))
115
+ if fmt == "json":
116
+ json.dump([_issue_json(i) for i in issues], sys.stdout, indent=2)
117
+ print()
118
+ else:
119
+ for issue in issues:
120
+ print(f"{_display_path(issue.path)}:{issue.line}:{issue.column}: {issue.severity} [{issue.rule}] {issue.message}")
121
+ print(f"{len(issues)} issue(s) in {file_count} file(s) analyzed", file=sys.stderr)
122
+ sys.stdout.flush()
123
+
124
+
125
+ def _display_path(path: Path) -> str:
126
+ try:
127
+ return os.path.relpath(path)
128
+ except ValueError: # different drive on Windows
129
+ return str(path)
130
+
131
+
132
+ def _issue_json(issue: Issue) -> dict[str, object]:
133
+ return {
134
+ "path": _display_path(issue.path),
135
+ "line": issue.line,
136
+ "column": issue.column,
137
+ "endLine": issue.end_line,
138
+ "endColumn": issue.end_column,
139
+ "rule": issue.rule,
140
+ "severity": issue.severity,
141
+ "message": issue.message,
142
+ }
exolot/lsp.py ADDED
@@ -0,0 +1,144 @@
1
+ """Minimal synchronous JSON-RPC/LSP client over a subprocess's stdio.
2
+
3
+ A background thread reads messages from the server. Responses resolve the
4
+ matching pending request; server-initiated requests and notifications are
5
+ dispatched to registered handlers. Server requests with no handler are
6
+ answered with ``null`` so the server never blocks waiting on us.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import subprocess
14
+ import threading
15
+ from concurrent.futures import Future
16
+ from typing import IO, Any, Callable
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ Handler = Callable[[Any], Any]
21
+
22
+
23
+ class LspError(Exception):
24
+ def __init__(self, error: dict[str, Any]):
25
+ super().__init__(f"{error.get('code')}: {error.get('message')}")
26
+ self.error = error
27
+
28
+
29
+ class LspClient:
30
+ def __init__(self, proc: subprocess.Popen[bytes]):
31
+ assert proc.stdin is not None and proc.stdout is not None
32
+ self._proc = proc
33
+ self._stdin: IO[bytes] = proc.stdin
34
+ self._stdout: IO[bytes] = proc.stdout
35
+ self._write_lock = threading.Lock()
36
+ self._next_id = 0
37
+ self._pending: dict[int, Future[Any]] = {}
38
+ self._pending_lock = threading.Lock()
39
+ self._request_handlers: dict[str, Handler] = {}
40
+ self._notification_handlers: dict[str, Handler] = {}
41
+ self._reader = threading.Thread(target=self._read_loop, name="lsp-reader", daemon=True)
42
+ self.closed = threading.Event()
43
+
44
+ def start(self) -> None:
45
+ self._reader.start()
46
+
47
+ def on_request(self, method: str, handler: Handler) -> None:
48
+ self._request_handlers[method] = handler
49
+
50
+ def on_notification(self, method: str, handler: Handler) -> None:
51
+ self._notification_handlers[method] = handler
52
+
53
+ def request(self, method: str, params: Any = None) -> Future[Any]:
54
+ with self._pending_lock:
55
+ self._next_id += 1
56
+ msg_id = self._next_id
57
+ future: Future[Any] = Future()
58
+ self._pending[msg_id] = future
59
+ self._send({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params})
60
+ return future
61
+
62
+ def notify(self, method: str, params: Any = None) -> None:
63
+ self._send({"jsonrpc": "2.0", "method": method, "params": params})
64
+
65
+ def _send(self, message: dict[str, Any]) -> None:
66
+ body = json.dumps(message, separators=(",", ":")).encode("utf-8")
67
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
68
+ log.debug("-> %s", body[:2000])
69
+ with self._write_lock:
70
+ self._stdin.write(header + body)
71
+ self._stdin.flush()
72
+
73
+ def _read_message(self) -> dict[str, Any] | None:
74
+ length = None
75
+ while True:
76
+ line = self._stdout.readline()
77
+ if not line:
78
+ return None
79
+ line = line.strip()
80
+ if not line:
81
+ break
82
+ name, _, value = line.decode("ascii").partition(":")
83
+ if name.strip().lower() == "content-length":
84
+ length = int(value.strip())
85
+ if length is None:
86
+ raise ValueError("LSP message without Content-Length header")
87
+ body = self._stdout.read(length)
88
+ if len(body) < length:
89
+ return None
90
+ log.debug("<- %s", body[:2000])
91
+ return json.loads(body)
92
+
93
+ def _read_loop(self) -> None:
94
+ try:
95
+ while (message := self._read_message()) is not None:
96
+ self._dispatch(message)
97
+ except Exception:
98
+ log.exception("LSP reader failed")
99
+ finally:
100
+ self.closed.set()
101
+ with self._pending_lock:
102
+ pending, self._pending = self._pending, {}
103
+ for future in pending.values():
104
+ if future.set_running_or_notify_cancel():
105
+ future.set_exception(ConnectionError("language server closed the connection"))
106
+
107
+ def _dispatch(self, message: dict[str, Any]) -> None:
108
+ method = message.get("method")
109
+ if method is None:
110
+ with self._pending_lock:
111
+ future = self._pending.pop(message.get("id"), None)
112
+ if future is None:
113
+ log.warning("response for unknown request id %r", message.get("id"))
114
+ elif not future.set_running_or_notify_cancel():
115
+ log.debug("dropping response to cancelled request %r", message.get("id"))
116
+ elif "error" in message:
117
+ future.set_exception(LspError(message["error"]))
118
+ else:
119
+ future.set_result(message.get("result"))
120
+ elif "id" in message:
121
+ self._handle_server_request(message["id"], method, message.get("params"))
122
+ else:
123
+ handler = self._notification_handlers.get(method)
124
+ if handler is None:
125
+ log.debug("unhandled notification %s", method)
126
+ return
127
+ try:
128
+ handler(message.get("params"))
129
+ except Exception:
130
+ log.exception("notification handler for %s failed", method)
131
+
132
+ def _handle_server_request(self, msg_id: Any, method: str, params: Any) -> None:
133
+ handler = self._request_handlers.get(method)
134
+ if handler is None:
135
+ log.debug("unhandled server request %s, replying null", method)
136
+ result = None
137
+ else:
138
+ try:
139
+ result = handler(params)
140
+ except Exception as e:
141
+ log.exception("request handler for %s failed", method)
142
+ self._send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32603, "message": str(e)}})
143
+ return
144
+ self._send({"jsonrpc": "2.0", "id": msg_id, "result": result})
exolot/server.py ADDED
@@ -0,0 +1,243 @@
1
+ """Locate, download, verify and unpack the language server from a VSIX.
2
+
3
+ The VSIX is the SonarQube for IDE extension for VS Code, published as GitHub
4
+ release assets of SonarSource/sonarlint-vscode. Platform-specific builds bundle
5
+ a JRE; the universal build does not, so on other platforms we fall back to it
6
+ and use a Java found on the system.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import logging
13
+ import os
14
+ import platform
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ import urllib.request
19
+ import zipfile
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ DEFAULT_SERVER_VERSION = "6.2.0+91200"
26
+
27
+ RELEASE_URL = "https://github.com/SonarSource/sonarlint-vscode/releases/download/{tag}/{asset}"
28
+
29
+ # sha256 of each release asset, keyed by tag then platform ("" = universal).
30
+ # Taken from the GitHub release metadata and checked against the downloads.
31
+ KNOWN_DIGESTS: dict[str, dict[str, str]] = {
32
+ "6.2.0+91200": {
33
+ "": "b0c13ecb37b6d2e8e2414f482f62ddd916eec5f3c2db0711e9c0b7bb17823850",
34
+ "darwin-arm64": "8d59d1101ed9edfb9b7bcb99fd3df8835b779c061bdceec01acf51c7fbc56446",
35
+ "darwin-x64": "d64a71f0fdcd6597e7b1b7fba22be46f4ea64aae92f428b7149eecfb99626963",
36
+ "linux-x64": "054f309995789e2cb5e20a1f0e071db7ab817e6dec11ae45bcebb45d69216cac",
37
+ "win32-x64": "9c9657d007327e0bab923185c08c3c699989af2130e25353bedb2be04910467e",
38
+ },
39
+ }
40
+
41
+ # Only these parts of the VSIX are needed to run the server.
42
+ EXTRACT_PREFIXES = ("extension/server/", "extension/analyzers/", "extension/jre/")
43
+ EXTRACT_FILES = ("extension/package.json", "extension/LICENSE.txt", "extension/NOTICE.txt")
44
+
45
+ # Analyzers passed to the server, in the order the VS Code client passes them.
46
+ # C/C++ and C# analyzers are downloaded by the server itself on demand.
47
+ ANALYZERS = (
48
+ "sonargo.jar",
49
+ "sonarjava.jar",
50
+ "sonarjavasymbolicexecution.jar",
51
+ "sonarjs.jar",
52
+ "sonarphp.jar",
53
+ "sonarpython.jar",
54
+ "sonarhtml.jar",
55
+ "sonarxml.jar",
56
+ "sonartext.jar",
57
+ "sonariac.jar",
58
+ "sonarlintomnisharp.jar",
59
+ )
60
+
61
+
62
+ class ServerError(Exception):
63
+ pass
64
+
65
+
66
+ def detect_platform() -> str:
67
+ """Return the VSIX platform suffix for this machine, or "" for universal."""
68
+ machine = platform.machine().lower()
69
+ arch = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"}.get(machine)
70
+ if sys.platform == "darwin":
71
+ os_name = "darwin"
72
+ elif sys.platform.startswith("linux"):
73
+ os_name = "linux"
74
+ elif sys.platform == "win32":
75
+ os_name = "win32"
76
+ else:
77
+ return ""
78
+ candidate = f"{os_name}-{arch}"
79
+ return candidate if candidate in {"darwin-arm64", "darwin-x64", "linux-x64", "win32-x64"} else ""
80
+
81
+
82
+ def cache_dir() -> Path:
83
+ if env := os.environ.get("EXOLOT_CACHE_DIR"):
84
+ return Path(env)
85
+ if sys.platform == "win32":
86
+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
87
+ return base / "exolot" / "Cache"
88
+ if sys.platform == "darwin":
89
+ return Path.home() / "Library" / "Caches" / "exolot"
90
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "exolot"
91
+
92
+
93
+ def asset_name(tag: str, plat: str) -> str:
94
+ version = tag.split("+", 1)[0]
95
+ return f"sonarlint-vscode-{plat}-{version}.vsix" if plat else f"sonarlint-vscode-{version}.vsix"
96
+
97
+
98
+ @dataclass
99
+ class Server:
100
+ root: Path # the extracted "extension" directory
101
+ java: Path
102
+
103
+ @property
104
+ def jar(self) -> Path:
105
+ return self.root / "server" / "sonarlint-ls.jar"
106
+
107
+ def analyzer_paths(self) -> list[Path]:
108
+ return [p for p in (self.root / "analyzers" / name for name in ANALYZERS) if p.exists()]
109
+
110
+ def command(self, jvm_args: list[str] | None = None) -> list[str]:
111
+ return [
112
+ str(self.java),
113
+ "-Dsonarlint.telemetry.disabled=true",
114
+ *(jvm_args or []),
115
+ "-jar",
116
+ str(self.jar),
117
+ "-stdio",
118
+ "-analyzers",
119
+ *(str(p) for p in self.analyzer_paths()),
120
+ ]
121
+
122
+
123
+ def ensure_server(
124
+ tag: str = DEFAULT_SERVER_VERSION,
125
+ plat: str | None = None,
126
+ java: str | None = None,
127
+ progress: bool = True,
128
+ ) -> Server:
129
+ """Return a ready-to-run server, downloading and unpacking it if needed."""
130
+ if plat is None:
131
+ plat = detect_platform()
132
+ install_dir = cache_dir() / "servers" / f"{tag}-{plat or 'universal'}"
133
+ if not (install_dir / ".complete").exists():
134
+ _install(tag, plat, install_dir, progress)
135
+ root = install_dir / "extension"
136
+ return Server(root=root, java=_find_java(root, java))
137
+
138
+
139
+ def _install(tag: str, plat: str, install_dir: Path, progress: bool) -> None:
140
+ asset = asset_name(tag, plat)
141
+ url = RELEASE_URL.format(tag=tag.replace("+", "%2B"), asset=asset)
142
+ expected = KNOWN_DIGESTS.get(tag, {}).get(plat)
143
+ install_dir.parent.mkdir(parents=True, exist_ok=True)
144
+ with tempfile.TemporaryDirectory(dir=install_dir.parent, prefix=".download-") as tmp:
145
+ vsix = Path(tmp) / asset
146
+ _download(url, vsix, progress)
147
+ digest = _sha256(vsix)
148
+ if expected is None:
149
+ log.warning("no known digest for %s; downloaded file has sha256 %s", asset, digest)
150
+ elif digest != expected:
151
+ raise ServerError(f"sha256 mismatch for {asset}: expected {expected}, got {digest}")
152
+ staging = Path(tmp) / "staging"
153
+ _extract(vsix, staging)
154
+ vsix.unlink()
155
+ (staging / ".complete").write_text(f"{tag} {plat or 'universal'} {digest}\n")
156
+ _publish(staging, install_dir)
157
+
158
+
159
+ def _publish(staging: Path, install_dir: Path) -> None:
160
+ """Move a finished install into place, tolerating concurrent installers.
161
+
162
+ The rename is atomic, so another exolot process either sees no install or
163
+ a complete one. If another process got there first, keep its copy.
164
+ """
165
+ try:
166
+ staging.rename(install_dir)
167
+ except OSError:
168
+ if (install_dir / ".complete").exists():
169
+ log.debug("%s was installed by another process", install_dir)
170
+ return
171
+ # A leftover directory without the marker (e.g. from an older layout).
172
+ shutil.rmtree(install_dir, ignore_errors=True)
173
+ staging.rename(install_dir)
174
+
175
+
176
+ def _download(url: str, dest: Path, progress: bool) -> None:
177
+ log.info("downloading %s", url)
178
+ # The timeout applies to each blocking read, not the whole download.
179
+ with urllib.request.urlopen(url, timeout=60) as response, open(dest, "wb") as out:
180
+ total = int(response.headers.get("Content-Length") or 0)
181
+ done = 0
182
+ last_pct = -1
183
+ while chunk := response.read(1 << 20):
184
+ out.write(chunk)
185
+ done += len(chunk)
186
+ if progress and total and sys.stderr.isatty():
187
+ pct = done * 100 // total
188
+ if pct != last_pct:
189
+ print(f"\rDownloading {dest.name}: {pct}%", end="", file=sys.stderr, flush=True)
190
+ last_pct = pct
191
+ if progress and total and sys.stderr.isatty():
192
+ print(file=sys.stderr)
193
+
194
+
195
+ def _sha256(path: Path) -> str:
196
+ h = hashlib.sha256()
197
+ with open(path, "rb") as f:
198
+ while chunk := f.read(1 << 20):
199
+ h.update(chunk)
200
+ return h.hexdigest()
201
+
202
+
203
+ def _extract(vsix: Path, dest: Path) -> None:
204
+ """Extract the parts we need, restoring Unix permission bits.
205
+
206
+ zipfile drops permission bits, so executables in the bundled JRE (java,
207
+ and lib/jspawnhelper which Java needs to start subprocesses) would not be
208
+ runnable without restoring them from each entry's external attributes.
209
+ """
210
+ dest_resolved = dest.resolve()
211
+ with zipfile.ZipFile(vsix) as z:
212
+ for info in z.infolist():
213
+ name = info.filename
214
+ if not (name.startswith(EXTRACT_PREFIXES) or name in EXTRACT_FILES):
215
+ continue
216
+ target = (dest / name).resolve()
217
+ if not target.is_relative_to(dest_resolved):
218
+ raise ServerError(f"refusing to extract {name!r} outside {dest}")
219
+ z.extract(info, dest)
220
+ mode = (info.external_attr >> 16) & 0o777
221
+ if mode and os.name == "posix":
222
+ os.chmod(target, mode)
223
+
224
+
225
+ def _find_java(root: Path, override: str | None) -> Path:
226
+ if override:
227
+ return Path(override)
228
+ exe = "java.exe" if sys.platform == "win32" else "java"
229
+ jre = root / "jre"
230
+ if jre.is_dir():
231
+ candidates = sorted(jre.rglob(f"bin/{exe}"), key=lambda p: len(p.parts))
232
+ if candidates:
233
+ return candidates[0]
234
+ if java_home := os.environ.get("JAVA_HOME"):
235
+ candidate = Path(java_home) / "bin" / exe
236
+ if candidate.exists():
237
+ return candidate
238
+ if found := shutil.which("java"):
239
+ return Path(found)
240
+ raise ServerError(
241
+ "this platform has no VSIX with a bundled Java runtime, and no Java was found; "
242
+ "install Java 17 or later, or pass --java"
243
+ )
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: exolot
3
+ Version: 0.1.0
4
+ Summary: Analyze code locally from the command line with the SonarQube for IDE language server
5
+ Project-URL: Repository, https://github.com/skissane/exolot
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Natural Language :: English
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Quality Assurance
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+
16
+ # exolot
17
+
18
+ Analyze code locally from the command line with the language server from
19
+ SonarQube for IDE (formerly SonarLint). No SonarQube account or server needed.
20
+
21
+ > exolot is an independent project. It is not affiliated with or endorsed by
22
+ > SonarSource. "SonarQube" and "SonarLint" are trademarks of SonarSource.
23
+
24
+ ## Why
25
+
26
+ The old `sonarlint-cli` was discontinued years ago. SonarSource's current
27
+ SonarQube CLI needs a SonarQube Cloud or Server account, and its code analysis
28
+ runs on the server. exolot fills the gap: account-free analysis that runs
29
+ entirely on your own machine. It needs network access only to download the
30
+ language server the first time (and see the note on C/C++ and C# below).
31
+
32
+ ## How it works
33
+
34
+ exolot downloads the SonarQube for IDE extension for VS Code (a `.vsix` file,
35
+ which is a zip) from the
36
+ [sonarlint-vscode GitHub releases](https://github.com/SonarSource/sonarlint-vscode/releases),
37
+ checks its sha256 digest, and unpacks the language server, the language
38
+ analyzers and the bundled Java runtime into a cache directory. It then runs the
39
+ language server headlessly, opens each file you ask it to analyze, and prints
40
+ the issues the server reports.
41
+
42
+ The idea comes from [sonarqube.nvim](https://github.com/iamkarasik/sonarqube.nvim),
43
+ which does the same thing for Neovim.
44
+
45
+ ## Usage
46
+
47
+ ```sh
48
+ uvx exolot # analyze the current directory
49
+ uvx exolot src/ tests/foo.py # analyze specific files or directories
50
+ uvx exolot --format json . # machine-readable output
51
+ uvx exolot install # just download and unpack the server
52
+ uvx exolot cache-dir # show where the server is cached
53
+ ```
54
+
55
+ Directories are expanded with `git ls-files` when inside a git repository (so
56
+ `.gitignore` is respected), and by walking the tree otherwise. Binary files and
57
+ files over 2 MiB are skipped.
58
+
59
+ Exit status is 0 when no issues are found, 1 when issues are found, and 2 on
60
+ errors.
61
+
62
+ ### Options
63
+
64
+ - `--server-version TAG` picks a sonarlint-vscode release (default: the
65
+ version exolot was tested with). Unknown versions are downloaded without
66
+ digest verification, with a warning.
67
+ - `--platform PLATFORM` overrides the detected VSIX platform (`linux-x64`,
68
+ `darwin-arm64`, `darwin-x64`, `win32-x64`, or `universal`).
69
+ - `--java PATH` uses a different Java executable.
70
+ - `--node PATH` sets the Node.js used for JavaScript/TypeScript analysis
71
+ (otherwise the server looks for `node` on the `PATH`).
72
+ - `--rules FILE` reads rule settings from a JSON file, in the same format as
73
+ VS Code's `sonarlint.rules` setting, e.g.
74
+ `{"python:S1764": {"level": "off"}}`.
75
+ - `-v` / `--verbose` shows the server's logs.
76
+
77
+ ## Platforms
78
+
79
+ Platform-specific builds with a bundled Java runtime exist for `linux-x64`,
80
+ `darwin-arm64`, `darwin-x64` and `win32-x64`. On other platforms (such as
81
+ Linux on ARM) exolot uses the universal build, which needs Java 17 or later
82
+ from `JAVA_HOME`, the `PATH`, or `--java`.
83
+
84
+ ## Notes
85
+
86
+ - The language server keeps its own state in `~/.sonarlint`, shared with any
87
+ SonarQube for IDE installation you have.
88
+ - Telemetry is disabled.
89
+ - C/C++ and C# analyzers are not included in the VSIX; the server tries to
90
+ download them from `binaries.sonarsource.com` when it starts.
91
+ - Each run starts a fresh JVM and loads every analyzer, so even a single file
92
+ takes around 15 seconds.
93
+ - The bundled analyzers are licensed by SonarSource under their own terms. exolot
94
+ downloads them from SonarSource's releases at run time and does not
95
+ redistribute them.
96
+
97
+ ## Development
98
+
99
+ ```sh
100
+ uv sync
101
+ uv run exolot --help
102
+ uv run pytest
103
+ ```
@@ -0,0 +1,11 @@
1
+ exolot/__init__.py,sha256=HEpmxJrGLz6sV_xyugVbSj2lp15exvExTfH6Fac425o,228
2
+ exolot/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ exolot/analyze.py,sha256=6YPkLm6cPhJuVKua6280SR5asaaaK_V56Kh18Gt0jVU,12630
4
+ exolot/cli.py,sha256=Rgj43Vowmel2I9WOTy4kunFOJCkvmQefbibpGHsYlAk,5358
5
+ exolot/lsp.py,sha256=ijDsjGoZsc_HgESDA51SeiyNNM2ky9khg87N-CqpiZ0,5685
6
+ exolot/server.py,sha256=au3Gsp2IYbp0z6WiFSlf64skHCSJGXlrI1V6SmTMsEk,8948
7
+ exolot-0.1.0.dist-info/METADATA,sha256=c550UAJeT1vWAlj1VBovwPl65v7U15M21uuR1oHgEcU,4115
8
+ exolot-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ exolot-0.1.0.dist-info/entry_points.txt,sha256=ZMr8S_lrBPluaGyjFVrq4T7GcuQzW0TTs8vXhR2EuW4,43
10
+ exolot-0.1.0.dist-info/licenses/LICENSE,sha256=yypQLTZMBkvEPhV87NpYU4480tnMIimc4xbXjnP2gFI,1075
11
+ exolot-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ exolot = exolot.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The exolot authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.