ground-control-cli 0.2.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.
- client/__init__.py +1 -0
- client/claude.py +240 -0
- client/cli.py +314 -0
- client/connect.py +128 -0
- client/machine.py +254 -0
- client/probes/__init__.py +57 -0
- client/probes/code/__init__.py +435 -0
- client/probes/code/bash.py +163 -0
- client/probes/code/javascript.js +171 -0
- client/probes/code/javascript.py +193 -0
- client/probes/code/python.py +143 -0
- client/probes/errors.py +67 -0
- client/probes/gh.py +126 -0
- client/probes/git.py +280 -0
- client/probes/health.py +128 -0
- client/probes/settings.py +72 -0
- client/probes/tests.py +532 -0
- client/repos.py +68 -0
- client/service.py +398 -0
- client/system/__init__.py +73 -0
- client/system/macos.py +92 -0
- client/update.py +110 -0
- ground_control_cli-0.2.0.dist-info/METADATA +5 -0
- ground_control_cli-0.2.0.dist-info/RECORD +26 -0
- ground_control_cli-0.2.0.dist-info/WHEEL +4 -0
- ground_control_cli-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""The code readers and the code probes: one interface over the source, whatever its language.
|
|
2
|
+
|
|
3
|
+
`symbols(path)` returns the classes, functions and methods a file defines, each a Symbol:
|
|
4
|
+
kind, qualified name, file, signature as written (on one line), first and last line.
|
|
5
|
+
`calls(path)` returns who calls what in a file, for information only.
|
|
6
|
+
|
|
7
|
+
A reader is chosen by file extension; a file with no reader for its extension whose first line
|
|
8
|
+
is a bash or sh shebang is a Bash script, read by the Bash reader and counted as `.sh`. READERS
|
|
9
|
+
is the closed list of readers; SOURCE is the closed list of extensions that are source code.
|
|
10
|
+
`languages(root)` groups the source files git tracks by extension and lists the extensions no
|
|
11
|
+
reader reads.
|
|
12
|
+
|
|
13
|
+
The probes code.symbols, code.callers and code.languages return what the readers found as facts
|
|
14
|
+
(app/method/facts.py): a name travels as `name_hash`, a signature as `signature_hash`, never as
|
|
15
|
+
text. These hash as the service's facts do, by the contract's signature vectors.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import NamedTuple
|
|
26
|
+
|
|
27
|
+
from .. import errors
|
|
28
|
+
from . import bash, javascript, python
|
|
29
|
+
|
|
30
|
+
KINDS = ("class", "function", "method")
|
|
31
|
+
|
|
32
|
+
# extension -> the module that reads it; each has symbols(path, name), calls(path, name),
|
|
33
|
+
# missing(), what to install for it to run ("" when nothing), and tools_missing(), the names of
|
|
34
|
+
# its tools that are missing.
|
|
35
|
+
READERS = {
|
|
36
|
+
".py": python,
|
|
37
|
+
".js": javascript,
|
|
38
|
+
".mjs": javascript,
|
|
39
|
+
".cjs": javascript,
|
|
40
|
+
".jsx": javascript,
|
|
41
|
+
".ts": javascript,
|
|
42
|
+
".tsx": javascript,
|
|
43
|
+
".html": javascript,
|
|
44
|
+
".sh": bash,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The extensions that are source code. HTML counts only for its inline <script> blocks.
|
|
48
|
+
SOURCE = (
|
|
49
|
+
".py",
|
|
50
|
+
".js",
|
|
51
|
+
".mjs",
|
|
52
|
+
".cjs",
|
|
53
|
+
".ts",
|
|
54
|
+
".tsx",
|
|
55
|
+
".jsx",
|
|
56
|
+
".sh",
|
|
57
|
+
".rb",
|
|
58
|
+
".go",
|
|
59
|
+
".java",
|
|
60
|
+
".kt",
|
|
61
|
+
".swift",
|
|
62
|
+
".php",
|
|
63
|
+
".cs",
|
|
64
|
+
".rs",
|
|
65
|
+
".c",
|
|
66
|
+
".h",
|
|
67
|
+
".cpp",
|
|
68
|
+
".html",
|
|
69
|
+
)
|
|
70
|
+
HTML = ".html"
|
|
71
|
+
SCRIPT = ".sh"
|
|
72
|
+
INLINE_SCRIPT_RE = re.compile(r"<script\b(?![^>]*\bsrc\s*=)[^>]*>\s*\S", re.IGNORECASE)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Symbol(NamedTuple):
|
|
76
|
+
"""A class, function or method a source file defines.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
kind: One of KINDS: "class", "function" or "method".
|
|
80
|
+
name: The qualified name.
|
|
81
|
+
file: The file, as it is shown.
|
|
82
|
+
signature: The signature as written, on one line.
|
|
83
|
+
first: The symbol's first line.
|
|
84
|
+
last: The symbol's last line.
|
|
85
|
+
|
|
86
|
+
Company: assumes none.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
kind: str
|
|
90
|
+
name: str
|
|
91
|
+
file: str
|
|
92
|
+
signature: str
|
|
93
|
+
first: int
|
|
94
|
+
last: int
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Call(NamedTuple):
|
|
98
|
+
"""One call in a source file, matched by name, not resolved.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
caller: The enclosing symbol's name, or `<module>`.
|
|
102
|
+
callee: The called name.
|
|
103
|
+
file: The file, as it is shown.
|
|
104
|
+
line: The line of the call.
|
|
105
|
+
|
|
106
|
+
Company: assumes none.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
caller: str
|
|
110
|
+
callee: str
|
|
111
|
+
file: str
|
|
112
|
+
line: int
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Languages(NamedTuple):
|
|
116
|
+
"""The tracked source files of a checkout, by extension.
|
|
117
|
+
|
|
118
|
+
Attributes:
|
|
119
|
+
files: Extension -> the tracked source files with it, in git's order.
|
|
120
|
+
unread: The extensions in `files` that no reader reads.
|
|
121
|
+
|
|
122
|
+
Company: assumes none.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
files: dict # extension -> the tracked source files with it, in git's order
|
|
126
|
+
unread: list # the extensions in `files` that no reader reads
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def reader(path):
|
|
130
|
+
"""The reader module for this file, or None: by its extension, else by its shebang."""
|
|
131
|
+
found = READERS.get(Path(path).suffix.lower())
|
|
132
|
+
if found is None and bash.is_script(path):
|
|
133
|
+
return bash
|
|
134
|
+
return found
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def symbols(path, name: str = "") -> list:
|
|
138
|
+
"""The file's symbols; `name` is the file as it is shown (default: the path)."""
|
|
139
|
+
found = reader(path)
|
|
140
|
+
if found is None:
|
|
141
|
+
raise errors.Refused(
|
|
142
|
+
f"{name or path}: no reader for `{Path(path).suffix}`; the readers "
|
|
143
|
+
f"are {', '.join(READERS)}",
|
|
144
|
+
errors.NO_READER,
|
|
145
|
+
)
|
|
146
|
+
return found.symbols(Path(path), name or str(path))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def missing(extensions) -> dict:
|
|
150
|
+
"""Say what to install for the readers of these extensions whose tooling is missing.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
extensions: The extensions to look at; one with no reader is skipped.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
What to install -> the extensions it reads; empty when nothing is missing.
|
|
157
|
+
|
|
158
|
+
Company: assumes none.
|
|
159
|
+
"""
|
|
160
|
+
needs = {}
|
|
161
|
+
for extension in extensions:
|
|
162
|
+
found = READERS.get(extension)
|
|
163
|
+
line = found.missing() if found else ""
|
|
164
|
+
if line:
|
|
165
|
+
needs.setdefault(line, []).append(extension)
|
|
166
|
+
return needs
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def calls(path, name: str = "") -> list:
|
|
170
|
+
"""Return who calls what in a file, for information only.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
path: The file to read.
|
|
174
|
+
name: The file as it is shown (default: the path).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The file's Calls, or an empty list when no reader reads it.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
errors.Refused: If the file's reader cannot parse it or its tooling is missing.
|
|
181
|
+
|
|
182
|
+
Company: assumes none.
|
|
183
|
+
"""
|
|
184
|
+
found = reader(path)
|
|
185
|
+
return found.calls(Path(path), name or str(path)) if found else []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def tracked(root) -> list:
|
|
189
|
+
"""The files git tracks under root, relative to it."""
|
|
190
|
+
out = subprocess.run(["git", "ls-files", "-z"], cwd=root, capture_output=True, text=True)
|
|
191
|
+
if out.returncode != 0:
|
|
192
|
+
raise errors.Refused(f"git ls-files: {out.stderr.strip()}", errors.failed(out.stderr))
|
|
193
|
+
return [f for f in out.stdout.split("\0") if f]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _language(root: Path, name: str) -> str:
|
|
197
|
+
"""The extension the file counts under as source code, or "" when it is not source code."""
|
|
198
|
+
suffix = Path(name).suffix.lower()
|
|
199
|
+
if suffix not in SOURCE:
|
|
200
|
+
return SCRIPT if bash.is_script(root / name) else ""
|
|
201
|
+
return suffix if _is_source(root, name) else ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _is_source(root: Path, name: str) -> bool:
|
|
205
|
+
suffix = Path(name).suffix.lower()
|
|
206
|
+
if suffix != HTML:
|
|
207
|
+
return True
|
|
208
|
+
try:
|
|
209
|
+
return bool(
|
|
210
|
+
INLINE_SCRIPT_RE.search((root / name).read_text(encoding="utf-8", errors="replace"))
|
|
211
|
+
)
|
|
212
|
+
except OSError:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def languages(root) -> Languages:
|
|
217
|
+
"""Group the source files git tracks by extension, and list the extensions no reader reads.
|
|
218
|
+
|
|
219
|
+
A file with no source extension counts as `.sh` when its first line is a bash or sh
|
|
220
|
+
shebang; an HTML file counts only when it has an inline `<script>` block.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
root: The checkout to read.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The Languages of the checkout.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
errors.Refused: If `git ls-files` fails.
|
|
230
|
+
|
|
231
|
+
Company: assumes none.
|
|
232
|
+
"""
|
|
233
|
+
root = Path(root)
|
|
234
|
+
files = {}
|
|
235
|
+
for name in tracked(root):
|
|
236
|
+
language = _language(root, name)
|
|
237
|
+
if language:
|
|
238
|
+
files.setdefault(language, []).append(name)
|
|
239
|
+
return Languages(files, [e for e in files if e not in READERS])
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def callers(root, names) -> dict:
|
|
243
|
+
"""Find, for each short name, the places that call it.
|
|
244
|
+
|
|
245
|
+
Only the tracked files a reader reads are looked at. Information only: a call is matched by
|
|
246
|
+
name, not resolved.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
root: The checkout to read.
|
|
250
|
+
names: The short names to look for.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Each name -> its callers, as `file:line caller` lines.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
errors.Refused: If `git ls-files` fails, or a reader cannot read a file.
|
|
257
|
+
|
|
258
|
+
Company: assumes none.
|
|
259
|
+
"""
|
|
260
|
+
root = Path(root)
|
|
261
|
+
wanted = {n: [] for n in names}
|
|
262
|
+
for name in tracked(root):
|
|
263
|
+
if reader(root / name) is None or not (root / name).is_file():
|
|
264
|
+
continue
|
|
265
|
+
for call in calls(root / name, name):
|
|
266
|
+
if call.callee in wanted:
|
|
267
|
+
wanted[call.callee].append(f"{call.file}:{call.line} {call.caller}")
|
|
268
|
+
return wanted
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# --- the facts ---------------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def name_hash(name: str) -> str:
|
|
275
|
+
"""The fingerprint of a name in code: sha256 of its UTF-8, in hex, as the service's facts."""
|
|
276
|
+
return hashlib.sha256(name.encode("utf-8")).hexdigest()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def bare(signature: str) -> str:
|
|
280
|
+
"""A signature without whitespace and without a leading def, class or function keyword."""
|
|
281
|
+
text = re.sub(r"^\s*(async\s+def|def|class|function)\s+", "", signature or "")
|
|
282
|
+
return re.sub(r"\s+", "", text)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def signature_hash(signature: str) -> str:
|
|
286
|
+
"""The fingerprint of a signature: name_hash of its bare form, so equal ones match."""
|
|
287
|
+
return name_hash(bare(signature))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _fact(symbol: Symbol) -> dict:
|
|
291
|
+
"""One symbol as a fact: its kind, its lines, and its names and signature hashed."""
|
|
292
|
+
return {
|
|
293
|
+
"kind": symbol.kind,
|
|
294
|
+
"name_hash": name_hash(symbol.name),
|
|
295
|
+
"short_hash": name_hash(symbol.name.split(".")[-1]),
|
|
296
|
+
"signature_hash": signature_hash(symbol.signature),
|
|
297
|
+
"first": symbol.first,
|
|
298
|
+
"last": symbol.last,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _file_symbols(target: Path, name: str) -> dict:
|
|
303
|
+
"""The FileSymbols fields of a file on disk, shown as `name`."""
|
|
304
|
+
if reader(target) is None:
|
|
305
|
+
return {"path": name, "error": errors.NO_READER}
|
|
306
|
+
try:
|
|
307
|
+
return {"path": name, "symbols": [_fact(s) for s in symbols(target, name)]}
|
|
308
|
+
except errors.Refused as e:
|
|
309
|
+
return {"path": name, "error": e.error}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _at_commit(root: Path, at: str, name: str) -> dict:
|
|
313
|
+
"""The FileSymbols fields of a file as a commit holds it, copied out of git to be read."""
|
|
314
|
+
out = subprocess.run(["git", "show", f"{at}:{name}"], cwd=root, capture_output=True)
|
|
315
|
+
if out.returncode != 0:
|
|
316
|
+
return {"path": name, "present": False}
|
|
317
|
+
with tempfile.TemporaryDirectory(prefix="method-code-") as folder:
|
|
318
|
+
target = Path(folder) / name
|
|
319
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
target.write_bytes(out.stdout)
|
|
321
|
+
return _file_symbols(target, name)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def symbols_probe(params: dict, root) -> dict:
|
|
325
|
+
"""code.symbols: the classes, functions and methods some files define, hashed.
|
|
326
|
+
|
|
327
|
+
HEAD reads the working tree; any other ref reads each file as git holds it there, whatever
|
|
328
|
+
is on disk. A file that is not there is not present; a ref that names no commit gives every
|
|
329
|
+
file not_found.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
params: {"files", "at"}.
|
|
333
|
+
root: The repo.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The SymbolsFacts fields.
|
|
337
|
+
|
|
338
|
+
Company: assumes none.
|
|
339
|
+
"""
|
|
340
|
+
root, at = Path(root), params.get("at") or "HEAD"
|
|
341
|
+
names = list(params.get("files") or [])
|
|
342
|
+
if at != "HEAD":
|
|
343
|
+
try:
|
|
344
|
+
known = subprocess.run(
|
|
345
|
+
["git", "rev-parse", "--verify", "-q", f"{at}^{{commit}}"],
|
|
346
|
+
cwd=root,
|
|
347
|
+
capture_output=True,
|
|
348
|
+
)
|
|
349
|
+
except FileNotFoundError:
|
|
350
|
+
return {"files": [{"path": n, "error": errors.TOOL_MISSING} for n in names]}
|
|
351
|
+
if known.returncode != 0:
|
|
352
|
+
return {
|
|
353
|
+
"files": [{"path": n, "present": False, "error": errors.NOT_FOUND} for n in names]
|
|
354
|
+
}
|
|
355
|
+
found = []
|
|
356
|
+
for name in names:
|
|
357
|
+
if at != "HEAD":
|
|
358
|
+
found.append(_at_commit(root, at, name))
|
|
359
|
+
elif not (root / name).is_file():
|
|
360
|
+
found.append({"path": name, "present": False})
|
|
361
|
+
else:
|
|
362
|
+
found.append(_file_symbols(root / name, name))
|
|
363
|
+
return {"files": found}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def callers_probe(params: dict, root) -> dict:
|
|
367
|
+
"""code.callers: the calls of some short names in the tracked files a reader reads.
|
|
368
|
+
|
|
369
|
+
A call is matched by name, not resolved. A file a reader cannot read is passed over; the
|
|
370
|
+
fact's error then says which error stopped the first one.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
params: {"short_hashes"}.
|
|
374
|
+
root: The repo.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
The CallersFacts fields.
|
|
378
|
+
|
|
379
|
+
Company: assumes none.
|
|
380
|
+
"""
|
|
381
|
+
root = Path(root)
|
|
382
|
+
wanted = set(params.get("short_hashes") or [])
|
|
383
|
+
try:
|
|
384
|
+
names = tracked(root)
|
|
385
|
+
except errors.Refused as e:
|
|
386
|
+
return {"error": e.error}
|
|
387
|
+
except FileNotFoundError:
|
|
388
|
+
return {"error": errors.TOOL_MISSING}
|
|
389
|
+
found, error = [], None
|
|
390
|
+
for name in names:
|
|
391
|
+
if reader(root / name) is None or not (root / name).is_file():
|
|
392
|
+
continue
|
|
393
|
+
try:
|
|
394
|
+
read = calls(root / name, name)
|
|
395
|
+
except errors.Refused as e:
|
|
396
|
+
error = error or e.error
|
|
397
|
+
continue
|
|
398
|
+
found += [
|
|
399
|
+
{"short_hash": h, "path": name, "line": c.line}
|
|
400
|
+
for c in read
|
|
401
|
+
if (h := name_hash(c.callee)) in wanted
|
|
402
|
+
]
|
|
403
|
+
found.sort(key=lambda c: (c["path"], c["line"]))
|
|
404
|
+
return dict({"calls": found}, **({"error": error} if error else {}))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def languages_probe(params: dict, root) -> dict:
|
|
408
|
+
"""code.languages: the repo's tracked source files by extension, and which a reader reads.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
params: Nothing.
|
|
412
|
+
root: The repo.
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
The LanguagesFacts fields.
|
|
416
|
+
|
|
417
|
+
Company: assumes none.
|
|
418
|
+
"""
|
|
419
|
+
try:
|
|
420
|
+
found = languages(root)
|
|
421
|
+
except errors.Refused as e:
|
|
422
|
+
return {"error": e.error}
|
|
423
|
+
except FileNotFoundError:
|
|
424
|
+
return {"error": errors.TOOL_MISSING}
|
|
425
|
+
return {
|
|
426
|
+
"languages": [
|
|
427
|
+
{
|
|
428
|
+
"extension": extension,
|
|
429
|
+
"files": len(found.files[extension]),
|
|
430
|
+
"read": extension in READERS,
|
|
431
|
+
"tool_missing": READERS[extension].tools_missing() if extension in READERS else [],
|
|
432
|
+
}
|
|
433
|
+
for extension in sorted(found.files)
|
|
434
|
+
]
|
|
435
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The Bash reader, with `shfmt --tojson`.
|
|
2
|
+
|
|
3
|
+
It reads .sh files, and files whose first line is a bash or sh shebang (`#!/bin/bash`,
|
|
4
|
+
`#!/usr/bin/env sh` and the like), whatever their extension. Every file is parsed as Bash, the
|
|
5
|
+
superset of sh. Symbols are the script's functions, in both forms, `name() {` and
|
|
6
|
+
`function name {`, wherever they are defined: at the top, or inside an `if`, a loop or a block.
|
|
7
|
+
A function inside a function is part of the outer one, as in the other readers. A Bash
|
|
8
|
+
function's signature is its name. The first line is the function's own line, the last its
|
|
9
|
+
closing line.
|
|
10
|
+
|
|
11
|
+
shfmt is v3.14.1, the method's own private copy: one binary in `<data folder>/bin`, downloaded
|
|
12
|
+
from the shfmt release for this system by the install line, and run from there, never from a
|
|
13
|
+
global install. The data folder is METHOD_DATA if set, else ~/.method. `missing()` says what to
|
|
14
|
+
install when that copy is not there.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .. import errors
|
|
25
|
+
from .javascript import data_dir
|
|
26
|
+
|
|
27
|
+
VERSION = "v3.14.1"
|
|
28
|
+
SHEBANG_RE = re.compile(r"#!\s*(?:\S*/)?(?:env\s+(?:-\S+\s+)*)?(?:\S*/)?(?:bash|sh)(?:\s|$)")
|
|
29
|
+
RELEASE = f"https://github.com/mvdan/sh/releases/download/{VERSION}/shfmt_{VERSION}"
|
|
30
|
+
SYSTEM = (
|
|
31
|
+
"$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')"
|
|
32
|
+
)
|
|
33
|
+
ERROR_RE = re.compile(r"(?:^|:)(\d+):(\d+): (.*)")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def shfmt() -> Path:
|
|
37
|
+
"""Where the method's copy of shfmt is (or is to be) installed."""
|
|
38
|
+
return data_dir() / "bin" / "shfmt"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def install_line() -> str:
|
|
42
|
+
"""Return the shell line that installs the method's copy of shfmt for this system.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A curl line that downloads shfmt into `shfmt()` and makes it executable.
|
|
46
|
+
|
|
47
|
+
Company: assumes none.
|
|
48
|
+
"""
|
|
49
|
+
target = shfmt()
|
|
50
|
+
return f"curl -fsSL --create-dirs -o {target} {RELEASE}_{SYSTEM} && chmod +x {target}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_script(path) -> bool:
|
|
54
|
+
"""True when the file's first line is a bash or sh shebang."""
|
|
55
|
+
try:
|
|
56
|
+
with open(path, "rb") as f:
|
|
57
|
+
first = f.readline(256)
|
|
58
|
+
except OSError:
|
|
59
|
+
return False
|
|
60
|
+
return bool(SHEBANG_RE.match(first.decode("utf-8", errors="replace").strip()))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _installed() -> bool:
|
|
64
|
+
try:
|
|
65
|
+
out = subprocess.run([str(shfmt()), "--version"], capture_output=True, text=True)
|
|
66
|
+
except OSError:
|
|
67
|
+
return False
|
|
68
|
+
return out.returncode == 0 and out.stdout.strip() == VERSION
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def missing() -> str:
|
|
72
|
+
"""What to install for this reader to run, or "" when the method's shfmt is there."""
|
|
73
|
+
return "" if _installed() else f"install shfmt: {install_line()}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def tools_missing() -> list:
|
|
77
|
+
"""The names of its missing tools: shfmt, when the method's copy is not there."""
|
|
78
|
+
return [] if _installed() else ["shfmt"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tree(path: Path, name: str) -> dict:
|
|
82
|
+
need = missing()
|
|
83
|
+
if need:
|
|
84
|
+
raise errors.Refused(f"{name}: reading it needs shfmt: {need}", errors.TOOL_MISSING)
|
|
85
|
+
out = subprocess.run(
|
|
86
|
+
[str(shfmt()), "-ln", "bash", "--tojson"],
|
|
87
|
+
input=path.read_text(encoding="utf-8", errors="replace"),
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
)
|
|
91
|
+
if out.returncode != 0:
|
|
92
|
+
error = ERROR_RE.search(out.stderr.strip().splitlines()[0] if out.stderr.strip() else "")
|
|
93
|
+
where = f"line {error.group(1)}: {error.group(3)}" if error else out.stderr.strip()
|
|
94
|
+
raise errors.Refused(f"{name}: shfmt cannot parse it: {where}")
|
|
95
|
+
return json.loads(out.stdout)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _walk(node, caller: str, found: list):
|
|
99
|
+
"""Collect each function and each call with its enclosing function, in written order.
|
|
100
|
+
|
|
101
|
+
Found as ("function", node, name) and ("call", callee, line, caller).
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(node, list):
|
|
104
|
+
for item in node:
|
|
105
|
+
_walk(item, caller, found)
|
|
106
|
+
return
|
|
107
|
+
if not isinstance(node, dict):
|
|
108
|
+
return
|
|
109
|
+
if node.get("Type") == "FuncDecl":
|
|
110
|
+
name = node["Name"]["Value"]
|
|
111
|
+
if not caller:
|
|
112
|
+
found.append(("function", node, name))
|
|
113
|
+
_walk(node.get("Body"), caller or name, found)
|
|
114
|
+
return
|
|
115
|
+
if node.get("Type") == "CallExpr" and node.get("Args"):
|
|
116
|
+
parts = node["Args"][0].get("Parts") or []
|
|
117
|
+
if len(parts) == 1 and parts[0].get("Type") == "Lit":
|
|
118
|
+
found.append(("call", parts[0]["Value"], parts[0]["ValuePos"]["Line"], caller))
|
|
119
|
+
for value in node.values():
|
|
120
|
+
if isinstance(value, (dict, list)):
|
|
121
|
+
_walk(value, caller, found)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _found(path: Path, name: str) -> list:
|
|
125
|
+
found = []
|
|
126
|
+
_walk(_tree(path, name), "", found)
|
|
127
|
+
return found
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def symbols(path: Path, name: str) -> list:
|
|
131
|
+
"""Return the script's top-level functions, wherever they are defined.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
path: The file to read.
|
|
135
|
+
name: The file as it is shown.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A Symbol of kind "function" for each function, its signature its name.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
errors.Refused: If shfmt is missing or cannot parse the file.
|
|
142
|
+
|
|
143
|
+
Company: assumes none.
|
|
144
|
+
"""
|
|
145
|
+
from . import Symbol
|
|
146
|
+
|
|
147
|
+
return [
|
|
148
|
+
Symbol("function", fname, name, fname, node["Pos"]["Line"], node["End"]["Line"])
|
|
149
|
+
for kind, node, fname in (f for f in _found(path, name) if f[0] == "function")
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def calls(path: Path, name: str) -> list:
|
|
154
|
+
"""Each command run by name: the enclosing function (or `<module>`), the name, the line."""
|
|
155
|
+
from . import Call
|
|
156
|
+
|
|
157
|
+
return sorted(
|
|
158
|
+
(
|
|
159
|
+
Call(caller or "<module>", callee, name, line)
|
|
160
|
+
for _, callee, line, caller in (f for f in _found(path, name) if f[0] == "call")
|
|
161
|
+
),
|
|
162
|
+
key=lambda c: c.line,
|
|
163
|
+
)
|