just-bash 0.1.8__py3-none-any.whl → 0.1.10__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/ast/factory.py +3 -1
- just_bash/bash.py +28 -6
- just_bash/commands/awk/awk.py +112 -7
- just_bash/commands/cat/cat.py +5 -1
- just_bash/commands/echo/echo.py +33 -1
- just_bash/commands/grep/grep.py +30 -1
- just_bash/commands/od/od.py +144 -30
- just_bash/commands/printf/printf.py +289 -87
- just_bash/commands/pwd/pwd.py +32 -2
- just_bash/commands/read/read.py +243 -64
- just_bash/commands/readlink/readlink.py +3 -9
- just_bash/commands/registry.py +24 -0
- just_bash/commands/rmdir/__init__.py +5 -0
- just_bash/commands/rmdir/rmdir.py +160 -0
- just_bash/commands/sed/sed.py +142 -31
- just_bash/commands/stat/stat.py +9 -0
- just_bash/commands/time/__init__.py +5 -0
- just_bash/commands/time/time.py +74 -0
- just_bash/commands/touch/touch.py +118 -8
- just_bash/commands/whoami/__init__.py +5 -0
- just_bash/commands/whoami/whoami.py +18 -0
- just_bash/fs/in_memory_fs.py +22 -0
- just_bash/fs/overlay_fs.py +14 -0
- just_bash/interpreter/__init__.py +1 -1
- just_bash/interpreter/builtins/__init__.py +2 -0
- just_bash/interpreter/builtins/control.py +4 -8
- just_bash/interpreter/builtins/declare.py +321 -24
- just_bash/interpreter/builtins/getopts.py +163 -0
- just_bash/interpreter/builtins/let.py +2 -2
- just_bash/interpreter/builtins/local.py +71 -5
- just_bash/interpreter/builtins/misc.py +22 -6
- just_bash/interpreter/builtins/readonly.py +38 -10
- just_bash/interpreter/builtins/set.py +58 -8
- just_bash/interpreter/builtins/test.py +136 -19
- just_bash/interpreter/builtins/unset.py +62 -10
- just_bash/interpreter/conditionals.py +29 -4
- just_bash/interpreter/control_flow.py +61 -17
- just_bash/interpreter/expansion.py +1647 -104
- just_bash/interpreter/interpreter.py +424 -70
- just_bash/interpreter/types.py +263 -2
- just_bash/parser/__init__.py +2 -0
- just_bash/parser/lexer.py +295 -26
- just_bash/parser/parser.py +523 -64
- just_bash/types.py +11 -0
- {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
- {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/RECORD +47 -40
- {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -41,7 +41,7 @@ from .errors import (
|
|
|
41
41
|
NounsetError,
|
|
42
42
|
ReturnError,
|
|
43
43
|
)
|
|
44
|
-
from .types import InterpreterContext, InterpreterState, ShellOptions
|
|
44
|
+
from .types import InterpreterContext, InterpreterState, ShellOptions, VariableStore, FDTable
|
|
45
45
|
from .expansion import expand_word_async, expand_word_with_glob, get_variable, evaluate_arithmetic
|
|
46
46
|
from .conditionals import evaluate_conditional
|
|
47
47
|
from .control_flow import (
|
|
@@ -80,6 +80,17 @@ def _is_shopt_set(env: dict[str, str], name: str) -> bool:
|
|
|
80
80
|
return DEFAULT_SHOPTS.get(name, False)
|
|
81
81
|
|
|
82
82
|
|
|
83
|
+
def _word_has_quoting(word) -> bool:
|
|
84
|
+
"""Check if a word contains any quoting (single, double, or escape)."""
|
|
85
|
+
from ..ast.types import SingleQuotedPart, DoubleQuotedPart, EscapedPart
|
|
86
|
+
if word is None or not hasattr(word, 'parts'):
|
|
87
|
+
return False
|
|
88
|
+
for part in word.parts:
|
|
89
|
+
if isinstance(part, (SingleQuotedPart, DoubleQuotedPart, EscapedPart)):
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
83
94
|
class Interpreter:
|
|
84
95
|
"""AST interpreter for bash scripts."""
|
|
85
96
|
|
|
@@ -102,14 +113,14 @@ class Interpreter:
|
|
|
102
113
|
self._commands = commands
|
|
103
114
|
self._limits = limits
|
|
104
115
|
self._state = state or InterpreterState(
|
|
105
|
-
env={
|
|
116
|
+
env=VariableStore({
|
|
106
117
|
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
|
107
118
|
"HOME": "/home/user",
|
|
108
119
|
"USER": "user",
|
|
109
120
|
"SHELL": "/bin/bash",
|
|
110
121
|
"PWD": "/home/user",
|
|
111
122
|
"?": "0",
|
|
112
|
-
},
|
|
123
|
+
}),
|
|
113
124
|
cwd="/home/user",
|
|
114
125
|
previous_dir="/home/user",
|
|
115
126
|
start_time=time.time(),
|
|
@@ -147,7 +158,9 @@ class Interpreter:
|
|
|
147
158
|
|
|
148
159
|
# Create a new state for the subshell if env/cwd are provided
|
|
149
160
|
if env or cwd:
|
|
150
|
-
new_env =
|
|
161
|
+
new_env = self._state.env.copy() if isinstance(self._state.env, VariableStore) else VariableStore(self._state.env)
|
|
162
|
+
if env:
|
|
163
|
+
new_env.update(env)
|
|
151
164
|
new_state = InterpreterState(
|
|
152
165
|
env=new_env,
|
|
153
166
|
cwd=cwd or self._state.cwd,
|
|
@@ -161,6 +174,7 @@ class Interpreter:
|
|
|
161
174
|
xtrace=self._state.options.xtrace,
|
|
162
175
|
verbose=self._state.options.verbose,
|
|
163
176
|
),
|
|
177
|
+
fd_table=self._state.fd_table.clone(),
|
|
164
178
|
)
|
|
165
179
|
sub_interpreter = Interpreter(
|
|
166
180
|
fs=self._fs,
|
|
@@ -300,7 +314,10 @@ class Interpreter:
|
|
|
300
314
|
|
|
301
315
|
async def execute_pipeline(self, node: PipelineNode) -> ExecResult:
|
|
302
316
|
"""Execute a pipeline AST node."""
|
|
303
|
-
|
|
317
|
+
# Use group_stdin for the first command if we're inside a group
|
|
318
|
+
stdin = self._state.group_stdin or ""
|
|
319
|
+
# Clear group_stdin after first use so it's not reused
|
|
320
|
+
self._state.group_stdin = ""
|
|
304
321
|
last_result = _ok()
|
|
305
322
|
pipefail_exit_code = 0
|
|
306
323
|
pipestatus_exit_codes: list[int] = []
|
|
@@ -403,7 +420,7 @@ class Interpreter:
|
|
|
403
420
|
"""Execute a subshell command."""
|
|
404
421
|
# Create a new interpreter with a copy of the state
|
|
405
422
|
new_state = InterpreterState(
|
|
406
|
-
env=
|
|
423
|
+
env=self._state.env.copy() if isinstance(self._state.env, VariableStore) else VariableStore(self._state.env),
|
|
407
424
|
cwd=self._state.cwd,
|
|
408
425
|
previous_dir=self._state.previous_dir,
|
|
409
426
|
functions=dict(self._state.functions),
|
|
@@ -415,6 +432,7 @@ class Interpreter:
|
|
|
415
432
|
xtrace=self._state.options.xtrace,
|
|
416
433
|
verbose=self._state.options.verbose,
|
|
417
434
|
),
|
|
435
|
+
fd_table=self._state.fd_table.clone(),
|
|
418
436
|
)
|
|
419
437
|
sub_interpreter = Interpreter(
|
|
420
438
|
fs=self._fs,
|
|
@@ -427,13 +445,25 @@ class Interpreter:
|
|
|
427
445
|
stdout = ""
|
|
428
446
|
stderr = ""
|
|
429
447
|
exit_code = 0
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
448
|
+
try:
|
|
449
|
+
for stmt in node.body:
|
|
450
|
+
result = await sub_interpreter.execute_statement(stmt)
|
|
451
|
+
stdout += result.stdout
|
|
452
|
+
stderr += result.stderr
|
|
453
|
+
exit_code = result.exit_code
|
|
454
|
+
except ExitError as e:
|
|
455
|
+
# exit inside a subshell only exits the subshell
|
|
456
|
+
stdout += e.stdout
|
|
457
|
+
stderr += e.stderr
|
|
458
|
+
exit_code = e.exit_code
|
|
435
459
|
|
|
436
|
-
|
|
460
|
+
result = _result(stdout, stderr, exit_code)
|
|
461
|
+
|
|
462
|
+
# Process output redirections on the subshell
|
|
463
|
+
if node.redirections:
|
|
464
|
+
result = await self._process_output_redirections(node.redirections, result)
|
|
465
|
+
|
|
466
|
+
return result
|
|
437
467
|
|
|
438
468
|
async def _execute_group(self, node: GroupNode, stdin: str) -> ExecResult:
|
|
439
469
|
"""Execute a command group { ... }."""
|
|
@@ -456,7 +486,13 @@ class Interpreter:
|
|
|
456
486
|
finally:
|
|
457
487
|
self._state.group_stdin = saved_group_stdin
|
|
458
488
|
|
|
459
|
-
|
|
489
|
+
result = _result(stdout, stderr, exit_code)
|
|
490
|
+
|
|
491
|
+
# Process output redirections on the group
|
|
492
|
+
if node.redirections:
|
|
493
|
+
result = await self._process_output_redirections(node.redirections, result)
|
|
494
|
+
|
|
495
|
+
return result
|
|
460
496
|
|
|
461
497
|
async def _execute_function_def(self, node: FunctionDefNode) -> ExecResult:
|
|
462
498
|
"""Execute a function definition."""
|
|
@@ -495,8 +531,9 @@ class Interpreter:
|
|
|
495
531
|
if node.line is not None:
|
|
496
532
|
self._state.current_line = node.line
|
|
497
533
|
|
|
498
|
-
# Clear expansion
|
|
534
|
+
# Clear expansion state
|
|
499
535
|
self._state.expansion_stderr = ""
|
|
536
|
+
self._state.expansion_exit_code = None
|
|
500
537
|
|
|
501
538
|
# Temporary assignments for command environment
|
|
502
539
|
temp_assignments: dict[str, str | None] = {}
|
|
@@ -505,34 +542,133 @@ class Interpreter:
|
|
|
505
542
|
for assignment in node.assignments:
|
|
506
543
|
name = assignment.name
|
|
507
544
|
|
|
508
|
-
# Check for array
|
|
509
|
-
|
|
510
|
-
|
|
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]
|
|
545
|
+
# Check for array subscript in name: a[idx]=value
|
|
546
|
+
import re as _re
|
|
547
|
+
_array_lhs = _re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', name)
|
|
515
548
|
|
|
516
|
-
|
|
517
|
-
|
|
549
|
+
# Resolve nameref for array assignment target
|
|
550
|
+
if (assignment.array
|
|
551
|
+
and isinstance(self._state.env, VariableStore)
|
|
552
|
+
and self._state.env.is_nameref(name)):
|
|
553
|
+
try:
|
|
554
|
+
name = self._state.env.resolve_nameref(name)
|
|
555
|
+
except ValueError:
|
|
556
|
+
pass
|
|
518
557
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
558
|
+
# Check for array assignment
|
|
559
|
+
if assignment.array:
|
|
560
|
+
if assignment.append:
|
|
561
|
+
# a+=(2 3) - append to existing array
|
|
562
|
+
# Find next available index
|
|
563
|
+
from .expansion import get_array_elements as _get_elems
|
|
564
|
+
existing_elems = _get_elems(self._ctx, name)
|
|
565
|
+
next_idx = (max(i for i, _ in existing_elems) + 1) if existing_elems else 0
|
|
566
|
+
|
|
567
|
+
# Mark as array if not already
|
|
568
|
+
if f"{name}__is_array" not in self._state.env:
|
|
569
|
+
self._state.env[f"{name}__is_array"] = "indexed"
|
|
570
|
+
|
|
571
|
+
# Expand and store each new element
|
|
572
|
+
for i, elem in enumerate(assignment.array):
|
|
573
|
+
elem_value = await expand_word_async(self._ctx, elem)
|
|
574
|
+
self._state.env[f"{name}_{next_idx + i}"] = elem_value
|
|
575
|
+
else:
|
|
576
|
+
# a=(1 2 3) - replace array
|
|
577
|
+
# Clear existing array elements
|
|
578
|
+
prefix = f"{name}_"
|
|
579
|
+
to_remove = [k for k in self._state.env if k.startswith(prefix) and not k.startswith(f"{name}__")]
|
|
580
|
+
for k in to_remove:
|
|
581
|
+
del self._state.env[k]
|
|
582
|
+
|
|
583
|
+
# Mark as array
|
|
584
|
+
self._state.env[f"{name}__is_array"] = "indexed"
|
|
585
|
+
|
|
586
|
+
# Expand and store each element, handling [idx]=value syntax
|
|
587
|
+
auto_idx = 0
|
|
588
|
+
for elem in assignment.array:
|
|
589
|
+
elem_value = await expand_word_async(self._ctx, elem)
|
|
590
|
+
# Check for [idx]=value syntax (from GlobPart + LiteralPart)
|
|
591
|
+
bracket_match = _re.match(r'^\[([^\]]+)\]=(.*)$', elem_value)
|
|
592
|
+
if bracket_match:
|
|
593
|
+
key = bracket_match.group(1)
|
|
594
|
+
val = bracket_match.group(2)
|
|
595
|
+
self._state.env[f"{name}_{key}"] = val
|
|
596
|
+
try:
|
|
597
|
+
auto_idx = int(key) + 1
|
|
598
|
+
except ValueError:
|
|
599
|
+
pass # assoc array key
|
|
600
|
+
else:
|
|
601
|
+
self._state.env[f"{name}_{auto_idx}"] = elem_value
|
|
602
|
+
auto_idx += 1
|
|
523
603
|
continue
|
|
524
604
|
|
|
605
|
+
# Resolve nameref for the assignment target
|
|
606
|
+
if (isinstance(self._state.env, VariableStore)
|
|
607
|
+
and not _array_lhs
|
|
608
|
+
and self._state.env.is_nameref(name)):
|
|
609
|
+
try:
|
|
610
|
+
name = self._state.env.resolve_nameref(name)
|
|
611
|
+
except ValueError:
|
|
612
|
+
pass
|
|
613
|
+
|
|
525
614
|
# Expand assignment value
|
|
526
615
|
value = ""
|
|
527
616
|
if assignment.value:
|
|
528
617
|
value = await expand_word_async(self._ctx, assignment.value)
|
|
529
618
|
|
|
619
|
+
# Apply attribute-based transformations
|
|
620
|
+
if isinstance(self._state.env, VariableStore):
|
|
621
|
+
attrs = self._state.env.get_attributes(name)
|
|
622
|
+
if "i" in attrs:
|
|
623
|
+
# Integer attribute: evaluate as arithmetic
|
|
624
|
+
try:
|
|
625
|
+
from .expansion import evaluate_arithmetic_sync
|
|
626
|
+
from ..parser.parser import Parser
|
|
627
|
+
parser = Parser()
|
|
628
|
+
arith_expr = parser._parse_arith_comma(value)
|
|
629
|
+
value = str(evaluate_arithmetic_sync(self._ctx, arith_expr))
|
|
630
|
+
except Exception:
|
|
631
|
+
try:
|
|
632
|
+
value = str(int(value))
|
|
633
|
+
except ValueError:
|
|
634
|
+
value = "0"
|
|
635
|
+
if "l" in attrs:
|
|
636
|
+
value = value.lower()
|
|
637
|
+
elif "u" in attrs:
|
|
638
|
+
value = value.upper()
|
|
639
|
+
|
|
530
640
|
if node.name is None:
|
|
531
641
|
# Assignment-only command - set in environment
|
|
532
|
-
if
|
|
642
|
+
if _array_lhs:
|
|
643
|
+
# a[idx]=value or a[idx]+=value
|
|
644
|
+
arr_name = _array_lhs.group(1)
|
|
645
|
+
subscript = _array_lhs.group(2)
|
|
646
|
+
# Resolve nameref for array base name
|
|
647
|
+
if isinstance(self._state.env, VariableStore) and self._state.env.is_nameref(arr_name):
|
|
648
|
+
try:
|
|
649
|
+
arr_name = self._state.env.resolve_nameref(arr_name)
|
|
650
|
+
except ValueError:
|
|
651
|
+
pass
|
|
652
|
+
# Mark as array if not already
|
|
653
|
+
if f"{arr_name}__is_array" not in self._state.env:
|
|
654
|
+
self._state.env[f"{arr_name}__is_array"] = "indexed"
|
|
655
|
+
if assignment.append:
|
|
656
|
+
existing = self._state.env.get(f"{arr_name}_{subscript}", "")
|
|
657
|
+
self._state.env[f"{arr_name}_{subscript}"] = existing + value
|
|
658
|
+
else:
|
|
659
|
+
self._state.env[f"{arr_name}_{subscript}"] = value
|
|
660
|
+
elif assignment.append:
|
|
533
661
|
existing = self._state.env.get(name, "")
|
|
534
662
|
self._state.env[name] = existing + value
|
|
535
663
|
else:
|
|
664
|
+
# Special handling for SECONDS - reset timer
|
|
665
|
+
if name == "SECONDS":
|
|
666
|
+
import time
|
|
667
|
+
try:
|
|
668
|
+
offset = int(value)
|
|
669
|
+
self._state.seconds_reset_time = time.time() - offset
|
|
670
|
+
except (ValueError, TypeError):
|
|
671
|
+
self._state.seconds_reset_time = time.time()
|
|
536
672
|
self._state.env[name] = value
|
|
537
673
|
else:
|
|
538
674
|
# Temporary assignment for command
|
|
@@ -559,17 +695,39 @@ class Interpreter:
|
|
|
559
695
|
lines = heredoc_content.split("\n")
|
|
560
696
|
heredoc_content = "\n".join(line.lstrip("\t") for line in lines)
|
|
561
697
|
stdin = heredoc_content
|
|
698
|
+
elif redir.operator == "<<<":
|
|
699
|
+
# Here-string: expand the word and use as stdin with trailing newline
|
|
700
|
+
if redir.target is not None and isinstance(redir.target, WordNode):
|
|
701
|
+
herestring_content = await expand_word_async(self._ctx, redir.target)
|
|
702
|
+
stdin = herestring_content + "\n"
|
|
562
703
|
elif redir.operator == "<":
|
|
563
|
-
# Input redirection: read file content
|
|
704
|
+
# Input redirection: read file content
|
|
705
|
+
fd = redir.fd if redir.fd is not None else 0
|
|
564
706
|
if redir.target is not None and isinstance(redir.target, WordNode):
|
|
565
707
|
target_path = await expand_word_async(self._ctx, redir.target)
|
|
566
708
|
target_path = self._fs.resolve_path(self._state.cwd, target_path)
|
|
567
709
|
try:
|
|
568
|
-
|
|
710
|
+
file_content = await self._fs.read_file(target_path)
|
|
569
711
|
except FileNotFoundError:
|
|
570
712
|
return _failure(f"bash: {target_path}: No such file or directory\n")
|
|
571
713
|
except IsADirectoryError:
|
|
572
714
|
return _failure(f"bash: {target_path}: Is a directory\n")
|
|
715
|
+
if fd == 0:
|
|
716
|
+
stdin = file_content
|
|
717
|
+
else:
|
|
718
|
+
# Custom FD input redirect - store in FD table
|
|
719
|
+
self._state.fd_table.open(fd, target_path, "r")
|
|
720
|
+
self._state.fd_table._fds[fd].content = file_content
|
|
721
|
+
elif redir.operator == "<&":
|
|
722
|
+
# Input FD duplication
|
|
723
|
+
fd = redir.fd if redir.fd is not None else 0
|
|
724
|
+
if redir.target is not None and isinstance(redir.target, WordNode):
|
|
725
|
+
target_str = await expand_word_async(self._ctx, redir.target)
|
|
726
|
+
if target_str == "-":
|
|
727
|
+
self._state.fd_table.close(fd)
|
|
728
|
+
elif target_str.isdigit():
|
|
729
|
+
target_fd = int(target_str)
|
|
730
|
+
self._state.fd_table.dup(target_fd, fd)
|
|
573
731
|
|
|
574
732
|
try:
|
|
575
733
|
# Expand command name
|
|
@@ -579,18 +737,37 @@ class Interpreter:
|
|
|
579
737
|
alias_args: list[str] = []
|
|
580
738
|
if _is_shopt_set(self._state.env, "expand_aliases"):
|
|
581
739
|
aliases = get_aliases(self._ctx)
|
|
582
|
-
if
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
740
|
+
# Don't expand if command name was quoted
|
|
741
|
+
name_is_quoted = _word_has_quoting(node.name) if node.name else False
|
|
742
|
+
if not name_is_quoted and cmd_name in aliases:
|
|
743
|
+
expanded_aliases: set[str] = set()
|
|
744
|
+
trailing_space = False
|
|
745
|
+
while cmd_name in aliases and cmd_name not in expanded_aliases:
|
|
746
|
+
expanded_aliases.add(cmd_name)
|
|
747
|
+
alias_value = aliases[cmd_name]
|
|
748
|
+
# Check if alias value ends with whitespace (triggers next-word expansion)
|
|
749
|
+
trailing_space = alias_value.endswith((" ", "\t"))
|
|
750
|
+
# Simple word splitting for alias value
|
|
751
|
+
import shlex
|
|
752
|
+
try:
|
|
753
|
+
alias_parts = shlex.split(alias_value)
|
|
754
|
+
except ValueError:
|
|
755
|
+
alias_parts = alias_value.split()
|
|
756
|
+
if alias_parts:
|
|
757
|
+
cmd_name = alias_parts[0]
|
|
758
|
+
alias_args = alias_parts[1:] + alias_args
|
|
759
|
+
# Trailing space: alias-expand first argument too
|
|
760
|
+
if trailing_space and node.args and aliases:
|
|
761
|
+
first_arg = await expand_word_async(self._ctx, node.args[0])
|
|
762
|
+
if not _word_has_quoting(node.args[0]) and first_arg in aliases:
|
|
763
|
+
arg_alias_value = aliases[first_arg]
|
|
764
|
+
import shlex
|
|
765
|
+
try:
|
|
766
|
+
arg_parts = shlex.split(arg_alias_value)
|
|
767
|
+
except ValueError:
|
|
768
|
+
arg_parts = arg_alias_value.split()
|
|
769
|
+
if arg_parts:
|
|
770
|
+
alias_args.extend(arg_parts)
|
|
594
771
|
|
|
595
772
|
# Check for function call first (functions override builtins)
|
|
596
773
|
if cmd_name in self._state.functions:
|
|
@@ -606,6 +783,14 @@ class Interpreter:
|
|
|
606
783
|
expanded = await expand_word_with_glob(self._ctx, arg)
|
|
607
784
|
args.extend(expanded["values"])
|
|
608
785
|
|
|
786
|
+
# Check for expansion errors (e.g., failglob)
|
|
787
|
+
if self._state.expansion_exit_code is not None:
|
|
788
|
+
exit_code = self._state.expansion_exit_code
|
|
789
|
+
stderr = self._state.expansion_stderr
|
|
790
|
+
self._state.expansion_exit_code = None
|
|
791
|
+
self._state.expansion_stderr = ""
|
|
792
|
+
return _result("", stderr, exit_code)
|
|
793
|
+
|
|
609
794
|
# Update last arg for $_
|
|
610
795
|
if args:
|
|
611
796
|
self._state.last_arg = args[-1]
|
|
@@ -616,6 +801,12 @@ class Interpreter:
|
|
|
616
801
|
# Create command context
|
|
617
802
|
from ..types import CommandContext
|
|
618
803
|
|
|
804
|
+
# Collect custom FD contents for commands
|
|
805
|
+
fd_contents: dict[int, str] = {}
|
|
806
|
+
for fd_num, fd_entry in self._state.fd_table._fds.items():
|
|
807
|
+
if fd_num >= 3 and not fd_entry.is_closed:
|
|
808
|
+
fd_contents[fd_num] = fd_entry.content
|
|
809
|
+
|
|
619
810
|
ctx = CommandContext(
|
|
620
811
|
fs=self._fs,
|
|
621
812
|
cwd=self._state.cwd,
|
|
@@ -626,6 +817,7 @@ class Interpreter:
|
|
|
626
817
|
script, opts.get("env"), opts["cwd"]
|
|
627
818
|
),
|
|
628
819
|
get_registered_commands=lambda: list(self._commands.keys()),
|
|
820
|
+
fd_contents=fd_contents,
|
|
629
821
|
)
|
|
630
822
|
result = await cmd.execute(args, ctx)
|
|
631
823
|
else:
|
|
@@ -652,12 +844,25 @@ class Interpreter:
|
|
|
652
844
|
stdout = result.stdout
|
|
653
845
|
stderr = result.stderr
|
|
654
846
|
|
|
655
|
-
for
|
|
847
|
+
# Pre-process: find the last file redirect index per fd for "last wins" semantics
|
|
848
|
+
# For > or >>, last redirect per fd gets the content; earlier ones just create/truncate
|
|
849
|
+
last_file_redir_for_fd: dict[int, int] = {}
|
|
850
|
+
for idx, redir in enumerate(redirections):
|
|
851
|
+
if not isinstance(redir, RedirectionNode):
|
|
852
|
+
continue
|
|
853
|
+
if redir.operator in (">", ">>"):
|
|
854
|
+
fd = redir.fd if redir.fd is not None else 1
|
|
855
|
+
last_file_redir_for_fd[fd] = idx
|
|
856
|
+
elif redir.operator == "&>":
|
|
857
|
+
last_file_redir_for_fd[1] = idx
|
|
858
|
+
last_file_redir_for_fd[2] = idx
|
|
859
|
+
|
|
860
|
+
for idx, redir in enumerate(redirections):
|
|
656
861
|
if not isinstance(redir, RedirectionNode):
|
|
657
862
|
continue
|
|
658
863
|
|
|
659
|
-
# Skip heredocs - already handled
|
|
660
|
-
if redir.operator in ("<<", "<<-"):
|
|
864
|
+
# Skip heredocs and input redirections - already handled
|
|
865
|
+
if redir.operator in ("<<", "<<-", "<<<", "<"):
|
|
661
866
|
continue
|
|
662
867
|
|
|
663
868
|
# Get the target path
|
|
@@ -670,20 +875,50 @@ class Interpreter:
|
|
|
670
875
|
else:
|
|
671
876
|
continue
|
|
672
877
|
|
|
673
|
-
# Resolve to absolute path
|
|
674
|
-
target_path = self._fs.resolve_path(self._state.cwd, target_path)
|
|
675
|
-
|
|
676
878
|
try:
|
|
677
879
|
fd = redir.fd if redir.fd is not None else 1 # Default to stdout
|
|
678
880
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
881
|
+
# Check for FD duplication operators - don't resolve as path
|
|
882
|
+
is_fd_dup = redir.operator in (">&", "<&")
|
|
883
|
+
is_fd_target = is_fd_dup and (target_path.isdigit() or target_path == "-")
|
|
884
|
+
|
|
885
|
+
# Handle /dev/null and special device files
|
|
886
|
+
if target_path in ("/dev/null", "/dev/zero"):
|
|
887
|
+
if redir.operator in (">", ">>"):
|
|
888
|
+
if fd == 1:
|
|
889
|
+
stdout = ""
|
|
890
|
+
elif fd == 2:
|
|
891
|
+
stderr = ""
|
|
892
|
+
elif redir.operator == "&>":
|
|
683
893
|
stdout = ""
|
|
684
|
-
elif fd == 2:
|
|
685
|
-
await self._fs.write_file(target_path, stderr)
|
|
686
894
|
stderr = ""
|
|
895
|
+
continue
|
|
896
|
+
elif target_path in ("/dev/stdout", "/dev/stderr", "/dev/stdin"):
|
|
897
|
+
continue
|
|
898
|
+
|
|
899
|
+
# Resolve to absolute path for file operations
|
|
900
|
+
if not is_fd_target:
|
|
901
|
+
target_path = self._fs.resolve_path(self._state.cwd, target_path)
|
|
902
|
+
|
|
903
|
+
if redir.operator == ">":
|
|
904
|
+
is_last_for_fd = last_file_redir_for_fd.get(fd) == idx
|
|
905
|
+
if is_last_for_fd:
|
|
906
|
+
# Last redirect for this fd - write content
|
|
907
|
+
if fd == 1:
|
|
908
|
+
await self._fs.write_file(target_path, stdout)
|
|
909
|
+
stdout = ""
|
|
910
|
+
elif fd == 2:
|
|
911
|
+
await self._fs.write_file(target_path, stderr)
|
|
912
|
+
stderr = ""
|
|
913
|
+
elif fd >= 3:
|
|
914
|
+
# Custom FD - register in FD table and create file
|
|
915
|
+
self._state.fd_table.open(fd, target_path, "w")
|
|
916
|
+
await self._fs.write_file(target_path, "")
|
|
917
|
+
else:
|
|
918
|
+
# Not the last redirect - just create/truncate the file
|
|
919
|
+
await self._fs.write_file(target_path, "")
|
|
920
|
+
if fd >= 3:
|
|
921
|
+
self._state.fd_table.open(fd, target_path, "w")
|
|
687
922
|
|
|
688
923
|
elif redir.operator == ">>":
|
|
689
924
|
# Append to file
|
|
@@ -691,12 +926,22 @@ class Interpreter:
|
|
|
691
926
|
existing = await self._fs.read_file(target_path)
|
|
692
927
|
except FileNotFoundError:
|
|
693
928
|
existing = ""
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
929
|
+
is_last_for_fd = last_file_redir_for_fd.get(fd) == idx
|
|
930
|
+
if is_last_for_fd:
|
|
931
|
+
if fd == 1:
|
|
932
|
+
await self._fs.write_file(target_path, existing + stdout)
|
|
933
|
+
stdout = ""
|
|
934
|
+
elif fd == 2:
|
|
935
|
+
await self._fs.write_file(target_path, existing + stderr)
|
|
936
|
+
stderr = ""
|
|
937
|
+
elif fd >= 3:
|
|
938
|
+
self._state.fd_table.open(fd, target_path, "a")
|
|
939
|
+
else:
|
|
940
|
+
# Not last - ensure file exists but don't write content
|
|
941
|
+
if not existing:
|
|
942
|
+
await self._fs.write_file(target_path, "")
|
|
943
|
+
if fd >= 3:
|
|
944
|
+
self._state.fd_table.open(fd, target_path, "a")
|
|
700
945
|
|
|
701
946
|
elif redir.operator == "&>":
|
|
702
947
|
# Redirect both stdout and stderr to file
|
|
@@ -705,16 +950,81 @@ class Interpreter:
|
|
|
705
950
|
stderr = ""
|
|
706
951
|
|
|
707
952
|
elif redir.operator == ">&":
|
|
708
|
-
#
|
|
709
|
-
if target_path == "
|
|
710
|
-
|
|
711
|
-
|
|
953
|
+
# FD duplication
|
|
954
|
+
if target_path == "-":
|
|
955
|
+
# Close FD
|
|
956
|
+
self._state.fd_table.close(fd)
|
|
957
|
+
elif target_path == "2":
|
|
958
|
+
# fd>&2: redirect fd to stderr
|
|
959
|
+
if fd == 1:
|
|
960
|
+
stderr = stderr + stdout
|
|
961
|
+
stdout = ""
|
|
962
|
+
elif fd >= 3:
|
|
963
|
+
# Custom FD to stderr - no content to redirect
|
|
964
|
+
self._state.fd_table.dup(2, fd)
|
|
712
965
|
elif target_path == "1":
|
|
713
|
-
|
|
714
|
-
|
|
966
|
+
# fd>&1: redirect fd to stdout
|
|
967
|
+
if fd == 2:
|
|
968
|
+
stdout = stdout + stderr
|
|
969
|
+
stderr = ""
|
|
970
|
+
elif fd >= 3:
|
|
971
|
+
self._state.fd_table.dup(1, fd)
|
|
972
|
+
elif target_path.isdigit():
|
|
973
|
+
target_fd = int(target_path)
|
|
974
|
+
if target_fd >= 3:
|
|
975
|
+
# Redirect fd to a custom FD
|
|
976
|
+
fd_entry = self._state.fd_table._fds.get(target_fd)
|
|
977
|
+
fd_path = self._state.fd_table.get_path(target_fd)
|
|
978
|
+
# Check if target FD is a dup of stdout or stderr
|
|
979
|
+
target_dup_of = fd_entry.dup_of if fd_entry else None
|
|
980
|
+
if fd == 1:
|
|
981
|
+
if target_dup_of == 1:
|
|
982
|
+
# FD is dup of stdout - content stays in stdout
|
|
983
|
+
pass
|
|
984
|
+
elif target_dup_of == 2:
|
|
985
|
+
# FD is dup of stderr - merge into stderr
|
|
986
|
+
stderr = stderr + stdout
|
|
987
|
+
stdout = ""
|
|
988
|
+
elif fd_path:
|
|
989
|
+
# Write stdout to the target FD's file
|
|
990
|
+
try:
|
|
991
|
+
existing = await self._fs.read_file(fd_path)
|
|
992
|
+
except FileNotFoundError:
|
|
993
|
+
existing = ""
|
|
994
|
+
await self._fs.write_file(fd_path, existing + stdout)
|
|
995
|
+
stdout = ""
|
|
996
|
+
else:
|
|
997
|
+
# Custom FD without file - store content
|
|
998
|
+
self._state.fd_table.write(target_fd, stdout)
|
|
999
|
+
stdout = ""
|
|
1000
|
+
elif fd == 2:
|
|
1001
|
+
if target_dup_of == 2:
|
|
1002
|
+
# FD is dup of stderr - content stays in stderr
|
|
1003
|
+
pass
|
|
1004
|
+
elif target_dup_of == 1:
|
|
1005
|
+
# FD is dup of stdout - merge into stdout
|
|
1006
|
+
stdout = stdout + stderr
|
|
1007
|
+
stderr = ""
|
|
1008
|
+
elif fd_path:
|
|
1009
|
+
try:
|
|
1010
|
+
existing = await self._fs.read_file(fd_path)
|
|
1011
|
+
except FileNotFoundError:
|
|
1012
|
+
existing = ""
|
|
1013
|
+
await self._fs.write_file(fd_path, existing + stderr)
|
|
1014
|
+
stderr = ""
|
|
1015
|
+
else:
|
|
1016
|
+
self._state.fd_table.write(target_fd, stderr)
|
|
1017
|
+
stderr = ""
|
|
1018
|
+
else:
|
|
1019
|
+
self._state.fd_table.dup(target_fd, fd)
|
|
1020
|
+
else:
|
|
1021
|
+
self._state.fd_table.dup(target_fd, fd)
|
|
715
1022
|
else:
|
|
716
|
-
|
|
717
|
-
|
|
1023
|
+
# >&file is same as &> for fd 1
|
|
1024
|
+
if fd == 1:
|
|
1025
|
+
await self._fs.write_file(target_path, stdout + stderr)
|
|
1026
|
+
stdout = ""
|
|
1027
|
+
stderr = ""
|
|
718
1028
|
|
|
719
1029
|
elif redir.operator == "2>&1":
|
|
720
1030
|
# Redirect stderr to stdout
|
|
@@ -768,8 +1078,14 @@ class Interpreter:
|
|
|
768
1078
|
self._state.env[str(i + 1)] = arg
|
|
769
1079
|
self._state.env["#"] = str(len(expanded_args))
|
|
770
1080
|
|
|
1081
|
+
# Save and set FUNCNAME
|
|
1082
|
+
saved_funcname = self._state.env.get("FUNCNAME")
|
|
1083
|
+
self._state.env["FUNCNAME"] = name
|
|
1084
|
+
|
|
771
1085
|
# Create local scope
|
|
772
1086
|
self._state.local_scopes.append({})
|
|
1087
|
+
if isinstance(self._state.env, VariableStore):
|
|
1088
|
+
self._state.env.push_local_meta_scope()
|
|
773
1089
|
|
|
774
1090
|
try:
|
|
775
1091
|
# Execute function body (which is a CompoundCommandNode)
|
|
@@ -779,6 +1095,40 @@ class Interpreter:
|
|
|
779
1095
|
except ReturnError as e:
|
|
780
1096
|
return _result(e.stdout, e.stderr, e.exit_code)
|
|
781
1097
|
finally:
|
|
1098
|
+
# Pop local scope and restore saved variables
|
|
1099
|
+
scope = self._state.local_scopes.pop()
|
|
1100
|
+
|
|
1101
|
+
# Pop and restore metadata scope
|
|
1102
|
+
if isinstance(self._state.env, VariableStore) and self._state.env._local_meta_scopes:
|
|
1103
|
+
meta_scope = self._state.env.pop_local_meta_scope()
|
|
1104
|
+
self._state.env.restore_metadata_from_scope(meta_scope)
|
|
1105
|
+
|
|
1106
|
+
# First pass: identify arrays that need element cleanup
|
|
1107
|
+
# If __is_array is restored to None, the array didn't exist before
|
|
1108
|
+
# and we need to clean up all element keys created inside the function
|
|
1109
|
+
arrays_to_clean = []
|
|
1110
|
+
for var_name, original_value in scope.items():
|
|
1111
|
+
if var_name.endswith("__is_array") and original_value is None:
|
|
1112
|
+
arr_name = var_name[:-len("__is_array")]
|
|
1113
|
+
arrays_to_clean.append(arr_name)
|
|
1114
|
+
|
|
1115
|
+
# Clean up array element keys created inside the function
|
|
1116
|
+
for arr_name in arrays_to_clean:
|
|
1117
|
+
prefix = f"{arr_name}_"
|
|
1118
|
+
to_remove = [
|
|
1119
|
+
k for k in self._state.env
|
|
1120
|
+
if k.startswith(prefix) and not k.startswith(f"{arr_name}__")
|
|
1121
|
+
]
|
|
1122
|
+
for k in to_remove:
|
|
1123
|
+
del self._state.env[k]
|
|
1124
|
+
|
|
1125
|
+
# Second pass: restore all saved variables
|
|
1126
|
+
for var_name, original_value in scope.items():
|
|
1127
|
+
if original_value is None:
|
|
1128
|
+
self._state.env.pop(var_name, None)
|
|
1129
|
+
else:
|
|
1130
|
+
self._state.env[var_name] = original_value
|
|
1131
|
+
|
|
782
1132
|
# Restore positional parameters
|
|
783
1133
|
i = 1
|
|
784
1134
|
while str(i) in self._state.env:
|
|
@@ -788,8 +1138,12 @@ class Interpreter:
|
|
|
788
1138
|
self._state.env[k] = v
|
|
789
1139
|
self._state.env["#"] = saved_count
|
|
790
1140
|
|
|
791
|
-
#
|
|
792
|
-
|
|
1141
|
+
# Restore FUNCNAME
|
|
1142
|
+
if saved_funcname is None:
|
|
1143
|
+
self._state.env.pop("FUNCNAME", None)
|
|
1144
|
+
else:
|
|
1145
|
+
self._state.env["FUNCNAME"] = saved_funcname
|
|
1146
|
+
|
|
793
1147
|
self._state.call_depth -= 1
|
|
794
1148
|
|
|
795
1149
|
async def _execute_builtin(
|