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 +8 -0
- exolot/__main__.py +3 -0
- exolot/analyze.py +340 -0
- exolot/cli.py +142 -0
- exolot/lsp.py +144 -0
- exolot/server.py +243 -0
- exolot-0.1.0.dist-info/METADATA +103 -0
- exolot-0.1.0.dist-info/RECORD +11 -0
- exolot-0.1.0.dist-info/WHEEL +4 -0
- exolot-0.1.0.dist-info/entry_points.txt +2 -0
- exolot-0.1.0.dist-info/licenses/LICENSE +21 -0
exolot/__init__.py
ADDED
exolot/__main__.py
ADDED
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,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.
|