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.
Files changed (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. 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)