cli-to-tools 0.2.1__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.
- cli_to_tools/__init__.py +33 -0
- cli_to_tools/_effects.py +241 -0
- cli_to_tools/_model.py +66 -0
- cli_to_tools/_shell.py +710 -0
- cli_to_tools/_spec.py +543 -0
- cli_to_tools/_translate.py +95 -0
- cli_to_tools/agentltl.py +198 -0
- cli_to_tools/packs/cloud.yaml +416 -0
- cli_to_tools/packs/docker.yaml +182 -0
- cli_to_tools/packs/files.yaml +136 -0
- cli_to_tools/packs/git.yaml +233 -0
- cli_to_tools/packs/network.yaml +63 -0
- cli_to_tools/packs/python.yaml +51 -0
- cli_to_tools/packs/secrets.yaml +156 -0
- cli_to_tools/packs/shell.yaml +344 -0
- cli_to_tools-0.2.1.dist-info/METADATA +246 -0
- cli_to_tools-0.2.1.dist-info/RECORD +19 -0
- cli_to_tools-0.2.1.dist-info/WHEEL +5 -0
- cli_to_tools-0.2.1.dist-info/top_level.txt +1 -0
cli_to_tools/_shell.py
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli_to_tools/_shell.py – bash source → ordered list of simple commands.
|
|
3
|
+
|
|
4
|
+
The command line is parsed with tree-sitter-bash and the parse tree is walked
|
|
5
|
+
in execution order. Anything that cannot be analysed statically raises
|
|
6
|
+
:class:`TranslationError` (fail closed).
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from cli_to_tools import parse_command_line
|
|
11
|
+
|
|
12
|
+
for cmd in parse_command_line('git add . && git commit -m "x"'):
|
|
13
|
+
print(cmd.operator, [w.text for w in cmd.argv])
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import codecs
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
23
|
+
|
|
24
|
+
import tree_sitter_bash
|
|
25
|
+
from tree_sitter import Language, Parser
|
|
26
|
+
|
|
27
|
+
from ._model import CommandNode, TranslationError, Word
|
|
28
|
+
|
|
29
|
+
_PARSER = Parser(Language(tree_sitter_bash.language()))
|
|
30
|
+
|
|
31
|
+
# ── Node classes ──────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
_UNSUPPORTED = {
|
|
34
|
+
"function_definition": "function definition",
|
|
35
|
+
}
|
|
36
|
+
_LOOPS = {"for_statement", "c_style_for_statement", "while_statement"}
|
|
37
|
+
_CONTROL = {*_LOOPS, "if_statement", "case_statement"}
|
|
38
|
+
_STATEMENTS = {
|
|
39
|
+
"command", "list", "pipeline", "subshell", "compound_statement", "negated_command",
|
|
40
|
+
"redirected_statement", "variable_assignment", "variable_assignments",
|
|
41
|
+
"declaration_command", "unset_command", "test_command", *_CONTROL, *_UNSUPPORTED,
|
|
42
|
+
}
|
|
43
|
+
_SUBSTITUTIONS = {"command_substitution", "process_substitution"}
|
|
44
|
+
_REDIRECTS = {"file_redirect", "heredoc_redirect", "herestring_redirect"}
|
|
45
|
+
_GROUPS = {"subshell", "compound_statement"}
|
|
46
|
+
|
|
47
|
+
# Commands that execute text the parser cannot see.
|
|
48
|
+
_FORBIDDEN = {"eval", "source", "."}
|
|
49
|
+
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh"}
|
|
50
|
+
_PYTHON = re.compile(r"python(\d(\.\d+)?)?")
|
|
51
|
+
_FIND_EXEC = {"-exec", "-execdir", "-ok", "-okdir"}
|
|
52
|
+
|
|
53
|
+
_GLOB = re.compile(r"(?<!\\)[*?\[]")
|
|
54
|
+
_VAR_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)")
|
|
55
|
+
_BRACE = re.compile(r"\{[^{}]*(,|\.\.)[^{}]*\}")
|
|
56
|
+
_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
57
|
+
|
|
58
|
+
# Loop values that can be pasted into the loop body without changing how it parses.
|
|
59
|
+
_SAFE_VALUE = r"[A-Za-z0-9_./:@%+=-]+"
|
|
60
|
+
_BRACE_LIST = re.compile(rf"({_SAFE_VALUE})?\{{({_SAFE_VALUE}(?:,{_SAFE_VALUE})+)\}}({_SAFE_VALUE})?")
|
|
61
|
+
_BRACE_RANGE = re.compile(rf"({_SAFE_VALUE})?\{{(-?\d+)\.\.(-?\d+)\}}({_SAFE_VALUE})?")
|
|
62
|
+
_MAX_UNROLL = 64
|
|
63
|
+
_LOOP_EXITS = {"break", "continue"}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Wrapper commands ──────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def _skip_options(
|
|
69
|
+
with_value: frozenset = frozenset(), assignments: bool = False, positional: int = 0,
|
|
70
|
+
) -> Callable[[List[Word]], Optional[int]]:
|
|
71
|
+
"""Build a function returning the index where a wrapper's inner command starts."""
|
|
72
|
+
|
|
73
|
+
def start(args: List[Word]) -> Optional[int]:
|
|
74
|
+
i = 0
|
|
75
|
+
while i < len(args):
|
|
76
|
+
tok = args[i].text
|
|
77
|
+
if tok == "--":
|
|
78
|
+
i += 1
|
|
79
|
+
break
|
|
80
|
+
if assignments and _ASSIGNMENT.match(tok):
|
|
81
|
+
i += 1
|
|
82
|
+
elif tok.startswith("-") and len(tok) > 1:
|
|
83
|
+
i += 2 if tok in with_value else 1
|
|
84
|
+
else:
|
|
85
|
+
break
|
|
86
|
+
return i + positional
|
|
87
|
+
|
|
88
|
+
return start
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _command_builtin(args: List[Word]) -> Optional[int]:
|
|
92
|
+
# `command -v name` only looks the name up; nothing is executed.
|
|
93
|
+
if any(a.text in ("-v", "-V") for a in args):
|
|
94
|
+
return None
|
|
95
|
+
return _skip_options()(args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_WRAPPERS: Dict[str, Callable[[List[Word]], Optional[int]]] = {
|
|
99
|
+
"sudo": _skip_options(frozenset({
|
|
100
|
+
"-u", "-g", "-p", "-h", "-C", "-D", "-R", "-T", "-U", "-r", "-t",
|
|
101
|
+
"--user", "--group", "--prompt", "--host", "--chdir",
|
|
102
|
+
}), assignments=True),
|
|
103
|
+
"doas": _skip_options(frozenset({"-u", "-C"})),
|
|
104
|
+
"env": _skip_options(frozenset({"-u", "--unset", "-C", "--chdir"}), assignments=True),
|
|
105
|
+
"time": _skip_options(frozenset({"-o", "--output", "-f", "--format"})),
|
|
106
|
+
"timeout": _skip_options(frozenset({"-s", "--signal", "-k", "--kill-after"}), positional=1),
|
|
107
|
+
"nohup": _skip_options(),
|
|
108
|
+
"setsid": _skip_options(),
|
|
109
|
+
"nice": _skip_options(frozenset({"-n", "--adjustment"})),
|
|
110
|
+
"stdbuf": _skip_options(frozenset({"-i", "-o", "-e"})),
|
|
111
|
+
"watch": _skip_options(frozenset({"-n", "--interval"})),
|
|
112
|
+
"exec": _skip_options(frozenset({"-a"})),
|
|
113
|
+
"builtin": _skip_options(),
|
|
114
|
+
"command": _command_builtin,
|
|
115
|
+
"xargs": _skip_options(frozenset({
|
|
116
|
+
"-I", "-n", "-P", "-L", "-d", "-E", "-s", "-a",
|
|
117
|
+
"--max-args", "--max-procs", "--max-lines", "--delimiter", "--arg-file", "--eof",
|
|
118
|
+
})),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Word resolution ───────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _text(node: Any) -> str:
|
|
125
|
+
return node.text.decode()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve(node: Any) -> Word:
|
|
129
|
+
"""Return the word a node denotes after quote removal, and whether it is static."""
|
|
130
|
+
kind = node.type
|
|
131
|
+
raw = _text(node)
|
|
132
|
+
if kind == "word":
|
|
133
|
+
# `~` and `~/x` are the home directory; `~user`, `~+` and `~-` depend on the system
|
|
134
|
+
static = not (_GLOB.search(raw) or (raw.startswith("~") and raw != "~"
|
|
135
|
+
and not raw.startswith("~/")))
|
|
136
|
+
return Word(re.sub(r"\\(.)", r"\1", raw.replace("\\\n", ""), flags=re.S), bool(static))
|
|
137
|
+
if kind in ("number", "variable_name", "test_operator"):
|
|
138
|
+
return Word(raw)
|
|
139
|
+
if kind == "raw_string":
|
|
140
|
+
return Word(raw[1:-1])
|
|
141
|
+
if kind == "ansi_c_string":
|
|
142
|
+
try:
|
|
143
|
+
return Word(codecs.decode(raw[2:-1], "unicode_escape"))
|
|
144
|
+
except UnicodeDecodeError:
|
|
145
|
+
return Word(raw, False)
|
|
146
|
+
if kind in ("string", "translated_string"):
|
|
147
|
+
return _resolve_string(node)
|
|
148
|
+
if kind == "concatenation":
|
|
149
|
+
parts = [_resolve(c) for c in node.children]
|
|
150
|
+
text = "".join(p.text for p in parts)
|
|
151
|
+
return Word(text, all(p.static for p in parts) and not _BRACE.search(text))
|
|
152
|
+
# Expansions, substitutions, arithmetic: the value is only known at run time.
|
|
153
|
+
return Word(raw, False)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_string(node: Any) -> Word:
|
|
157
|
+
"""Resolve a double-quoted string, keeping expansions as literal source text."""
|
|
158
|
+
def unescape(chunk: bytes) -> str:
|
|
159
|
+
return re.sub(r'\\([$`"\\\n])', lambda m: "" if m.group(1) == "\n" else m.group(1),
|
|
160
|
+
chunk.decode())
|
|
161
|
+
|
|
162
|
+
source = node.text
|
|
163
|
+
base = node.start_byte
|
|
164
|
+
pos = source.index(b'"') + 1
|
|
165
|
+
end = len(source) - 1
|
|
166
|
+
out: List[str] = []
|
|
167
|
+
static = True
|
|
168
|
+
for child in node.children:
|
|
169
|
+
start, stop = child.start_byte - base, child.end_byte - base
|
|
170
|
+
if child.type == '"' or start < pos or stop > end:
|
|
171
|
+
continue
|
|
172
|
+
out.append(unescape(source[pos:start]))
|
|
173
|
+
if child.type == "string_content":
|
|
174
|
+
out.append(unescape(source[start:stop]))
|
|
175
|
+
else:
|
|
176
|
+
out.append(_text(child))
|
|
177
|
+
static = False
|
|
178
|
+
pos = stop
|
|
179
|
+
out.append(unescape(source[pos:end]))
|
|
180
|
+
return Word("".join(out), static)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── Tree walk ─────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
class _Walker:
|
|
186
|
+
"""Walks a bash parse tree and collects simple commands in execution order."""
|
|
187
|
+
|
|
188
|
+
def __init__(self, strict: bool = False) -> None:
|
|
189
|
+
self.out: List[CommandNode] = []
|
|
190
|
+
self.strict = strict
|
|
191
|
+
self._pipelines = 0
|
|
192
|
+
self._repeated = 0 # > 0 while inside a loop that could not be unrolled
|
|
193
|
+
# Shell variables assigned a literal earlier in the command line (`F=a.txt; rm $F`).
|
|
194
|
+
# None marks one whose value is not known statically.
|
|
195
|
+
self.vars: Dict[str, Optional[str]] = {}
|
|
196
|
+
|
|
197
|
+
def _subst(self, word: Word) -> Word:
|
|
198
|
+
"""Replace ``$NAME`` / ``${NAME}`` by values assigned earlier in the command line."""
|
|
199
|
+
if word.static or "$" not in word.text:
|
|
200
|
+
return word
|
|
201
|
+
|
|
202
|
+
def value(m: "re.Match[str]") -> str:
|
|
203
|
+
known = self.vars.get(m.group(1) or m.group(2))
|
|
204
|
+
return m.group(0) if known is None else known
|
|
205
|
+
|
|
206
|
+
text = _VAR_REF.sub(value, word.text)
|
|
207
|
+
if text == word.text:
|
|
208
|
+
return word
|
|
209
|
+
static = not (re.search(r"[$`]", text) or _GLOB.search(text) or text.startswith("~"))
|
|
210
|
+
return Word(text, static)
|
|
211
|
+
|
|
212
|
+
def assign(self, node: Any, cond: bool) -> None:
|
|
213
|
+
"""Record ``NAME=value`` statements; a value known only at run time is recorded as None."""
|
|
214
|
+
nodes = [node] if node.type == "variable_assignment" else [
|
|
215
|
+
c for c in node.children if c.type == "variable_assignment"]
|
|
216
|
+
for child in nodes:
|
|
217
|
+
name = _text(child.children[0])
|
|
218
|
+
parts = child.children[2:]
|
|
219
|
+
word = self._subst(_resolve(parts[0])) if parts else Word("")
|
|
220
|
+
new = word.text if word.static else None
|
|
221
|
+
if self._repeated or (cond and self.vars.get(name, new) != new):
|
|
222
|
+
new = None # set in a loop, or on some paths only: no single value
|
|
223
|
+
self.vars[name] = new
|
|
224
|
+
|
|
225
|
+
def _new_pipeline(self) -> int:
|
|
226
|
+
self._pipelines += 1
|
|
227
|
+
return self._pipelines
|
|
228
|
+
|
|
229
|
+
def statements(
|
|
230
|
+
self, nodes: List[Any], op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Visit a ``;`` / newline separated sequence; the first statement inherits *op*."""
|
|
233
|
+
first = True
|
|
234
|
+
for node in nodes:
|
|
235
|
+
if node.type == "&":
|
|
236
|
+
raise TranslationError("background execution (&) is not supported")
|
|
237
|
+
if not node.is_named or node.type == "comment" or node.type in _REDIRECTS:
|
|
238
|
+
continue
|
|
239
|
+
self.visit(node, op if first else ";", cond, depth, pipe)
|
|
240
|
+
first = False
|
|
241
|
+
|
|
242
|
+
def visit(
|
|
243
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
244
|
+
) -> None:
|
|
245
|
+
kind = node.type
|
|
246
|
+
if kind == "command":
|
|
247
|
+
self.command(node, op, cond, depth, pipe)
|
|
248
|
+
elif kind == "list":
|
|
249
|
+
left, connector, right = node.children[0], node.children[1], node.children[-1]
|
|
250
|
+
self.visit(left, op, cond, depth, pipe)
|
|
251
|
+
self.visit(right, connector.type, True, depth, pipe)
|
|
252
|
+
elif kind == "pipeline":
|
|
253
|
+
self.pipeline(node, op, cond, depth, self._new_pipeline())
|
|
254
|
+
elif kind in _GROUPS:
|
|
255
|
+
self.statements(node.children, op, cond, depth + 1, pipe)
|
|
256
|
+
elif kind == "negated_command":
|
|
257
|
+
self.statements(node.children, op, cond, depth, pipe)
|
|
258
|
+
elif kind == "redirected_statement":
|
|
259
|
+
self.redirected(node, op, cond, depth, pipe)
|
|
260
|
+
elif kind in ("variable_assignment", "variable_assignments"):
|
|
261
|
+
self.substitutions(node, depth)
|
|
262
|
+
self.assign(node, cond)
|
|
263
|
+
elif kind in ("declaration_command", "unset_command", "test_command"):
|
|
264
|
+
self.builtin(node, op, cond, depth, pipe)
|
|
265
|
+
elif kind == "for_statement":
|
|
266
|
+
self.for_loop(node, op, cond, depth, pipe)
|
|
267
|
+
elif kind in _LOOPS:
|
|
268
|
+
self.loop_once(node, op, cond, depth, pipe)
|
|
269
|
+
elif kind == "if_statement":
|
|
270
|
+
self._approximate("if statement", node)
|
|
271
|
+
self.branch(node, op, cond, depth, pipe, in_body=False)
|
|
272
|
+
elif kind == "case_statement":
|
|
273
|
+
self._approximate("case statement", node)
|
|
274
|
+
self.substitutions(node, depth)
|
|
275
|
+
for item in node.children:
|
|
276
|
+
if item.type == "case_item":
|
|
277
|
+
self.statements(
|
|
278
|
+
[c for c in item.children if c.type in _STATEMENTS], ";", True, depth, pipe)
|
|
279
|
+
elif kind in _UNSUPPORTED:
|
|
280
|
+
raise TranslationError(f"{_UNSUPPORTED[kind]} is not supported", _text(node))
|
|
281
|
+
else:
|
|
282
|
+
raise TranslationError(f"unsupported shell construct '{kind}'", _text(node))
|
|
283
|
+
|
|
284
|
+
# ── Control flow ──────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
def _approximate(self, what: str, node: Any) -> None:
|
|
287
|
+
"""Gate for constructs that are listed as an over-approximation of what runs."""
|
|
288
|
+
if self.strict:
|
|
289
|
+
raise TranslationError(f"{what} cannot be expanded statically (strict mode)", _text(node))
|
|
290
|
+
|
|
291
|
+
def branch(
|
|
292
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
293
|
+
in_body: bool,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Visit an ``if`` / ``elif`` / ``else``: conditions, then every branch as conditional."""
|
|
296
|
+
first = True
|
|
297
|
+
for child in node.children:
|
|
298
|
+
if child.type == "then":
|
|
299
|
+
in_body = True
|
|
300
|
+
elif child.type == "elif_clause":
|
|
301
|
+
self.branch(child, ";", True, depth, pipe, in_body=False)
|
|
302
|
+
elif child.type == "else_clause":
|
|
303
|
+
self.branch(child, ";", True, depth, pipe, in_body=True)
|
|
304
|
+
elif child.is_named and child.type != "comment":
|
|
305
|
+
self.visit(child, op if first else ";", cond or in_body, depth, pipe)
|
|
306
|
+
first = False
|
|
307
|
+
|
|
308
|
+
def loop_once(
|
|
309
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
310
|
+
) -> None:
|
|
311
|
+
"""List a loop's condition and body once, flagged ``repeated`` (domain unknown)."""
|
|
312
|
+
self._approximate("loop", node)
|
|
313
|
+
for child in node.children: # the loop variable takes values only known at run time
|
|
314
|
+
if child.type == "variable_name":
|
|
315
|
+
self.vars[_text(child)] = None
|
|
316
|
+
self._repeated += 1
|
|
317
|
+
try:
|
|
318
|
+
first = True
|
|
319
|
+
for child in node.children:
|
|
320
|
+
if child.type in ("do_group", "compound_statement"):
|
|
321
|
+
self.statements(child.children, op if first else ";", True, depth, pipe)
|
|
322
|
+
elif child.type in _STATEMENTS:
|
|
323
|
+
self.visit(child, op if first else ";", cond, depth, pipe)
|
|
324
|
+
else:
|
|
325
|
+
# A `for` list (`in $(ls)`) is evaluated once, before the loop.
|
|
326
|
+
self._repeated -= 1
|
|
327
|
+
self.substitutions(child, depth, include_self=True)
|
|
328
|
+
self._repeated += 1
|
|
329
|
+
continue
|
|
330
|
+
first = False
|
|
331
|
+
finally:
|
|
332
|
+
self._repeated -= 1
|
|
333
|
+
|
|
334
|
+
def for_loop(
|
|
335
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Unroll ``for x in <literal list>``; fall back to :meth:`loop_once` otherwise."""
|
|
338
|
+
var = next(_text(c) for c in node.children if c.type == "variable_name")
|
|
339
|
+
body = next(c for c in node.children if c.type == "do_group")
|
|
340
|
+
types = [c.type for c in node.children]
|
|
341
|
+
values = node.children[types.index("in") + 1:types.index("do_group")] if "in" in types else None
|
|
342
|
+
domain = _loop_domain([v for v in values if v.is_named]) if values is not None else None
|
|
343
|
+
if domain is None or len(domain) > _MAX_UNROLL or _rebinds(body, var):
|
|
344
|
+
return self.loop_once(node, op, cond, depth, pipe)
|
|
345
|
+
|
|
346
|
+
# `break` / `continue` can cut an iteration short: later calls become conditional.
|
|
347
|
+
exits = _has_command(body, _LOOP_EXITS)
|
|
348
|
+
start, stop = body.children[0].end_byte, body.children[-1].start_byte
|
|
349
|
+
spans = sorted(_references(body, var))
|
|
350
|
+
for i, value in enumerate(domain):
|
|
351
|
+
source, pos = b"", start
|
|
352
|
+
for a, b in spans:
|
|
353
|
+
source += body.text[pos - body.start_byte:a - body.start_byte] + value.encode()
|
|
354
|
+
pos = b
|
|
355
|
+
source += body.text[pos - body.start_byte:stop - body.start_byte]
|
|
356
|
+
root = _PARSER.parse(source).root_node
|
|
357
|
+
if root.has_error:
|
|
358
|
+
raise TranslationError("loop body cannot be expanded", _text(node))
|
|
359
|
+
self.statements(root.children, op if i == 0 else ";", cond or exits, depth, pipe)
|
|
360
|
+
|
|
361
|
+
def pipeline(self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: int) -> None:
|
|
362
|
+
first = True
|
|
363
|
+
for child in node.children:
|
|
364
|
+
if child.is_named:
|
|
365
|
+
self.visit(child, op if first else "|", cond, depth, pipe)
|
|
366
|
+
first = False
|
|
367
|
+
|
|
368
|
+
def substitutions(self, node: Any, depth: int, include_self: bool = False) -> None:
|
|
369
|
+
"""Emit the commands of every ``$(...)`` / ``<(...)`` nested in *node*."""
|
|
370
|
+
for child in ([node] if include_self else node.children):
|
|
371
|
+
if child.type in _SUBSTITUTIONS:
|
|
372
|
+
self.statements(child.children, "$()", False, depth + 1, None)
|
|
373
|
+
elif child.type not in _STATEMENTS:
|
|
374
|
+
self.substitutions(child, depth)
|
|
375
|
+
|
|
376
|
+
def redirects(self, node: Any) -> List[Dict[str, str]]:
|
|
377
|
+
"""The redirections of one redirect node. A heredoc carries the redirections written
|
|
378
|
+
after its marker (``cat <<EOF > f.txt``) as children of its own."""
|
|
379
|
+
if node.type == "heredoc_redirect":
|
|
380
|
+
info = {"op": "<<"}
|
|
381
|
+
nested: List[Dict[str, str]] = []
|
|
382
|
+
for child in node.children:
|
|
383
|
+
if child.type == "heredoc_start":
|
|
384
|
+
info["target"] = _text(child)
|
|
385
|
+
elif child.type in ("file_redirect", "herestring_redirect"):
|
|
386
|
+
nested.extend(self.redirects(child))
|
|
387
|
+
return [info] + nested
|
|
388
|
+
info = {}
|
|
389
|
+
for child in node.children:
|
|
390
|
+
if child.type == "file_descriptor":
|
|
391
|
+
info["fd"] = _text(child)
|
|
392
|
+
elif not child.is_named:
|
|
393
|
+
info["op"] = info.get("op", "") + _text(child)
|
|
394
|
+
else:
|
|
395
|
+
info["target"] = self._subst(_resolve(child)).text
|
|
396
|
+
return [info]
|
|
397
|
+
|
|
398
|
+
def heredoc_tail(self, node: Any, cond: bool, depth: int) -> None:
|
|
399
|
+
"""Visit commands that follow a heredoc marker (``cat <<EOF | grep x``,
|
|
400
|
+
``cat <<EOF && ls``)."""
|
|
401
|
+
op: Optional[str] = None
|
|
402
|
+
for child in node.children:
|
|
403
|
+
if not child.is_named and child.type in ("&&", "||"):
|
|
404
|
+
op = child.type
|
|
405
|
+
elif child.type == "pipeline":
|
|
406
|
+
if self.out and self.out[-1].pipeline is None:
|
|
407
|
+
self.out[-1].pipeline = self._new_pipeline()
|
|
408
|
+
pipe = self.out[-1].pipeline if self.out else self._new_pipeline()
|
|
409
|
+
for sub in child.children:
|
|
410
|
+
if sub.is_named:
|
|
411
|
+
self.visit(sub, "|", cond, depth, pipe)
|
|
412
|
+
elif child.type in _STATEMENTS:
|
|
413
|
+
if op is None:
|
|
414
|
+
raise TranslationError("unsupported command after heredoc marker",
|
|
415
|
+
_text(node))
|
|
416
|
+
self.visit(child, op, True, depth, None)
|
|
417
|
+
|
|
418
|
+
def redirected(
|
|
419
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
420
|
+
) -> None:
|
|
421
|
+
nodes = [c for c in node.children if c.type in _REDIRECTS]
|
|
422
|
+
for child in nodes:
|
|
423
|
+
self.substitutions(child, depth)
|
|
424
|
+
start = len(self.out)
|
|
425
|
+
body = None
|
|
426
|
+
for child in node.children:
|
|
427
|
+
if child.type not in _REDIRECTS and child.is_named:
|
|
428
|
+
body = child
|
|
429
|
+
self.visit(child, op, cond, depth, pipe)
|
|
430
|
+
redirects = [r for c in nodes for r in self.redirects(c)]
|
|
431
|
+
whole = body is not None and body.type in (_GROUPS | _CONTROL)
|
|
432
|
+
targets = self.out[start:] if whole else self.out[start:][-1:]
|
|
433
|
+
for cmd in targets:
|
|
434
|
+
cmd.redirects.extend(redirects)
|
|
435
|
+
for child in nodes:
|
|
436
|
+
self.heredoc_tail(child, cond, depth)
|
|
437
|
+
|
|
438
|
+
def builtin(
|
|
439
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
440
|
+
) -> None:
|
|
441
|
+
"""Emit ``export`` / ``unset`` / ``[ ... ]`` style statements as plain commands."""
|
|
442
|
+
self.substitutions(node, depth)
|
|
443
|
+
if node.type == "test_command":
|
|
444
|
+
name, rest = "test", [c for c in node.children if c.is_named]
|
|
445
|
+
else:
|
|
446
|
+
name, rest = _text(node.children[0]), node.children[1:]
|
|
447
|
+
if node.type == "declaration_command": # export F=x / local F=x
|
|
448
|
+
self.assign(node, cond)
|
|
449
|
+
argv = [Word(name)]
|
|
450
|
+
for child in rest:
|
|
451
|
+
text = _text(child)
|
|
452
|
+
argv.append(Word(text, not re.search(r"[$`]", text)))
|
|
453
|
+
self.out.append(
|
|
454
|
+
CommandNode(argv, _text(node), op, pipe, cond, depth, repeated=self._repeated > 0))
|
|
455
|
+
|
|
456
|
+
def command(
|
|
457
|
+
self, node: Any, op: Optional[str], cond: bool, depth: int, pipe: Optional[int],
|
|
458
|
+
) -> None:
|
|
459
|
+
self.substitutions(node, depth)
|
|
460
|
+
env: Dict[str, str] = {}
|
|
461
|
+
argv: List[Word] = []
|
|
462
|
+
redirects: List[Dict[str, str]] = []
|
|
463
|
+
tails: List[Any] = []
|
|
464
|
+
for child in node.children:
|
|
465
|
+
if child.type == "variable_assignment":
|
|
466
|
+
value = child.children[2:]
|
|
467
|
+
env[_text(child.children[0])] = _resolve(value[0]).text if value else ""
|
|
468
|
+
elif child.type == "command_name":
|
|
469
|
+
argv.append(self._subst(_resolve(child.children[0])))
|
|
470
|
+
elif child.type in _REDIRECTS:
|
|
471
|
+
redirects.extend(self.redirects(child))
|
|
472
|
+
tails.append(child)
|
|
473
|
+
elif child.is_named:
|
|
474
|
+
argv.append(self._subst(_resolve(child)))
|
|
475
|
+
if not argv:
|
|
476
|
+
return
|
|
477
|
+
cmd = CommandNode(
|
|
478
|
+
argv, _text(node), op, pipe, cond, depth, redirects, env, repeated=self._repeated > 0)
|
|
479
|
+
self.emit(cmd)
|
|
480
|
+
for child in tails:
|
|
481
|
+
self.heredoc_tail(child, cond, depth)
|
|
482
|
+
|
|
483
|
+
def emit(self, cmd: CommandNode) -> None:
|
|
484
|
+
"""Append *cmd*, then any command it wraps (``sudo``, ``xargs``, ``bash -c`` ...)."""
|
|
485
|
+
head = cmd.argv[0]
|
|
486
|
+
if not head.static:
|
|
487
|
+
raise TranslationError("command name is not static", cmd.source)
|
|
488
|
+
name = os.path.basename(head.text)
|
|
489
|
+
if name in _FORBIDDEN:
|
|
490
|
+
raise TranslationError(f"'{name}' executes text that cannot be analysed", cmd.source)
|
|
491
|
+
if name in _LOOP_EXITS:
|
|
492
|
+
return # loop control, not a call; its effect is the `conditional` flag
|
|
493
|
+
self.out.append(cmd)
|
|
494
|
+
args = cmd.argv[1:]
|
|
495
|
+
|
|
496
|
+
if name in _SHELLS:
|
|
497
|
+
script = self._shell_script(args)
|
|
498
|
+
if script is not None:
|
|
499
|
+
if not script.static:
|
|
500
|
+
raise TranslationError(f"dynamic script passed to '{name} -c'", cmd.source)
|
|
501
|
+
self._nested(script.text, cmd, name)
|
|
502
|
+
elif not any(not a.text.startswith(("-", "+")) for a in args):
|
|
503
|
+
# `curl ... | sh`, `bash <<< "$X"`: the script arrives on stdin.
|
|
504
|
+
raise TranslationError(f"'{name}' reads commands from stdin", cmd.source)
|
|
505
|
+
elif name in _WRAPPERS:
|
|
506
|
+
start = _WRAPPERS[name](args)
|
|
507
|
+
if start is not None and start < len(args):
|
|
508
|
+
self._wrapped(args[start:], cmd, name)
|
|
509
|
+
elif _PYTHON.fullmatch(name):
|
|
510
|
+
# `python -m pip install x` runs pip: surface the module as a command.
|
|
511
|
+
for i, arg in enumerate(args):
|
|
512
|
+
if arg.text == "-m" and i + 1 < len(args):
|
|
513
|
+
self._wrapped(args[i + 1:], cmd, "python -m")
|
|
514
|
+
if arg.text in ("-m", "-c") or not arg.text.startswith("-"):
|
|
515
|
+
break
|
|
516
|
+
elif name == "find":
|
|
517
|
+
# `-exec CMD {} ;` hands CMD to find: emit CMD as its own call, and give find
|
|
518
|
+
# the segment as one `-exec "CMD {} ;"` word so it does not read as paths.
|
|
519
|
+
kept: List[Word] = [cmd.argv[0]]
|
|
520
|
+
i = 0
|
|
521
|
+
while i < len(args):
|
|
522
|
+
if args[i].text in _FIND_EXEC:
|
|
523
|
+
stop = i + 1
|
|
524
|
+
while stop < len(args) and args[stop].text not in (";", "+"):
|
|
525
|
+
stop += 1
|
|
526
|
+
if stop > i + 1:
|
|
527
|
+
self._wrapped(args[i + 1:stop], cmd, "find")
|
|
528
|
+
segment = " ".join(w.text for w in args[i + 1:stop + 1])
|
|
529
|
+
kept += [args[i], Word(segment, all(w.static for w in args[i + 1:stop]))]
|
|
530
|
+
i = stop + 1
|
|
531
|
+
continue
|
|
532
|
+
kept.append(args[i])
|
|
533
|
+
i += 1
|
|
534
|
+
cmd.argv = kept
|
|
535
|
+
|
|
536
|
+
@staticmethod
|
|
537
|
+
def _shell_script(args: List[Word]) -> Optional[Word]:
|
|
538
|
+
"""Return the script word of ``bash -c <script>``, or None when there is none."""
|
|
539
|
+
i = 0
|
|
540
|
+
while i < len(args):
|
|
541
|
+
tok = args[i].text
|
|
542
|
+
if tok in ("-o", "-O", "+o", "+O"):
|
|
543
|
+
i += 2
|
|
544
|
+
elif tok.startswith("-") and not tok.startswith("--") and "c" in tok[1:]:
|
|
545
|
+
return args[i + 1] if i + 1 < len(args) else None
|
|
546
|
+
elif tok.startswith(("-", "+")) and len(tok) > 1:
|
|
547
|
+
i += 1
|
|
548
|
+
else:
|
|
549
|
+
return None
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
def _wrapped(self, argv: List[Word], parent: CommandNode, wrapper: str) -> None:
|
|
553
|
+
inner = CommandNode(
|
|
554
|
+
argv, shlex.join(w.text for w in argv), "wrap", parent.pipeline,
|
|
555
|
+
parent.conditional, parent.depth + 1, wrapper=wrapper, repeated=parent.repeated,
|
|
556
|
+
)
|
|
557
|
+
self.emit(inner)
|
|
558
|
+
|
|
559
|
+
def _nested(self, script: str, parent: CommandNode, wrapper: str) -> None:
|
|
560
|
+
pipelines: Dict[int, int] = {}
|
|
561
|
+
for i, inner in enumerate(parse_command_line(script, self.strict)):
|
|
562
|
+
if inner.pipeline is not None:
|
|
563
|
+
inner.pipeline = pipelines.setdefault(inner.pipeline, self._new_pipeline())
|
|
564
|
+
if i == 0:
|
|
565
|
+
inner.operator = "wrap"
|
|
566
|
+
inner.depth += parent.depth + 1
|
|
567
|
+
inner.conditional = inner.conditional or parent.conditional
|
|
568
|
+
inner.repeated = inner.repeated or parent.repeated
|
|
569
|
+
inner.wrapper = inner.wrapper or wrapper
|
|
570
|
+
self.out.append(inner)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _loop_domain(nodes: List[Any]) -> Optional[List[str]]:
|
|
574
|
+
"""Values of a ``for`` list when they are all literal, else None."""
|
|
575
|
+
out: List[str] = []
|
|
576
|
+
for node in nodes:
|
|
577
|
+
raw = _text(node)
|
|
578
|
+
word = _resolve(node)
|
|
579
|
+
if word.static and re.fullmatch(_SAFE_VALUE, word.text):
|
|
580
|
+
out.append(word.text)
|
|
581
|
+
elif node.type in ("concatenation", "brace_expression") and (m := _BRACE_RANGE.fullmatch(raw)):
|
|
582
|
+
lo, hi = int(m.group(2)), int(m.group(3))
|
|
583
|
+
if abs(hi - lo) >= _MAX_UNROLL:
|
|
584
|
+
return None
|
|
585
|
+
step = 1 if hi >= lo else -1
|
|
586
|
+
out.extend(f"{m.group(1) or ''}{n}{m.group(4) or ''}" for n in range(lo, hi + step, step))
|
|
587
|
+
elif node.type == "concatenation" and (m := _BRACE_LIST.fullmatch(raw)):
|
|
588
|
+
out.extend(f"{m.group(1) or ''}{item}{m.group(3) or ''}" for item in m.group(2).split(","))
|
|
589
|
+
else:
|
|
590
|
+
return None
|
|
591
|
+
return out
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _walk(node: Any) -> Any:
|
|
595
|
+
yield node
|
|
596
|
+
for child in node.children:
|
|
597
|
+
yield from _walk(child)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _references(body: Any, var: str) -> List[tuple]:
|
|
601
|
+
"""Byte spans of plain ``$var`` / ``${var}`` references inside *body*."""
|
|
602
|
+
spans = []
|
|
603
|
+
for node in _walk(body):
|
|
604
|
+
kinds = [c.type for c in node.children]
|
|
605
|
+
if ((node.type == "simple_expansion" and kinds == ["$", "variable_name"])
|
|
606
|
+
or (node.type == "expansion" and kinds == ["${", "variable_name", "}"])):
|
|
607
|
+
if _text(node.children[1]) == var:
|
|
608
|
+
spans.append((node.start_byte, node.end_byte))
|
|
609
|
+
return spans
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _rebinds(body: Any, var: str) -> bool:
|
|
613
|
+
"""True if *body* assigns *var* or reuses it as an inner loop / ``read`` variable."""
|
|
614
|
+
for node in _walk(body):
|
|
615
|
+
if node.type in ("variable_assignment", "for_statement", "c_style_for_statement"):
|
|
616
|
+
if any(c.type == "variable_name" and _text(c) == var for c in _walk(node)
|
|
617
|
+
if c.parent is not None and c.parent.type != "simple_expansion"
|
|
618
|
+
and c.parent.type != "expansion"):
|
|
619
|
+
return True
|
|
620
|
+
if node.type == "command" and node.children and _text(node.children[0]) == "read":
|
|
621
|
+
if any(_text(c) == var for c in node.children[1:]):
|
|
622
|
+
return True
|
|
623
|
+
return False
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _has_command(body: Any, names: set) -> bool:
|
|
627
|
+
return any(n.type == "command_name" and _text(n) in names for n in _walk(body))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _first_error(node: Any) -> Any:
|
|
631
|
+
if node.type == "ERROR" or node.is_missing:
|
|
632
|
+
return node
|
|
633
|
+
for child in node.children:
|
|
634
|
+
if child.has_error or child.is_missing:
|
|
635
|
+
return _first_error(child)
|
|
636
|
+
return node
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# A redirection written after a heredoc marker, before the rest of the line:
|
|
640
|
+
# `cmd <<'EOF' 2>&1 | tail`. tree-sitter-bash cannot parse it when a pipe or another
|
|
641
|
+
# command follows.
|
|
642
|
+
_HEREDOC_THEN_REDIRECTS = re.compile(
|
|
643
|
+
r"""(<<-?[ \t]*(?:'[^'\n]*'|"[^"\n]*"|[^\s|;&<>()]+))"""
|
|
644
|
+
r"((?:[ \t]+\d*(?:&>>?|>>|>&|<&|>\||>|<)[ \t]*[^\s|;&<>()]+)+)")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
_HEREDOC_THEN_LIST = re.compile(
|
|
648
|
+
r"""<<(-?)[ \t]*('([^'\n]*)'|"([^"\n]*)"|([^\s|;&<>()]+))[ \t]*;[ \t]*([^\n]*)""")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _defer_after_heredoc(source: str) -> str:
|
|
652
|
+
"""``cat <<EOF; ls`` runs ls after cat, so it is the same as writing ls on the line
|
|
653
|
+
after the heredoc's end marker -- which tree-sitter-bash can parse."""
|
|
654
|
+
lines = source.split("\n")
|
|
655
|
+
for i, line in enumerate(lines):
|
|
656
|
+
m = _HEREDOC_THEN_LIST.search(line)
|
|
657
|
+
if not m:
|
|
658
|
+
continue
|
|
659
|
+
word = m.group(3) if m.group(3) is not None else (
|
|
660
|
+
m.group(4) if m.group(4) is not None else m.group(5))
|
|
661
|
+
rest = m.group(6)
|
|
662
|
+
for j in range(i + 1, len(lines)):
|
|
663
|
+
if (lines[j].lstrip("\t") if m.group(1) else lines[j]) == word:
|
|
664
|
+
head = line[:m.start()] + line[m.start():m.start(6)].rstrip().rstrip(";").rstrip()
|
|
665
|
+
return "\n".join(lines[:i] + [head] + lines[i + 1:j + 1] + [rest]
|
|
666
|
+
+ lines[j + 1:])
|
|
667
|
+
break
|
|
668
|
+
return source
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _hoist_heredoc_redirects(source: str) -> str:
|
|
672
|
+
"""Move redirections written after a heredoc marker in front of it. Each applies to a
|
|
673
|
+
different file descriptor than the heredoc (stdin), so the meaning is unchanged; one
|
|
674
|
+
that reads stdin (`<`, `<&`) is left in place."""
|
|
675
|
+
def swap(m: "re.Match[str]") -> str:
|
|
676
|
+
if re.search(r"(^|[ \t])\d*<", m.group(2)):
|
|
677
|
+
return m.group(0)
|
|
678
|
+
return m.group(2).lstrip() + " " + m.group(1)
|
|
679
|
+
return _HEREDOC_THEN_REDIRECTS.sub(swap, source)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def parse_command_line(source: str, strict: bool = False) -> List[CommandNode]:
|
|
683
|
+
"""Parse a bash command line into its simple commands, in execution order.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
source: The command line, possibly a chain (``;`` ``&&`` ``||`` ``|``).
|
|
687
|
+
strict: Reject control flow that can only be over-approximated (``if``,
|
|
688
|
+
``case``, ``while``, ``for`` over a run-time list). ``for`` loops over
|
|
689
|
+
a literal list are unrolled exactly either way.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
One :class:`CommandNode` per simple command. Commands inside ``$(...)``
|
|
693
|
+
come before the command that consumes them.
|
|
694
|
+
|
|
695
|
+
Raises:
|
|
696
|
+
TranslationError: On a syntax error or a construct that cannot be
|
|
697
|
+
analysed statically (``eval``, background jobs, function definitions, ...).
|
|
698
|
+
"""
|
|
699
|
+
if not isinstance(source, str):
|
|
700
|
+
raise TranslationError("command line must be a string", repr(source))
|
|
701
|
+
root = _PARSER.parse(source.encode()).root_node
|
|
702
|
+
if root.has_error:
|
|
703
|
+
hoisted = _defer_after_heredoc(_hoist_heredoc_redirects(source))
|
|
704
|
+
retry = _PARSER.parse(hoisted.encode()).root_node if hoisted != source else root
|
|
705
|
+
if retry.has_error:
|
|
706
|
+
raise TranslationError("shell syntax error", _text(_first_error(root)))
|
|
707
|
+
root = retry
|
|
708
|
+
walker = _Walker(strict)
|
|
709
|
+
walker.statements(root.children, None, False, 0, None)
|
|
710
|
+
return walker.out
|