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.
- just_bash/__init__.py +55 -0
- just_bash/ast/__init__.py +213 -0
- just_bash/ast/factory.py +320 -0
- just_bash/ast/types.py +953 -0
- just_bash/bash.py +220 -0
- just_bash/commands/__init__.py +23 -0
- just_bash/commands/argv/__init__.py +5 -0
- just_bash/commands/argv/argv.py +21 -0
- just_bash/commands/awk/__init__.py +5 -0
- just_bash/commands/awk/awk.py +1168 -0
- just_bash/commands/base64/__init__.py +5 -0
- just_bash/commands/base64/base64.py +138 -0
- just_bash/commands/basename/__init__.py +5 -0
- just_bash/commands/basename/basename.py +72 -0
- just_bash/commands/bash/__init__.py +5 -0
- just_bash/commands/bash/bash.py +188 -0
- just_bash/commands/cat/__init__.py +5 -0
- just_bash/commands/cat/cat.py +173 -0
- just_bash/commands/checksum/__init__.py +5 -0
- just_bash/commands/checksum/checksum.py +179 -0
- just_bash/commands/chmod/__init__.py +5 -0
- just_bash/commands/chmod/chmod.py +216 -0
- just_bash/commands/column/__init__.py +5 -0
- just_bash/commands/column/column.py +180 -0
- just_bash/commands/comm/__init__.py +5 -0
- just_bash/commands/comm/comm.py +150 -0
- just_bash/commands/compression/__init__.py +5 -0
- just_bash/commands/compression/compression.py +298 -0
- just_bash/commands/cp/__init__.py +5 -0
- just_bash/commands/cp/cp.py +149 -0
- just_bash/commands/curl/__init__.py +5 -0
- just_bash/commands/curl/curl.py +801 -0
- just_bash/commands/cut/__init__.py +5 -0
- just_bash/commands/cut/cut.py +327 -0
- just_bash/commands/date/__init__.py +5 -0
- just_bash/commands/date/date.py +258 -0
- just_bash/commands/diff/__init__.py +5 -0
- just_bash/commands/diff/diff.py +118 -0
- just_bash/commands/dirname/__init__.py +5 -0
- just_bash/commands/dirname/dirname.py +56 -0
- just_bash/commands/du/__init__.py +5 -0
- just_bash/commands/du/du.py +150 -0
- just_bash/commands/echo/__init__.py +5 -0
- just_bash/commands/echo/echo.py +125 -0
- just_bash/commands/env/__init__.py +5 -0
- just_bash/commands/env/env.py +163 -0
- just_bash/commands/expand/__init__.py +5 -0
- just_bash/commands/expand/expand.py +299 -0
- just_bash/commands/expr/__init__.py +5 -0
- just_bash/commands/expr/expr.py +273 -0
- just_bash/commands/file/__init__.py +5 -0
- just_bash/commands/file/file.py +274 -0
- just_bash/commands/find/__init__.py +5 -0
- just_bash/commands/find/find.py +623 -0
- just_bash/commands/fold/__init__.py +5 -0
- just_bash/commands/fold/fold.py +160 -0
- just_bash/commands/grep/__init__.py +5 -0
- just_bash/commands/grep/grep.py +418 -0
- just_bash/commands/head/__init__.py +5 -0
- just_bash/commands/head/head.py +167 -0
- just_bash/commands/help/__init__.py +5 -0
- just_bash/commands/help/help.py +67 -0
- just_bash/commands/hostname/__init__.py +5 -0
- just_bash/commands/hostname/hostname.py +21 -0
- just_bash/commands/html_to_markdown/__init__.py +5 -0
- just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
- just_bash/commands/join/__init__.py +5 -0
- just_bash/commands/join/join.py +252 -0
- just_bash/commands/jq/__init__.py +5 -0
- just_bash/commands/jq/jq.py +280 -0
- just_bash/commands/ln/__init__.py +5 -0
- just_bash/commands/ln/ln.py +127 -0
- just_bash/commands/ls/__init__.py +5 -0
- just_bash/commands/ls/ls.py +280 -0
- just_bash/commands/mkdir/__init__.py +5 -0
- just_bash/commands/mkdir/mkdir.py +92 -0
- just_bash/commands/mv/__init__.py +5 -0
- just_bash/commands/mv/mv.py +142 -0
- just_bash/commands/nl/__init__.py +5 -0
- just_bash/commands/nl/nl.py +180 -0
- just_bash/commands/od/__init__.py +5 -0
- just_bash/commands/od/od.py +157 -0
- just_bash/commands/paste/__init__.py +5 -0
- just_bash/commands/paste/paste.py +100 -0
- just_bash/commands/printf/__init__.py +5 -0
- just_bash/commands/printf/printf.py +157 -0
- just_bash/commands/pwd/__init__.py +5 -0
- just_bash/commands/pwd/pwd.py +23 -0
- just_bash/commands/read/__init__.py +5 -0
- just_bash/commands/read/read.py +185 -0
- just_bash/commands/readlink/__init__.py +5 -0
- just_bash/commands/readlink/readlink.py +86 -0
- just_bash/commands/registry.py +844 -0
- just_bash/commands/rev/__init__.py +5 -0
- just_bash/commands/rev/rev.py +74 -0
- just_bash/commands/rg/__init__.py +5 -0
- just_bash/commands/rg/rg.py +1048 -0
- just_bash/commands/rm/__init__.py +5 -0
- just_bash/commands/rm/rm.py +106 -0
- just_bash/commands/search_engine/__init__.py +13 -0
- just_bash/commands/search_engine/matcher.py +170 -0
- just_bash/commands/search_engine/regex.py +159 -0
- just_bash/commands/sed/__init__.py +5 -0
- just_bash/commands/sed/sed.py +863 -0
- just_bash/commands/seq/__init__.py +5 -0
- just_bash/commands/seq/seq.py +190 -0
- just_bash/commands/shell/__init__.py +5 -0
- just_bash/commands/shell/shell.py +206 -0
- just_bash/commands/sleep/__init__.py +5 -0
- just_bash/commands/sleep/sleep.py +62 -0
- just_bash/commands/sort/__init__.py +5 -0
- just_bash/commands/sort/sort.py +411 -0
- just_bash/commands/split/__init__.py +5 -0
- just_bash/commands/split/split.py +237 -0
- just_bash/commands/sqlite3/__init__.py +5 -0
- just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
- just_bash/commands/stat/__init__.py +5 -0
- just_bash/commands/stat/stat.py +150 -0
- just_bash/commands/strings/__init__.py +5 -0
- just_bash/commands/strings/strings.py +150 -0
- just_bash/commands/tac/__init__.py +5 -0
- just_bash/commands/tac/tac.py +158 -0
- just_bash/commands/tail/__init__.py +5 -0
- just_bash/commands/tail/tail.py +180 -0
- just_bash/commands/tar/__init__.py +5 -0
- just_bash/commands/tar/tar.py +1067 -0
- just_bash/commands/tee/__init__.py +5 -0
- just_bash/commands/tee/tee.py +63 -0
- just_bash/commands/timeout/__init__.py +5 -0
- just_bash/commands/timeout/timeout.py +188 -0
- just_bash/commands/touch/__init__.py +5 -0
- just_bash/commands/touch/touch.py +91 -0
- just_bash/commands/tr/__init__.py +5 -0
- just_bash/commands/tr/tr.py +297 -0
- just_bash/commands/tree/__init__.py +5 -0
- just_bash/commands/tree/tree.py +139 -0
- just_bash/commands/true/__init__.py +5 -0
- just_bash/commands/true/true.py +32 -0
- just_bash/commands/uniq/__init__.py +5 -0
- just_bash/commands/uniq/uniq.py +323 -0
- just_bash/commands/wc/__init__.py +5 -0
- just_bash/commands/wc/wc.py +169 -0
- just_bash/commands/which/__init__.py +5 -0
- just_bash/commands/which/which.py +52 -0
- just_bash/commands/xan/__init__.py +5 -0
- just_bash/commands/xan/xan.py +1663 -0
- just_bash/commands/xargs/__init__.py +5 -0
- just_bash/commands/xargs/xargs.py +136 -0
- just_bash/commands/yq/__init__.py +5 -0
- just_bash/commands/yq/yq.py +848 -0
- just_bash/fs/__init__.py +29 -0
- just_bash/fs/in_memory_fs.py +621 -0
- just_bash/fs/mountable_fs.py +504 -0
- just_bash/fs/overlay_fs.py +894 -0
- just_bash/fs/read_write_fs.py +455 -0
- just_bash/interpreter/__init__.py +37 -0
- just_bash/interpreter/builtins/__init__.py +92 -0
- just_bash/interpreter/builtins/alias.py +154 -0
- just_bash/interpreter/builtins/cd.py +76 -0
- just_bash/interpreter/builtins/control.py +127 -0
- just_bash/interpreter/builtins/declare.py +336 -0
- just_bash/interpreter/builtins/export.py +56 -0
- just_bash/interpreter/builtins/let.py +44 -0
- just_bash/interpreter/builtins/local.py +57 -0
- just_bash/interpreter/builtins/mapfile.py +152 -0
- just_bash/interpreter/builtins/misc.py +378 -0
- just_bash/interpreter/builtins/readonly.py +80 -0
- just_bash/interpreter/builtins/set.py +234 -0
- just_bash/interpreter/builtins/shopt.py +201 -0
- just_bash/interpreter/builtins/source.py +136 -0
- just_bash/interpreter/builtins/test.py +290 -0
- just_bash/interpreter/builtins/unset.py +53 -0
- just_bash/interpreter/conditionals.py +387 -0
- just_bash/interpreter/control_flow.py +381 -0
- just_bash/interpreter/errors.py +116 -0
- just_bash/interpreter/expansion.py +1156 -0
- just_bash/interpreter/interpreter.py +813 -0
- just_bash/interpreter/types.py +134 -0
- just_bash/network/__init__.py +1 -0
- just_bash/parser/__init__.py +39 -0
- just_bash/parser/lexer.py +948 -0
- just_bash/parser/parser.py +2162 -0
- just_bash/py.typed +0 -0
- just_bash/query_engine/__init__.py +83 -0
- just_bash/query_engine/builtins/__init__.py +1283 -0
- just_bash/query_engine/evaluator.py +578 -0
- just_bash/query_engine/parser.py +525 -0
- just_bash/query_engine/tokenizer.py +329 -0
- just_bash/query_engine/types.py +373 -0
- just_bash/types.py +180 -0
- just_bash-0.1.5.dist-info/METADATA +410 -0
- just_bash-0.1.5.dist-info/RECORD +193 -0
- 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
|