just-bash 0.1.5__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 (49) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +362 -17
  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 +141 -3
  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 +32 -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/shuf/__init__.py +5 -0
  17. just_bash/commands/shuf/shuf.py +242 -0
  18. just_bash/commands/stat/stat.py +9 -0
  19. just_bash/commands/time/__init__.py +5 -0
  20. just_bash/commands/time/time.py +74 -0
  21. just_bash/commands/touch/touch.py +118 -8
  22. just_bash/commands/whoami/__init__.py +5 -0
  23. just_bash/commands/whoami/whoami.py +18 -0
  24. just_bash/fs/in_memory_fs.py +22 -0
  25. just_bash/fs/overlay_fs.py +22 -1
  26. just_bash/interpreter/__init__.py +1 -1
  27. just_bash/interpreter/builtins/__init__.py +2 -0
  28. just_bash/interpreter/builtins/control.py +4 -8
  29. just_bash/interpreter/builtins/declare.py +321 -24
  30. just_bash/interpreter/builtins/getopts.py +163 -0
  31. just_bash/interpreter/builtins/let.py +2 -2
  32. just_bash/interpreter/builtins/local.py +71 -5
  33. just_bash/interpreter/builtins/misc.py +22 -6
  34. just_bash/interpreter/builtins/readonly.py +38 -10
  35. just_bash/interpreter/builtins/set.py +58 -8
  36. just_bash/interpreter/builtins/test.py +136 -19
  37. just_bash/interpreter/builtins/unset.py +62 -10
  38. just_bash/interpreter/conditionals.py +29 -4
  39. just_bash/interpreter/control_flow.py +61 -17
  40. just_bash/interpreter/expansion.py +1647 -104
  41. just_bash/interpreter/interpreter.py +436 -69
  42. just_bash/interpreter/types.py +263 -2
  43. just_bash/parser/__init__.py +2 -0
  44. just_bash/parser/lexer.py +295 -26
  45. just_bash/parser/parser.py +523 -64
  46. just_bash/types.py +11 -0
  47. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  48. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/RECORD +49 -40
  49. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
@@ -14,9 +14,34 @@ if TYPE_CHECKING:
14
14
  from ...types import ExecResult
15
15
 
16
16
 
17
+ def _save_array_in_scope(ctx: "InterpreterContext", name: str, scope: dict) -> None:
18
+ """Save all array-related keys for a variable in the local scope."""
19
+ env = ctx.state.env
20
+ # Save the array marker
21
+ array_key = f"{name}__is_array"
22
+ if array_key not in scope:
23
+ scope[array_key] = env.get(array_key)
24
+
25
+ # Save all existing array element keys
26
+ prefix = f"{name}_"
27
+ for key in list(env.keys()):
28
+ if key.startswith(prefix) and not key.startswith(f"{name}__"):
29
+ if key not in scope:
30
+ scope[key] = env.get(key)
31
+
32
+
33
+ def _clear_array_elements(ctx: "InterpreterContext", name: str) -> None:
34
+ """Remove all array element keys for a variable."""
35
+ prefix = f"{name}_"
36
+ to_remove = [k for k in ctx.state.env if k.startswith(prefix) and not k.startswith(f"{name}__")]
37
+ for k in to_remove:
38
+ del ctx.state.env[k]
39
+
40
+
17
41
  async def handle_local(ctx: "InterpreterContext", args: list[str]) -> "ExecResult":
18
42
  """Execute the local builtin."""
19
43
  from ...types import ExecResult
44
+ from .declare import _parse_array_assignment
20
45
 
21
46
  # Check if we're inside a function
22
47
  if not ctx.state.local_scopes:
@@ -28,11 +53,24 @@ async def handle_local(ctx: "InterpreterContext", args: list[str]) -> "ExecResul
28
53
 
29
54
  current_scope = ctx.state.local_scopes[-1]
30
55
 
56
+ # Parse flags
57
+ is_array = False
58
+ is_assoc = False
59
+ remaining_args = []
60
+
31
61
  for arg in args:
32
- # Skip options
33
- if arg.startswith("-"):
34
- continue
62
+ if arg.startswith("-") and not ("=" in arg):
63
+ # Parse flag characters
64
+ for ch in arg[1:]:
65
+ if ch == "a":
66
+ is_array = True
67
+ elif ch == "A":
68
+ is_assoc = True
69
+ # Other flags like -i, -r, -x are ignored for now
70
+ else:
71
+ remaining_args.append(arg)
35
72
 
73
+ for arg in remaining_args:
36
74
  if "=" in arg:
37
75
  name, value = arg.split("=", 1)
38
76
  else:
@@ -51,7 +89,35 @@ async def handle_local(ctx: "InterpreterContext", args: list[str]) -> "ExecResul
51
89
  if name not in current_scope:
52
90
  current_scope[name] = ctx.state.env.get(name)
53
91
 
54
- # Set the new value
55
- ctx.state.env[name] = value
92
+ # Also save metadata
93
+ from ..types import VariableStore
94
+ if isinstance(ctx.state.env, VariableStore):
95
+ ctx.state.env.save_metadata_in_scope(name)
96
+
97
+ # Handle array initialization
98
+ if (is_array or is_assoc) and value.startswith("(") and value.endswith(")"):
99
+ # Save existing array keys before overwriting
100
+ _save_array_in_scope(ctx, name, current_scope)
101
+
102
+ # Set array type marker
103
+ array_key = f"{name}__is_array"
104
+ ctx.state.env[array_key] = "assoc" if is_assoc else "indexed"
105
+
106
+ # Clear existing elements and parse new ones
107
+ _clear_array_elements(ctx, name)
108
+ inner = value[1:-1].strip()
109
+ if inner:
110
+ _parse_array_assignment(ctx, name, inner, is_assoc)
111
+ elif is_array or is_assoc:
112
+ # Declare as array without initialization
113
+ _save_array_in_scope(ctx, name, current_scope)
114
+ array_key = f"{name}__is_array"
115
+ ctx.state.env[array_key] = "assoc" if is_assoc else "indexed"
116
+ if "=" in arg:
117
+ # Simple value assignment - set element 0
118
+ ctx.state.env[f"{name}_0"] = value
119
+ else:
120
+ # Simple variable
121
+ ctx.state.env[name] = value
56
122
 
57
123
  return ExecResult(stdout="", stderr="", exit_code=0)
@@ -95,8 +95,15 @@ async def handle_type(
95
95
  "{", "}", "!", "[[", "]]"
96
96
  }
97
97
 
98
- # Get builtins
98
+ # Get builtins (includes commands that bash treats as builtins)
99
99
  from . import BUILTINS
100
+ # Commands that are implemented externally but should be reported as builtins
101
+ _builtin_command_names = {
102
+ "echo", "printf", "read", "pwd", "test", "[", "kill", "enable",
103
+ "help", "hash", "ulimit", "umask", "jobs", "fg", "bg", "disown",
104
+ "suspend", "logout", "dirs", "pushd", "popd", "times", "trap",
105
+ "caller", "complete", "compgen", "compopt",
106
+ }
100
107
 
101
108
  # Get aliases
102
109
  aliases = get_aliases(ctx)
@@ -143,7 +150,7 @@ async def handle_type(
143
150
  continue
144
151
 
145
152
  # Check builtin (unless -P)
146
- if not force_path and name in BUILTINS:
153
+ if not force_path and (name in BUILTINS or name in _builtin_command_names):
147
154
  found = True
148
155
  if type_only:
149
156
  output.append("builtin")
@@ -154,7 +161,7 @@ async def handle_type(
154
161
 
155
162
  # Check command registry
156
163
  from ...commands import COMMAND_NAMES
157
- if name in COMMAND_NAMES:
164
+ if name in COMMAND_NAMES and name not in _builtin_command_names:
158
165
  found = True
159
166
  if type_only:
160
167
  output.append("file")
@@ -167,7 +174,7 @@ async def handle_type(
167
174
 
168
175
  if not found:
169
176
  if type_only:
170
- output.append("")
177
+ pass # bash outputs nothing for type -t on not-found commands
171
178
  else:
172
179
  output.append(f"bash: type: {name}: not found")
173
180
  exit_code = 1
@@ -228,7 +235,14 @@ async def handle_command(
228
235
  from . import BUILTINS
229
236
  from ...commands import COMMAND_NAMES
230
237
 
231
- if cmd_name in BUILTINS:
238
+ functions = getattr(ctx.state, 'functions', {})
239
+
240
+ if cmd_name in functions:
241
+ if verbose:
242
+ return _result(f"{cmd_name} is a function\n", "", 0)
243
+ else:
244
+ return _result(f"{cmd_name}\n", "", 0)
245
+ elif cmd_name in BUILTINS:
232
246
  if verbose:
233
247
  return _result(f"{cmd_name} is a shell builtin\n", "", 0)
234
248
  else:
@@ -332,8 +346,10 @@ async def handle_exec(
332
346
  break
333
347
  i += 1
334
348
 
335
- # If no command, exec just affects redirections (which we don't handle here)
349
+ # If no command, exec affects persistent FD redirections
336
350
  if not cmd_args:
351
+ # Redirections are handled by the interpreter's redirect processing
352
+ # which now supports the FD table. Return success.
337
353
  return _result("", "", 0)
338
354
 
339
355
  # In sandboxed mode, just execute the command
@@ -26,6 +26,8 @@ async def handle_readonly(
26
26
  ctx: "InterpreterContext", args: list[str]
27
27
  ) -> "ExecResult":
28
28
  """Execute the readonly builtin."""
29
+ from ..types import VariableStore
30
+
29
31
  # Parse options
30
32
  show_all = False
31
33
  names = []
@@ -45,13 +47,26 @@ async def handle_readonly(
45
47
  names.append(arg)
46
48
  i += 1
47
49
 
50
+ env = ctx.state.env
51
+
48
52
  # If no names and -p or no args, show all readonly variables
49
53
  if not names or show_all:
50
54
  output = []
51
- readonly_vars = ctx.state.env.get("__readonly__", "").split()
55
+ # Collect readonly vars from metadata and legacy __readonly__
56
+ readonly_vars: set[str] = set()
57
+ if isinstance(env, VariableStore):
58
+ for vname, meta in env._metadata.items():
59
+ if "r" in meta.attributes:
60
+ readonly_vars.add(vname)
61
+ # Also check legacy __readonly__ key
62
+ legacy_readonly = env.get("__readonly__", "").split()
63
+ readonly_vars.update(v for v in legacy_readonly if v)
64
+ # Also check state.readonly_vars
65
+ readonly_vars.update(ctx.state.readonly_vars)
66
+
52
67
  for var in sorted(readonly_vars):
53
- if var in ctx.state.env:
54
- value = ctx.state.env[var]
68
+ if var in env:
69
+ value = env[var]
55
70
  output.append(f"declare -r {var}=\"{value}\"")
56
71
  else:
57
72
  output.append(f"declare -r {var}")
@@ -60,21 +75,34 @@ async def handle_readonly(
60
75
  return _result("", "", 0)
61
76
 
62
77
  # Mark variables as readonly
63
- readonly_set = set(ctx.state.env.get("__readonly__", "").split())
64
-
65
78
  for name_value in names:
66
79
  if "=" in name_value:
67
80
  name, value = name_value.split("=", 1)
68
81
  # Check if already readonly
69
- if name in readonly_set:
82
+ if _is_readonly(ctx, name):
70
83
  return _result("", f"bash: readonly: {name}: readonly variable\n", 1)
71
- ctx.state.env[name] = value
84
+ env[name] = value
72
85
  else:
73
86
  name = name_value
74
87
 
88
+ # Set readonly via metadata
89
+ if isinstance(env, VariableStore):
90
+ env.set_attribute(name, "r")
91
+ ctx.state.readonly_vars.add(name)
92
+ # Also update legacy __readonly__ for backwards compat
93
+ readonly_set = set(env.get("__readonly__", "").split())
75
94
  readonly_set.add(name)
76
-
77
- # Store readonly set
78
- ctx.state.env["__readonly__"] = " ".join(sorted(readonly_set))
95
+ env["__readonly__"] = " ".join(sorted(readonly_set))
79
96
 
80
97
  return _result("", "", 0)
98
+
99
+
100
+ def _is_readonly(ctx: "InterpreterContext", name: str) -> bool:
101
+ """Check if a variable is readonly."""
102
+ from ..types import VariableStore
103
+ env = ctx.state.env
104
+ if isinstance(env, VariableStore) and env.is_readonly(name):
105
+ return True
106
+ if name in ctx.state.readonly_vars:
107
+ return True
108
+ return name in env.get("__readonly__", "").split()
@@ -79,17 +79,67 @@ async def handle_set(ctx: "InterpreterContext", args: list[str]) -> "ExecResult"
79
79
  return ExecResult(stdout=stdout, stderr="", exit_code=0)
80
80
 
81
81
  # Handle short options like -e, -u, -x, -v
82
+ # Also handle -euo pipefail where 'o' consumes the next argument
82
83
  elif arg.startswith("-") and len(arg) > 1 and arg[1] != "-":
83
- for c in arg[1:]:
84
- result = _set_short_option(ctx, c, True)
85
- if result:
86
- return result
84
+ chars = arg[1:]
85
+ j = 0
86
+ while j < len(chars):
87
+ c = chars[j]
88
+ if c == "o":
89
+ # -o requires an option name: either remaining chars or next arg
90
+ if j + 1 < len(chars):
91
+ # Option name is rest of this arg (e.g., -opipefail)
92
+ opt_name = chars[j + 1:]
93
+ result = _set_option(ctx, opt_name, True)
94
+ if result:
95
+ return result
96
+ break # Done with this arg
97
+ elif i + 1 < len(args):
98
+ # Option name is next arg (e.g., -euo pipefail)
99
+ i += 1
100
+ opt_name = args[i]
101
+ result = _set_option(ctx, opt_name, True)
102
+ if result:
103
+ return result
104
+ break # Done with this arg
105
+ else:
106
+ # No option name provided, list options
107
+ stdout = _list_options(ctx)
108
+ return ExecResult(stdout=stdout, stderr="", exit_code=0)
109
+ else:
110
+ result = _set_short_option(ctx, c, True)
111
+ if result:
112
+ return result
113
+ j += 1
87
114
 
88
115
  elif arg.startswith("+") and len(arg) > 1:
89
- for c in arg[1:]:
90
- result = _set_short_option(ctx, c, False)
91
- if result:
92
- return result
116
+ chars = arg[1:]
117
+ j = 0
118
+ while j < len(chars):
119
+ c = chars[j]
120
+ if c == "o":
121
+ # +o requires an option name
122
+ if j + 1 < len(chars):
123
+ opt_name = chars[j + 1:]
124
+ result = _set_option(ctx, opt_name, False)
125
+ if result:
126
+ return result
127
+ break
128
+ elif i + 1 < len(args):
129
+ i += 1
130
+ opt_name = args[i]
131
+ result = _set_option(ctx, opt_name, False)
132
+ if result:
133
+ return result
134
+ break
135
+ else:
136
+ stdout = _list_options_script(ctx)
137
+ return ExecResult(stdout=stdout, stderr="", exit_code=0)
138
+ else:
139
+ result = _set_short_option(ctx, c, False)
140
+ if result:
141
+ return result
142
+ j += 1
93
143
 
94
144
  # Treat as positional parameter
95
145
  else:
@@ -95,12 +95,17 @@ async def _evaluate(ctx: "InterpreterContext", args: list[str]) -> bool:
95
95
  if not args:
96
96
  return False
97
97
 
98
+ # Single argument: non-empty string is true (POSIX rule)
99
+ # Must come before operator checks since operators are valid strings
100
+ if len(args) == 1:
101
+ return args[0] != ""
102
+
98
103
  # Handle negation
99
- if args[0] == "!":
104
+ if args[0] == "!" and len(args) > 1:
100
105
  return not await _evaluate(ctx, args[1:])
101
106
 
102
- # Handle parentheses
103
- if args[0] == "(":
107
+ # Handle parentheses (only when there are enough args)
108
+ if args[0] == "(" and len(args) > 1:
104
109
  # Find matching )
105
110
  depth = 1
106
111
  end_idx = 1
@@ -133,26 +138,30 @@ async def _evaluate(ctx: "InterpreterContext", args: list[str]) -> bool:
133
138
  right = await _evaluate(ctx, args[i + 1:])
134
139
  return left or right
135
140
 
136
- # Single argument: non-empty string is true, but check for misused operators
137
- if len(args) == 1:
138
- arg = args[0]
139
- # If it looks like an operator (starts with -) but isn't followed by
140
- # an operand, it could be a misused unary operator
141
- if arg.startswith("-") and len(arg) > 1:
142
- # Check if this looks like an operator that needs an operand
143
- if arg in _UNARY_OPS:
144
- raise ValueError(f"{arg}: unary operator expected")
145
- return arg != ""
146
-
147
141
  # Two arguments: unary operators
148
142
  if len(args) == 2:
149
143
  return await _unary_test(ctx, args[0], args[1])
150
144
 
151
145
  # Three arguments: binary operators
152
146
  if len(args) == 3:
153
- return await _binary_test(ctx, args[0], args[1], args[2])
154
-
155
- # More than 3 args should be handled by -a/-o above
147
+ # Special case: if middle arg is a known binary operator, use binary test
148
+ if args[1] in ("=", "==", "!=", "<", ">",
149
+ "-eq", "-ne", "-lt", "-le", "-gt", "-ge",
150
+ "-nt", "-ot", "-ef"):
151
+ return await _binary_test(ctx, args[0], args[1], args[2])
152
+ # Otherwise handle as compound (e.g., [ ! -f file ])
153
+ if args[0] == "!":
154
+ return not await _evaluate(ctx, args[1:])
155
+ raise ValueError(f"unknown binary operator '{args[1]}'")
156
+
157
+ # Four arguments: could be ! with 3-arg expression, or compound
158
+ if len(args) == 4:
159
+ if args[0] == "!":
160
+ return not await _evaluate(ctx, args[1:])
161
+ # Otherwise handled by -a/-o above
162
+ raise ValueError("too many arguments")
163
+
164
+ # More than 4 args should be handled by -a/-o above
156
165
  raise ValueError("too many arguments")
157
166
 
158
167
 
@@ -185,7 +194,7 @@ async def _unary_test(ctx: "InterpreterContext", op: str, arg: str) -> bool:
185
194
  return await _file_test(ctx, arg, "file")
186
195
  if op == "-d":
187
196
  return await _file_test(ctx, arg, "directory")
188
- if op == "-e":
197
+ if op in ("-e", "-a"):
189
198
  return await _file_test(ctx, arg, "exists")
190
199
  if op == "-s":
191
200
  return await _file_test(ctx, arg, "size")
@@ -197,6 +206,26 @@ async def _unary_test(ctx: "InterpreterContext", op: str, arg: str) -> bool:
197
206
  return await _file_test(ctx, arg, "executable")
198
207
  if op in ("-h", "-L"):
199
208
  return await _file_test(ctx, arg, "symlink")
209
+ if op == "-b":
210
+ return False # block special - not in virtual fs
211
+ if op == "-c":
212
+ return False # character special - not in virtual fs
213
+ if op == "-p":
214
+ return False # named pipe - not in virtual fs
215
+ if op == "-S":
216
+ return False # socket - not in virtual fs
217
+ if op == "-g":
218
+ return False # setgid - not in virtual fs
219
+ if op == "-G":
220
+ return await _file_test(ctx, arg, "exists") # owned by effective group
221
+ if op == "-k":
222
+ return False # sticky bit - not in virtual fs
223
+ if op == "-O":
224
+ return await _file_test(ctx, arg, "exists") # owned by effective user
225
+ if op == "-u":
226
+ return False # setuid - not in virtual fs
227
+ if op == "-N":
228
+ return await _file_test(ctx, arg, "exists") # modified since last read
200
229
 
201
230
  # String tests
202
231
  if op == "-z":
@@ -204,7 +233,44 @@ async def _unary_test(ctx: "InterpreterContext", op: str, arg: str) -> bool:
204
233
  if op == "-n":
205
234
  return arg != ""
206
235
 
207
- # Default: two non-operator args means binary comparison
236
+ # Variable test: -v checks if a variable is set
237
+ if op == "-v":
238
+ # Check if variable is set (even to empty string)
239
+ from ..types import VariableStore
240
+ env = ctx.state.env
241
+ # Handle array subscripts: var[idx]
242
+ if "[" in arg and arg.endswith("]"):
243
+ bracket_idx = arg.index("[")
244
+ base_name = arg[:bracket_idx]
245
+ subscript = arg[bracket_idx + 1:-1]
246
+ if subscript in ("@", "*"):
247
+ # Check if array has any elements
248
+ prefix = f"{base_name}_"
249
+ return any(k.startswith(prefix) and not k.startswith(f"{base_name}__")
250
+ for k in env.keys())
251
+ key = f"{base_name}_{subscript}"
252
+ return key in env
253
+ return arg in env
254
+
255
+ # Shell option test: -o checks if an option is enabled
256
+ if op == "-o":
257
+ if arg == "errexit":
258
+ return getattr(ctx.state.options, 'errexit', False)
259
+ elif arg == "nounset":
260
+ return getattr(ctx.state.options, 'nounset', False)
261
+ elif arg == "xtrace":
262
+ return getattr(ctx.state.options, 'xtrace', False)
263
+ elif arg == "pipefail":
264
+ return getattr(ctx.state.options, 'pipefail', False)
265
+ return False
266
+
267
+ # Terminal test: -t checks if fd is a terminal (always false in virtual env)
268
+ if op == "-t":
269
+ return False
270
+
271
+ # If op is not a known operator, treat as 2-arg string test
272
+ # e.g., [ "str1" = "str2" ] should be handled by _binary_test,
273
+ # but [ "str1" "str2" ] is an error
208
274
  raise ValueError(f"unknown unary operator '{op}'")
209
275
 
210
276
 
@@ -245,9 +311,60 @@ async def _binary_test(
245
311
  if op == "-ge":
246
312
  return left_num >= right_num
247
313
 
314
+ # File comparison operators
315
+ if op == "-nt":
316
+ # FILE1 is newer than FILE2
317
+ return await _file_compare(ctx, left, right, "newer")
318
+ if op == "-ot":
319
+ # FILE1 is older than FILE2
320
+ return await _file_compare(ctx, left, right, "older")
321
+ if op == "-ef":
322
+ # FILE1 and FILE2 refer to same file (same device and inode)
323
+ full_left = ctx.fs.resolve_path(ctx.state.cwd, left)
324
+ full_right = ctx.fs.resolve_path(ctx.state.cwd, right)
325
+ try:
326
+ return (await ctx.fs.exists(full_left)
327
+ and await ctx.fs.exists(full_right)
328
+ and full_left == full_right)
329
+ except Exception:
330
+ return False
331
+
248
332
  raise ValueError(f"unknown binary operator '{op}'")
249
333
 
250
334
 
335
+ async def _file_compare(ctx: "InterpreterContext", file1: str, file2: str, comparison: str) -> bool:
336
+ """Compare two files by modification time."""
337
+ full1 = ctx.fs.resolve_path(ctx.state.cwd, file1)
338
+ full2 = ctx.fs.resolve_path(ctx.state.cwd, file2)
339
+ try:
340
+ exists1 = await ctx.fs.exists(full1)
341
+ exists2 = await ctx.fs.exists(full2)
342
+
343
+ if not exists1 and not exists2:
344
+ return False
345
+ if not exists1:
346
+ return comparison == "older"
347
+ if not exists2:
348
+ return comparison == "newer"
349
+
350
+ # In virtual filesystem, try to get stat info
351
+ try:
352
+ stat1 = await ctx.fs.stat(full1)
353
+ stat2 = await ctx.fs.stat(full2)
354
+ if hasattr(stat1, 'mtime') and hasattr(stat2, 'mtime'):
355
+ if comparison == "newer":
356
+ return stat1.mtime > stat2.mtime
357
+ else:
358
+ return stat1.mtime < stat2.mtime
359
+ except (AttributeError, Exception):
360
+ pass
361
+
362
+ # Without mtime, files that exist are considered equal
363
+ return False
364
+ except Exception:
365
+ return False
366
+
367
+
251
368
  async def _file_test(ctx: "InterpreterContext", path: str, test_type: str) -> bool:
252
369
  """Perform a file test."""
253
370
  # Resolve path relative to cwd
@@ -19,8 +19,10 @@ if TYPE_CHECKING:
19
19
  async def handle_unset(ctx: "InterpreterContext", args: list[str]) -> "ExecResult":
20
20
  """Execute the unset builtin."""
21
21
  from ...types import ExecResult
22
+ from ..types import VariableStore
22
23
 
23
24
  mode = "variable"
25
+ unset_nameref = False
24
26
  names = []
25
27
 
26
28
  for arg in args:
@@ -29,25 +31,75 @@ async def handle_unset(ctx: "InterpreterContext", args: list[str]) -> "ExecResul
29
31
  elif arg == "-f":
30
32
  mode = "function"
31
33
  elif arg == "-n":
32
- # -n treats name as a nameref - we don't support this but ignore
33
- pass
34
+ # -n: unset the nameref itself, not the target
35
+ unset_nameref = True
34
36
  elif arg.startswith("-"):
35
37
  # Skip unknown options
36
38
  pass
37
39
  else:
38
40
  names.append(arg)
39
41
 
42
+ import re
43
+ env = ctx.state.env
44
+
40
45
  for name in names:
41
46
  if mode == "function":
42
47
  ctx.state.functions.pop(name, None)
43
48
  else:
44
- # Check if variable is readonly
45
- if name in ctx.state.readonly_vars:
46
- return ExecResult(
47
- stdout="",
48
- stderr=f"bash: unset: {name}: cannot unset: readonly variable\n",
49
- exit_code=1,
50
- )
51
- ctx.state.env.pop(name, None)
49
+ # Handle -n flag: unset the nameref variable itself
50
+ if unset_nameref and isinstance(env, VariableStore):
51
+ env.clear_nameref(name)
52
+ env.pop(name, None)
53
+ continue
54
+
55
+ # Resolve nameref for unset target
56
+ resolved_name = name
57
+ if isinstance(env, VariableStore) and env.is_nameref(name):
58
+ try:
59
+ resolved_name = env.resolve_nameref(name)
60
+ except ValueError:
61
+ resolved_name = name
62
+
63
+ # Check for array element syntax: a[idx]
64
+ array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', resolved_name)
65
+ if array_match:
66
+ arr_name = array_match.group(1)
67
+ subscript = array_match.group(2)
68
+ # Check if variable is readonly
69
+ if _is_readonly(ctx, arr_name):
70
+ return ExecResult(
71
+ stdout="",
72
+ stderr=f"bash: unset: {arr_name}: cannot unset: readonly variable\n",
73
+ exit_code=1,
74
+ )
75
+ # Remove specific array element
76
+ env.pop(f"{arr_name}_{subscript}", None)
77
+ else:
78
+ # Check if variable is readonly
79
+ if _is_readonly(ctx, resolved_name):
80
+ return ExecResult(
81
+ stdout="",
82
+ stderr=f"bash: unset: {resolved_name}: cannot unset: readonly variable\n",
83
+ exit_code=1,
84
+ )
85
+ # Remove the variable
86
+ env.pop(resolved_name, None)
87
+ # Also remove all array elements if this is an array
88
+ prefix = f"{resolved_name}_"
89
+ to_remove = [k for k in env if k.startswith(prefix)]
90
+ for k in to_remove:
91
+ del env[k]
92
+ # Clean up metadata
93
+ if isinstance(env, VariableStore):
94
+ env._metadata.pop(resolved_name, None)
52
95
 
53
96
  return ExecResult(stdout="", stderr="", exit_code=0)
97
+
98
+
99
+ def _is_readonly(ctx: "InterpreterContext", name: str) -> bool:
100
+ """Check if a variable is readonly."""
101
+ from ..types import VariableStore
102
+ env = ctx.state.env
103
+ if isinstance(env, VariableStore) and env.is_readonly(name):
104
+ return True
105
+ return name in ctx.state.readonly_vars