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/_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