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
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
#
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
54
|
-
value =
|
|
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
|
|
82
|
+
if _is_readonly(ctx, name):
|
|
70
83
|
return _result("", f"bash: readonly: {name}: readonly variable\n", 1)
|
|
71
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
33
|
-
|
|
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
|
-
#
|
|
45
|
-
if
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|