just-bash 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- just_bash/__init__.py +55 -0
- just_bash/ast/__init__.py +213 -0
- just_bash/ast/factory.py +320 -0
- just_bash/ast/types.py +953 -0
- just_bash/bash.py +220 -0
- just_bash/commands/__init__.py +23 -0
- just_bash/commands/argv/__init__.py +5 -0
- just_bash/commands/argv/argv.py +21 -0
- just_bash/commands/awk/__init__.py +5 -0
- just_bash/commands/awk/awk.py +1168 -0
- just_bash/commands/base64/__init__.py +5 -0
- just_bash/commands/base64/base64.py +138 -0
- just_bash/commands/basename/__init__.py +5 -0
- just_bash/commands/basename/basename.py +72 -0
- just_bash/commands/bash/__init__.py +5 -0
- just_bash/commands/bash/bash.py +188 -0
- just_bash/commands/cat/__init__.py +5 -0
- just_bash/commands/cat/cat.py +173 -0
- just_bash/commands/checksum/__init__.py +5 -0
- just_bash/commands/checksum/checksum.py +179 -0
- just_bash/commands/chmod/__init__.py +5 -0
- just_bash/commands/chmod/chmod.py +216 -0
- just_bash/commands/column/__init__.py +5 -0
- just_bash/commands/column/column.py +180 -0
- just_bash/commands/comm/__init__.py +5 -0
- just_bash/commands/comm/comm.py +150 -0
- just_bash/commands/compression/__init__.py +5 -0
- just_bash/commands/compression/compression.py +298 -0
- just_bash/commands/cp/__init__.py +5 -0
- just_bash/commands/cp/cp.py +149 -0
- just_bash/commands/curl/__init__.py +5 -0
- just_bash/commands/curl/curl.py +801 -0
- just_bash/commands/cut/__init__.py +5 -0
- just_bash/commands/cut/cut.py +327 -0
- just_bash/commands/date/__init__.py +5 -0
- just_bash/commands/date/date.py +258 -0
- just_bash/commands/diff/__init__.py +5 -0
- just_bash/commands/diff/diff.py +118 -0
- just_bash/commands/dirname/__init__.py +5 -0
- just_bash/commands/dirname/dirname.py +56 -0
- just_bash/commands/du/__init__.py +5 -0
- just_bash/commands/du/du.py +150 -0
- just_bash/commands/echo/__init__.py +5 -0
- just_bash/commands/echo/echo.py +125 -0
- just_bash/commands/env/__init__.py +5 -0
- just_bash/commands/env/env.py +163 -0
- just_bash/commands/expand/__init__.py +5 -0
- just_bash/commands/expand/expand.py +299 -0
- just_bash/commands/expr/__init__.py +5 -0
- just_bash/commands/expr/expr.py +273 -0
- just_bash/commands/file/__init__.py +5 -0
- just_bash/commands/file/file.py +274 -0
- just_bash/commands/find/__init__.py +5 -0
- just_bash/commands/find/find.py +623 -0
- just_bash/commands/fold/__init__.py +5 -0
- just_bash/commands/fold/fold.py +160 -0
- just_bash/commands/grep/__init__.py +5 -0
- just_bash/commands/grep/grep.py +418 -0
- just_bash/commands/head/__init__.py +5 -0
- just_bash/commands/head/head.py +167 -0
- just_bash/commands/help/__init__.py +5 -0
- just_bash/commands/help/help.py +67 -0
- just_bash/commands/hostname/__init__.py +5 -0
- just_bash/commands/hostname/hostname.py +21 -0
- just_bash/commands/html_to_markdown/__init__.py +5 -0
- just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
- just_bash/commands/join/__init__.py +5 -0
- just_bash/commands/join/join.py +252 -0
- just_bash/commands/jq/__init__.py +5 -0
- just_bash/commands/jq/jq.py +280 -0
- just_bash/commands/ln/__init__.py +5 -0
- just_bash/commands/ln/ln.py +127 -0
- just_bash/commands/ls/__init__.py +5 -0
- just_bash/commands/ls/ls.py +280 -0
- just_bash/commands/mkdir/__init__.py +5 -0
- just_bash/commands/mkdir/mkdir.py +92 -0
- just_bash/commands/mv/__init__.py +5 -0
- just_bash/commands/mv/mv.py +142 -0
- just_bash/commands/nl/__init__.py +5 -0
- just_bash/commands/nl/nl.py +180 -0
- just_bash/commands/od/__init__.py +5 -0
- just_bash/commands/od/od.py +157 -0
- just_bash/commands/paste/__init__.py +5 -0
- just_bash/commands/paste/paste.py +100 -0
- just_bash/commands/printf/__init__.py +5 -0
- just_bash/commands/printf/printf.py +157 -0
- just_bash/commands/pwd/__init__.py +5 -0
- just_bash/commands/pwd/pwd.py +23 -0
- just_bash/commands/read/__init__.py +5 -0
- just_bash/commands/read/read.py +185 -0
- just_bash/commands/readlink/__init__.py +5 -0
- just_bash/commands/readlink/readlink.py +86 -0
- just_bash/commands/registry.py +844 -0
- just_bash/commands/rev/__init__.py +5 -0
- just_bash/commands/rev/rev.py +74 -0
- just_bash/commands/rg/__init__.py +5 -0
- just_bash/commands/rg/rg.py +1048 -0
- just_bash/commands/rm/__init__.py +5 -0
- just_bash/commands/rm/rm.py +106 -0
- just_bash/commands/search_engine/__init__.py +13 -0
- just_bash/commands/search_engine/matcher.py +170 -0
- just_bash/commands/search_engine/regex.py +159 -0
- just_bash/commands/sed/__init__.py +5 -0
- just_bash/commands/sed/sed.py +863 -0
- just_bash/commands/seq/__init__.py +5 -0
- just_bash/commands/seq/seq.py +190 -0
- just_bash/commands/shell/__init__.py +5 -0
- just_bash/commands/shell/shell.py +206 -0
- just_bash/commands/sleep/__init__.py +5 -0
- just_bash/commands/sleep/sleep.py +62 -0
- just_bash/commands/sort/__init__.py +5 -0
- just_bash/commands/sort/sort.py +411 -0
- just_bash/commands/split/__init__.py +5 -0
- just_bash/commands/split/split.py +237 -0
- just_bash/commands/sqlite3/__init__.py +5 -0
- just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
- just_bash/commands/stat/__init__.py +5 -0
- just_bash/commands/stat/stat.py +150 -0
- just_bash/commands/strings/__init__.py +5 -0
- just_bash/commands/strings/strings.py +150 -0
- just_bash/commands/tac/__init__.py +5 -0
- just_bash/commands/tac/tac.py +158 -0
- just_bash/commands/tail/__init__.py +5 -0
- just_bash/commands/tail/tail.py +180 -0
- just_bash/commands/tar/__init__.py +5 -0
- just_bash/commands/tar/tar.py +1067 -0
- just_bash/commands/tee/__init__.py +5 -0
- just_bash/commands/tee/tee.py +63 -0
- just_bash/commands/timeout/__init__.py +5 -0
- just_bash/commands/timeout/timeout.py +188 -0
- just_bash/commands/touch/__init__.py +5 -0
- just_bash/commands/touch/touch.py +91 -0
- just_bash/commands/tr/__init__.py +5 -0
- just_bash/commands/tr/tr.py +297 -0
- just_bash/commands/tree/__init__.py +5 -0
- just_bash/commands/tree/tree.py +139 -0
- just_bash/commands/true/__init__.py +5 -0
- just_bash/commands/true/true.py +32 -0
- just_bash/commands/uniq/__init__.py +5 -0
- just_bash/commands/uniq/uniq.py +323 -0
- just_bash/commands/wc/__init__.py +5 -0
- just_bash/commands/wc/wc.py +169 -0
- just_bash/commands/which/__init__.py +5 -0
- just_bash/commands/which/which.py +52 -0
- just_bash/commands/xan/__init__.py +5 -0
- just_bash/commands/xan/xan.py +1663 -0
- just_bash/commands/xargs/__init__.py +5 -0
- just_bash/commands/xargs/xargs.py +136 -0
- just_bash/commands/yq/__init__.py +5 -0
- just_bash/commands/yq/yq.py +848 -0
- just_bash/fs/__init__.py +29 -0
- just_bash/fs/in_memory_fs.py +621 -0
- just_bash/fs/mountable_fs.py +504 -0
- just_bash/fs/overlay_fs.py +894 -0
- just_bash/fs/read_write_fs.py +455 -0
- just_bash/interpreter/__init__.py +37 -0
- just_bash/interpreter/builtins/__init__.py +92 -0
- just_bash/interpreter/builtins/alias.py +154 -0
- just_bash/interpreter/builtins/cd.py +76 -0
- just_bash/interpreter/builtins/control.py +127 -0
- just_bash/interpreter/builtins/declare.py +336 -0
- just_bash/interpreter/builtins/export.py +56 -0
- just_bash/interpreter/builtins/let.py +44 -0
- just_bash/interpreter/builtins/local.py +57 -0
- just_bash/interpreter/builtins/mapfile.py +152 -0
- just_bash/interpreter/builtins/misc.py +378 -0
- just_bash/interpreter/builtins/readonly.py +80 -0
- just_bash/interpreter/builtins/set.py +234 -0
- just_bash/interpreter/builtins/shopt.py +201 -0
- just_bash/interpreter/builtins/source.py +136 -0
- just_bash/interpreter/builtins/test.py +290 -0
- just_bash/interpreter/builtins/unset.py +53 -0
- just_bash/interpreter/conditionals.py +387 -0
- just_bash/interpreter/control_flow.py +381 -0
- just_bash/interpreter/errors.py +116 -0
- just_bash/interpreter/expansion.py +1156 -0
- just_bash/interpreter/interpreter.py +813 -0
- just_bash/interpreter/types.py +134 -0
- just_bash/network/__init__.py +1 -0
- just_bash/parser/__init__.py +39 -0
- just_bash/parser/lexer.py +948 -0
- just_bash/parser/parser.py +2162 -0
- just_bash/py.typed +0 -0
- just_bash/query_engine/__init__.py +83 -0
- just_bash/query_engine/builtins/__init__.py +1283 -0
- just_bash/query_engine/evaluator.py +578 -0
- just_bash/query_engine/parser.py +525 -0
- just_bash/query_engine/tokenizer.py +329 -0
- just_bash/query_engine/types.py +373 -0
- just_bash/types.py +180 -0
- just_bash-0.1.5.dist-info/METADATA +410 -0
- just_bash-0.1.5.dist-info/RECORD +193 -0
- just_bash-0.1.5.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
"""Word Expansion System.
|
|
2
|
+
|
|
3
|
+
Handles shell word expansion including:
|
|
4
|
+
- Variable expansion ($VAR, ${VAR})
|
|
5
|
+
- Command substitution $(...)
|
|
6
|
+
- Arithmetic expansion $((...))
|
|
7
|
+
- Tilde expansion (~)
|
|
8
|
+
- Brace expansion {a,b,c}
|
|
9
|
+
- Glob expansion (*, ?, [...])
|
|
10
|
+
- Parameter operations (${VAR:-default}, ${VAR:+alt}, ${#VAR}, etc.)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fnmatch
|
|
14
|
+
import re
|
|
15
|
+
from typing import TYPE_CHECKING, Optional
|
|
16
|
+
|
|
17
|
+
from ..ast.types import (
|
|
18
|
+
WordNode,
|
|
19
|
+
WordPart,
|
|
20
|
+
LiteralPart,
|
|
21
|
+
SingleQuotedPart,
|
|
22
|
+
DoubleQuotedPart,
|
|
23
|
+
EscapedPart,
|
|
24
|
+
ParameterExpansionPart,
|
|
25
|
+
CommandSubstitutionPart,
|
|
26
|
+
ArithmeticExpansionPart,
|
|
27
|
+
TildeExpansionPart,
|
|
28
|
+
GlobPart,
|
|
29
|
+
BraceExpansionPart,
|
|
30
|
+
)
|
|
31
|
+
from .errors import BadSubstitutionError, ExecutionLimitError, ExitError, NounsetError
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from .types import InterpreterContext
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_variable(ctx: "InterpreterContext", name: str, check_nounset: bool = True) -> str:
|
|
38
|
+
"""Get a variable value from the environment.
|
|
39
|
+
|
|
40
|
+
Handles special parameters like $?, $#, $@, $*, $0-$9, etc.
|
|
41
|
+
Also handles array subscript syntax: arr[idx], arr[@], arr[*]
|
|
42
|
+
"""
|
|
43
|
+
env = ctx.state.env
|
|
44
|
+
|
|
45
|
+
# Check for array subscript syntax: name[subscript]
|
|
46
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', name)
|
|
47
|
+
if array_match:
|
|
48
|
+
arr_name = array_match.group(1)
|
|
49
|
+
subscript = array_match.group(2)
|
|
50
|
+
|
|
51
|
+
# Handle arr[@] and arr[*] - all elements
|
|
52
|
+
if subscript in ("@", "*"):
|
|
53
|
+
elements = get_array_elements(ctx, arr_name)
|
|
54
|
+
return " ".join(val for _, val in elements)
|
|
55
|
+
|
|
56
|
+
# Handle numeric or variable subscript
|
|
57
|
+
try:
|
|
58
|
+
# Try to evaluate subscript as arithmetic expression
|
|
59
|
+
idx = _eval_array_subscript(ctx, subscript)
|
|
60
|
+
# Negative indices count from end
|
|
61
|
+
if idx < 0:
|
|
62
|
+
elements = get_array_elements(ctx, arr_name)
|
|
63
|
+
if elements:
|
|
64
|
+
max_idx = max(i for i, _ in elements)
|
|
65
|
+
idx = max_idx + 1 + idx
|
|
66
|
+
key = f"{arr_name}_{idx}"
|
|
67
|
+
if key in env:
|
|
68
|
+
return env[key]
|
|
69
|
+
elif check_nounset and ctx.state.options.nounset:
|
|
70
|
+
raise NounsetError(name)
|
|
71
|
+
return ""
|
|
72
|
+
except (ValueError, TypeError):
|
|
73
|
+
# Invalid subscript - return empty
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
# Special parameters
|
|
77
|
+
if name == "?":
|
|
78
|
+
return str(ctx.state.last_exit_code)
|
|
79
|
+
elif name == "#":
|
|
80
|
+
# Number of positional parameters
|
|
81
|
+
count = 0
|
|
82
|
+
while str(count + 1) in env:
|
|
83
|
+
count += 1
|
|
84
|
+
return str(count)
|
|
85
|
+
elif name == "@" or name == "*":
|
|
86
|
+
# All positional parameters
|
|
87
|
+
params = []
|
|
88
|
+
i = 1
|
|
89
|
+
while str(i) in env:
|
|
90
|
+
params.append(env[str(i)])
|
|
91
|
+
i += 1
|
|
92
|
+
return " ".join(params)
|
|
93
|
+
elif name == "0":
|
|
94
|
+
return env.get("0", "bash")
|
|
95
|
+
elif name == "$":
|
|
96
|
+
return str(env.get("$", "1")) # PID (simulated)
|
|
97
|
+
elif name == "!":
|
|
98
|
+
return str(ctx.state.last_background_pid)
|
|
99
|
+
elif name == "_":
|
|
100
|
+
return ctx.state.last_arg
|
|
101
|
+
elif name == "LINENO":
|
|
102
|
+
return str(ctx.state.current_line)
|
|
103
|
+
elif name == "RANDOM":
|
|
104
|
+
import random
|
|
105
|
+
return str(random.randint(0, 32767))
|
|
106
|
+
elif name == "SECONDS":
|
|
107
|
+
import time
|
|
108
|
+
return str(int(time.time() - ctx.state.start_time))
|
|
109
|
+
|
|
110
|
+
# Check for array subscript: arr[idx]
|
|
111
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', name)
|
|
112
|
+
if array_match:
|
|
113
|
+
array_name, subscript = array_match.groups()
|
|
114
|
+
if subscript == "@" or subscript == "*":
|
|
115
|
+
# Get all array elements
|
|
116
|
+
elements = get_array_elements(ctx, array_name)
|
|
117
|
+
return " ".join(v for _, v in elements)
|
|
118
|
+
else:
|
|
119
|
+
# Single element
|
|
120
|
+
try:
|
|
121
|
+
idx = int(subscript)
|
|
122
|
+
except ValueError:
|
|
123
|
+
# Try to evaluate as variable
|
|
124
|
+
idx_val = env.get(subscript, "0")
|
|
125
|
+
try:
|
|
126
|
+
idx = int(idx_val)
|
|
127
|
+
except ValueError:
|
|
128
|
+
idx = 0
|
|
129
|
+
return env.get(f"{array_name}_{idx}", "")
|
|
130
|
+
|
|
131
|
+
# Regular variable
|
|
132
|
+
value = env.get(name)
|
|
133
|
+
|
|
134
|
+
if value is None:
|
|
135
|
+
# Check nounset (set -u)
|
|
136
|
+
if check_nounset and ctx.state.options.nounset:
|
|
137
|
+
raise NounsetError(name, "", f"bash: {name}: unbound variable\n")
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_array_elements(ctx: "InterpreterContext", name: str) -> list[tuple[int, str]]:
|
|
144
|
+
"""Get all elements of an array as (index, value) pairs."""
|
|
145
|
+
elements = []
|
|
146
|
+
env = ctx.state.env
|
|
147
|
+
|
|
148
|
+
# Look for name_0, name_1, etc.
|
|
149
|
+
prefix = f"{name}_"
|
|
150
|
+
for key, value in env.items():
|
|
151
|
+
if key.startswith(prefix) and not key.endswith("__length"):
|
|
152
|
+
try:
|
|
153
|
+
idx = int(key[len(prefix):])
|
|
154
|
+
elements.append((idx, value))
|
|
155
|
+
except ValueError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
# Sort by index
|
|
159
|
+
elements.sort(key=lambda x: x[0])
|
|
160
|
+
return elements
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_array(ctx: "InterpreterContext", name: str) -> bool:
|
|
164
|
+
"""Check if a variable is an array."""
|
|
165
|
+
prefix = f"{name}_"
|
|
166
|
+
for key in ctx.state.env:
|
|
167
|
+
if key.startswith(prefix) and not key.endswith("__length"):
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _eval_array_subscript(ctx: "InterpreterContext", subscript: str) -> int:
|
|
173
|
+
"""Evaluate an array subscript to an integer index.
|
|
174
|
+
|
|
175
|
+
Supports:
|
|
176
|
+
- Literal integers: arr[0], arr[42]
|
|
177
|
+
- Variable references: arr[i], arr[idx], arr[$i]
|
|
178
|
+
- Simple arithmetic: arr[i+1], arr[n-1]
|
|
179
|
+
"""
|
|
180
|
+
subscript = subscript.strip()
|
|
181
|
+
|
|
182
|
+
# First, expand any $VAR references in the subscript
|
|
183
|
+
expanded = _expand_subscript_vars(ctx, subscript)
|
|
184
|
+
|
|
185
|
+
# Try direct integer
|
|
186
|
+
try:
|
|
187
|
+
return int(expanded)
|
|
188
|
+
except ValueError:
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
# Try variable reference (bare name without $)
|
|
192
|
+
if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', expanded):
|
|
193
|
+
val = ctx.state.env.get(expanded, "0")
|
|
194
|
+
try:
|
|
195
|
+
return int(val)
|
|
196
|
+
except ValueError:
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
# Try arithmetic expression - expand bare variables first
|
|
200
|
+
arith_expanded = _expand_arith_vars(ctx, expanded)
|
|
201
|
+
try:
|
|
202
|
+
# Use Python eval with restricted builtins for safety
|
|
203
|
+
result = eval(arith_expanded, {"__builtins__": {}}, {})
|
|
204
|
+
return int(result)
|
|
205
|
+
except Exception:
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _expand_arith_vars(ctx: "InterpreterContext", expr: str) -> str:
|
|
210
|
+
"""Expand bare variable names in arithmetic expression."""
|
|
211
|
+
# Replace variable names with their values
|
|
212
|
+
result = []
|
|
213
|
+
i = 0
|
|
214
|
+
while i < len(expr):
|
|
215
|
+
# Check for variable name (not preceded by digit)
|
|
216
|
+
if (expr[i].isalpha() or expr[i] == '_'):
|
|
217
|
+
j = i
|
|
218
|
+
while j < len(expr) and (expr[j].isalnum() or expr[j] == '_'):
|
|
219
|
+
j += 1
|
|
220
|
+
var_name = expr[i:j]
|
|
221
|
+
val = ctx.state.env.get(var_name, "0")
|
|
222
|
+
try:
|
|
223
|
+
result.append(str(int(val)))
|
|
224
|
+
except ValueError:
|
|
225
|
+
result.append("0")
|
|
226
|
+
i = j
|
|
227
|
+
else:
|
|
228
|
+
result.append(expr[i])
|
|
229
|
+
i += 1
|
|
230
|
+
return ''.join(result)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _expand_subscript_vars(ctx: "InterpreterContext", subscript: str) -> str:
|
|
234
|
+
"""Expand $VAR and ${VAR} references in array subscript."""
|
|
235
|
+
result = []
|
|
236
|
+
i = 0
|
|
237
|
+
while i < len(subscript):
|
|
238
|
+
if subscript[i] == '$':
|
|
239
|
+
if i + 1 < len(subscript):
|
|
240
|
+
if subscript[i + 1] == '{':
|
|
241
|
+
# ${VAR} syntax
|
|
242
|
+
j = subscript.find('}', i + 2)
|
|
243
|
+
if j != -1:
|
|
244
|
+
var_name = subscript[i + 2:j]
|
|
245
|
+
val = ctx.state.env.get(var_name, "0")
|
|
246
|
+
result.append(val)
|
|
247
|
+
i = j + 1
|
|
248
|
+
continue
|
|
249
|
+
elif subscript[i + 1].isalpha() or subscript[i + 1] == '_':
|
|
250
|
+
# $VAR syntax
|
|
251
|
+
j = i + 1
|
|
252
|
+
while j < len(subscript) and (subscript[j].isalnum() or subscript[j] == '_'):
|
|
253
|
+
j += 1
|
|
254
|
+
var_name = subscript[i + 1:j]
|
|
255
|
+
val = ctx.state.env.get(var_name, "0")
|
|
256
|
+
result.append(val)
|
|
257
|
+
i = j
|
|
258
|
+
continue
|
|
259
|
+
result.append(subscript[i])
|
|
260
|
+
i += 1
|
|
261
|
+
return ''.join(result)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_array_keys(ctx: "InterpreterContext", name: str) -> list[str]:
|
|
265
|
+
"""Get all keys of an array (indices for indexed arrays, keys for associative)."""
|
|
266
|
+
keys = []
|
|
267
|
+
env = ctx.state.env
|
|
268
|
+
prefix = f"{name}_"
|
|
269
|
+
|
|
270
|
+
for key in env:
|
|
271
|
+
if key.startswith(prefix) and not key.startswith(f"{name}__"):
|
|
272
|
+
idx_part = key[len(prefix):]
|
|
273
|
+
keys.append(idx_part)
|
|
274
|
+
|
|
275
|
+
# Sort numerically if all indices are numbers
|
|
276
|
+
try:
|
|
277
|
+
keys.sort(key=int)
|
|
278
|
+
except ValueError:
|
|
279
|
+
keys.sort()
|
|
280
|
+
|
|
281
|
+
return keys
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def expand_word(ctx: "InterpreterContext", word: WordNode) -> str:
|
|
285
|
+
"""Expand a word synchronously (no command substitution)."""
|
|
286
|
+
parts = []
|
|
287
|
+
for part in word.parts:
|
|
288
|
+
parts.append(expand_part_sync(ctx, part))
|
|
289
|
+
return "".join(parts)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def expand_word_async(ctx: "InterpreterContext", word: WordNode) -> str:
|
|
293
|
+
"""Expand a word asynchronously (supports command substitution)."""
|
|
294
|
+
parts = []
|
|
295
|
+
for part in word.parts:
|
|
296
|
+
parts.append(await expand_part(ctx, part))
|
|
297
|
+
return "".join(parts)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def expand_part_sync(ctx: "InterpreterContext", part: WordPart, in_double_quotes: bool = False) -> str:
|
|
301
|
+
"""Expand a word part synchronously."""
|
|
302
|
+
if isinstance(part, LiteralPart):
|
|
303
|
+
return part.value
|
|
304
|
+
elif isinstance(part, SingleQuotedPart):
|
|
305
|
+
return part.value
|
|
306
|
+
elif isinstance(part, EscapedPart):
|
|
307
|
+
return part.value
|
|
308
|
+
elif isinstance(part, DoubleQuotedPart):
|
|
309
|
+
# Recursively expand parts inside double quotes
|
|
310
|
+
result = []
|
|
311
|
+
for p in part.parts:
|
|
312
|
+
result.append(expand_part_sync(ctx, p, in_double_quotes=True))
|
|
313
|
+
return "".join(result)
|
|
314
|
+
elif isinstance(part, ParameterExpansionPart):
|
|
315
|
+
return expand_parameter(ctx, part, in_double_quotes)
|
|
316
|
+
elif isinstance(part, TildeExpansionPart):
|
|
317
|
+
if in_double_quotes:
|
|
318
|
+
# Tilde is literal inside double quotes
|
|
319
|
+
return "~" if part.user is None else f"~{part.user}"
|
|
320
|
+
if part.user is None:
|
|
321
|
+
return ctx.state.env.get("HOME", "/home/user")
|
|
322
|
+
elif part.user == "root":
|
|
323
|
+
return "/root"
|
|
324
|
+
else:
|
|
325
|
+
return f"~{part.user}"
|
|
326
|
+
elif isinstance(part, GlobPart):
|
|
327
|
+
return part.pattern
|
|
328
|
+
elif isinstance(part, ArithmeticExpansionPart):
|
|
329
|
+
# Evaluate arithmetic synchronously
|
|
330
|
+
# Unwrap ArithmeticExpressionNode to get the actual ArithExpr
|
|
331
|
+
expr = part.expression.expression if part.expression else None
|
|
332
|
+
return str(evaluate_arithmetic_sync(ctx, expr))
|
|
333
|
+
elif isinstance(part, BraceExpansionPart):
|
|
334
|
+
# Expand brace items
|
|
335
|
+
results = []
|
|
336
|
+
for item in part.items:
|
|
337
|
+
if item.type == "Range":
|
|
338
|
+
expanded = expand_brace_range(item.start, item.end, item.step)
|
|
339
|
+
results.extend(expanded)
|
|
340
|
+
else:
|
|
341
|
+
results.append(expand_word(ctx, item.word))
|
|
342
|
+
return " ".join(results)
|
|
343
|
+
elif isinstance(part, CommandSubstitutionPart):
|
|
344
|
+
# Command substitution requires async
|
|
345
|
+
raise RuntimeError("Command substitution requires async expansion")
|
|
346
|
+
else:
|
|
347
|
+
return ""
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def expand_part(ctx: "InterpreterContext", part: WordPart, in_double_quotes: bool = False) -> str:
|
|
351
|
+
"""Expand a word part asynchronously."""
|
|
352
|
+
if isinstance(part, LiteralPart):
|
|
353
|
+
return part.value
|
|
354
|
+
elif isinstance(part, SingleQuotedPart):
|
|
355
|
+
return part.value
|
|
356
|
+
elif isinstance(part, EscapedPart):
|
|
357
|
+
return part.value
|
|
358
|
+
elif isinstance(part, DoubleQuotedPart):
|
|
359
|
+
result = []
|
|
360
|
+
for p in part.parts:
|
|
361
|
+
result.append(await expand_part(ctx, p, in_double_quotes=True))
|
|
362
|
+
return "".join(result)
|
|
363
|
+
elif isinstance(part, ParameterExpansionPart):
|
|
364
|
+
return await expand_parameter_async(ctx, part, in_double_quotes)
|
|
365
|
+
elif isinstance(part, TildeExpansionPart):
|
|
366
|
+
if in_double_quotes:
|
|
367
|
+
return "~" if part.user is None else f"~{part.user}"
|
|
368
|
+
if part.user is None:
|
|
369
|
+
return ctx.state.env.get("HOME", "/home/user")
|
|
370
|
+
elif part.user == "root":
|
|
371
|
+
return "/root"
|
|
372
|
+
else:
|
|
373
|
+
return f"~{part.user}"
|
|
374
|
+
elif isinstance(part, GlobPart):
|
|
375
|
+
return part.pattern
|
|
376
|
+
elif isinstance(part, ArithmeticExpansionPart):
|
|
377
|
+
# Unwrap ArithmeticExpressionNode to get the actual ArithExpr
|
|
378
|
+
expr = part.expression.expression if part.expression else None
|
|
379
|
+
return str(await evaluate_arithmetic(ctx, expr))
|
|
380
|
+
elif isinstance(part, BraceExpansionPart):
|
|
381
|
+
results = []
|
|
382
|
+
for item in part.items:
|
|
383
|
+
if item.type == "Range":
|
|
384
|
+
expanded = expand_brace_range(item.start, item.end, item.step)
|
|
385
|
+
results.extend(expanded)
|
|
386
|
+
else:
|
|
387
|
+
results.append(await expand_word_async(ctx, item.word))
|
|
388
|
+
return " ".join(results)
|
|
389
|
+
elif isinstance(part, CommandSubstitutionPart):
|
|
390
|
+
# Execute the command substitution
|
|
391
|
+
try:
|
|
392
|
+
result = await ctx.execute_script(part.body)
|
|
393
|
+
ctx.state.last_exit_code = result.exit_code
|
|
394
|
+
ctx.state.env["?"] = str(result.exit_code)
|
|
395
|
+
# Remove trailing newlines
|
|
396
|
+
return result.stdout.rstrip("\n")
|
|
397
|
+
except ExecutionLimitError:
|
|
398
|
+
raise
|
|
399
|
+
except ExitError as e:
|
|
400
|
+
ctx.state.last_exit_code = e.exit_code
|
|
401
|
+
ctx.state.env["?"] = str(e.exit_code)
|
|
402
|
+
return e.stdout.rstrip("\n")
|
|
403
|
+
else:
|
|
404
|
+
return ""
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in_double_quotes: bool = False) -> str:
|
|
408
|
+
"""Expand a parameter expansion synchronously."""
|
|
409
|
+
parameter = part.parameter
|
|
410
|
+
operation = part.operation
|
|
411
|
+
|
|
412
|
+
# Handle variable indirection: ${!var}
|
|
413
|
+
if parameter.startswith("!"):
|
|
414
|
+
indirect_name = parameter[1:]
|
|
415
|
+
|
|
416
|
+
# ${!arr[@]} or ${!arr[*]} - get array keys
|
|
417
|
+
array_keys_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$', indirect_name)
|
|
418
|
+
if array_keys_match:
|
|
419
|
+
arr_name = array_keys_match.group(1)
|
|
420
|
+
keys = get_array_keys(ctx, arr_name)
|
|
421
|
+
return " ".join(keys)
|
|
422
|
+
|
|
423
|
+
# ${!prefix*} or ${!prefix@} - get variable names starting with prefix
|
|
424
|
+
prefix_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)[@*]$', indirect_name)
|
|
425
|
+
if prefix_match:
|
|
426
|
+
prefix = prefix_match.group(1)
|
|
427
|
+
matching = [k for k in ctx.state.env.keys()
|
|
428
|
+
if k.startswith(prefix) and not "__" in k]
|
|
429
|
+
return " ".join(sorted(matching))
|
|
430
|
+
|
|
431
|
+
# ${!var} - variable indirection
|
|
432
|
+
ref_name = get_variable(ctx, indirect_name, False)
|
|
433
|
+
if ref_name:
|
|
434
|
+
return get_variable(ctx, ref_name, False)
|
|
435
|
+
return ""
|
|
436
|
+
|
|
437
|
+
# Check if operation handles unset variables
|
|
438
|
+
skip_nounset = operation and operation.type in (
|
|
439
|
+
"DefaultValue", "AssignDefault", "UseAlternative", "ErrorIfUnset"
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
value = get_variable(ctx, parameter, not skip_nounset)
|
|
443
|
+
|
|
444
|
+
if not operation:
|
|
445
|
+
return value
|
|
446
|
+
|
|
447
|
+
is_unset = parameter not in ctx.state.env
|
|
448
|
+
is_empty = value == ""
|
|
449
|
+
|
|
450
|
+
if operation.type == "DefaultValue":
|
|
451
|
+
use_default = is_unset or (operation.check_empty and is_empty)
|
|
452
|
+
if use_default and operation.word:
|
|
453
|
+
return expand_word(ctx, operation.word)
|
|
454
|
+
return value
|
|
455
|
+
|
|
456
|
+
elif operation.type == "AssignDefault":
|
|
457
|
+
use_default = is_unset or (operation.check_empty and is_empty)
|
|
458
|
+
if use_default and operation.word:
|
|
459
|
+
default_value = expand_word(ctx, operation.word)
|
|
460
|
+
ctx.state.env[parameter] = default_value
|
|
461
|
+
return default_value
|
|
462
|
+
return value
|
|
463
|
+
|
|
464
|
+
elif operation.type == "ErrorIfUnset":
|
|
465
|
+
should_error = is_unset or (operation.check_empty and is_empty)
|
|
466
|
+
if should_error:
|
|
467
|
+
message = expand_word(ctx, operation.word) if operation.word else f"{parameter}: parameter null or not set"
|
|
468
|
+
raise ExitError(1, "", f"bash: {message}\n")
|
|
469
|
+
return value
|
|
470
|
+
|
|
471
|
+
elif operation.type == "UseAlternative":
|
|
472
|
+
use_alt = not (is_unset or (operation.check_empty and is_empty))
|
|
473
|
+
if use_alt and operation.word:
|
|
474
|
+
return expand_word(ctx, operation.word)
|
|
475
|
+
return ""
|
|
476
|
+
|
|
477
|
+
elif operation.type == "Length":
|
|
478
|
+
# Check for array length
|
|
479
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$', parameter)
|
|
480
|
+
if array_match:
|
|
481
|
+
elements = get_array_elements(ctx, array_match.group(1))
|
|
482
|
+
return str(len(elements))
|
|
483
|
+
return str(len(value))
|
|
484
|
+
|
|
485
|
+
elif operation.type == "Substring":
|
|
486
|
+
offset = operation.offset if hasattr(operation, 'offset') else 0
|
|
487
|
+
length = operation.length if hasattr(operation, 'length') else None
|
|
488
|
+
|
|
489
|
+
# Handle negative offset
|
|
490
|
+
if offset < 0:
|
|
491
|
+
offset = max(0, len(value) + offset)
|
|
492
|
+
|
|
493
|
+
if length is not None:
|
|
494
|
+
if length < 0:
|
|
495
|
+
end_pos = len(value) + length
|
|
496
|
+
return value[offset:max(offset, end_pos)]
|
|
497
|
+
return value[offset:offset + length]
|
|
498
|
+
return value[offset:]
|
|
499
|
+
|
|
500
|
+
elif operation.type == "PatternRemoval":
|
|
501
|
+
pattern = expand_word(ctx, operation.pattern) if operation.pattern else ""
|
|
502
|
+
greedy = operation.greedy
|
|
503
|
+
from_end = operation.side == "suffix"
|
|
504
|
+
|
|
505
|
+
# Convert glob pattern to regex
|
|
506
|
+
regex_pattern = glob_to_regex(pattern, greedy, from_end)
|
|
507
|
+
|
|
508
|
+
if from_end:
|
|
509
|
+
# Remove from end: ${var%pattern} or ${var%%pattern}
|
|
510
|
+
match = re.search(regex_pattern + "$", value)
|
|
511
|
+
if match:
|
|
512
|
+
return value[:match.start()]
|
|
513
|
+
else:
|
|
514
|
+
# Remove from start: ${var#pattern} or ${var##pattern}
|
|
515
|
+
match = re.match(regex_pattern, value)
|
|
516
|
+
if match:
|
|
517
|
+
return value[match.end():]
|
|
518
|
+
return value
|
|
519
|
+
|
|
520
|
+
elif operation.type == "PatternReplace":
|
|
521
|
+
pattern = expand_word(ctx, operation.pattern) if operation.pattern else ""
|
|
522
|
+
replacement = expand_word(ctx, operation.replacement) if operation.replacement else ""
|
|
523
|
+
replace_all = operation.replace_all
|
|
524
|
+
|
|
525
|
+
regex_pattern = glob_to_regex(pattern, greedy=False)
|
|
526
|
+
|
|
527
|
+
if replace_all:
|
|
528
|
+
return re.sub(regex_pattern, replacement, value)
|
|
529
|
+
else:
|
|
530
|
+
return re.sub(regex_pattern, replacement, value, count=1)
|
|
531
|
+
|
|
532
|
+
elif operation.type == "CaseModification":
|
|
533
|
+
# ${var^^} or ${var,,} for case conversion
|
|
534
|
+
if operation.direction == "upper":
|
|
535
|
+
if operation.all:
|
|
536
|
+
return value.upper()
|
|
537
|
+
return value[0].upper() + value[1:] if value else ""
|
|
538
|
+
else:
|
|
539
|
+
if operation.all:
|
|
540
|
+
return value.lower()
|
|
541
|
+
return value[0].lower() + value[1:] if value else ""
|
|
542
|
+
|
|
543
|
+
elif operation.type == "Transform":
|
|
544
|
+
# ${var@Q}, ${var@P}, ${var@a}, ${var@A}, ${var@E}, ${var@K}
|
|
545
|
+
op = operation.operator
|
|
546
|
+
if op == "Q":
|
|
547
|
+
# Quoted form - escape special chars and wrap in quotes
|
|
548
|
+
if not value:
|
|
549
|
+
return "''"
|
|
550
|
+
# Simple quoting - use single quotes if no single quotes in value
|
|
551
|
+
if "'" not in value:
|
|
552
|
+
return f"'{value}'"
|
|
553
|
+
# Use $'...' quoting with escapes
|
|
554
|
+
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
555
|
+
return f"$'{escaped}'"
|
|
556
|
+
elif op == "E":
|
|
557
|
+
# Expand escape sequences like $'...'
|
|
558
|
+
result = []
|
|
559
|
+
i = 0
|
|
560
|
+
while i < len(value):
|
|
561
|
+
if value[i] == '\\' and i + 1 < len(value):
|
|
562
|
+
c = value[i + 1]
|
|
563
|
+
if c == 'n':
|
|
564
|
+
result.append('\n')
|
|
565
|
+
elif c == 't':
|
|
566
|
+
result.append('\t')
|
|
567
|
+
elif c == 'r':
|
|
568
|
+
result.append('\r')
|
|
569
|
+
elif c == '\\':
|
|
570
|
+
result.append('\\')
|
|
571
|
+
elif c == "'":
|
|
572
|
+
result.append("'")
|
|
573
|
+
elif c == '"':
|
|
574
|
+
result.append('"')
|
|
575
|
+
else:
|
|
576
|
+
result.append(value[i:i+2])
|
|
577
|
+
i += 2
|
|
578
|
+
else:
|
|
579
|
+
result.append(value[i])
|
|
580
|
+
i += 1
|
|
581
|
+
return ''.join(result)
|
|
582
|
+
elif op == "P":
|
|
583
|
+
# Prompt expansion - for now just return value
|
|
584
|
+
# Full implementation would expand \u, \h, \w, etc.
|
|
585
|
+
return value
|
|
586
|
+
elif op == "A":
|
|
587
|
+
# Assignment statement form
|
|
588
|
+
return f"{parameter}={_shell_quote(value)}"
|
|
589
|
+
elif op == "a":
|
|
590
|
+
# Attributes - check if array, readonly, etc.
|
|
591
|
+
attrs = []
|
|
592
|
+
if ctx.state.env.get(f"{parameter}__is_array") == "indexed":
|
|
593
|
+
attrs.append("a")
|
|
594
|
+
elif ctx.state.env.get(f"{parameter}__is_array") == "associative":
|
|
595
|
+
attrs.append("A")
|
|
596
|
+
readonly_set = ctx.state.env.get("__readonly__", "").split()
|
|
597
|
+
if parameter in readonly_set:
|
|
598
|
+
attrs.append("r")
|
|
599
|
+
return "".join(attrs)
|
|
600
|
+
elif op == "K":
|
|
601
|
+
# Key-value pairs for associative arrays
|
|
602
|
+
# For indexed arrays, show index=value pairs
|
|
603
|
+
elements = get_array_elements(ctx, parameter)
|
|
604
|
+
if elements:
|
|
605
|
+
pairs = [f"[{idx}]=\"{val}\"" for idx, val in elements]
|
|
606
|
+
return " ".join(pairs)
|
|
607
|
+
return value
|
|
608
|
+
|
|
609
|
+
return value
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _shell_quote(s: str) -> str:
|
|
613
|
+
"""Quote a string for shell use."""
|
|
614
|
+
if not s:
|
|
615
|
+
return "''"
|
|
616
|
+
if "'" not in s:
|
|
617
|
+
return f"'{s}'"
|
|
618
|
+
return f"$'{s.replace(chr(92), chr(92)+chr(92)).replace(chr(39), chr(92)+chr(39))}'"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def expand_parameter_async(ctx: "InterpreterContext", part: ParameterExpansionPart, in_double_quotes: bool = False) -> str:
|
|
622
|
+
"""Expand a parameter expansion asynchronously."""
|
|
623
|
+
# For now, use sync version - async needed for command substitution in default values
|
|
624
|
+
return expand_parameter(ctx, part, in_double_quotes)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def expand_brace_range(start: int, end: int, step: int = 1) -> list[str]:
|
|
628
|
+
"""Expand a brace range like {1..10} or {a..z}."""
|
|
629
|
+
results = []
|
|
630
|
+
|
|
631
|
+
if step == 0:
|
|
632
|
+
step = 1
|
|
633
|
+
|
|
634
|
+
if start <= end:
|
|
635
|
+
i = start
|
|
636
|
+
while i <= end:
|
|
637
|
+
results.append(str(i))
|
|
638
|
+
i += abs(step)
|
|
639
|
+
else:
|
|
640
|
+
i = start
|
|
641
|
+
while i >= end:
|
|
642
|
+
results.append(str(i))
|
|
643
|
+
i -= abs(step)
|
|
644
|
+
|
|
645
|
+
return results
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def glob_to_regex(pattern: str, greedy: bool = True, from_end: bool = False) -> str:
|
|
649
|
+
"""Convert a glob pattern to a regex pattern."""
|
|
650
|
+
result = []
|
|
651
|
+
i = 0
|
|
652
|
+
while i < len(pattern):
|
|
653
|
+
c = pattern[i]
|
|
654
|
+
if c == "*":
|
|
655
|
+
if greedy:
|
|
656
|
+
result.append(".*")
|
|
657
|
+
else:
|
|
658
|
+
result.append(".*?")
|
|
659
|
+
elif c == "?":
|
|
660
|
+
result.append(".")
|
|
661
|
+
elif c == "[":
|
|
662
|
+
# Character class
|
|
663
|
+
j = i + 1
|
|
664
|
+
if j < len(pattern) and pattern[j] == "!":
|
|
665
|
+
result.append("[^")
|
|
666
|
+
j += 1
|
|
667
|
+
else:
|
|
668
|
+
result.append("[")
|
|
669
|
+
while j < len(pattern) and pattern[j] != "]":
|
|
670
|
+
result.append(pattern[j])
|
|
671
|
+
j += 1
|
|
672
|
+
result.append("]")
|
|
673
|
+
i = j
|
|
674
|
+
elif c in r"\^$.|+(){}":
|
|
675
|
+
result.append("\\" + c)
|
|
676
|
+
else:
|
|
677
|
+
result.append(c)
|
|
678
|
+
i += 1
|
|
679
|
+
return "".join(result)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
async def expand_word_with_glob(
|
|
683
|
+
ctx: "InterpreterContext",
|
|
684
|
+
word: WordNode,
|
|
685
|
+
) -> dict:
|
|
686
|
+
"""Expand a word with glob expansion support.
|
|
687
|
+
|
|
688
|
+
Returns dict with 'values' (list of strings) and 'quoted' (bool).
|
|
689
|
+
"""
|
|
690
|
+
# Check if word contains any quoted parts
|
|
691
|
+
has_quoted = any(
|
|
692
|
+
isinstance(p, (SingleQuotedPart, DoubleQuotedPart, EscapedPart))
|
|
693
|
+
for p in word.parts
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
# Special handling for "$@" and "$*" in double quotes
|
|
697
|
+
# "$@" expands to multiple words (one per positional parameter)
|
|
698
|
+
# "$*" expands to single word (params joined by IFS)
|
|
699
|
+
if len(word.parts) == 1 and isinstance(word.parts[0], DoubleQuotedPart):
|
|
700
|
+
dq = word.parts[0]
|
|
701
|
+
if len(dq.parts) == 1 and isinstance(dq.parts[0], ParameterExpansionPart):
|
|
702
|
+
param_part = dq.parts[0]
|
|
703
|
+
if param_part.parameter == "@" and param_part.operation is None:
|
|
704
|
+
# "$@" - return each positional parameter as separate word
|
|
705
|
+
params = _get_positional_params(ctx)
|
|
706
|
+
if not params:
|
|
707
|
+
return {"values": [], "quoted": True}
|
|
708
|
+
return {"values": params, "quoted": True}
|
|
709
|
+
elif param_part.parameter == "*" and param_part.operation is None:
|
|
710
|
+
# "$*" - return all params joined by first char of IFS
|
|
711
|
+
params = _get_positional_params(ctx)
|
|
712
|
+
ifs = ctx.state.env.get("IFS", " \t\n")
|
|
713
|
+
sep = ifs[0] if ifs else ""
|
|
714
|
+
return {"values": [sep.join(params)] if params else [""], "quoted": True}
|
|
715
|
+
|
|
716
|
+
# Handle more complex cases with "$@" embedded in other content
|
|
717
|
+
# e.g., "prefix$@suffix" -> ["prefix$1", "$2", ..., "$nsuffix"]
|
|
718
|
+
values = await _expand_word_with_at(ctx, word)
|
|
719
|
+
if values is not None:
|
|
720
|
+
return {"values": values, "quoted": True}
|
|
721
|
+
|
|
722
|
+
# Expand the word
|
|
723
|
+
value = await expand_word_async(ctx, word)
|
|
724
|
+
|
|
725
|
+
# For unquoted words, perform IFS word splitting
|
|
726
|
+
if not has_quoted:
|
|
727
|
+
# Check for glob patterns first
|
|
728
|
+
if any(c in value for c in "*?["):
|
|
729
|
+
matches = await glob_expand(ctx, value)
|
|
730
|
+
if matches:
|
|
731
|
+
return {"values": matches, "quoted": False}
|
|
732
|
+
|
|
733
|
+
# Perform IFS word splitting
|
|
734
|
+
if value == "":
|
|
735
|
+
return {"values": [], "quoted": False}
|
|
736
|
+
|
|
737
|
+
# Check if the word contained parameter/command expansion that should be split
|
|
738
|
+
has_expansion = any(
|
|
739
|
+
isinstance(p, (ParameterExpansionPart, CommandSubstitutionPart, ArithmeticExpansionPart))
|
|
740
|
+
for p in word.parts
|
|
741
|
+
)
|
|
742
|
+
if has_expansion:
|
|
743
|
+
ifs = ctx.state.env.get("IFS", " \t\n")
|
|
744
|
+
if ifs:
|
|
745
|
+
# Split on IFS characters
|
|
746
|
+
words = _split_on_ifs(value, ifs)
|
|
747
|
+
return {"values": words, "quoted": False}
|
|
748
|
+
|
|
749
|
+
return {"values": [value], "quoted": has_quoted}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _split_on_ifs(value: str, ifs: str) -> list[str]:
|
|
753
|
+
"""Split a string on IFS characters.
|
|
754
|
+
|
|
755
|
+
IFS whitespace (space, tab, newline) is treated specially:
|
|
756
|
+
- Leading/trailing IFS whitespace is trimmed
|
|
757
|
+
- Consecutive IFS whitespace is treated as one delimiter
|
|
758
|
+
Non-whitespace IFS characters produce empty fields.
|
|
759
|
+
"""
|
|
760
|
+
if not value:
|
|
761
|
+
return []
|
|
762
|
+
|
|
763
|
+
# Identify which IFS chars are whitespace
|
|
764
|
+
ifs_whitespace = "".join(c for c in ifs if c in " \t\n")
|
|
765
|
+
ifs_nonws = "".join(c for c in ifs if c not in " \t\n")
|
|
766
|
+
|
|
767
|
+
# If all IFS chars are whitespace, simple split
|
|
768
|
+
if not ifs_nonws:
|
|
769
|
+
return value.split()
|
|
770
|
+
|
|
771
|
+
# Complex case: mix of whitespace and non-whitespace IFS
|
|
772
|
+
result = []
|
|
773
|
+
current = []
|
|
774
|
+
i = 0
|
|
775
|
+
while i < len(value):
|
|
776
|
+
c = value[i]
|
|
777
|
+
if c in ifs_whitespace:
|
|
778
|
+
# Skip leading/consecutive whitespace
|
|
779
|
+
if current:
|
|
780
|
+
result.append("".join(current))
|
|
781
|
+
current = []
|
|
782
|
+
# Skip all consecutive whitespace
|
|
783
|
+
while i < len(value) and value[i] in ifs_whitespace:
|
|
784
|
+
i += 1
|
|
785
|
+
elif c in ifs_nonws:
|
|
786
|
+
# Non-whitespace delimiter produces field
|
|
787
|
+
result.append("".join(current))
|
|
788
|
+
current = []
|
|
789
|
+
i += 1
|
|
790
|
+
else:
|
|
791
|
+
current.append(c)
|
|
792
|
+
i += 1
|
|
793
|
+
|
|
794
|
+
if current:
|
|
795
|
+
result.append("".join(current))
|
|
796
|
+
|
|
797
|
+
return result
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _get_positional_params(ctx: "InterpreterContext") -> list[str]:
|
|
801
|
+
"""Get all positional parameters ($1, $2, ...) as a list."""
|
|
802
|
+
params = []
|
|
803
|
+
i = 1
|
|
804
|
+
while str(i) in ctx.state.env:
|
|
805
|
+
params.append(ctx.state.env[str(i)])
|
|
806
|
+
i += 1
|
|
807
|
+
return params
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
async def _expand_word_with_at(ctx: "InterpreterContext", word: WordNode) -> list[str] | None:
|
|
811
|
+
"""Expand a word that may contain $@ in double quotes.
|
|
812
|
+
|
|
813
|
+
Returns None if the word doesn't contain $@ in double quotes.
|
|
814
|
+
Returns list of expanded values if it does.
|
|
815
|
+
"""
|
|
816
|
+
# Check if any part contains $@ in double quotes
|
|
817
|
+
has_at_in_quotes = False
|
|
818
|
+
for part in word.parts:
|
|
819
|
+
if isinstance(part, DoubleQuotedPart):
|
|
820
|
+
for inner in part.parts:
|
|
821
|
+
if (isinstance(inner, ParameterExpansionPart) and
|
|
822
|
+
inner.parameter == "@" and inner.operation is None):
|
|
823
|
+
has_at_in_quotes = True
|
|
824
|
+
break
|
|
825
|
+
|
|
826
|
+
if not has_at_in_quotes:
|
|
827
|
+
return None
|
|
828
|
+
|
|
829
|
+
# Get positional parameters
|
|
830
|
+
params = _get_positional_params(ctx)
|
|
831
|
+
if not params:
|
|
832
|
+
# No positional params - expand without $@
|
|
833
|
+
result = []
|
|
834
|
+
for part in word.parts:
|
|
835
|
+
if isinstance(part, DoubleQuotedPart):
|
|
836
|
+
inner_result = []
|
|
837
|
+
for inner in part.parts:
|
|
838
|
+
if (isinstance(inner, ParameterExpansionPart) and
|
|
839
|
+
inner.parameter == "@" and inner.operation is None):
|
|
840
|
+
pass # Skip $@ - produces nothing
|
|
841
|
+
else:
|
|
842
|
+
inner_result.append(await expand_part(ctx, inner, in_double_quotes=True))
|
|
843
|
+
result.append("".join(inner_result))
|
|
844
|
+
else:
|
|
845
|
+
result.append(await expand_part(ctx, part))
|
|
846
|
+
return ["".join(result)] if "".join(result) else []
|
|
847
|
+
|
|
848
|
+
# Complex case: expand $@ to multiple words
|
|
849
|
+
# For "prefix$@suffix", produce ["prefix$1", "$2", ..., "$n-1", "$nsuffix"]
|
|
850
|
+
# Build prefix (everything before $@) and suffix (everything after $@)
|
|
851
|
+
prefix_parts = []
|
|
852
|
+
suffix_parts = []
|
|
853
|
+
found_at = False
|
|
854
|
+
|
|
855
|
+
for part in word.parts:
|
|
856
|
+
if isinstance(part, DoubleQuotedPart):
|
|
857
|
+
for inner in part.parts:
|
|
858
|
+
if (isinstance(inner, ParameterExpansionPart) and
|
|
859
|
+
inner.parameter == "@" and inner.operation is None):
|
|
860
|
+
found_at = True
|
|
861
|
+
elif not found_at:
|
|
862
|
+
prefix_parts.append(await expand_part(ctx, inner, in_double_quotes=True))
|
|
863
|
+
else:
|
|
864
|
+
suffix_parts.append(await expand_part(ctx, inner, in_double_quotes=True))
|
|
865
|
+
elif not found_at:
|
|
866
|
+
prefix_parts.append(await expand_part(ctx, part))
|
|
867
|
+
else:
|
|
868
|
+
suffix_parts.append(await expand_part(ctx, part))
|
|
869
|
+
|
|
870
|
+
prefix = "".join(prefix_parts)
|
|
871
|
+
suffix = "".join(suffix_parts)
|
|
872
|
+
|
|
873
|
+
# Build result: first param gets prefix, last param gets suffix
|
|
874
|
+
if len(params) == 1:
|
|
875
|
+
return [prefix + params[0] + suffix]
|
|
876
|
+
else:
|
|
877
|
+
result = [prefix + params[0]]
|
|
878
|
+
result.extend(params[1:-1])
|
|
879
|
+
result.append(params[-1] + suffix)
|
|
880
|
+
return result
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
async def glob_expand(ctx: "InterpreterContext", pattern: str) -> list[str]:
|
|
884
|
+
"""Expand a glob pattern against the filesystem."""
|
|
885
|
+
import os
|
|
886
|
+
|
|
887
|
+
cwd = ctx.state.cwd
|
|
888
|
+
fs = ctx.fs
|
|
889
|
+
|
|
890
|
+
# Handle absolute vs relative paths
|
|
891
|
+
if pattern.startswith("/"):
|
|
892
|
+
base_dir = "/"
|
|
893
|
+
pattern = pattern[1:]
|
|
894
|
+
else:
|
|
895
|
+
base_dir = cwd
|
|
896
|
+
|
|
897
|
+
# Split pattern into parts
|
|
898
|
+
parts = pattern.split("/")
|
|
899
|
+
|
|
900
|
+
async def expand_parts(current_dir: str, remaining_parts: list[str]) -> list[str]:
|
|
901
|
+
if not remaining_parts:
|
|
902
|
+
return [current_dir]
|
|
903
|
+
|
|
904
|
+
part = remaining_parts[0]
|
|
905
|
+
rest = remaining_parts[1:]
|
|
906
|
+
|
|
907
|
+
# Check if this part has glob characters
|
|
908
|
+
if not any(c in part for c in "*?["):
|
|
909
|
+
# No glob - just check if path exists
|
|
910
|
+
new_path = os.path.join(current_dir, part)
|
|
911
|
+
if await fs.exists(new_path):
|
|
912
|
+
return await expand_parts(new_path, rest)
|
|
913
|
+
return []
|
|
914
|
+
|
|
915
|
+
# Glob expansion needed
|
|
916
|
+
try:
|
|
917
|
+
entries = await fs.readdir(current_dir)
|
|
918
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
919
|
+
return []
|
|
920
|
+
|
|
921
|
+
matches = []
|
|
922
|
+
for entry in entries:
|
|
923
|
+
if fnmatch.fnmatch(entry, part):
|
|
924
|
+
new_path = os.path.join(current_dir, entry)
|
|
925
|
+
if rest:
|
|
926
|
+
# More parts to match - entry must be a directory
|
|
927
|
+
if await fs.is_directory(new_path):
|
|
928
|
+
matches.extend(await expand_parts(new_path, rest))
|
|
929
|
+
else:
|
|
930
|
+
matches.append(new_path)
|
|
931
|
+
|
|
932
|
+
return sorted(matches)
|
|
933
|
+
|
|
934
|
+
results = await expand_parts(base_dir, parts)
|
|
935
|
+
|
|
936
|
+
# Return relative paths if pattern was relative
|
|
937
|
+
if not pattern.startswith("/") and results:
|
|
938
|
+
results = [os.path.relpath(r, cwd) if r.startswith(cwd) else r for r in results]
|
|
939
|
+
|
|
940
|
+
return results
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _parse_base_n_value(value_str: str, base: int) -> int:
|
|
944
|
+
"""Parse a value in base N (2-64).
|
|
945
|
+
|
|
946
|
+
Digits:
|
|
947
|
+
- 0-9 = values 0-9
|
|
948
|
+
- a-z = values 10-35
|
|
949
|
+
- A-Z = values 36-61 (or 10-35 if base <= 36)
|
|
950
|
+
- @ = 62, _ = 63
|
|
951
|
+
"""
|
|
952
|
+
result = 0
|
|
953
|
+
for char in value_str:
|
|
954
|
+
if char.isdigit():
|
|
955
|
+
digit = int(char)
|
|
956
|
+
elif 'a' <= char <= 'z':
|
|
957
|
+
digit = ord(char) - ord('a') + 10
|
|
958
|
+
elif 'A' <= char <= 'Z':
|
|
959
|
+
if base <= 36:
|
|
960
|
+
# Case insensitive for bases <= 36
|
|
961
|
+
digit = ord(char.lower()) - ord('a') + 10
|
|
962
|
+
else:
|
|
963
|
+
# A-Z are 36-61 for bases > 36
|
|
964
|
+
digit = ord(char) - ord('A') + 36
|
|
965
|
+
elif char == '@':
|
|
966
|
+
digit = 62
|
|
967
|
+
elif char == '_':
|
|
968
|
+
digit = 63
|
|
969
|
+
else:
|
|
970
|
+
raise ValueError(f"Invalid digit {char} for base {base}")
|
|
971
|
+
|
|
972
|
+
if digit >= base:
|
|
973
|
+
raise ValueError(f"Digit {char} out of range for base {base}")
|
|
974
|
+
|
|
975
|
+
result = result * base + digit
|
|
976
|
+
return result
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
980
|
+
"""Evaluate an arithmetic expression synchronously."""
|
|
981
|
+
# Simple implementation for basic arithmetic
|
|
982
|
+
if hasattr(expr, 'type'):
|
|
983
|
+
if expr.type == "ArithNumber":
|
|
984
|
+
return expr.value
|
|
985
|
+
elif expr.type == "ArithVariable":
|
|
986
|
+
name = expr.name
|
|
987
|
+
# Handle dynamic base constants like $base#value or base#value where base is a variable
|
|
988
|
+
if "#" in name:
|
|
989
|
+
hash_pos = name.index("#")
|
|
990
|
+
base_part = name[:hash_pos]
|
|
991
|
+
value_part = name[hash_pos + 1:]
|
|
992
|
+
# Check if base_part is a variable reference
|
|
993
|
+
if base_part.startswith("$"):
|
|
994
|
+
base_var = base_part[1:]
|
|
995
|
+
if base_var.startswith("{") and base_var.endswith("}"):
|
|
996
|
+
base_var = base_var[1:-1]
|
|
997
|
+
base_str = get_variable(ctx, base_var, False)
|
|
998
|
+
else:
|
|
999
|
+
# Try treating base_part as a variable name
|
|
1000
|
+
base_str = get_variable(ctx, base_part, False)
|
|
1001
|
+
if not base_str:
|
|
1002
|
+
# Fall back to treating as literal
|
|
1003
|
+
base_str = base_part
|
|
1004
|
+
try:
|
|
1005
|
+
base = int(base_str)
|
|
1006
|
+
if 2 <= base <= 64:
|
|
1007
|
+
return _parse_base_n_value(value_part, base)
|
|
1008
|
+
except (ValueError, TypeError):
|
|
1009
|
+
pass
|
|
1010
|
+
val = get_variable(ctx, name, False)
|
|
1011
|
+
try:
|
|
1012
|
+
return int(val) if val else 0
|
|
1013
|
+
except ValueError:
|
|
1014
|
+
return 0
|
|
1015
|
+
elif expr.type == "ArithBinary":
|
|
1016
|
+
left = evaluate_arithmetic_sync(ctx, expr.left)
|
|
1017
|
+
right = evaluate_arithmetic_sync(ctx, expr.right)
|
|
1018
|
+
op = expr.operator
|
|
1019
|
+
if op == "+":
|
|
1020
|
+
return left + right
|
|
1021
|
+
elif op == "-":
|
|
1022
|
+
return left - right
|
|
1023
|
+
elif op == "*":
|
|
1024
|
+
return left * right
|
|
1025
|
+
elif op == "/":
|
|
1026
|
+
return left // right if right != 0 else 0
|
|
1027
|
+
elif op == "%":
|
|
1028
|
+
return left % right if right != 0 else 0
|
|
1029
|
+
elif op == "**":
|
|
1030
|
+
return left ** right
|
|
1031
|
+
elif op == "<":
|
|
1032
|
+
return 1 if left < right else 0
|
|
1033
|
+
elif op == ">":
|
|
1034
|
+
return 1 if left > right else 0
|
|
1035
|
+
elif op == "<=":
|
|
1036
|
+
return 1 if left <= right else 0
|
|
1037
|
+
elif op == ">=":
|
|
1038
|
+
return 1 if left >= right else 0
|
|
1039
|
+
elif op == "==":
|
|
1040
|
+
return 1 if left == right else 0
|
|
1041
|
+
elif op == "!=":
|
|
1042
|
+
return 1 if left != right else 0
|
|
1043
|
+
elif op == "&&":
|
|
1044
|
+
return 1 if left and right else 0
|
|
1045
|
+
elif op == "||":
|
|
1046
|
+
return 1 if left or right else 0
|
|
1047
|
+
elif op == "&":
|
|
1048
|
+
return left & right
|
|
1049
|
+
elif op == "|":
|
|
1050
|
+
return left | right
|
|
1051
|
+
elif op == "^":
|
|
1052
|
+
return left ^ right
|
|
1053
|
+
elif op == "<<":
|
|
1054
|
+
return left << right
|
|
1055
|
+
elif op == ">>":
|
|
1056
|
+
return left >> right
|
|
1057
|
+
elif op == ",":
|
|
1058
|
+
# Comma operator: evaluate both, return right
|
|
1059
|
+
return right
|
|
1060
|
+
elif expr.type == "ArithUnary":
|
|
1061
|
+
op = expr.operator
|
|
1062
|
+
# Handle increment/decrement specially (need variable name)
|
|
1063
|
+
if op in ("++", "--"):
|
|
1064
|
+
if hasattr(expr.operand, 'name'):
|
|
1065
|
+
var_name = expr.operand.name
|
|
1066
|
+
val = get_variable(ctx, var_name, False)
|
|
1067
|
+
try:
|
|
1068
|
+
current = int(val) if val else 0
|
|
1069
|
+
except ValueError:
|
|
1070
|
+
current = 0
|
|
1071
|
+
new_val = current + 1 if op == "++" else current - 1
|
|
1072
|
+
|
|
1073
|
+
# Handle array element syntax: arr[idx]
|
|
1074
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', var_name)
|
|
1075
|
+
if array_match:
|
|
1076
|
+
arr_name = array_match.group(1)
|
|
1077
|
+
subscript = array_match.group(2)
|
|
1078
|
+
idx = _eval_array_subscript(ctx, subscript)
|
|
1079
|
+
ctx.state.env[f"{arr_name}_{idx}"] = str(new_val)
|
|
1080
|
+
else:
|
|
1081
|
+
ctx.state.env[var_name] = str(new_val)
|
|
1082
|
+
|
|
1083
|
+
# Prefix returns new value, postfix returns old value
|
|
1084
|
+
return new_val if expr.prefix else current
|
|
1085
|
+
else:
|
|
1086
|
+
# Operand is not a variable - just evaluate
|
|
1087
|
+
operand = evaluate_arithmetic_sync(ctx, expr.operand)
|
|
1088
|
+
return operand + 1 if op == "++" else operand - 1
|
|
1089
|
+
operand = evaluate_arithmetic_sync(ctx, expr.operand)
|
|
1090
|
+
if op == "-":
|
|
1091
|
+
return -operand
|
|
1092
|
+
elif op == "+":
|
|
1093
|
+
return operand
|
|
1094
|
+
elif op == "!":
|
|
1095
|
+
return 0 if operand else 1
|
|
1096
|
+
elif op == "~":
|
|
1097
|
+
return ~operand
|
|
1098
|
+
elif expr.type == "ArithTernary":
|
|
1099
|
+
cond = evaluate_arithmetic_sync(ctx, expr.condition)
|
|
1100
|
+
if cond:
|
|
1101
|
+
return evaluate_arithmetic_sync(ctx, expr.consequent)
|
|
1102
|
+
else:
|
|
1103
|
+
return evaluate_arithmetic_sync(ctx, expr.alternate)
|
|
1104
|
+
elif expr.type == "ArithAssignment":
|
|
1105
|
+
# Handle compound assignments: = += -= *= /= %= <<= >>= &= |= ^=
|
|
1106
|
+
op = getattr(expr, 'operator', '=')
|
|
1107
|
+
var_name = getattr(expr, 'variable', None) or getattr(expr, 'name', None)
|
|
1108
|
+
rhs = evaluate_arithmetic_sync(ctx, expr.value)
|
|
1109
|
+
|
|
1110
|
+
if op == '=':
|
|
1111
|
+
value = rhs
|
|
1112
|
+
else:
|
|
1113
|
+
# Get current value for compound operators
|
|
1114
|
+
current = 0
|
|
1115
|
+
if var_name:
|
|
1116
|
+
val = get_variable(ctx, var_name, False)
|
|
1117
|
+
try:
|
|
1118
|
+
current = int(val) if val else 0
|
|
1119
|
+
except ValueError:
|
|
1120
|
+
current = 0
|
|
1121
|
+
|
|
1122
|
+
if op == '+=':
|
|
1123
|
+
value = current + rhs
|
|
1124
|
+
elif op == '-=':
|
|
1125
|
+
value = current - rhs
|
|
1126
|
+
elif op == '*=':
|
|
1127
|
+
value = current * rhs
|
|
1128
|
+
elif op == '/=':
|
|
1129
|
+
value = current // rhs if rhs != 0 else 0
|
|
1130
|
+
elif op == '%=':
|
|
1131
|
+
value = current % rhs if rhs != 0 else 0
|
|
1132
|
+
elif op == '<<=':
|
|
1133
|
+
value = current << rhs
|
|
1134
|
+
elif op == '>>=':
|
|
1135
|
+
value = current >> rhs
|
|
1136
|
+
elif op == '&=':
|
|
1137
|
+
value = current & rhs
|
|
1138
|
+
elif op == '|=':
|
|
1139
|
+
value = current | rhs
|
|
1140
|
+
elif op == '^=':
|
|
1141
|
+
value = current ^ rhs
|
|
1142
|
+
else:
|
|
1143
|
+
value = rhs
|
|
1144
|
+
|
|
1145
|
+
if var_name:
|
|
1146
|
+
ctx.state.env[var_name] = str(value)
|
|
1147
|
+
return value
|
|
1148
|
+
elif expr.type == "ArithGroup":
|
|
1149
|
+
return evaluate_arithmetic_sync(ctx, expr.expression)
|
|
1150
|
+
return 0
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
async def evaluate_arithmetic(ctx: "InterpreterContext", expr) -> int:
|
|
1154
|
+
"""Evaluate an arithmetic expression asynchronously."""
|
|
1155
|
+
# For now, use sync version
|
|
1156
|
+
return evaluate_arithmetic_sync(ctx, expr)
|