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.
Files changed (47) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +112 -7
  4. just_bash/commands/cat/cat.py +5 -1
  5. just_bash/commands/echo/echo.py +33 -1
  6. just_bash/commands/grep/grep.py +30 -1
  7. just_bash/commands/od/od.py +144 -30
  8. just_bash/commands/printf/printf.py +289 -87
  9. just_bash/commands/pwd/pwd.py +32 -2
  10. just_bash/commands/read/read.py +243 -64
  11. just_bash/commands/readlink/readlink.py +3 -9
  12. just_bash/commands/registry.py +24 -0
  13. just_bash/commands/rmdir/__init__.py +5 -0
  14. just_bash/commands/rmdir/rmdir.py +160 -0
  15. just_bash/commands/sed/sed.py +142 -31
  16. just_bash/commands/stat/stat.py +9 -0
  17. just_bash/commands/time/__init__.py +5 -0
  18. just_bash/commands/time/time.py +74 -0
  19. just_bash/commands/touch/touch.py +118 -8
  20. just_bash/commands/whoami/__init__.py +5 -0
  21. just_bash/commands/whoami/whoami.py +18 -0
  22. just_bash/fs/in_memory_fs.py +22 -0
  23. just_bash/fs/overlay_fs.py +14 -0
  24. just_bash/interpreter/__init__.py +1 -1
  25. just_bash/interpreter/builtins/__init__.py +2 -0
  26. just_bash/interpreter/builtins/control.py +4 -8
  27. just_bash/interpreter/builtins/declare.py +321 -24
  28. just_bash/interpreter/builtins/getopts.py +163 -0
  29. just_bash/interpreter/builtins/let.py +2 -2
  30. just_bash/interpreter/builtins/local.py +71 -5
  31. just_bash/interpreter/builtins/misc.py +22 -6
  32. just_bash/interpreter/builtins/readonly.py +38 -10
  33. just_bash/interpreter/builtins/set.py +58 -8
  34. just_bash/interpreter/builtins/test.py +136 -19
  35. just_bash/interpreter/builtins/unset.py +62 -10
  36. just_bash/interpreter/conditionals.py +29 -4
  37. just_bash/interpreter/control_flow.py +61 -17
  38. just_bash/interpreter/expansion.py +1647 -104
  39. just_bash/interpreter/interpreter.py +424 -70
  40. just_bash/interpreter/types.py +263 -2
  41. just_bash/parser/__init__.py +2 -0
  42. just_bash/parser/lexer.py +295 -26
  43. just_bash/parser/parser.py +523 -64
  44. just_bash/types.py +11 -0
  45. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  46. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/RECORD +47 -40
  47. {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 = {**self._state.env, **(env or {})}
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
- stdin = ""
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=dict(self._state.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
- 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
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
- return _result(stdout, stderr, exit_code)
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
- return _result(stdout, stderr, exit_code)
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 stderr
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 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]
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
- # Mark as array
517
- self._state.env[f"{name}__is_array"] = "indexed"
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
- # 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
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 assignment.append:
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 into stdin
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
- stdin = await self._fs.read_file(target_path)
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 cmd_name in aliases:
583
- alias_value = aliases[cmd_name]
584
- # Simple word splitting for alias value
585
- import shlex
586
- try:
587
- alias_parts = shlex.split(alias_value)
588
- except ValueError:
589
- # Fall back to simple split if shlex fails
590
- alias_parts = alias_value.split()
591
- if alias_parts:
592
- cmd_name = alias_parts[0]
593
- alias_args = alias_parts[1:]
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 redir in redirections:
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
- if redir.operator == ">":
680
- # Overwrite file
681
- if fd == 1:
682
- await self._fs.write_file(target_path, stdout)
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
- if fd == 1:
695
- await self._fs.write_file(target_path, existing + stdout)
696
- stdout = ""
697
- elif fd == 2:
698
- await self._fs.write_file(target_path, existing + stderr)
699
- stderr = ""
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
- # Redirect stdout to stderr or fd duplication
709
- if target_path == "2":
710
- stderr = stderr + stdout
711
- stdout = ""
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
- stdout = stdout + stderr
714
- stderr = ""
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
- await self._fs.write_file(target_path, stdout)
717
- stdout = ""
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
- # Pop local scope
792
- self._state.local_scopes.pop()
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(