just-bash 0.1.5__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.
Files changed (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,813 @@
1
+ """Interpreter - AST Execution Engine.
2
+
3
+ Main interpreter class that executes bash AST nodes.
4
+ Delegates to specialized modules for:
5
+ - Word expansion (expansion.py)
6
+ - Arithmetic evaluation (arithmetic.py)
7
+ - Conditional evaluation (conditionals.py)
8
+ - Built-in commands (builtins/)
9
+ - Redirections (redirections.py)
10
+ """
11
+
12
+ import time
13
+ from typing import Optional
14
+
15
+ from ..ast.types import (
16
+ CommandNode,
17
+ PipelineNode,
18
+ ScriptNode,
19
+ SimpleCommandNode,
20
+ StatementNode,
21
+ IfNode,
22
+ ForNode,
23
+ CStyleForNode,
24
+ WhileNode,
25
+ UntilNode,
26
+ CaseNode,
27
+ SubshellNode,
28
+ GroupNode,
29
+ FunctionDefNode,
30
+ ConditionalCommandNode,
31
+ ArithmeticCommandNode,
32
+ )
33
+ from ..types import Command, ExecResult, ExecutionLimits, IFileSystem
34
+ from .errors import (
35
+ BadSubstitutionError,
36
+ BreakError,
37
+ ContinueError,
38
+ ErrexitError,
39
+ ExecutionLimitError,
40
+ ExitError,
41
+ NounsetError,
42
+ ReturnError,
43
+ )
44
+ from .types import InterpreterContext, InterpreterState, ShellOptions
45
+ from .expansion import expand_word_async, expand_word_with_glob, get_variable, evaluate_arithmetic
46
+ from .conditionals import evaluate_conditional
47
+ from .control_flow import (
48
+ execute_if,
49
+ execute_for,
50
+ execute_c_style_for,
51
+ execute_while,
52
+ execute_until,
53
+ execute_case,
54
+ )
55
+ from .builtins import BUILTINS
56
+ from .builtins.alias import get_aliases
57
+ from .builtins.shopt import DEFAULT_SHOPTS
58
+
59
+
60
+ def _ok() -> ExecResult:
61
+ """Return a successful result."""
62
+ return ExecResult(stdout="", stderr="", exit_code=0)
63
+
64
+
65
+ def _result(stdout: str, stderr: str, exit_code: int) -> ExecResult:
66
+ """Create an ExecResult."""
67
+ return ExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
68
+
69
+
70
+ def _failure(stderr: str) -> ExecResult:
71
+ """Create a failed result with stderr."""
72
+ return ExecResult(stdout="", stderr=stderr, exit_code=1)
73
+
74
+
75
+ def _is_shopt_set(env: dict[str, str], name: str) -> bool:
76
+ """Check if a shopt option is set."""
77
+ key = f"__shopt_{name}__"
78
+ if key in env:
79
+ return env[key] == "1"
80
+ return DEFAULT_SHOPTS.get(name, False)
81
+
82
+
83
+ class Interpreter:
84
+ """AST interpreter for bash scripts."""
85
+
86
+ def __init__(
87
+ self,
88
+ fs: IFileSystem,
89
+ commands: dict[str, Command],
90
+ limits: ExecutionLimits,
91
+ state: Optional[InterpreterState] = None,
92
+ ):
93
+ """Initialize the interpreter.
94
+
95
+ Args:
96
+ fs: Filesystem interface
97
+ commands: Command registry
98
+ limits: Execution limits
99
+ state: Optional initial state (creates default if not provided)
100
+ """
101
+ self._fs = fs
102
+ self._commands = commands
103
+ self._limits = limits
104
+ self._state = state or InterpreterState(
105
+ env={
106
+ "PATH": "/usr/local/bin:/usr/bin:/bin",
107
+ "HOME": "/home/user",
108
+ "USER": "user",
109
+ "SHELL": "/bin/bash",
110
+ "PWD": "/home/user",
111
+ "?": "0",
112
+ },
113
+ cwd="/home/user",
114
+ previous_dir="/home/user",
115
+ start_time=time.time(),
116
+ )
117
+
118
+ # Build the context
119
+ self._ctx = InterpreterContext(
120
+ state=self._state,
121
+ fs=fs,
122
+ commands=commands,
123
+ limits=limits,
124
+ exec_fn=self._exec_fn,
125
+ execute_script=self.execute_script,
126
+ execute_statement=self.execute_statement,
127
+ execute_command=self.execute_command,
128
+ )
129
+
130
+ @property
131
+ def state(self) -> InterpreterState:
132
+ """Get the interpreter state."""
133
+ return self._state
134
+
135
+ async def _exec_fn(
136
+ self,
137
+ script: str,
138
+ env: Optional[dict[str, str]] = None,
139
+ cwd: Optional[str] = None,
140
+ ) -> ExecResult:
141
+ """Execute a script string (for subcommands)."""
142
+ # Import here to avoid circular imports
143
+ from ..parser import parse
144
+
145
+ # Parse the script
146
+ ast = parse(script)
147
+
148
+ # Create a new state for the subshell if env/cwd are provided
149
+ if env or cwd:
150
+ new_env = {**self._state.env, **(env or {})}
151
+ new_state = InterpreterState(
152
+ env=new_env,
153
+ cwd=cwd or self._state.cwd,
154
+ previous_dir=self._state.previous_dir,
155
+ functions=dict(self._state.functions),
156
+ start_time=self._state.start_time,
157
+ options=ShellOptions(
158
+ errexit=self._state.options.errexit,
159
+ pipefail=self._state.options.pipefail,
160
+ nounset=self._state.options.nounset,
161
+ xtrace=self._state.options.xtrace,
162
+ verbose=self._state.options.verbose,
163
+ ),
164
+ )
165
+ sub_interpreter = Interpreter(
166
+ fs=self._fs,
167
+ commands=self._commands,
168
+ limits=self._limits,
169
+ state=new_state,
170
+ )
171
+ return await sub_interpreter.execute_script(ast)
172
+
173
+ return await self.execute_script(ast)
174
+
175
+ async def execute_script(self, node: ScriptNode) -> ExecResult:
176
+ """Execute a script AST node."""
177
+ stdout = ""
178
+ stderr = ""
179
+ exit_code = 0
180
+
181
+ for statement in node.statements:
182
+ try:
183
+ result = await self.execute_statement(statement)
184
+ stdout += result.stdout
185
+ stderr += result.stderr
186
+ exit_code = result.exit_code
187
+ self._state.last_exit_code = exit_code
188
+ self._state.env["?"] = str(exit_code)
189
+ except ExitError as error:
190
+ # ExitError always propagates up to terminate the script
191
+ error.prepend_output(stdout, stderr)
192
+ raise
193
+ except ExecutionLimitError:
194
+ # ExecutionLimitError must always propagate
195
+ raise
196
+ except ErrexitError as error:
197
+ stdout += error.stdout
198
+ stderr += error.stderr
199
+ exit_code = error.exit_code
200
+ self._state.last_exit_code = exit_code
201
+ self._state.env["?"] = str(exit_code)
202
+ return ExecResult(
203
+ stdout=stdout,
204
+ stderr=stderr,
205
+ exit_code=exit_code,
206
+ env=dict(self._state.env),
207
+ )
208
+ except NounsetError as error:
209
+ stdout += error.stdout
210
+ stderr += error.stderr
211
+ exit_code = 1
212
+ self._state.last_exit_code = exit_code
213
+ self._state.env["?"] = str(exit_code)
214
+ return ExecResult(
215
+ stdout=stdout,
216
+ stderr=stderr,
217
+ exit_code=exit_code,
218
+ env=dict(self._state.env),
219
+ )
220
+ except BadSubstitutionError as error:
221
+ stdout += error.stdout
222
+ stderr += error.stderr
223
+ exit_code = 1
224
+ self._state.last_exit_code = exit_code
225
+ self._state.env["?"] = str(exit_code)
226
+ return ExecResult(
227
+ stdout=stdout,
228
+ stderr=stderr,
229
+ exit_code=exit_code,
230
+ env=dict(self._state.env),
231
+ )
232
+ except (BreakError, ContinueError) as error:
233
+ # Handle break/continue errors
234
+ if self._state.loop_depth > 0:
235
+ # Inside a loop, propagate the error
236
+ error.prepend_output(stdout, stderr)
237
+ raise
238
+ # Outside loops, silently continue
239
+ stdout += error.stdout
240
+ stderr += error.stderr
241
+ continue
242
+ except ReturnError as error:
243
+ # Handle return - prepend accumulated output before propagating
244
+ error.prepend_output(stdout, stderr)
245
+ raise
246
+
247
+ return ExecResult(
248
+ stdout=stdout,
249
+ stderr=stderr,
250
+ exit_code=exit_code,
251
+ env=dict(self._state.env),
252
+ )
253
+
254
+ async def execute_statement(self, node: StatementNode) -> ExecResult:
255
+ """Execute a statement AST node."""
256
+ self._state.command_count += 1
257
+ if self._state.command_count > self._limits.max_command_count:
258
+ raise ExecutionLimitError(
259
+ f"too many commands executed (>{self._limits.max_command_count}), "
260
+ "increase execution_limits.max_command_count",
261
+ "commands",
262
+ )
263
+
264
+ stdout = ""
265
+ stderr = ""
266
+ exit_code = 0
267
+ last_executed_index = -1
268
+ last_pipeline_negated = False
269
+
270
+ for i, pipeline in enumerate(node.pipelines):
271
+ operator = node.operators[i - 1] if i > 0 else None
272
+
273
+ if operator == "&&" and exit_code != 0:
274
+ continue
275
+ if operator == "||" and exit_code == 0:
276
+ continue
277
+
278
+ result = await self.execute_pipeline(pipeline)
279
+ stdout += result.stdout
280
+ stderr += result.stderr
281
+ exit_code = result.exit_code
282
+ last_executed_index = i
283
+ last_pipeline_negated = pipeline.negated
284
+
285
+ # Update $? after each pipeline
286
+ self._state.last_exit_code = exit_code
287
+ self._state.env["?"] = str(exit_code)
288
+
289
+ # Check errexit (set -e)
290
+ if (
291
+ self._state.options.errexit
292
+ and exit_code != 0
293
+ and last_executed_index == len(node.pipelines) - 1
294
+ and not last_pipeline_negated
295
+ and not self._state.in_condition
296
+ ):
297
+ raise ErrexitError(exit_code, stdout, stderr)
298
+
299
+ return _result(stdout, stderr, exit_code)
300
+
301
+ async def execute_pipeline(self, node: PipelineNode) -> ExecResult:
302
+ """Execute a pipeline AST node."""
303
+ stdin = ""
304
+ last_result = _ok()
305
+ pipefail_exit_code = 0
306
+ pipestatus_exit_codes: list[int] = []
307
+
308
+ for i, command in enumerate(node.commands):
309
+ is_last = i == len(node.commands) - 1
310
+
311
+ try:
312
+ result = await self.execute_command(command, stdin)
313
+ except BadSubstitutionError as error:
314
+ result = ExecResult(
315
+ stdout=error.stdout,
316
+ stderr=error.stderr,
317
+ exit_code=1,
318
+ )
319
+ except ExitError as error:
320
+ # In a multi-command pipeline, each command runs in subshell context
321
+ if len(node.commands) > 1:
322
+ result = ExecResult(
323
+ stdout=error.stdout,
324
+ stderr=error.stderr,
325
+ exit_code=error.exit_code,
326
+ )
327
+ else:
328
+ raise
329
+
330
+ # Track exit code for PIPESTATUS
331
+ pipestatus_exit_codes.append(result.exit_code)
332
+
333
+ # Track failing exit code for pipefail
334
+ if result.exit_code != 0:
335
+ pipefail_exit_code = result.exit_code
336
+
337
+ if not is_last:
338
+ stdin = result.stdout
339
+ last_result = ExecResult(
340
+ stdout="",
341
+ stderr=result.stderr,
342
+ exit_code=result.exit_code,
343
+ )
344
+ else:
345
+ last_result = result
346
+
347
+ # Set PIPESTATUS array
348
+ for key in list(self._state.env.keys()):
349
+ if key.startswith("PIPESTATUS_"):
350
+ del self._state.env[key]
351
+ for i, code in enumerate(pipestatus_exit_codes):
352
+ self._state.env[f"PIPESTATUS_{i}"] = str(code)
353
+ self._state.env["PIPESTATUS__length"] = str(len(pipestatus_exit_codes))
354
+
355
+ # Apply pipefail
356
+ if self._state.options.pipefail and pipefail_exit_code != 0:
357
+ last_result = ExecResult(
358
+ stdout=last_result.stdout,
359
+ stderr=last_result.stderr,
360
+ exit_code=pipefail_exit_code,
361
+ )
362
+
363
+ # Apply negation
364
+ if node.negated:
365
+ last_result = ExecResult(
366
+ stdout=last_result.stdout,
367
+ stderr=last_result.stderr,
368
+ exit_code=1 if last_result.exit_code == 0 else 0,
369
+ )
370
+
371
+ return last_result
372
+
373
+ async def execute_command(self, node: CommandNode, stdin: str) -> ExecResult:
374
+ """Execute a command AST node."""
375
+ if isinstance(node, SimpleCommandNode) or node.type == "SimpleCommand":
376
+ return await self._execute_simple_command(node, stdin)
377
+ elif isinstance(node, IfNode) or node.type == "If":
378
+ return await execute_if(self._ctx, node)
379
+ elif isinstance(node, ForNode) or node.type == "For":
380
+ return await execute_for(self._ctx, node)
381
+ elif isinstance(node, CStyleForNode) or node.type == "CStyleFor":
382
+ return await execute_c_style_for(self._ctx, node)
383
+ elif isinstance(node, WhileNode) or node.type == "While":
384
+ return await execute_while(self._ctx, node, stdin)
385
+ elif isinstance(node, UntilNode) or node.type == "Until":
386
+ return await execute_until(self._ctx, node)
387
+ elif isinstance(node, CaseNode) or node.type == "Case":
388
+ return await execute_case(self._ctx, node)
389
+ elif isinstance(node, SubshellNode) or node.type == "Subshell":
390
+ return await self._execute_subshell(node, stdin)
391
+ elif isinstance(node, GroupNode) or node.type == "Group":
392
+ return await self._execute_group(node, stdin)
393
+ elif isinstance(node, FunctionDefNode) or node.type == "FunctionDef":
394
+ return await self._execute_function_def(node)
395
+ elif isinstance(node, ConditionalCommandNode) or node.type == "ConditionalCommand":
396
+ return await self._execute_conditional(node)
397
+ elif isinstance(node, ArithmeticCommandNode) or node.type == "ArithmeticCommand":
398
+ return await self._execute_arithmetic(node)
399
+ else:
400
+ return _ok()
401
+
402
+ async def _execute_subshell(self, node: SubshellNode, stdin: str) -> ExecResult:
403
+ """Execute a subshell command."""
404
+ # Create a new interpreter with a copy of the state
405
+ new_state = InterpreterState(
406
+ env=dict(self._state.env),
407
+ cwd=self._state.cwd,
408
+ previous_dir=self._state.previous_dir,
409
+ functions=dict(self._state.functions),
410
+ start_time=self._state.start_time,
411
+ options=ShellOptions(
412
+ errexit=self._state.options.errexit,
413
+ pipefail=self._state.options.pipefail,
414
+ nounset=self._state.options.nounset,
415
+ xtrace=self._state.options.xtrace,
416
+ verbose=self._state.options.verbose,
417
+ ),
418
+ )
419
+ sub_interpreter = Interpreter(
420
+ fs=self._fs,
421
+ commands=self._commands,
422
+ limits=self._limits,
423
+ state=new_state,
424
+ )
425
+
426
+ # Execute statements in subshell
427
+ stdout = ""
428
+ stderr = ""
429
+ exit_code = 0
430
+ for stmt in node.body:
431
+ result = await sub_interpreter.execute_statement(stmt)
432
+ stdout += result.stdout
433
+ stderr += result.stderr
434
+ exit_code = result.exit_code
435
+
436
+ return _result(stdout, stderr, exit_code)
437
+
438
+ async def _execute_group(self, node: GroupNode, stdin: str) -> ExecResult:
439
+ """Execute a command group { ... }."""
440
+ # Groups execute in the current shell context
441
+ stdout = ""
442
+ stderr = ""
443
+ exit_code = 0
444
+
445
+ # Save and set group stdin
446
+ saved_group_stdin = self._state.group_stdin
447
+ if stdin:
448
+ self._state.group_stdin = stdin
449
+
450
+ try:
451
+ for stmt in node.body:
452
+ result = await self.execute_statement(stmt)
453
+ stdout += result.stdout
454
+ stderr += result.stderr
455
+ exit_code = result.exit_code
456
+ finally:
457
+ self._state.group_stdin = saved_group_stdin
458
+
459
+ return _result(stdout, stderr, exit_code)
460
+
461
+ async def _execute_function_def(self, node: FunctionDefNode) -> ExecResult:
462
+ """Execute a function definition."""
463
+ # Store the function in state
464
+ self._state.functions[node.name] = node
465
+ return _ok()
466
+
467
+ async def _execute_conditional(self, node: ConditionalCommandNode) -> ExecResult:
468
+ """Execute a conditional command [[ ... ]]."""
469
+ if node.expression is None:
470
+ return _result("", "", 0)
471
+
472
+ try:
473
+ result = await evaluate_conditional(self._ctx, node.expression)
474
+ return _result("", "", 0 if result else 1)
475
+ except ValueError as e:
476
+ return _result("", f"bash: conditional: {e}\n", 2)
477
+
478
+ async def _execute_arithmetic(self, node: ArithmeticCommandNode) -> ExecResult:
479
+ """Execute an arithmetic command (( ... ))."""
480
+ if node.expression is None:
481
+ return _result("", "", 0)
482
+
483
+ try:
484
+ result = await evaluate_arithmetic(self._ctx, node.expression.expression)
485
+ # (( expr )) returns 0 if result is non-zero, 1 if result is zero
486
+ return _result("", "", 0 if result != 0 else 1)
487
+ except Exception as e:
488
+ return _result("", f"bash: arithmetic: {e}\n", 1)
489
+
490
+ async def _execute_simple_command(
491
+ self, node: SimpleCommandNode, stdin: str
492
+ ) -> ExecResult:
493
+ """Execute a simple command."""
494
+ # Update currentLine for $LINENO
495
+ if node.line is not None:
496
+ self._state.current_line = node.line
497
+
498
+ # Clear expansion stderr
499
+ self._state.expansion_stderr = ""
500
+
501
+ # Temporary assignments for command environment
502
+ temp_assignments: dict[str, str | None] = {}
503
+
504
+ # Handle assignments
505
+ for assignment in node.assignments:
506
+ name = assignment.name
507
+
508
+ # Check for array assignment
509
+ if assignment.array:
510
+ # Clear existing array elements
511
+ prefix = f"{name}_"
512
+ to_remove = [k for k in self._state.env if k.startswith(prefix) and not k.startswith(f"{name}__")]
513
+ for k in to_remove:
514
+ del self._state.env[k]
515
+
516
+ # Mark as array
517
+ self._state.env[f"{name}__is_array"] = "indexed"
518
+
519
+ # Expand and store each element
520
+ for idx, elem in enumerate(assignment.array):
521
+ elem_value = await expand_word_async(self._ctx, elem)
522
+ self._state.env[f"{name}_{idx}"] = elem_value
523
+ continue
524
+
525
+ # Expand assignment value
526
+ value = ""
527
+ if assignment.value:
528
+ value = await expand_word_async(self._ctx, assignment.value)
529
+
530
+ if node.name is None:
531
+ # Assignment-only command - set in environment
532
+ if assignment.append:
533
+ existing = self._state.env.get(name, "")
534
+ self._state.env[name] = existing + value
535
+ else:
536
+ self._state.env[name] = value
537
+ else:
538
+ # Temporary assignment for command
539
+ temp_assignments[name] = self._state.env.get(name)
540
+ self._state.env[name] = value
541
+
542
+ # If no command name, it's an assignment-only statement
543
+ if node.name is None:
544
+ return _ok()
545
+
546
+ # Process redirections for heredocs
547
+ for redir in node.redirections:
548
+ if redir.operator in ("<<", "<<-"):
549
+ # Here-document: the target should be a HereDocNode
550
+ target = redir.target
551
+ if hasattr(target, 'content'):
552
+ # Expand content - parser handles quoted vs unquoted delimiter
553
+ # (quoted delimiter parses content as literal SingleQuotedPart)
554
+ heredoc_content = await expand_word_async(self._ctx, target.content)
555
+ # Strip leading tabs if <<-
556
+ if redir.operator == "<<-":
557
+ lines = heredoc_content.split("\n")
558
+ heredoc_content = "\n".join(line.lstrip("\t") for line in lines)
559
+ stdin = heredoc_content
560
+
561
+ try:
562
+ # Expand command name
563
+ cmd_name = await expand_word_async(self._ctx, node.name)
564
+
565
+ # Alias expansion (before checking functions/builtins)
566
+ alias_args: list[str] = []
567
+ if _is_shopt_set(self._state.env, "expand_aliases"):
568
+ aliases = get_aliases(self._ctx)
569
+ if cmd_name in aliases:
570
+ alias_value = aliases[cmd_name]
571
+ # Simple word splitting for alias value
572
+ import shlex
573
+ try:
574
+ alias_parts = shlex.split(alias_value)
575
+ except ValueError:
576
+ # Fall back to simple split if shlex fails
577
+ alias_parts = alias_value.split()
578
+ if alias_parts:
579
+ cmd_name = alias_parts[0]
580
+ alias_args = alias_parts[1:]
581
+
582
+ # Check for function call first (functions override builtins)
583
+ if cmd_name in self._state.functions:
584
+ return await self._call_function(cmd_name, node.args, stdin, alias_args)
585
+
586
+ # Check for builtins (which need InterpreterContext access)
587
+ if cmd_name in BUILTINS:
588
+ return await self._execute_builtin(cmd_name, node, stdin, alias_args)
589
+
590
+ # Expand arguments with glob support
591
+ args: list[str] = list(alias_args) # Start with alias args
592
+ for arg in node.args:
593
+ expanded = await expand_word_with_glob(self._ctx, arg)
594
+ args.extend(expanded["values"])
595
+
596
+ # Update last arg for $_
597
+ if args:
598
+ self._state.last_arg = args[-1]
599
+
600
+ # Look up command
601
+ if cmd_name in self._commands:
602
+ cmd = self._commands[cmd_name]
603
+ # Create command context
604
+ from ..types import CommandContext
605
+
606
+ ctx = CommandContext(
607
+ fs=self._fs,
608
+ cwd=self._state.cwd,
609
+ env=self._state.env,
610
+ stdin=stdin,
611
+ limits=self._limits,
612
+ exec=lambda script, opts: self._exec_fn(
613
+ script, opts.get("env"), opts["cwd"]
614
+ ),
615
+ get_registered_commands=lambda: list(self._commands.keys()),
616
+ )
617
+ result = await cmd.execute(args, ctx)
618
+ else:
619
+ # Command not found
620
+ result = _failure(f"bash: {cmd_name}: command not found\n")
621
+
622
+ # Process output redirections
623
+ result = await self._process_output_redirections(node.redirections, result)
624
+ return result
625
+ finally:
626
+ # Restore temporary assignments
627
+ for name, old_value in temp_assignments.items():
628
+ if old_value is None:
629
+ del self._state.env[name]
630
+ else:
631
+ self._state.env[name] = old_value
632
+
633
+ async def _process_output_redirections(
634
+ self, redirections: list, result: ExecResult
635
+ ) -> ExecResult:
636
+ """Process output redirections after command execution."""
637
+ from ..ast.types import RedirectionNode, WordNode
638
+
639
+ stdout = result.stdout
640
+ stderr = result.stderr
641
+
642
+ for redir in redirections:
643
+ if not isinstance(redir, RedirectionNode):
644
+ continue
645
+
646
+ # Skip heredocs - already handled
647
+ if redir.operator in ("<<", "<<-"):
648
+ continue
649
+
650
+ # Get the target path
651
+ if redir.target is None:
652
+ continue
653
+
654
+ # Expand the target if it's a WordNode
655
+ if isinstance(redir.target, WordNode):
656
+ target_path = await expand_word_async(self._ctx, redir.target)
657
+ else:
658
+ continue
659
+
660
+ # Resolve to absolute path
661
+ target_path = self._fs.resolve_path(self._state.cwd, target_path)
662
+
663
+ try:
664
+ fd = redir.fd if redir.fd is not None else 1 # Default to stdout
665
+
666
+ if redir.operator == ">":
667
+ # Overwrite file
668
+ if fd == 1:
669
+ await self._fs.write_file(target_path, stdout)
670
+ stdout = ""
671
+ elif fd == 2:
672
+ await self._fs.write_file(target_path, stderr)
673
+ stderr = ""
674
+
675
+ elif redir.operator == ">>":
676
+ # Append to file
677
+ try:
678
+ existing = await self._fs.read_file(target_path)
679
+ except FileNotFoundError:
680
+ existing = ""
681
+ if fd == 1:
682
+ await self._fs.write_file(target_path, existing + stdout)
683
+ stdout = ""
684
+ elif fd == 2:
685
+ await self._fs.write_file(target_path, existing + stderr)
686
+ stderr = ""
687
+
688
+ elif redir.operator == "&>":
689
+ # Redirect both stdout and stderr to file
690
+ await self._fs.write_file(target_path, stdout + stderr)
691
+ stdout = ""
692
+ stderr = ""
693
+
694
+ elif redir.operator == ">&":
695
+ # Redirect stdout to stderr or fd duplication
696
+ if target_path == "2":
697
+ stderr = stderr + stdout
698
+ stdout = ""
699
+ elif target_path == "1":
700
+ stdout = stdout + stderr
701
+ stderr = ""
702
+ else:
703
+ await self._fs.write_file(target_path, stdout)
704
+ stdout = ""
705
+
706
+ elif redir.operator == "2>&1":
707
+ # Redirect stderr to stdout
708
+ stdout = stdout + stderr
709
+ stderr = ""
710
+
711
+ except Exception as e:
712
+ return ExecResult(
713
+ stdout=stdout,
714
+ stderr=stderr + f"bash: {target_path}: {e}\n",
715
+ exit_code=1,
716
+ )
717
+
718
+ return ExecResult(
719
+ stdout=stdout,
720
+ stderr=stderr,
721
+ exit_code=result.exit_code,
722
+ )
723
+
724
+ async def _call_function(
725
+ self, name: str, args: list, stdin: str, alias_args: list[str] | None = None
726
+ ) -> ExecResult:
727
+ """Call a user-defined function."""
728
+ func_def = self._state.functions[name]
729
+
730
+ # Check call depth
731
+ self._state.call_depth += 1
732
+ if self._state.call_depth > self._limits.max_call_depth:
733
+ self._state.call_depth -= 1
734
+ raise ExecutionLimitError(
735
+ f"function call depth exceeded ({self._limits.max_call_depth})",
736
+ "call_depth",
737
+ )
738
+
739
+ # Save positional parameters
740
+ saved_params = {}
741
+ i = 1
742
+ while str(i) in self._state.env:
743
+ saved_params[str(i)] = self._state.env[str(i)]
744
+ del self._state.env[str(i)]
745
+ i += 1
746
+ saved_count = self._state.env.get("#", "0")
747
+
748
+ # Set new positional parameters (alias args first, then expanded args)
749
+ expanded_args: list[str] = list(alias_args) if alias_args else []
750
+ for arg in args:
751
+ expanded = await expand_word_with_glob(self._ctx, arg)
752
+ expanded_args.extend(expanded["values"])
753
+
754
+ for i, arg in enumerate(expanded_args):
755
+ self._state.env[str(i + 1)] = arg
756
+ self._state.env["#"] = str(len(expanded_args))
757
+
758
+ # Create local scope
759
+ self._state.local_scopes.append({})
760
+
761
+ try:
762
+ # Execute function body (which is a CompoundCommandNode)
763
+ try:
764
+ result = await self.execute_command(func_def.body, stdin)
765
+ return result
766
+ except ReturnError as e:
767
+ return _result(e.stdout, e.stderr, e.exit_code)
768
+ finally:
769
+ # Restore positional parameters
770
+ i = 1
771
+ while str(i) in self._state.env:
772
+ del self._state.env[str(i)]
773
+ i += 1
774
+ for k, v in saved_params.items():
775
+ self._state.env[k] = v
776
+ self._state.env["#"] = saved_count
777
+
778
+ # Pop local scope
779
+ self._state.local_scopes.pop()
780
+ self._state.call_depth -= 1
781
+
782
+ async def _execute_builtin(
783
+ self, cmd_name: str, node: SimpleCommandNode, stdin: str,
784
+ alias_args: list[str] | None = None
785
+ ) -> ExecResult:
786
+ """Execute a shell builtin command.
787
+
788
+ Builtins get direct access to InterpreterContext so they can
789
+ modify interpreter state (env, cwd, options, etc.).
790
+ """
791
+ # Expand arguments with glob support (alias args first)
792
+ args: list[str] = list(alias_args) if alias_args else []
793
+ for arg in node.args:
794
+ expanded = await expand_word_with_glob(self._ctx, arg)
795
+ args.extend(expanded["values"])
796
+
797
+ # Update last arg for $_
798
+ if args:
799
+ self._state.last_arg = args[-1]
800
+
801
+ # Get the builtin handler and execute
802
+ handler = BUILTINS[cmd_name]
803
+ # Some builtins (like mapfile) need stdin - check signature
804
+ import inspect
805
+ sig = inspect.signature(handler)
806
+ if len(sig.parameters) >= 3:
807
+ result = await handler(self._ctx, args, stdin)
808
+ else:
809
+ result = await handler(self._ctx, args)
810
+
811
+ # Process output redirections
812
+ result = await self._process_output_redirections(node.redirections, result)
813
+ return result