just-bash 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- just_bash/ast/factory.py +3 -1
- just_bash/bash.py +28 -6
- just_bash/commands/awk/awk.py +112 -7
- just_bash/commands/cat/cat.py +5 -1
- just_bash/commands/echo/echo.py +33 -1
- just_bash/commands/grep/grep.py +30 -1
- just_bash/commands/od/od.py +144 -30
- just_bash/commands/printf/printf.py +289 -87
- just_bash/commands/pwd/pwd.py +32 -2
- just_bash/commands/read/read.py +243 -64
- just_bash/commands/readlink/readlink.py +3 -9
- just_bash/commands/registry.py +24 -0
- just_bash/commands/rmdir/__init__.py +5 -0
- just_bash/commands/rmdir/rmdir.py +160 -0
- just_bash/commands/sed/sed.py +142 -31
- just_bash/commands/stat/stat.py +9 -0
- just_bash/commands/time/__init__.py +5 -0
- just_bash/commands/time/time.py +74 -0
- just_bash/commands/touch/touch.py +118 -8
- just_bash/commands/whoami/__init__.py +5 -0
- just_bash/commands/whoami/whoami.py +18 -0
- just_bash/fs/in_memory_fs.py +22 -0
- just_bash/fs/overlay_fs.py +14 -0
- just_bash/interpreter/__init__.py +1 -1
- just_bash/interpreter/builtins/__init__.py +2 -0
- just_bash/interpreter/builtins/control.py +4 -8
- just_bash/interpreter/builtins/declare.py +321 -24
- just_bash/interpreter/builtins/getopts.py +163 -0
- just_bash/interpreter/builtins/let.py +2 -2
- just_bash/interpreter/builtins/local.py +71 -5
- just_bash/interpreter/builtins/misc.py +22 -6
- just_bash/interpreter/builtins/readonly.py +38 -10
- just_bash/interpreter/builtins/set.py +58 -8
- just_bash/interpreter/builtins/test.py +136 -19
- just_bash/interpreter/builtins/unset.py +62 -10
- just_bash/interpreter/conditionals.py +29 -4
- just_bash/interpreter/control_flow.py +61 -17
- just_bash/interpreter/expansion.py +1647 -104
- just_bash/interpreter/interpreter.py +424 -70
- just_bash/interpreter/types.py +263 -2
- just_bash/parser/__init__.py +2 -0
- just_bash/parser/lexer.py +295 -26
- just_bash/parser/parser.py +523 -64
- just_bash/types.py +11 -0
- {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
- {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/RECORD +47 -40
- {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
|
@@ -12,6 +12,7 @@ Handles shell word expansion including:
|
|
|
12
12
|
|
|
13
13
|
import fnmatch
|
|
14
14
|
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
15
16
|
from typing import TYPE_CHECKING, Optional
|
|
16
17
|
|
|
17
18
|
from ..ast.types import (
|
|
@@ -34,6 +35,13 @@ if TYPE_CHECKING:
|
|
|
34
35
|
from .types import InterpreterContext
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
@dataclass
|
|
39
|
+
class ExpandedSegment:
|
|
40
|
+
"""A segment of expanded text with quoting context."""
|
|
41
|
+
text: str
|
|
42
|
+
quoted: bool # True = protected from IFS splitting and globbing
|
|
43
|
+
|
|
44
|
+
|
|
37
45
|
def get_variable(ctx: "InterpreterContext", name: str, check_nounset: bool = True) -> str:
|
|
38
46
|
"""Get a variable value from the environment.
|
|
39
47
|
|
|
@@ -42,18 +50,51 @@ def get_variable(ctx: "InterpreterContext", name: str, check_nounset: bool = Tru
|
|
|
42
50
|
"""
|
|
43
51
|
env = ctx.state.env
|
|
44
52
|
|
|
53
|
+
# Resolve nameref for regular variable names (not special params or array subscripts)
|
|
54
|
+
from .types import VariableStore
|
|
55
|
+
if (isinstance(env, VariableStore)
|
|
56
|
+
and re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name)
|
|
57
|
+
and env.is_nameref(name)):
|
|
58
|
+
try:
|
|
59
|
+
name = env.resolve_nameref(name)
|
|
60
|
+
except ValueError:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
45
63
|
# Check for array subscript syntax: name[subscript]
|
|
46
64
|
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', name)
|
|
47
65
|
if array_match:
|
|
48
66
|
arr_name = array_match.group(1)
|
|
49
67
|
subscript = array_match.group(2)
|
|
50
68
|
|
|
69
|
+
# Resolve nameref for array base name
|
|
70
|
+
if isinstance(env, VariableStore) and env.is_nameref(arr_name):
|
|
71
|
+
try:
|
|
72
|
+
arr_name = env.resolve_nameref(arr_name)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return ""
|
|
75
|
+
|
|
51
76
|
# Handle arr[@] and arr[*] - all elements
|
|
52
77
|
if subscript in ("@", "*"):
|
|
53
78
|
elements = get_array_elements(ctx, arr_name)
|
|
79
|
+
if subscript == "*":
|
|
80
|
+
# $* / ${arr[*]} joins with first char of IFS
|
|
81
|
+
ifs = env.get("IFS", " \t\n")
|
|
82
|
+
sep = ifs[0] if ifs else ""
|
|
83
|
+
return sep.join(val for _, val in elements)
|
|
54
84
|
return " ".join(val for _, val in elements)
|
|
55
85
|
|
|
56
|
-
#
|
|
86
|
+
# Check if this is an associative array
|
|
87
|
+
is_assoc = env.get(f"{arr_name}__is_array") == "assoc"
|
|
88
|
+
if is_assoc:
|
|
89
|
+
# For associative arrays, use subscript as string key directly
|
|
90
|
+
key = f"{arr_name}_{subscript}"
|
|
91
|
+
if key in env:
|
|
92
|
+
return env[key]
|
|
93
|
+
elif check_nounset and ctx.state.options.nounset:
|
|
94
|
+
raise NounsetError(name)
|
|
95
|
+
return ""
|
|
96
|
+
|
|
97
|
+
# Handle numeric or variable subscript for indexed arrays
|
|
57
98
|
try:
|
|
58
99
|
# Try to evaluate subscript as arithmetic expression
|
|
59
100
|
idx = _eval_array_subscript(ctx, subscript)
|
|
@@ -82,14 +123,24 @@ def get_variable(ctx: "InterpreterContext", name: str, check_nounset: bool = Tru
|
|
|
82
123
|
while str(count + 1) in env:
|
|
83
124
|
count += 1
|
|
84
125
|
return str(count)
|
|
85
|
-
elif name == "@"
|
|
86
|
-
# All positional parameters
|
|
126
|
+
elif name == "@":
|
|
127
|
+
# All positional parameters (space-separated for unquoted)
|
|
87
128
|
params = []
|
|
88
129
|
i = 1
|
|
89
130
|
while str(i) in env:
|
|
90
131
|
params.append(env[str(i)])
|
|
91
132
|
i += 1
|
|
92
133
|
return " ".join(params)
|
|
134
|
+
elif name == "*":
|
|
135
|
+
# All positional parameters (joined with first char of IFS)
|
|
136
|
+
params = []
|
|
137
|
+
i = 1
|
|
138
|
+
while str(i) in env:
|
|
139
|
+
params.append(env[str(i)])
|
|
140
|
+
i += 1
|
|
141
|
+
ifs = env.get("IFS", " \t\n")
|
|
142
|
+
sep = ifs[0] if ifs else ""
|
|
143
|
+
return sep.join(params)
|
|
93
144
|
elif name == "0":
|
|
94
145
|
return env.get("0", "bash")
|
|
95
146
|
elif name == "$":
|
|
@@ -99,39 +150,43 @@ def get_variable(ctx: "InterpreterContext", name: str, check_nounset: bool = Tru
|
|
|
99
150
|
elif name == "_":
|
|
100
151
|
return ctx.state.last_arg
|
|
101
152
|
elif name == "LINENO":
|
|
102
|
-
return str(ctx.state.current_line)
|
|
153
|
+
return str(ctx.state.current_line or 1)
|
|
103
154
|
elif name == "RANDOM":
|
|
104
|
-
import random
|
|
105
|
-
|
|
155
|
+
import random as _random
|
|
156
|
+
# Check if RANDOM has been assigned (seed value)
|
|
157
|
+
seed_val = env.get("RANDOM")
|
|
158
|
+
if seed_val is not None:
|
|
159
|
+
try:
|
|
160
|
+
seed = int(seed_val)
|
|
161
|
+
ctx.state.random_generator = _random.Random(seed)
|
|
162
|
+
except ValueError:
|
|
163
|
+
pass
|
|
164
|
+
# Remove seed from env so it doesn't re-seed on next read
|
|
165
|
+
del env["RANDOM"]
|
|
166
|
+
# Use seeded generator if available, else global random
|
|
167
|
+
if ctx.state.random_generator is not None:
|
|
168
|
+
return str(ctx.state.random_generator.randint(0, 32767))
|
|
169
|
+
return str(_random.randint(0, 32767))
|
|
106
170
|
elif name == "SECONDS":
|
|
107
171
|
import time
|
|
172
|
+
# Check if SECONDS was reset (seconds_reset_time is set)
|
|
173
|
+
if hasattr(ctx.state, 'seconds_reset_time') and ctx.state.seconds_reset_time is not None:
|
|
174
|
+
return str(int(time.time() - ctx.state.seconds_reset_time))
|
|
108
175
|
return str(int(time.time() - ctx.state.start_time))
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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}", "")
|
|
176
|
+
elif name == "SHLVL":
|
|
177
|
+
return env.get("SHLVL", "1")
|
|
178
|
+
elif name == "BASH_VERSION":
|
|
179
|
+
return env.get("BASH_VERSION", "5.0.0(1)-release")
|
|
180
|
+
elif name == "BASHPID":
|
|
181
|
+
return str(env.get("$", "1"))
|
|
130
182
|
|
|
131
183
|
# Regular variable
|
|
132
184
|
value = env.get(name)
|
|
133
185
|
|
|
134
186
|
if value is None:
|
|
187
|
+
# Check if this is an array name without subscript - return element 0
|
|
188
|
+
if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name) and f"{name}__is_array" in env:
|
|
189
|
+
return env.get(f"{name}_0", "")
|
|
135
190
|
# Check nounset (set -u)
|
|
136
191
|
if check_nounset and ctx.state.options.nounset:
|
|
137
192
|
raise NounsetError(name, "", f"bash: {name}: unbound variable\n")
|
|
@@ -141,22 +196,34 @@ def get_variable(ctx: "InterpreterContext", name: str, check_nounset: bool = Tru
|
|
|
141
196
|
|
|
142
197
|
|
|
143
198
|
def get_array_elements(ctx: "InterpreterContext", name: str) -> list[tuple[int, str]]:
|
|
144
|
-
"""Get all elements of an array as (index, value) pairs.
|
|
199
|
+
"""Get all elements of an array as (index, value) pairs.
|
|
200
|
+
|
|
201
|
+
For associative arrays, the index is a synthetic sequential number.
|
|
202
|
+
Use get_array_elements_raw() for the actual key-value pairs.
|
|
203
|
+
"""
|
|
145
204
|
elements = []
|
|
146
205
|
env = ctx.state.env
|
|
206
|
+
is_assoc = env.get(f"{name}__is_array") == "assoc"
|
|
147
207
|
|
|
148
|
-
# Look for
|
|
208
|
+
# Look for name_KEY entries
|
|
149
209
|
prefix = f"{name}_"
|
|
150
210
|
for key, value in env.items():
|
|
151
|
-
if key.startswith(prefix) and not key.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
211
|
+
if key.startswith(prefix) and not key.startswith(f"{name}__"):
|
|
212
|
+
idx_part = key[len(prefix):]
|
|
213
|
+
if is_assoc:
|
|
214
|
+
# For assoc arrays, use synthetic index
|
|
215
|
+
elements.append((len(elements), value))
|
|
216
|
+
else:
|
|
217
|
+
try:
|
|
218
|
+
idx = int(idx_part)
|
|
219
|
+
elements.append((idx, value))
|
|
220
|
+
except ValueError:
|
|
221
|
+
# Non-numeric key in indexed array context - skip
|
|
222
|
+
pass
|
|
157
223
|
|
|
158
|
-
# Sort by index
|
|
159
|
-
|
|
224
|
+
# Sort by index for indexed arrays
|
|
225
|
+
if not is_assoc:
|
|
226
|
+
elements.sort(key=lambda x: x[0])
|
|
160
227
|
return elements
|
|
161
228
|
|
|
162
229
|
|
|
@@ -206,6 +273,25 @@ def _eval_array_subscript(ctx: "InterpreterContext", subscript: str) -> int:
|
|
|
206
273
|
return 0
|
|
207
274
|
|
|
208
275
|
|
|
276
|
+
def _expand_braced_param_sync(ctx: "InterpreterContext", content: str) -> str:
|
|
277
|
+
"""Expand ${content} as a full parameter expansion (sync).
|
|
278
|
+
|
|
279
|
+
Handles operations like ${var:-default}, ${var:+alt}, ${#var},
|
|
280
|
+
${var#pattern}, etc. inside arithmetic expressions.
|
|
281
|
+
"""
|
|
282
|
+
# Try parsing through the parser's parameter expansion handler
|
|
283
|
+
try:
|
|
284
|
+
from ..parser.parser import Parser
|
|
285
|
+
parser = Parser()
|
|
286
|
+
part = parser._parse_parameter_expansion(content)
|
|
287
|
+
return expand_parameter(ctx, part, False)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
# Fallback: simple variable lookup
|
|
292
|
+
return get_variable(ctx, content, False)
|
|
293
|
+
|
|
294
|
+
|
|
209
295
|
def _expand_arith_vars(ctx: "InterpreterContext", expr: str) -> str:
|
|
210
296
|
"""Expand bare variable names in arithmetic expression."""
|
|
211
297
|
# Replace variable names with their values
|
|
@@ -297,6 +383,66 @@ async def expand_word_async(ctx: "InterpreterContext", word: WordNode) -> str:
|
|
|
297
383
|
return "".join(parts)
|
|
298
384
|
|
|
299
385
|
|
|
386
|
+
def _escape_glob_chars(s: str) -> str:
|
|
387
|
+
"""Escape glob metacharacters for fnmatch (literal matching).
|
|
388
|
+
|
|
389
|
+
Uses [x] notation which fnmatch always treats as literal character class.
|
|
390
|
+
"""
|
|
391
|
+
return s.replace("[", "[[]").replace("*", "[*]").replace("?", "[?]")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def expand_word_for_case_pattern(ctx: "InterpreterContext", word: WordNode) -> str:
|
|
395
|
+
"""Expand a word for use as a case pattern.
|
|
396
|
+
|
|
397
|
+
Glob metacharacters from quoted sources are escaped so they match
|
|
398
|
+
literally, while unquoted glob chars remain active.
|
|
399
|
+
"""
|
|
400
|
+
parts = []
|
|
401
|
+
for part in word.parts:
|
|
402
|
+
parts.append(await _expand_part_for_pattern(ctx, part))
|
|
403
|
+
return "".join(parts)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
async def _expand_part_for_pattern(
|
|
407
|
+
ctx: "InterpreterContext", part: WordPart, in_double_quotes: bool = False
|
|
408
|
+
) -> str:
|
|
409
|
+
"""Expand a word part for case pattern matching.
|
|
410
|
+
|
|
411
|
+
Quoted parts have glob metacharacters escaped.
|
|
412
|
+
"""
|
|
413
|
+
if isinstance(part, LiteralPart):
|
|
414
|
+
# Unquoted literal - glob chars remain active
|
|
415
|
+
return part.value
|
|
416
|
+
elif isinstance(part, SingleQuotedPart):
|
|
417
|
+
# Single-quoted - all glob chars escaped
|
|
418
|
+
return _escape_glob_chars(part.value)
|
|
419
|
+
elif isinstance(part, EscapedPart):
|
|
420
|
+
# Escaped char - literal
|
|
421
|
+
return _escape_glob_chars(part.value)
|
|
422
|
+
elif isinstance(part, DoubleQuotedPart):
|
|
423
|
+
# Double-quoted - glob chars escaped, but expansions still happen
|
|
424
|
+
result = []
|
|
425
|
+
for p in part.parts:
|
|
426
|
+
expanded = await _expand_part_for_pattern(ctx, p, in_double_quotes=True)
|
|
427
|
+
if isinstance(p, (LiteralPart, EscapedPart)):
|
|
428
|
+
# Literal text inside double quotes is protected
|
|
429
|
+
expanded = _escape_glob_chars(expanded)
|
|
430
|
+
elif isinstance(p, ParameterExpansionPart):
|
|
431
|
+
# Parameter expansion result inside double quotes is protected
|
|
432
|
+
expanded = _escape_glob_chars(expanded)
|
|
433
|
+
result.append(expanded)
|
|
434
|
+
return "".join(result)
|
|
435
|
+
elif isinstance(part, GlobPart):
|
|
436
|
+
# Unquoted glob - stays active
|
|
437
|
+
return part.pattern
|
|
438
|
+
elif isinstance(part, ParameterExpansionPart):
|
|
439
|
+
# Unquoted parameter expansion - glob chars stay active
|
|
440
|
+
return await expand_parameter_async(ctx, part, in_double_quotes)
|
|
441
|
+
else:
|
|
442
|
+
# All other parts: delegate to normal expansion
|
|
443
|
+
return await expand_part(ctx, part, in_double_quotes)
|
|
444
|
+
|
|
445
|
+
|
|
300
446
|
def expand_part_sync(ctx: "InterpreterContext", part: WordPart, in_double_quotes: bool = False) -> str:
|
|
301
447
|
"""Expand a word part synchronously."""
|
|
302
448
|
if isinstance(part, LiteralPart):
|
|
@@ -319,6 +465,10 @@ def expand_part_sync(ctx: "InterpreterContext", part: WordPart, in_double_quotes
|
|
|
319
465
|
return "~" if part.user is None else f"~{part.user}"
|
|
320
466
|
if part.user is None:
|
|
321
467
|
return ctx.state.env.get("HOME", "/home/user")
|
|
468
|
+
elif part.user == "+":
|
|
469
|
+
return ctx.state.env.get("PWD", ctx.state.cwd)
|
|
470
|
+
elif part.user == "-":
|
|
471
|
+
return ctx.state.env.get("OLDPWD", "")
|
|
322
472
|
elif part.user == "root":
|
|
323
473
|
return "/root"
|
|
324
474
|
else:
|
|
@@ -329,7 +479,10 @@ def expand_part_sync(ctx: "InterpreterContext", part: WordPart, in_double_quotes
|
|
|
329
479
|
# Evaluate arithmetic synchronously
|
|
330
480
|
# Unwrap ArithmeticExpressionNode to get the actual ArithExpr
|
|
331
481
|
expr = part.expression.expression if part.expression else None
|
|
332
|
-
|
|
482
|
+
try:
|
|
483
|
+
return str(evaluate_arithmetic_sync(ctx, expr))
|
|
484
|
+
except (ValueError, ZeroDivisionError) as e:
|
|
485
|
+
raise ExitError(1, "", f"bash: {e}\n")
|
|
333
486
|
elif isinstance(part, BraceExpansionPart):
|
|
334
487
|
# Expand brace items
|
|
335
488
|
results = []
|
|
@@ -367,6 +520,10 @@ async def expand_part(ctx: "InterpreterContext", part: WordPart, in_double_quote
|
|
|
367
520
|
return "~" if part.user is None else f"~{part.user}"
|
|
368
521
|
if part.user is None:
|
|
369
522
|
return ctx.state.env.get("HOME", "/home/user")
|
|
523
|
+
elif part.user == "+":
|
|
524
|
+
return ctx.state.env.get("PWD", ctx.state.cwd)
|
|
525
|
+
elif part.user == "-":
|
|
526
|
+
return ctx.state.env.get("OLDPWD", "")
|
|
370
527
|
elif part.user == "root":
|
|
371
528
|
return "/root"
|
|
372
529
|
else:
|
|
@@ -376,7 +533,10 @@ async def expand_part(ctx: "InterpreterContext", part: WordPart, in_double_quote
|
|
|
376
533
|
elif isinstance(part, ArithmeticExpansionPart):
|
|
377
534
|
# Unwrap ArithmeticExpressionNode to get the actual ArithExpr
|
|
378
535
|
expr = part.expression.expression if part.expression else None
|
|
379
|
-
|
|
536
|
+
try:
|
|
537
|
+
return str(await evaluate_arithmetic(ctx, expr))
|
|
538
|
+
except (ValueError, ZeroDivisionError) as e:
|
|
539
|
+
raise ExitError(1, "", f"bash: {e}\n")
|
|
380
540
|
elif isinstance(part, BraceExpansionPart):
|
|
381
541
|
results = []
|
|
382
542
|
for item in part.items:
|
|
@@ -404,6 +564,211 @@ async def expand_part(ctx: "InterpreterContext", part: WordPart, in_double_quote
|
|
|
404
564
|
return ""
|
|
405
565
|
|
|
406
566
|
|
|
567
|
+
async def expand_word_segments(
|
|
568
|
+
ctx: "InterpreterContext", word: WordNode
|
|
569
|
+
) -> list[ExpandedSegment]:
|
|
570
|
+
"""Expand a word into a list of segments preserving quoting context.
|
|
571
|
+
|
|
572
|
+
Each segment carries its text and whether it was quoted (protected from
|
|
573
|
+
IFS splitting and globbing).
|
|
574
|
+
"""
|
|
575
|
+
segments: list[ExpandedSegment] = []
|
|
576
|
+
for part in word.parts:
|
|
577
|
+
segments.extend(await _expand_part_segments(ctx, part, in_double_quotes=False))
|
|
578
|
+
return segments
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def _expand_part_segments(
|
|
582
|
+
ctx: "InterpreterContext", part: WordPart, in_double_quotes: bool = False
|
|
583
|
+
) -> list[ExpandedSegment]:
|
|
584
|
+
"""Expand a single part into segments preserving quoting context."""
|
|
585
|
+
if isinstance(part, LiteralPart):
|
|
586
|
+
return [ExpandedSegment(text=part.value, quoted=in_double_quotes)]
|
|
587
|
+
|
|
588
|
+
elif isinstance(part, SingleQuotedPart):
|
|
589
|
+
return [ExpandedSegment(text=part.value, quoted=True)]
|
|
590
|
+
|
|
591
|
+
elif isinstance(part, EscapedPart):
|
|
592
|
+
return [ExpandedSegment(text=part.value, quoted=True)]
|
|
593
|
+
|
|
594
|
+
elif isinstance(part, DoubleQuotedPart):
|
|
595
|
+
segments: list[ExpandedSegment] = []
|
|
596
|
+
for p in part.parts:
|
|
597
|
+
segments.extend(
|
|
598
|
+
await _expand_part_segments(ctx, p, in_double_quotes=True)
|
|
599
|
+
)
|
|
600
|
+
return segments
|
|
601
|
+
|
|
602
|
+
elif isinstance(part, ParameterExpansionPart):
|
|
603
|
+
value = await expand_parameter_async(ctx, part, in_double_quotes)
|
|
604
|
+
return [ExpandedSegment(text=value, quoted=in_double_quotes)]
|
|
605
|
+
|
|
606
|
+
elif isinstance(part, TildeExpansionPart):
|
|
607
|
+
if in_double_quotes:
|
|
608
|
+
text = "~" if part.user is None else f"~{part.user}"
|
|
609
|
+
return [ExpandedSegment(text=text, quoted=True)]
|
|
610
|
+
if part.user is None:
|
|
611
|
+
text = ctx.state.env.get("HOME", "/home/user")
|
|
612
|
+
elif part.user == "+":
|
|
613
|
+
text = ctx.state.env.get("PWD", ctx.state.cwd)
|
|
614
|
+
elif part.user == "-":
|
|
615
|
+
text = ctx.state.env.get("OLDPWD", "")
|
|
616
|
+
elif part.user == "root":
|
|
617
|
+
text = "/root"
|
|
618
|
+
else:
|
|
619
|
+
text = f"~{part.user}"
|
|
620
|
+
# Tilde expansion result is not subject to further splitting
|
|
621
|
+
return [ExpandedSegment(text=text, quoted=True)]
|
|
622
|
+
|
|
623
|
+
elif isinstance(part, GlobPart):
|
|
624
|
+
return [ExpandedSegment(text=part.pattern, quoted=False)]
|
|
625
|
+
|
|
626
|
+
elif isinstance(part, ArithmeticExpansionPart):
|
|
627
|
+
expr = part.expression.expression if part.expression else None
|
|
628
|
+
try:
|
|
629
|
+
text = str(await evaluate_arithmetic(ctx, expr))
|
|
630
|
+
except (ValueError, ZeroDivisionError) as e:
|
|
631
|
+
raise ExitError(1, "", f"bash: {e}\n")
|
|
632
|
+
return [ExpandedSegment(text=text, quoted=in_double_quotes)]
|
|
633
|
+
|
|
634
|
+
elif isinstance(part, BraceExpansionPart):
|
|
635
|
+
results = []
|
|
636
|
+
for item in part.items:
|
|
637
|
+
if item.type == "Range":
|
|
638
|
+
expanded = expand_brace_range(item.start, item.end, item.step)
|
|
639
|
+
results.extend(expanded)
|
|
640
|
+
else:
|
|
641
|
+
results.append(await expand_word_async(ctx, item.word))
|
|
642
|
+
return [ExpandedSegment(text=" ".join(results), quoted=in_double_quotes)]
|
|
643
|
+
|
|
644
|
+
elif isinstance(part, CommandSubstitutionPart):
|
|
645
|
+
try:
|
|
646
|
+
result = await ctx.execute_script(part.body)
|
|
647
|
+
ctx.state.last_exit_code = result.exit_code
|
|
648
|
+
ctx.state.env["?"] = str(result.exit_code)
|
|
649
|
+
text = result.stdout.rstrip("\n")
|
|
650
|
+
except ExecutionLimitError:
|
|
651
|
+
raise
|
|
652
|
+
except ExitError as e:
|
|
653
|
+
ctx.state.last_exit_code = e.exit_code
|
|
654
|
+
ctx.state.env["?"] = str(e.exit_code)
|
|
655
|
+
text = e.stdout.rstrip("\n")
|
|
656
|
+
return [ExpandedSegment(text=text, quoted=in_double_quotes)]
|
|
657
|
+
|
|
658
|
+
return [ExpandedSegment(text="", quoted=in_double_quotes)]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _segments_to_string(segments: list[ExpandedSegment]) -> str:
|
|
662
|
+
"""Flatten segments into a single string."""
|
|
663
|
+
return "".join(seg.text for seg in segments)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _segments_has_unquoted_glob(segments: list[ExpandedSegment]) -> bool:
|
|
667
|
+
"""Check if segments contain unquoted glob characters."""
|
|
668
|
+
for seg in segments:
|
|
669
|
+
if not seg.quoted and (
|
|
670
|
+
any(c in seg.text for c in "*?[")
|
|
671
|
+
or re.search(r'[@?*+!]\(', seg.text)
|
|
672
|
+
):
|
|
673
|
+
return True
|
|
674
|
+
return False
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _split_segments_on_ifs(
|
|
678
|
+
segments: list[ExpandedSegment], ifs: str
|
|
679
|
+
) -> list[str]:
|
|
680
|
+
"""Split segments on IFS characters, only splitting in unquoted segments.
|
|
681
|
+
|
|
682
|
+
Quoted segments are never split. Unquoted segments are split on IFS chars.
|
|
683
|
+
Adjacent segments (quoted or unquoted) that don't contain IFS delimiters
|
|
684
|
+
are concatenated into the same output word.
|
|
685
|
+
|
|
686
|
+
IFS splitting rules:
|
|
687
|
+
- IFS whitespace (space/tab/newline): leading/trailing stripped, consecutive
|
|
688
|
+
merged into one delimiter
|
|
689
|
+
- IFS non-whitespace: each produces a field boundary
|
|
690
|
+
- Whitespace adjacent to non-whitespace IFS is part of that delimiter
|
|
691
|
+
"""
|
|
692
|
+
if not segments:
|
|
693
|
+
return []
|
|
694
|
+
|
|
695
|
+
ifs_whitespace = set(c for c in ifs if c in " \t\n")
|
|
696
|
+
ifs_nonws = set(c for c in ifs if c not in " \t\n")
|
|
697
|
+
|
|
698
|
+
words: list[str] = []
|
|
699
|
+
current: list[str] = []
|
|
700
|
+
had_content = False # Track if we've seen any non-IFS content
|
|
701
|
+
|
|
702
|
+
# Build a flat list of (char, splittable) pairs
|
|
703
|
+
chars: list[tuple[str, bool]] = []
|
|
704
|
+
for seg in segments:
|
|
705
|
+
if seg.quoted:
|
|
706
|
+
for c in seg.text:
|
|
707
|
+
chars.append((c, False))
|
|
708
|
+
else:
|
|
709
|
+
for c in seg.text:
|
|
710
|
+
chars.append((c, True))
|
|
711
|
+
|
|
712
|
+
i = 0
|
|
713
|
+
n = len(chars)
|
|
714
|
+
|
|
715
|
+
# Skip leading IFS whitespace
|
|
716
|
+
while i < n:
|
|
717
|
+
c, splittable = chars[i]
|
|
718
|
+
if splittable and c in ifs_whitespace:
|
|
719
|
+
i += 1
|
|
720
|
+
else:
|
|
721
|
+
break
|
|
722
|
+
|
|
723
|
+
while i < n:
|
|
724
|
+
c, splittable = chars[i]
|
|
725
|
+
if not splittable:
|
|
726
|
+
current.append(c)
|
|
727
|
+
had_content = True
|
|
728
|
+
i += 1
|
|
729
|
+
elif c in ifs_nonws:
|
|
730
|
+
# Non-whitespace IFS: always produces a field boundary
|
|
731
|
+
words.append("".join(current))
|
|
732
|
+
current = []
|
|
733
|
+
had_content = False
|
|
734
|
+
i += 1
|
|
735
|
+
# Skip trailing IFS whitespace after non-ws delimiter
|
|
736
|
+
while i < n and chars[i][1] and chars[i][0] in ifs_whitespace:
|
|
737
|
+
i += 1
|
|
738
|
+
elif c in ifs_whitespace:
|
|
739
|
+
# IFS whitespace: skip consecutive, check for adjacent non-ws
|
|
740
|
+
if had_content or current:
|
|
741
|
+
# Save word boundary position but don't emit yet -
|
|
742
|
+
# if a non-ws IFS follows, it's one composite delimiter
|
|
743
|
+
saved_word = "".join(current)
|
|
744
|
+
current = []
|
|
745
|
+
had_content = False
|
|
746
|
+
# Skip consecutive whitespace
|
|
747
|
+
while i < n and chars[i][1] and chars[i][0] in ifs_whitespace:
|
|
748
|
+
i += 1
|
|
749
|
+
# Check if next is a non-ws IFS char
|
|
750
|
+
if i < n and chars[i][1] and chars[i][0] in ifs_nonws:
|
|
751
|
+
# Composite delimiter: ws + nonws
|
|
752
|
+
# Emit the saved word, then let the nonws handler run
|
|
753
|
+
words.append(saved_word)
|
|
754
|
+
else:
|
|
755
|
+
# Just whitespace delimiter
|
|
756
|
+
words.append(saved_word)
|
|
757
|
+
else:
|
|
758
|
+
# Leading whitespace (or whitespace after delimiter) - skip
|
|
759
|
+
while i < n and chars[i][1] and chars[i][0] in ifs_whitespace:
|
|
760
|
+
i += 1
|
|
761
|
+
else:
|
|
762
|
+
current.append(c)
|
|
763
|
+
had_content = True
|
|
764
|
+
i += 1
|
|
765
|
+
|
|
766
|
+
if current or had_content:
|
|
767
|
+
words.append("".join(current))
|
|
768
|
+
|
|
769
|
+
return words
|
|
770
|
+
|
|
771
|
+
|
|
407
772
|
def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in_double_quotes: bool = False) -> str:
|
|
408
773
|
"""Expand a parameter expansion synchronously."""
|
|
409
774
|
parameter = part.parameter
|
|
@@ -429,6 +794,18 @@ def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in
|
|
|
429
794
|
return " ".join(sorted(matching))
|
|
430
795
|
|
|
431
796
|
# ${!var} - variable indirection
|
|
797
|
+
# For namerefs: ${!nameref} returns the target variable NAME
|
|
798
|
+
from .types import VariableStore
|
|
799
|
+
env = ctx.state.env
|
|
800
|
+
if (isinstance(env, VariableStore)
|
|
801
|
+
and re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', indirect_name)
|
|
802
|
+
and env.is_nameref(indirect_name)):
|
|
803
|
+
meta = env._metadata.get(indirect_name)
|
|
804
|
+
if meta and meta.nameref_target:
|
|
805
|
+
return meta.nameref_target
|
|
806
|
+
return ""
|
|
807
|
+
|
|
808
|
+
# Standard indirect: ${!var} uses value of var as variable name
|
|
432
809
|
ref_name = get_variable(ctx, indirect_name, False)
|
|
433
810
|
if ref_name:
|
|
434
811
|
return get_variable(ctx, ref_name, False)
|
|
@@ -444,7 +821,18 @@ def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in
|
|
|
444
821
|
if not operation:
|
|
445
822
|
return value
|
|
446
823
|
|
|
447
|
-
|
|
824
|
+
# Check if variable is unset - handle array subscript parameters
|
|
825
|
+
array_param_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$', parameter)
|
|
826
|
+
if array_param_match:
|
|
827
|
+
arr_name = array_param_match.group(1)
|
|
828
|
+
elements = get_array_elements(ctx, arr_name)
|
|
829
|
+
is_unset = len(elements) == 0
|
|
830
|
+
elif re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', parameter) and f"{parameter}__is_array" in ctx.state.env:
|
|
831
|
+
# Bare array name - check if any elements exist
|
|
832
|
+
elements = get_array_elements(ctx, parameter)
|
|
833
|
+
is_unset = len(elements) == 0
|
|
834
|
+
else:
|
|
835
|
+
is_unset = parameter not in ctx.state.env
|
|
448
836
|
is_empty = value == ""
|
|
449
837
|
|
|
450
838
|
if operation.type == "DefaultValue":
|
|
@@ -480,12 +868,34 @@ def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in
|
|
|
480
868
|
if array_match:
|
|
481
869
|
elements = get_array_elements(ctx, array_match.group(1))
|
|
482
870
|
return str(len(elements))
|
|
871
|
+
# ${#a} for arrays should return length of a[0]
|
|
872
|
+
if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', parameter) and f"{parameter}__is_array" in ctx.state.env:
|
|
873
|
+
first_val = ctx.state.env.get(f"{parameter}_0", "")
|
|
874
|
+
return str(len(first_val))
|
|
483
875
|
return str(len(value))
|
|
484
876
|
|
|
485
877
|
elif operation.type == "Substring":
|
|
486
878
|
offset = operation.offset if hasattr(operation, 'offset') else 0
|
|
487
879
|
length = operation.length if hasattr(operation, 'length') else None
|
|
488
880
|
|
|
881
|
+
# Check for array slicing: ${a[@]:offset:length}
|
|
882
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$', parameter)
|
|
883
|
+
if array_match:
|
|
884
|
+
elements = get_array_elements(ctx, array_match.group(1))
|
|
885
|
+
values = [v for _, v in elements]
|
|
886
|
+
# Handle negative offset
|
|
887
|
+
if offset < 0:
|
|
888
|
+
offset = max(0, len(values) + offset)
|
|
889
|
+
if length is not None:
|
|
890
|
+
if length < 0:
|
|
891
|
+
end_pos = len(values) + length
|
|
892
|
+
sliced = values[offset:max(offset, end_pos)]
|
|
893
|
+
else:
|
|
894
|
+
sliced = values[offset:offset + length]
|
|
895
|
+
else:
|
|
896
|
+
sliced = values[offset:]
|
|
897
|
+
return " ".join(sliced)
|
|
898
|
+
|
|
489
899
|
# Handle negative offset
|
|
490
900
|
if offset < 0:
|
|
491
901
|
offset = max(0, len(value) + offset)
|
|
@@ -502,52 +912,143 @@ def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in
|
|
|
502
912
|
greedy = operation.greedy
|
|
503
913
|
from_end = operation.side == "suffix"
|
|
504
914
|
|
|
915
|
+
# Check for array per-element operation: ${a[@]#pattern}
|
|
916
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$', parameter)
|
|
917
|
+
if array_match:
|
|
918
|
+
elements = get_array_elements(ctx, array_match.group(1))
|
|
919
|
+
regex_pat = glob_to_regex(pattern, greedy=True, from_end=from_end)
|
|
920
|
+
results = []
|
|
921
|
+
for _, elem_val in elements:
|
|
922
|
+
results.append(_apply_pattern_removal(elem_val, regex_pat, pattern, greedy, from_end))
|
|
923
|
+
return " ".join(results)
|
|
924
|
+
|
|
505
925
|
# Convert glob pattern to regex
|
|
506
|
-
regex_pattern = glob_to_regex(pattern, greedy, from_end)
|
|
926
|
+
regex_pattern = glob_to_regex(pattern, greedy=True, from_end=from_end)
|
|
507
927
|
|
|
508
928
|
if from_end:
|
|
509
929
|
# Remove from end: ${var%pattern} or ${var%%pattern}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
930
|
+
if greedy:
|
|
931
|
+
# ${var%%pattern}: remove longest matching suffix
|
|
932
|
+
match = re.search(regex_pattern + "$", value)
|
|
933
|
+
if match:
|
|
934
|
+
return value[:match.start()]
|
|
935
|
+
else:
|
|
936
|
+
# ${var%pattern}: remove shortest matching suffix
|
|
937
|
+
# Try matching from the end, starting with the shortest suffix
|
|
938
|
+
for start in range(len(value) - 1, -1, -1):
|
|
939
|
+
suffix = value[start:]
|
|
940
|
+
if re.fullmatch(regex_pattern, suffix):
|
|
941
|
+
return value[:start]
|
|
942
|
+
# Also check empty suffix
|
|
943
|
+
if re.fullmatch(regex_pattern, ""):
|
|
944
|
+
return value
|
|
513
945
|
else:
|
|
514
946
|
# Remove from start: ${var#pattern} or ${var##pattern}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
947
|
+
if greedy:
|
|
948
|
+
# ${var##pattern}: remove longest matching prefix
|
|
949
|
+
regex_greedy = glob_to_regex(pattern, greedy=True, from_end=False)
|
|
950
|
+
match = re.match(regex_greedy, value)
|
|
951
|
+
if match:
|
|
952
|
+
return value[match.end():]
|
|
953
|
+
else:
|
|
954
|
+
# ${var#pattern}: remove shortest matching prefix
|
|
955
|
+
regex_nongreedy = glob_to_regex(pattern, greedy=False, from_end=False)
|
|
956
|
+
match = re.match(regex_nongreedy, value)
|
|
957
|
+
if match:
|
|
958
|
+
return value[match.end():]
|
|
518
959
|
return value
|
|
519
960
|
|
|
520
|
-
elif operation.type == "
|
|
961
|
+
elif operation.type == "PatternReplacement":
|
|
521
962
|
pattern = expand_word(ctx, operation.pattern) if operation.pattern else ""
|
|
522
963
|
replacement = expand_word(ctx, operation.replacement) if operation.replacement else ""
|
|
523
|
-
replace_all = operation.
|
|
964
|
+
replace_all = operation.all
|
|
965
|
+
anchor = getattr(operation, 'anchor', None)
|
|
524
966
|
|
|
525
967
|
regex_pattern = glob_to_regex(pattern, greedy=False)
|
|
526
968
|
|
|
527
|
-
|
|
969
|
+
# Check for array per-element operation: ${a[@]/pat/rep}
|
|
970
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[[@*]\]$', parameter)
|
|
971
|
+
if array_match:
|
|
972
|
+
elements = get_array_elements(ctx, array_match.group(1))
|
|
973
|
+
results = []
|
|
974
|
+
for _, elem_val in elements:
|
|
975
|
+
results.append(_apply_pattern_replacement(elem_val, regex_pattern, pattern, replacement, replace_all, anchor))
|
|
976
|
+
return " ".join(results)
|
|
977
|
+
|
|
978
|
+
if anchor == "start":
|
|
979
|
+
# Anchored at start - only match at beginning
|
|
980
|
+
if pattern == "":
|
|
981
|
+
# Empty pattern at start means insert at beginning
|
|
982
|
+
return replacement + value
|
|
983
|
+
anchored_pattern = "^" + regex_pattern
|
|
984
|
+
return re.sub(anchored_pattern, replacement, value, count=1)
|
|
985
|
+
elif anchor == "end":
|
|
986
|
+
# Anchored at end - only match at end
|
|
987
|
+
if pattern == "":
|
|
988
|
+
# Empty pattern at end means append
|
|
989
|
+
return value + replacement
|
|
990
|
+
anchored_pattern = regex_pattern + "$"
|
|
991
|
+
return re.sub(anchored_pattern, replacement, value, count=1)
|
|
992
|
+
elif replace_all:
|
|
528
993
|
return re.sub(regex_pattern, replacement, value)
|
|
529
994
|
else:
|
|
530
995
|
return re.sub(regex_pattern, replacement, value, count=1)
|
|
531
996
|
|
|
532
997
|
elif operation.type == "CaseModification":
|
|
533
998
|
# ${var^^} or ${var,,} for case conversion
|
|
999
|
+
# ${var^^pattern} - only convert chars matching pattern
|
|
1000
|
+
pattern = None
|
|
1001
|
+
if operation.pattern:
|
|
1002
|
+
try:
|
|
1003
|
+
from .expansion import expand_word_async
|
|
1004
|
+
import asyncio
|
|
1005
|
+
# For sync context, try to get the raw pattern
|
|
1006
|
+
if operation.pattern.parts:
|
|
1007
|
+
pattern = "".join(
|
|
1008
|
+
getattr(p, 'value', '') for p in operation.pattern.parts
|
|
1009
|
+
)
|
|
1010
|
+
except Exception:
|
|
1011
|
+
pass
|
|
1012
|
+
|
|
534
1013
|
if operation.direction == "upper":
|
|
1014
|
+
if pattern:
|
|
1015
|
+
# Only uppercase chars matching pattern
|
|
1016
|
+
result = []
|
|
1017
|
+
for c in value:
|
|
1018
|
+
if fnmatch.fnmatch(c, pattern):
|
|
1019
|
+
result.append(c.upper())
|
|
1020
|
+
else:
|
|
1021
|
+
result.append(c)
|
|
1022
|
+
return "".join(result) if operation.all else (
|
|
1023
|
+
_case_first_matching(value, pattern, str.upper) if value else ""
|
|
1024
|
+
)
|
|
535
1025
|
if operation.all:
|
|
536
1026
|
return value.upper()
|
|
537
1027
|
return value[0].upper() + value[1:] if value else ""
|
|
538
1028
|
else:
|
|
1029
|
+
if pattern:
|
|
1030
|
+
# Only lowercase chars matching pattern
|
|
1031
|
+
result = []
|
|
1032
|
+
for c in value:
|
|
1033
|
+
if fnmatch.fnmatch(c, pattern):
|
|
1034
|
+
result.append(c.lower())
|
|
1035
|
+
else:
|
|
1036
|
+
result.append(c)
|
|
1037
|
+
return "".join(result) if operation.all else (
|
|
1038
|
+
_case_first_matching(value, pattern, str.lower) if value else ""
|
|
1039
|
+
)
|
|
539
1040
|
if operation.all:
|
|
540
1041
|
return value.lower()
|
|
541
1042
|
return value[0].lower() + value[1:] if value else ""
|
|
542
1043
|
|
|
543
1044
|
elif operation.type == "Transform":
|
|
544
1045
|
# ${var@Q}, ${var@P}, ${var@a}, ${var@A}, ${var@E}, ${var@K}
|
|
1046
|
+
# ${var@u}, ${var@U}, ${var@L} (case transforms)
|
|
545
1047
|
op = operation.operator
|
|
546
1048
|
if op == "Q":
|
|
547
|
-
# Quoted form -
|
|
1049
|
+
# Quoted form - produce bash-compatible single-quoted output
|
|
548
1050
|
if not value:
|
|
549
1051
|
return "''"
|
|
550
|
-
# Simple quoting - use single quotes if no single quotes in value
|
|
551
1052
|
if "'" not in value:
|
|
552
1053
|
return f"'{value}'"
|
|
553
1054
|
# Use $'...' quoting with escapes
|
|
@@ -572,6 +1073,36 @@ def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in
|
|
|
572
1073
|
result.append("'")
|
|
573
1074
|
elif c == '"':
|
|
574
1075
|
result.append('"')
|
|
1076
|
+
elif c == 'a':
|
|
1077
|
+
result.append('\a')
|
|
1078
|
+
elif c == 'b':
|
|
1079
|
+
result.append('\b')
|
|
1080
|
+
elif c == 'f':
|
|
1081
|
+
result.append('\f')
|
|
1082
|
+
elif c == 'v':
|
|
1083
|
+
result.append('\v')
|
|
1084
|
+
elif c == 'x' and i + 3 < len(value):
|
|
1085
|
+
# Hex: \xNN
|
|
1086
|
+
hex_str = value[i+2:i+4]
|
|
1087
|
+
try:
|
|
1088
|
+
result.append(chr(int(hex_str, 16)))
|
|
1089
|
+
i += 4
|
|
1090
|
+
continue
|
|
1091
|
+
except ValueError:
|
|
1092
|
+
result.append(value[i:i+2])
|
|
1093
|
+
elif c in '0123456789':
|
|
1094
|
+
# Octal: \NNN
|
|
1095
|
+
oct_str = ""
|
|
1096
|
+
j = i + 1
|
|
1097
|
+
while j < len(value) and j < i + 4 and value[j] in '01234567':
|
|
1098
|
+
oct_str += value[j]
|
|
1099
|
+
j += 1
|
|
1100
|
+
try:
|
|
1101
|
+
result.append(chr(int(oct_str, 8)))
|
|
1102
|
+
i = j
|
|
1103
|
+
continue
|
|
1104
|
+
except ValueError:
|
|
1105
|
+
result.append(value[i:i+2])
|
|
575
1106
|
else:
|
|
576
1107
|
result.append(value[i:i+2])
|
|
577
1108
|
i += 2
|
|
@@ -580,35 +1111,111 @@ def expand_parameter(ctx: "InterpreterContext", part: ParameterExpansionPart, in
|
|
|
580
1111
|
i += 1
|
|
581
1112
|
return ''.join(result)
|
|
582
1113
|
elif op == "P":
|
|
583
|
-
# Prompt expansion
|
|
584
|
-
# Full implementation would expand \u, \h, \w, etc.
|
|
1114
|
+
# Prompt expansion
|
|
585
1115
|
return value
|
|
586
1116
|
elif op == "A":
|
|
587
1117
|
# Assignment statement form
|
|
588
1118
|
return f"{parameter}={_shell_quote(value)}"
|
|
589
1119
|
elif op == "a":
|
|
590
|
-
# Attributes - check
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
1120
|
+
# Attributes - check VariableStore metadata first
|
|
1121
|
+
from .types import VariableStore
|
|
1122
|
+
env = ctx.state.env
|
|
1123
|
+
attrs = ""
|
|
1124
|
+
if isinstance(env, VariableStore):
|
|
1125
|
+
var_attrs = env.get_attributes(parameter)
|
|
1126
|
+
# Build flags in standard order
|
|
1127
|
+
for flag in "aAilnrtux":
|
|
1128
|
+
if flag in var_attrs:
|
|
1129
|
+
attrs += flag
|
|
1130
|
+
# Check array type if not in metadata
|
|
1131
|
+
if "a" not in attrs and "A" not in attrs:
|
|
1132
|
+
is_array = env.get(f"{parameter}__is_array")
|
|
1133
|
+
if is_array == "indexed":
|
|
1134
|
+
attrs = "a" + attrs
|
|
1135
|
+
elif is_array == "assoc":
|
|
1136
|
+
attrs = "A" + attrs
|
|
1137
|
+
else:
|
|
1138
|
+
if env.get(f"{parameter}__is_array") == "indexed":
|
|
1139
|
+
attrs += "a"
|
|
1140
|
+
elif env.get(f"{parameter}__is_array") == "assoc":
|
|
1141
|
+
attrs += "A"
|
|
1142
|
+
if parameter in getattr(ctx.state, 'readonly_vars', set()):
|
|
1143
|
+
attrs += "r"
|
|
1144
|
+
return attrs
|
|
600
1145
|
elif op == "K":
|
|
601
|
-
# Key-value pairs
|
|
602
|
-
# For indexed arrays, show index=value pairs
|
|
1146
|
+
# Key-value pairs
|
|
603
1147
|
elements = get_array_elements(ctx, parameter)
|
|
604
1148
|
if elements:
|
|
605
1149
|
pairs = [f"[{idx}]=\"{val}\"" for idx, val in elements]
|
|
606
1150
|
return " ".join(pairs)
|
|
607
1151
|
return value
|
|
1152
|
+
elif op == "u":
|
|
1153
|
+
# Uppercase first character
|
|
1154
|
+
return value[0].upper() + value[1:] if value else ""
|
|
1155
|
+
elif op == "U":
|
|
1156
|
+
# Uppercase all
|
|
1157
|
+
return value.upper()
|
|
1158
|
+
elif op == "L":
|
|
1159
|
+
# Lowercase all
|
|
1160
|
+
return value.lower()
|
|
608
1161
|
|
|
609
1162
|
return value
|
|
610
1163
|
|
|
611
1164
|
|
|
1165
|
+
def _apply_pattern_removal(value: str, regex_pattern: str, pattern: str, greedy: bool, from_end: bool) -> str:
|
|
1166
|
+
"""Apply pattern removal to a single string value."""
|
|
1167
|
+
if from_end:
|
|
1168
|
+
if greedy:
|
|
1169
|
+
match = re.search(regex_pattern + "$", value)
|
|
1170
|
+
if match:
|
|
1171
|
+
return value[:match.start()]
|
|
1172
|
+
else:
|
|
1173
|
+
for start in range(len(value) - 1, -1, -1):
|
|
1174
|
+
suffix = value[start:]
|
|
1175
|
+
if re.fullmatch(regex_pattern, suffix):
|
|
1176
|
+
return value[:start]
|
|
1177
|
+
if re.fullmatch(regex_pattern, ""):
|
|
1178
|
+
return value
|
|
1179
|
+
else:
|
|
1180
|
+
if greedy:
|
|
1181
|
+
regex_greedy = glob_to_regex(pattern, greedy=True, from_end=False)
|
|
1182
|
+
match = re.match(regex_greedy, value)
|
|
1183
|
+
if match:
|
|
1184
|
+
return value[match.end():]
|
|
1185
|
+
else:
|
|
1186
|
+
regex_nongreedy = glob_to_regex(pattern, greedy=False, from_end=False)
|
|
1187
|
+
match = re.match(regex_nongreedy, value)
|
|
1188
|
+
if match:
|
|
1189
|
+
return value[match.end():]
|
|
1190
|
+
return value
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def _apply_pattern_replacement(value: str, regex_pattern: str, pattern: str, replacement: str, replace_all: bool, anchor) -> str:
|
|
1194
|
+
"""Apply pattern replacement to a single string value."""
|
|
1195
|
+
if anchor == "start":
|
|
1196
|
+
if pattern == "":
|
|
1197
|
+
return replacement + value
|
|
1198
|
+
anchored = "^" + regex_pattern
|
|
1199
|
+
return re.sub(anchored, replacement, value, count=1)
|
|
1200
|
+
elif anchor == "end":
|
|
1201
|
+
if pattern == "":
|
|
1202
|
+
return value + replacement
|
|
1203
|
+
anchored = regex_pattern + "$"
|
|
1204
|
+
return re.sub(anchored, replacement, value, count=1)
|
|
1205
|
+
elif replace_all:
|
|
1206
|
+
return re.sub(regex_pattern, replacement, value)
|
|
1207
|
+
else:
|
|
1208
|
+
return re.sub(regex_pattern, replacement, value, count=1)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _case_first_matching(value: str, pattern: str, transform) -> str:
|
|
1212
|
+
"""Apply case transform to the first character matching pattern."""
|
|
1213
|
+
for i, c in enumerate(value):
|
|
1214
|
+
if fnmatch.fnmatch(c, pattern):
|
|
1215
|
+
return value[:i] + transform(c) + value[i + 1:]
|
|
1216
|
+
return value
|
|
1217
|
+
|
|
1218
|
+
|
|
612
1219
|
def _shell_quote(s: str) -> str:
|
|
613
1220
|
"""Quote a string for shell use."""
|
|
614
1221
|
if not s:
|
|
@@ -645,12 +1252,254 @@ def expand_brace_range(start: int, end: int, step: int = 1) -> list[str]:
|
|
|
645
1252
|
return results
|
|
646
1253
|
|
|
647
1254
|
|
|
1255
|
+
def expand_braces(s: str) -> list[str]:
|
|
1256
|
+
"""Expand brace patterns in a string.
|
|
1257
|
+
|
|
1258
|
+
Handles:
|
|
1259
|
+
- Comma lists: {a,b,c} -> a b c
|
|
1260
|
+
- Numeric sequences: {1..5} -> 1 2 3 4 5
|
|
1261
|
+
- Alpha sequences: {a..e} -> a b c d e
|
|
1262
|
+
- Step sequences: {1..10..2} -> 1 3 5 7 9
|
|
1263
|
+
- Zero padding: {01..05} -> 01 02 03 04 05
|
|
1264
|
+
- Prefix/suffix: pre{a,b}suf -> preasuf prebsuf
|
|
1265
|
+
- Nested braces: {a,{b,c}} -> a b c
|
|
1266
|
+
"""
|
|
1267
|
+
# Find the first valid brace expansion pattern
|
|
1268
|
+
# Must have { and } with , or .. inside, and not be quoted
|
|
1269
|
+
i = 0
|
|
1270
|
+
while i < len(s):
|
|
1271
|
+
if s[i] == '\\' and i + 1 < len(s):
|
|
1272
|
+
# Skip escaped character
|
|
1273
|
+
i += 2
|
|
1274
|
+
continue
|
|
1275
|
+
if s[i] == '{':
|
|
1276
|
+
# Find matching closing brace
|
|
1277
|
+
depth = 1
|
|
1278
|
+
j = i + 1
|
|
1279
|
+
has_comma = False
|
|
1280
|
+
has_dotdot = False
|
|
1281
|
+
while j < len(s) and depth > 0:
|
|
1282
|
+
if s[j] == '\\' and j + 1 < len(s):
|
|
1283
|
+
j += 2
|
|
1284
|
+
continue
|
|
1285
|
+
if s[j] == '{':
|
|
1286
|
+
depth += 1
|
|
1287
|
+
elif s[j] == '}':
|
|
1288
|
+
depth -= 1
|
|
1289
|
+
elif depth == 1 and s[j] == ',':
|
|
1290
|
+
has_comma = True
|
|
1291
|
+
elif depth == 1 and s[j:j+2] == '..':
|
|
1292
|
+
has_dotdot = True
|
|
1293
|
+
j += 1
|
|
1294
|
+
|
|
1295
|
+
if depth == 0 and (has_comma or has_dotdot):
|
|
1296
|
+
# Found a valid brace expansion
|
|
1297
|
+
prefix = s[:i]
|
|
1298
|
+
suffix = s[j:]
|
|
1299
|
+
brace_content = s[i+1:j-1]
|
|
1300
|
+
|
|
1301
|
+
# Expand this brace pattern
|
|
1302
|
+
expansions = _expand_brace_content(brace_content)
|
|
1303
|
+
|
|
1304
|
+
# Combine with prefix/suffix and recursively expand
|
|
1305
|
+
result = []
|
|
1306
|
+
for exp in expansions:
|
|
1307
|
+
combined = prefix + exp + suffix
|
|
1308
|
+
# Recursively expand any remaining braces
|
|
1309
|
+
result.extend(expand_braces(combined))
|
|
1310
|
+
return result
|
|
1311
|
+
i += 1
|
|
1312
|
+
|
|
1313
|
+
# No brace expansion found
|
|
1314
|
+
return [s]
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _expand_brace_content(content: str) -> list[str]:
|
|
1318
|
+
"""Expand the content inside braces.
|
|
1319
|
+
|
|
1320
|
+
Handles comma-separated lists and sequences.
|
|
1321
|
+
"""
|
|
1322
|
+
# Check for sequence pattern (..): a..z, 1..10, 1..10..2
|
|
1323
|
+
if '..' in content and ',' not in content:
|
|
1324
|
+
return _expand_sequence(content)
|
|
1325
|
+
|
|
1326
|
+
# Handle comma-separated list, respecting nested braces
|
|
1327
|
+
items = []
|
|
1328
|
+
current = []
|
|
1329
|
+
depth = 0
|
|
1330
|
+
i = 0
|
|
1331
|
+
while i < len(content):
|
|
1332
|
+
c = content[i]
|
|
1333
|
+
if c == '\\' and i + 1 < len(content):
|
|
1334
|
+
current.append(c)
|
|
1335
|
+
current.append(content[i + 1])
|
|
1336
|
+
i += 2
|
|
1337
|
+
continue
|
|
1338
|
+
if c == '{':
|
|
1339
|
+
depth += 1
|
|
1340
|
+
current.append(c)
|
|
1341
|
+
elif c == '}':
|
|
1342
|
+
depth -= 1
|
|
1343
|
+
current.append(c)
|
|
1344
|
+
elif c == ',' and depth == 0:
|
|
1345
|
+
items.append(''.join(current))
|
|
1346
|
+
current = []
|
|
1347
|
+
else:
|
|
1348
|
+
current.append(c)
|
|
1349
|
+
i += 1
|
|
1350
|
+
items.append(''.join(current))
|
|
1351
|
+
|
|
1352
|
+
# Recursively expand nested braces in each item
|
|
1353
|
+
result = []
|
|
1354
|
+
for item in items:
|
|
1355
|
+
result.extend(expand_braces(item))
|
|
1356
|
+
return result
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _expand_sequence(content: str) -> list[str]:
|
|
1360
|
+
"""Expand a sequence like 1..10, a..z, or 1..10..2."""
|
|
1361
|
+
parts = content.split('..')
|
|
1362
|
+
if len(parts) < 2 or len(parts) > 3:
|
|
1363
|
+
return ['{' + content + '}'] # Not a valid sequence
|
|
1364
|
+
|
|
1365
|
+
start_str = parts[0]
|
|
1366
|
+
end_str = parts[1]
|
|
1367
|
+
step = 1
|
|
1368
|
+
if len(parts) == 3:
|
|
1369
|
+
try:
|
|
1370
|
+
step = int(parts[2])
|
|
1371
|
+
if step == 0:
|
|
1372
|
+
step = 1
|
|
1373
|
+
except ValueError:
|
|
1374
|
+
return ['{' + content + '}'] # Invalid step
|
|
1375
|
+
|
|
1376
|
+
# Determine padding width
|
|
1377
|
+
pad_width = 0
|
|
1378
|
+
if start_str.startswith('0') and len(start_str) > 1:
|
|
1379
|
+
pad_width = max(pad_width, len(start_str))
|
|
1380
|
+
if end_str.startswith('0') and len(end_str) > 1:
|
|
1381
|
+
pad_width = max(pad_width, len(end_str))
|
|
1382
|
+
|
|
1383
|
+
# Try numeric sequence
|
|
1384
|
+
try:
|
|
1385
|
+
start_num = int(start_str)
|
|
1386
|
+
end_num = int(end_str)
|
|
1387
|
+
results = []
|
|
1388
|
+
if start_num <= end_num:
|
|
1389
|
+
i = start_num
|
|
1390
|
+
while i <= end_num:
|
|
1391
|
+
if pad_width:
|
|
1392
|
+
results.append(str(i).zfill(pad_width))
|
|
1393
|
+
else:
|
|
1394
|
+
results.append(str(i))
|
|
1395
|
+
i += abs(step)
|
|
1396
|
+
else:
|
|
1397
|
+
i = start_num
|
|
1398
|
+
while i >= end_num:
|
|
1399
|
+
if pad_width:
|
|
1400
|
+
results.append(str(i).zfill(pad_width))
|
|
1401
|
+
else:
|
|
1402
|
+
results.append(str(i))
|
|
1403
|
+
i -= abs(step)
|
|
1404
|
+
return results
|
|
1405
|
+
except ValueError:
|
|
1406
|
+
pass
|
|
1407
|
+
|
|
1408
|
+
# Try alpha sequence (single characters)
|
|
1409
|
+
if len(start_str) == 1 and len(end_str) == 1:
|
|
1410
|
+
start_ord = ord(start_str)
|
|
1411
|
+
end_ord = ord(end_str)
|
|
1412
|
+
results = []
|
|
1413
|
+
if start_ord <= end_ord:
|
|
1414
|
+
i = start_ord
|
|
1415
|
+
while i <= end_ord:
|
|
1416
|
+
results.append(chr(i))
|
|
1417
|
+
i += abs(step)
|
|
1418
|
+
else:
|
|
1419
|
+
i = start_ord
|
|
1420
|
+
while i >= end_ord:
|
|
1421
|
+
results.append(chr(i))
|
|
1422
|
+
i -= abs(step)
|
|
1423
|
+
return results
|
|
1424
|
+
|
|
1425
|
+
# Not a valid sequence
|
|
1426
|
+
return ['{' + content + '}']
|
|
1427
|
+
|
|
1428
|
+
|
|
648
1429
|
def glob_to_regex(pattern: str, greedy: bool = True, from_end: bool = False) -> str:
|
|
649
|
-
"""Convert a glob pattern to a regex pattern.
|
|
1430
|
+
"""Convert a glob pattern to a regex pattern.
|
|
1431
|
+
|
|
1432
|
+
Supports standard globs (*, ?, [...]) and extended globs (@, ?, *, +, !)(pat|pat).
|
|
1433
|
+
"""
|
|
1434
|
+
# POSIX character class mappings
|
|
1435
|
+
posix_classes = {
|
|
1436
|
+
"[:alpha:]": "a-zA-Z",
|
|
1437
|
+
"[:digit:]": "0-9",
|
|
1438
|
+
"[:alnum:]": "a-zA-Z0-9",
|
|
1439
|
+
"[:upper:]": "A-Z",
|
|
1440
|
+
"[:lower:]": "a-z",
|
|
1441
|
+
"[:space:]": " \\t\\n\\r\\f\\v",
|
|
1442
|
+
"[:blank:]": " \\t",
|
|
1443
|
+
"[:punct:]": r"!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~",
|
|
1444
|
+
"[:graph:]": "!-~",
|
|
1445
|
+
"[:print:]": " -~",
|
|
1446
|
+
"[:cntrl:]": "\\x00-\\x1f\\x7f",
|
|
1447
|
+
"[:xdigit:]": "0-9a-fA-F",
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
def _convert_extglob_body(body: str) -> str:
|
|
1451
|
+
"""Convert the body of an extglob (between parens), handling nested patterns."""
|
|
1452
|
+
# Split on | but respect nesting
|
|
1453
|
+
parts = []
|
|
1454
|
+
current = []
|
|
1455
|
+
depth = 0
|
|
1456
|
+
for ch in body:
|
|
1457
|
+
if ch == "(" and current and current[-1] in "@?*+!":
|
|
1458
|
+
depth += 1
|
|
1459
|
+
current.append(ch)
|
|
1460
|
+
elif ch == ")" and depth > 0:
|
|
1461
|
+
depth -= 1
|
|
1462
|
+
current.append(ch)
|
|
1463
|
+
elif ch == "|" and depth == 0:
|
|
1464
|
+
parts.append("".join(current))
|
|
1465
|
+
current = []
|
|
1466
|
+
else:
|
|
1467
|
+
current.append(ch)
|
|
1468
|
+
parts.append("".join(current))
|
|
1469
|
+
# Convert each alternative
|
|
1470
|
+
return "|".join(glob_to_regex(p, greedy, from_end) for p in parts)
|
|
1471
|
+
|
|
650
1472
|
result = []
|
|
651
1473
|
i = 0
|
|
652
1474
|
while i < len(pattern):
|
|
653
1475
|
c = pattern[i]
|
|
1476
|
+
# Check for extglob patterns: @( ?( *( +( !(
|
|
1477
|
+
if c in "@?*+!" and i + 1 < len(pattern) and pattern[i + 1] == "(":
|
|
1478
|
+
# Find matching closing paren
|
|
1479
|
+
depth = 1
|
|
1480
|
+
j = i + 2
|
|
1481
|
+
while j < len(pattern) and depth > 0:
|
|
1482
|
+
if pattern[j] == "(":
|
|
1483
|
+
depth += 1
|
|
1484
|
+
elif pattern[j] == ")":
|
|
1485
|
+
depth -= 1
|
|
1486
|
+
j += 1
|
|
1487
|
+
body = pattern[i + 2:j - 1] # Content between parens
|
|
1488
|
+
converted_body = _convert_extglob_body(body)
|
|
1489
|
+
if c == "@":
|
|
1490
|
+
result.append(f"(?:{converted_body})")
|
|
1491
|
+
elif c == "?":
|
|
1492
|
+
result.append(f"(?:{converted_body})?")
|
|
1493
|
+
elif c == "*":
|
|
1494
|
+
result.append(f"(?:{converted_body})*")
|
|
1495
|
+
elif c == "+":
|
|
1496
|
+
result.append(f"(?:{converted_body})+")
|
|
1497
|
+
elif c == "!":
|
|
1498
|
+
# !(pat) - match anything that doesn't match
|
|
1499
|
+
# Use negative lookahead anchored to end
|
|
1500
|
+
result.append(f"(?!(?:{converted_body})$).*")
|
|
1501
|
+
i = j
|
|
1502
|
+
continue
|
|
654
1503
|
if c == "*":
|
|
655
1504
|
if greedy:
|
|
656
1505
|
result.append(".*")
|
|
@@ -667,6 +1516,16 @@ def glob_to_regex(pattern: str, greedy: bool = True, from_end: bool = False) ->
|
|
|
667
1516
|
else:
|
|
668
1517
|
result.append("[")
|
|
669
1518
|
while j < len(pattern) and pattern[j] != "]":
|
|
1519
|
+
# Check for POSIX character classes like [:alpha:]
|
|
1520
|
+
if pattern[j] == "[" and j + 1 < len(pattern) and pattern[j + 1] == ":":
|
|
1521
|
+
# Find the closing :]
|
|
1522
|
+
end = pattern.find(":]", j + 2)
|
|
1523
|
+
if end != -1:
|
|
1524
|
+
posix_name = pattern[j:end + 2]
|
|
1525
|
+
if posix_name in posix_classes:
|
|
1526
|
+
result.append(posix_classes[posix_name])
|
|
1527
|
+
j = end + 2
|
|
1528
|
+
continue
|
|
670
1529
|
result.append(pattern[j])
|
|
671
1530
|
j += 1
|
|
672
1531
|
result.append("]")
|
|
@@ -679,6 +1538,45 @@ def glob_to_regex(pattern: str, greedy: bool = True, from_end: bool = False) ->
|
|
|
679
1538
|
return "".join(result)
|
|
680
1539
|
|
|
681
1540
|
|
|
1541
|
+
def _find_brace_in_literal_parts(parts: list) -> tuple[int, int, int, str] | None:
|
|
1542
|
+
"""Find a valid brace expansion pattern within LiteralPart nodes.
|
|
1543
|
+
|
|
1544
|
+
Returns (part_index, brace_start, brace_end, content) or None.
|
|
1545
|
+
Only finds patterns entirely within a single LiteralPart.
|
|
1546
|
+
"""
|
|
1547
|
+
for idx, part in enumerate(parts):
|
|
1548
|
+
if not isinstance(part, LiteralPart):
|
|
1549
|
+
continue
|
|
1550
|
+
text = part.value
|
|
1551
|
+
i = 0
|
|
1552
|
+
while i < len(text):
|
|
1553
|
+
if text[i] == '\\' and i + 1 < len(text):
|
|
1554
|
+
i += 2
|
|
1555
|
+
continue
|
|
1556
|
+
if text[i] == '{':
|
|
1557
|
+
depth = 1
|
|
1558
|
+
j = i + 1
|
|
1559
|
+
has_comma = False
|
|
1560
|
+
has_dotdot = False
|
|
1561
|
+
while j < len(text) and depth > 0:
|
|
1562
|
+
if text[j] == '\\' and j + 1 < len(text):
|
|
1563
|
+
j += 2
|
|
1564
|
+
continue
|
|
1565
|
+
if text[j] == '{':
|
|
1566
|
+
depth += 1
|
|
1567
|
+
elif text[j] == '}':
|
|
1568
|
+
depth -= 1
|
|
1569
|
+
elif depth == 1 and text[j] == ',':
|
|
1570
|
+
has_comma = True
|
|
1571
|
+
elif depth == 1 and j + 1 < len(text) and text[j:j+2] == '..':
|
|
1572
|
+
has_dotdot = True
|
|
1573
|
+
j += 1
|
|
1574
|
+
if depth == 0 and (has_comma or has_dotdot):
|
|
1575
|
+
return (idx, i, j, text[i+1:j-1])
|
|
1576
|
+
i += 1
|
|
1577
|
+
return None
|
|
1578
|
+
|
|
1579
|
+
|
|
682
1580
|
async def expand_word_with_glob(
|
|
683
1581
|
ctx: "InterpreterContext",
|
|
684
1582
|
word: WordNode,
|
|
@@ -693,7 +1591,33 @@ async def expand_word_with_glob(
|
|
|
693
1591
|
for p in word.parts
|
|
694
1592
|
)
|
|
695
1593
|
|
|
1594
|
+
# Parts-level brace expansion: only expand braces in LiteralPart nodes
|
|
1595
|
+
brace_info = _find_brace_in_literal_parts(word.parts)
|
|
1596
|
+
if brace_info is not None:
|
|
1597
|
+
part_idx, brace_start, brace_end, content = brace_info
|
|
1598
|
+
lit_part = word.parts[part_idx]
|
|
1599
|
+
prefix_text = lit_part.value[:brace_start]
|
|
1600
|
+
suffix_text = lit_part.value[brace_end:]
|
|
1601
|
+
|
|
1602
|
+
before_parts = list(word.parts[:part_idx])
|
|
1603
|
+
after_parts = list(word.parts[part_idx + 1:])
|
|
1604
|
+
if prefix_text:
|
|
1605
|
+
before_parts.append(LiteralPart(value=prefix_text))
|
|
1606
|
+
if suffix_text:
|
|
1607
|
+
after_parts.insert(0, LiteralPart(value=suffix_text))
|
|
1608
|
+
|
|
1609
|
+
expansions = _expand_brace_content(content)
|
|
1610
|
+
|
|
1611
|
+
all_results = []
|
|
1612
|
+
for exp_text in expansions:
|
|
1613
|
+
new_parts = before_parts + [LiteralPart(value=exp_text)] + after_parts
|
|
1614
|
+
new_word = WordNode(parts=tuple(new_parts))
|
|
1615
|
+
sub_result = await expand_word_with_glob(ctx, new_word)
|
|
1616
|
+
all_results.extend(sub_result["values"])
|
|
1617
|
+
return {"values": all_results, "quoted": False}
|
|
1618
|
+
|
|
696
1619
|
# Special handling for "$@" and "$*" in double quotes
|
|
1620
|
+
# (check BEFORE segment expansion to avoid double command substitution)
|
|
697
1621
|
# "$@" expands to multiple words (one per positional parameter)
|
|
698
1622
|
# "$*" expands to single word (params joined by IFS)
|
|
699
1623
|
if len(word.parts) == 1 and isinstance(word.parts[0], DoubleQuotedPart):
|
|
@@ -713,22 +1637,87 @@ async def expand_word_with_glob(
|
|
|
713
1637
|
sep = ifs[0] if ifs else ""
|
|
714
1638
|
return {"values": [sep.join(params)] if params else [""], "quoted": True}
|
|
715
1639
|
|
|
1640
|
+
# "${arr[@]}" - return each array element as separate word
|
|
1641
|
+
array_at_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[@\]$', param_part.parameter)
|
|
1642
|
+
if array_at_match and param_part.operation is None:
|
|
1643
|
+
arr_name = array_at_match.group(1)
|
|
1644
|
+
elements = get_array_elements(ctx, arr_name)
|
|
1645
|
+
if not elements:
|
|
1646
|
+
return {"values": [], "quoted": True}
|
|
1647
|
+
return {"values": [val for _, val in elements], "quoted": True}
|
|
1648
|
+
|
|
1649
|
+
# "${arr[*]}" - join with first char of IFS
|
|
1650
|
+
array_star_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[\*\]$', param_part.parameter)
|
|
1651
|
+
if array_star_match and param_part.operation is None:
|
|
1652
|
+
arr_name = array_star_match.group(1)
|
|
1653
|
+
elements = get_array_elements(ctx, arr_name)
|
|
1654
|
+
ifs = ctx.state.env.get("IFS", " \t\n")
|
|
1655
|
+
sep = ifs[0] if ifs else ""
|
|
1656
|
+
return {"values": [sep.join(val for _, val in elements)] if elements else [""], "quoted": True}
|
|
1657
|
+
|
|
1658
|
+
# "${!arr[@]}" / "${!arr[*]}" - return array keys as separate words or joined
|
|
1659
|
+
if param_part.parameter.startswith("!") and param_part.operation is None:
|
|
1660
|
+
indirect = param_part.parameter[1:]
|
|
1661
|
+
array_keys_at = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[@\]$', indirect)
|
|
1662
|
+
if array_keys_at:
|
|
1663
|
+
arr_name = array_keys_at.group(1)
|
|
1664
|
+
keys = get_array_keys(ctx, arr_name)
|
|
1665
|
+
if not keys:
|
|
1666
|
+
return {"values": [], "quoted": True}
|
|
1667
|
+
return {"values": keys, "quoted": True}
|
|
1668
|
+
array_keys_star = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[\*\]$', indirect)
|
|
1669
|
+
if array_keys_star:
|
|
1670
|
+
arr_name = array_keys_star.group(1)
|
|
1671
|
+
keys = get_array_keys(ctx, arr_name)
|
|
1672
|
+
ifs = ctx.state.env.get("IFS", " \t\n")
|
|
1673
|
+
sep = ifs[0] if ifs else ""
|
|
1674
|
+
return {"values": [sep.join(keys)] if keys else [""], "quoted": True}
|
|
1675
|
+
|
|
716
1676
|
# Handle more complex cases with "$@" embedded in other content
|
|
717
1677
|
# e.g., "prefix$@suffix" -> ["prefix$1", "$2", ..., "$nsuffix"]
|
|
718
1678
|
values = await _expand_word_with_at(ctx, word)
|
|
719
1679
|
if values is not None:
|
|
720
1680
|
return {"values": values, "quoted": True}
|
|
721
1681
|
|
|
722
|
-
# Expand
|
|
723
|
-
|
|
1682
|
+
# Expand word to segments (primary expansion, handles command substitution etc.)
|
|
1683
|
+
segments = await expand_word_segments(ctx, word)
|
|
1684
|
+
value = _segments_to_string(segments)
|
|
1685
|
+
# A word is "all quoted" only if every segment is quoted AND there's at least one segment
|
|
1686
|
+
all_quoted = bool(segments) and all(seg.quoted for seg in segments)
|
|
724
1687
|
|
|
725
|
-
#
|
|
1688
|
+
# String-level brace expansion fallback for unquoted words where braces
|
|
1689
|
+
# span across multiple parts (e.g., {$x,other} where $x is a ParameterExpansionPart)
|
|
1690
|
+
# Use has_quoted (AST-level check) to avoid expanding braces that came from escaped/quoted parts
|
|
726
1691
|
if not has_quoted:
|
|
727
|
-
|
|
728
|
-
|
|
1692
|
+
if '{' in value and '}' in value:
|
|
1693
|
+
brace_expanded = expand_braces(value)
|
|
1694
|
+
if len(brace_expanded) > 1 or (len(brace_expanded) == 1 and brace_expanded[0] != value):
|
|
1695
|
+
all_results = []
|
|
1696
|
+
for exp_text in brace_expanded:
|
|
1697
|
+
if any(c in exp_text for c in "*?["):
|
|
1698
|
+
matches = await glob_expand(ctx, exp_text)
|
|
1699
|
+
if matches:
|
|
1700
|
+
all_results.extend(matches)
|
|
1701
|
+
continue
|
|
1702
|
+
if exp_text:
|
|
1703
|
+
all_results.append(exp_text)
|
|
1704
|
+
return {"values": all_results, "quoted": False}
|
|
1705
|
+
|
|
1706
|
+
# For words with unquoted parts, perform glob expansion and IFS word splitting
|
|
1707
|
+
if not all_quoted:
|
|
1708
|
+
# Check for glob patterns in unquoted segments
|
|
1709
|
+
if _segments_has_unquoted_glob(segments):
|
|
729
1710
|
matches = await glob_expand(ctx, value)
|
|
730
1711
|
if matches:
|
|
731
1712
|
return {"values": matches, "quoted": False}
|
|
1713
|
+
# No matches - check nullglob/failglob
|
|
1714
|
+
env = ctx.state.env
|
|
1715
|
+
if env.get("__shopt_nullglob__") == "1":
|
|
1716
|
+
return {"values": [], "quoted": False}
|
|
1717
|
+
if env.get("__shopt_failglob__") == "1":
|
|
1718
|
+
ctx.state.expansion_stderr = f"bash: no match: {value}\n"
|
|
1719
|
+
ctx.state.expansion_exit_code = 1
|
|
1720
|
+
return {"values": [], "quoted": False}
|
|
732
1721
|
|
|
733
1722
|
# Perform IFS word splitting
|
|
734
1723
|
if value == "":
|
|
@@ -742,11 +1731,11 @@ async def expand_word_with_glob(
|
|
|
742
1731
|
if has_expansion:
|
|
743
1732
|
ifs = ctx.state.env.get("IFS", " \t\n")
|
|
744
1733
|
if ifs:
|
|
745
|
-
# Split on IFS characters
|
|
746
|
-
words =
|
|
1734
|
+
# Split on IFS characters using segment-aware splitting
|
|
1735
|
+
words = _split_segments_on_ifs(segments, ifs)
|
|
747
1736
|
return {"values": words, "quoted": False}
|
|
748
1737
|
|
|
749
|
-
return {"values": [value], "quoted":
|
|
1738
|
+
return {"values": [value], "quoted": all_quoted}
|
|
750
1739
|
|
|
751
1740
|
|
|
752
1741
|
def _split_on_ifs(value: str, ifs: str) -> list[str]:
|
|
@@ -797,6 +1786,94 @@ def _split_on_ifs(value: str, ifs: str) -> list[str]:
|
|
|
797
1786
|
return result
|
|
798
1787
|
|
|
799
1788
|
|
|
1789
|
+
def _get_word_raw_text(word: WordNode) -> str:
|
|
1790
|
+
"""Get the raw text of a word (for brace expansion detection).
|
|
1791
|
+
|
|
1792
|
+
Returns the literal text from all unquoted literal parts.
|
|
1793
|
+
"""
|
|
1794
|
+
result = []
|
|
1795
|
+
for part in word.parts:
|
|
1796
|
+
if isinstance(part, LiteralPart):
|
|
1797
|
+
result.append(part.value)
|
|
1798
|
+
elif isinstance(part, SingleQuotedPart):
|
|
1799
|
+
# Quoted content doesn't participate in brace expansion
|
|
1800
|
+
result.append("'" + part.value + "'")
|
|
1801
|
+
elif isinstance(part, DoubleQuotedPart):
|
|
1802
|
+
# Quoted content doesn't participate in brace expansion
|
|
1803
|
+
result.append('"' + _get_parts_raw_text(part.parts) + '"')
|
|
1804
|
+
elif isinstance(part, EscapedPart):
|
|
1805
|
+
result.append('\\' + part.value)
|
|
1806
|
+
elif isinstance(part, ParameterExpansionPart):
|
|
1807
|
+
# Keep parameter expansion as-is for later expansion
|
|
1808
|
+
if part.operation:
|
|
1809
|
+
result.append("${" + part.parameter + "}")
|
|
1810
|
+
else:
|
|
1811
|
+
result.append("$" + part.parameter)
|
|
1812
|
+
elif isinstance(part, CommandSubstitutionPart):
|
|
1813
|
+
result.append("$()") # Placeholder
|
|
1814
|
+
elif isinstance(part, ArithmeticExpansionPart):
|
|
1815
|
+
result.append("$(())") # Placeholder
|
|
1816
|
+
else:
|
|
1817
|
+
# For other parts, use empty string
|
|
1818
|
+
pass
|
|
1819
|
+
return "".join(result)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def _get_parts_raw_text(parts: tuple) -> str:
|
|
1823
|
+
"""Get raw text from a tuple of parts."""
|
|
1824
|
+
result = []
|
|
1825
|
+
for part in parts:
|
|
1826
|
+
if isinstance(part, LiteralPart):
|
|
1827
|
+
result.append(part.value)
|
|
1828
|
+
elif isinstance(part, ParameterExpansionPart):
|
|
1829
|
+
if part.operation:
|
|
1830
|
+
result.append("${" + part.parameter + "}")
|
|
1831
|
+
else:
|
|
1832
|
+
result.append("$" + part.parameter)
|
|
1833
|
+
return "".join(result)
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
async def _expand_word_without_braces(
|
|
1837
|
+
ctx: "InterpreterContext",
|
|
1838
|
+
word: WordNode,
|
|
1839
|
+
) -> dict:
|
|
1840
|
+
"""Expand a word without brace expansion (to avoid infinite recursion)."""
|
|
1841
|
+
# Check if word contains any quoted parts
|
|
1842
|
+
has_quoted = any(
|
|
1843
|
+
isinstance(p, (SingleQuotedPart, DoubleQuotedPart, EscapedPart))
|
|
1844
|
+
for p in word.parts
|
|
1845
|
+
)
|
|
1846
|
+
|
|
1847
|
+
# Expand the word
|
|
1848
|
+
value = await expand_word_async(ctx, word)
|
|
1849
|
+
|
|
1850
|
+
# For unquoted words, perform IFS word splitting and glob expansion
|
|
1851
|
+
if not has_quoted:
|
|
1852
|
+
# Check for glob patterns first (including extglob)
|
|
1853
|
+
if any(c in value for c in "*?[") or re.search(r'[@?*+!]\(', value):
|
|
1854
|
+
matches = await glob_expand(ctx, value)
|
|
1855
|
+
if matches:
|
|
1856
|
+
return {"values": matches, "quoted": False}
|
|
1857
|
+
|
|
1858
|
+
# Perform IFS word splitting
|
|
1859
|
+
if value == "":
|
|
1860
|
+
return {"values": [], "quoted": False}
|
|
1861
|
+
|
|
1862
|
+
# Check if the word contained parameter/command expansion that should be split
|
|
1863
|
+
has_expansion = any(
|
|
1864
|
+
isinstance(p, (ParameterExpansionPart, CommandSubstitutionPart, ArithmeticExpansionPart))
|
|
1865
|
+
for p in word.parts
|
|
1866
|
+
)
|
|
1867
|
+
if has_expansion:
|
|
1868
|
+
ifs = ctx.state.env.get("IFS", " \t\n")
|
|
1869
|
+
if ifs:
|
|
1870
|
+
# Split on IFS characters
|
|
1871
|
+
words = _split_on_ifs(value, ifs)
|
|
1872
|
+
return {"values": words, "quoted": False}
|
|
1873
|
+
|
|
1874
|
+
return {"values": [value], "quoted": has_quoted}
|
|
1875
|
+
|
|
1876
|
+
|
|
800
1877
|
def _get_positional_params(ctx: "InterpreterContext") -> list[str]:
|
|
801
1878
|
"""Get all positional parameters ($1, $2, ...) as a list."""
|
|
802
1879
|
params = []
|
|
@@ -886,8 +1963,14 @@ async def glob_expand(ctx: "InterpreterContext", pattern: str) -> list[str]:
|
|
|
886
1963
|
|
|
887
1964
|
cwd = ctx.state.cwd
|
|
888
1965
|
fs = ctx.fs
|
|
1966
|
+
env = ctx.state.env
|
|
1967
|
+
|
|
1968
|
+
# Check shopt options
|
|
1969
|
+
dotglob = env.get("__shopt_dotglob__") == "1"
|
|
1970
|
+
globstar = env.get("__shopt_globstar__") == "1"
|
|
889
1971
|
|
|
890
1972
|
# Handle absolute vs relative paths
|
|
1973
|
+
original_pattern = pattern
|
|
891
1974
|
if pattern.startswith("/"):
|
|
892
1975
|
base_dir = "/"
|
|
893
1976
|
pattern = pattern[1:]
|
|
@@ -897,6 +1980,29 @@ async def glob_expand(ctx: "InterpreterContext", pattern: str) -> list[str]:
|
|
|
897
1980
|
# Split pattern into parts
|
|
898
1981
|
parts = pattern.split("/")
|
|
899
1982
|
|
|
1983
|
+
def _should_include(entry: str, pattern_part: str) -> bool:
|
|
1984
|
+
"""Check if an entry should be included (dotfile filtering)."""
|
|
1985
|
+
if entry.startswith("."):
|
|
1986
|
+
# Dotfiles only match if: dotglob is on, or pattern starts with '.'
|
|
1987
|
+
if not dotglob and not pattern_part.startswith("."):
|
|
1988
|
+
return False
|
|
1989
|
+
return True
|
|
1990
|
+
|
|
1991
|
+
async def _recurse_dirs(current_dir: str) -> list[str]:
|
|
1992
|
+
"""Recursively list all directories for globstar."""
|
|
1993
|
+
dirs = [current_dir]
|
|
1994
|
+
try:
|
|
1995
|
+
entries = await fs.readdir(current_dir)
|
|
1996
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
1997
|
+
return dirs
|
|
1998
|
+
for entry in entries:
|
|
1999
|
+
if entry.startswith(".") and not dotglob:
|
|
2000
|
+
continue
|
|
2001
|
+
path = os.path.join(current_dir, entry)
|
|
2002
|
+
if await fs.is_directory(path):
|
|
2003
|
+
dirs.extend(await _recurse_dirs(path))
|
|
2004
|
+
return dirs
|
|
2005
|
+
|
|
900
2006
|
async def expand_parts(current_dir: str, remaining_parts: list[str]) -> list[str]:
|
|
901
2007
|
if not remaining_parts:
|
|
902
2008
|
return [current_dir]
|
|
@@ -904,8 +2010,29 @@ async def glob_expand(ctx: "InterpreterContext", pattern: str) -> list[str]:
|
|
|
904
2010
|
part = remaining_parts[0]
|
|
905
2011
|
rest = remaining_parts[1:]
|
|
906
2012
|
|
|
907
|
-
#
|
|
908
|
-
if
|
|
2013
|
+
# Handle globstar (**)
|
|
2014
|
+
if part == "**" and globstar:
|
|
2015
|
+
all_dirs = await _recurse_dirs(current_dir)
|
|
2016
|
+
results = []
|
|
2017
|
+
for d in all_dirs:
|
|
2018
|
+
if rest:
|
|
2019
|
+
results.extend(await expand_parts(d, rest))
|
|
2020
|
+
else:
|
|
2021
|
+
# ** alone matches everything recursively
|
|
2022
|
+
try:
|
|
2023
|
+
entries = await fs.readdir(d)
|
|
2024
|
+
for entry in entries:
|
|
2025
|
+
if _should_include(entry, "*"):
|
|
2026
|
+
results.append(os.path.join(d, entry))
|
|
2027
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
2028
|
+
pass
|
|
2029
|
+
results.append(d)
|
|
2030
|
+
return sorted(set(results))
|
|
2031
|
+
|
|
2032
|
+
# Check if this part has glob characters (including extglob)
|
|
2033
|
+
has_glob = any(c in part for c in "*?[")
|
|
2034
|
+
has_extglob = bool(re.search(r'[@?*+!]\(', part))
|
|
2035
|
+
if not has_glob and not has_extglob:
|
|
909
2036
|
# No glob - just check if path exists
|
|
910
2037
|
new_path = os.path.join(current_dir, part)
|
|
911
2038
|
if await fs.exists(new_path):
|
|
@@ -918,9 +2045,25 @@ async def glob_expand(ctx: "InterpreterContext", pattern: str) -> list[str]:
|
|
|
918
2045
|
except (FileNotFoundError, NotADirectoryError):
|
|
919
2046
|
return []
|
|
920
2047
|
|
|
2048
|
+
# Use regex matching for extglob patterns, fnmatch for standard globs
|
|
2049
|
+
if has_extglob:
|
|
2050
|
+
regex_pat = "^" + glob_to_regex(part) + "$"
|
|
2051
|
+
try:
|
|
2052
|
+
compiled = re.compile(regex_pat)
|
|
2053
|
+
except re.error:
|
|
2054
|
+
compiled = None
|
|
2055
|
+
else:
|
|
2056
|
+
compiled = None
|
|
2057
|
+
|
|
921
2058
|
matches = []
|
|
922
2059
|
for entry in entries:
|
|
923
|
-
if
|
|
2060
|
+
if not _should_include(entry, part):
|
|
2061
|
+
continue
|
|
2062
|
+
if compiled:
|
|
2063
|
+
matched = compiled.match(entry) is not None
|
|
2064
|
+
else:
|
|
2065
|
+
matched = fnmatch.fnmatch(entry, part)
|
|
2066
|
+
if matched:
|
|
924
2067
|
new_path = os.path.join(current_dir, entry)
|
|
925
2068
|
if rest:
|
|
926
2069
|
# More parts to match - entry must be a directory
|
|
@@ -934,7 +2077,7 @@ async def glob_expand(ctx: "InterpreterContext", pattern: str) -> list[str]:
|
|
|
934
2077
|
results = await expand_parts(base_dir, parts)
|
|
935
2078
|
|
|
936
2079
|
# Return relative paths if pattern was relative
|
|
937
|
-
if not
|
|
2080
|
+
if not original_pattern.startswith("/") and results:
|
|
938
2081
|
results = [os.path.relpath(r, cwd) if r.startswith(cwd) else r for r in results]
|
|
939
2082
|
|
|
940
2083
|
return results
|
|
@@ -976,6 +2119,46 @@ def _parse_base_n_value(value_str: str, base: int) -> int:
|
|
|
976
2119
|
return result
|
|
977
2120
|
|
|
978
2121
|
|
|
2122
|
+
def _parse_arith_value(val: str) -> int:
|
|
2123
|
+
"""Parse a string value as an arithmetic integer.
|
|
2124
|
+
|
|
2125
|
+
Handles octal (0NNN), hex (0xNNN), and base-N (N#NNN) constants
|
|
2126
|
+
like bash does when evaluating variable values in arithmetic context.
|
|
2127
|
+
"""
|
|
2128
|
+
if not val:
|
|
2129
|
+
return 0
|
|
2130
|
+
val = val.strip()
|
|
2131
|
+
if not val:
|
|
2132
|
+
return 0
|
|
2133
|
+
# Hex
|
|
2134
|
+
if val.startswith("0x") or val.startswith("0X"):
|
|
2135
|
+
try:
|
|
2136
|
+
return int(val, 16)
|
|
2137
|
+
except ValueError:
|
|
2138
|
+
return 0
|
|
2139
|
+
# Base-N: N#value
|
|
2140
|
+
if "#" in val:
|
|
2141
|
+
parts = val.split("#", 1)
|
|
2142
|
+
try:
|
|
2143
|
+
base = int(parts[0])
|
|
2144
|
+
if 2 <= base <= 64:
|
|
2145
|
+
return _parse_base_n_value(parts[1], base)
|
|
2146
|
+
except (ValueError, TypeError):
|
|
2147
|
+
pass
|
|
2148
|
+
return 0
|
|
2149
|
+
# Octal (starts with 0 and has more digits)
|
|
2150
|
+
if val.startswith("0") and len(val) > 1 and val[1:].isdigit():
|
|
2151
|
+
try:
|
|
2152
|
+
return int(val, 8)
|
|
2153
|
+
except ValueError:
|
|
2154
|
+
return 0
|
|
2155
|
+
# Regular integer
|
|
2156
|
+
try:
|
|
2157
|
+
return int(val)
|
|
2158
|
+
except ValueError:
|
|
2159
|
+
return 0
|
|
2160
|
+
|
|
2161
|
+
|
|
979
2162
|
def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
980
2163
|
"""Evaluate an arithmetic expression synchronously."""
|
|
981
2164
|
# Simple implementation for basic arithmetic
|
|
@@ -985,7 +2168,7 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
985
2168
|
elif expr.type == "ArithVariable":
|
|
986
2169
|
name = expr.name
|
|
987
2170
|
# Handle dynamic base constants like $base#value or base#value where base is a variable
|
|
988
|
-
if "#" in name:
|
|
2171
|
+
if "#" in name and not name.startswith("$"):
|
|
989
2172
|
hash_pos = name.index("#")
|
|
990
2173
|
base_part = name[:hash_pos]
|
|
991
2174
|
value_part = name[hash_pos + 1:]
|
|
@@ -1007,15 +2190,42 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1007
2190
|
return _parse_base_n_value(value_part, base)
|
|
1008
2191
|
except (ValueError, TypeError):
|
|
1009
2192
|
pass
|
|
2193
|
+
# Handle ${...} parameter expansion that parser fell back to ArithVariable
|
|
2194
|
+
if name.startswith("${") and name.endswith("}"):
|
|
2195
|
+
inner = name[2:-1]
|
|
2196
|
+
val = _expand_braced_param_sync(ctx, inner)
|
|
2197
|
+
return _parse_arith_value(val)
|
|
2198
|
+
# Handle $var simple variable reference
|
|
2199
|
+
if name.startswith("$") and not name.startswith("$("):
|
|
2200
|
+
var_name = name[1:]
|
|
2201
|
+
if var_name.startswith("{") and var_name.endswith("}"):
|
|
2202
|
+
var_name = var_name[1:-1]
|
|
2203
|
+
val = get_variable(ctx, var_name, False)
|
|
2204
|
+
return _parse_arith_value(val)
|
|
1010
2205
|
val = get_variable(ctx, name, False)
|
|
1011
|
-
|
|
1012
|
-
return int(val) if val else 0
|
|
1013
|
-
except ValueError:
|
|
1014
|
-
return 0
|
|
2206
|
+
return _parse_arith_value(val)
|
|
1015
2207
|
elif expr.type == "ArithBinary":
|
|
1016
|
-
left = evaluate_arithmetic_sync(ctx, expr.left)
|
|
1017
|
-
right = evaluate_arithmetic_sync(ctx, expr.right)
|
|
1018
2208
|
op = expr.operator
|
|
2209
|
+
# Short-circuit for && and ||
|
|
2210
|
+
if op == "&&":
|
|
2211
|
+
left = evaluate_arithmetic_sync(ctx, expr.left)
|
|
2212
|
+
if not left:
|
|
2213
|
+
return 0
|
|
2214
|
+
right = evaluate_arithmetic_sync(ctx, expr.right)
|
|
2215
|
+
return 1 if right else 0
|
|
2216
|
+
elif op == "||":
|
|
2217
|
+
left = evaluate_arithmetic_sync(ctx, expr.left)
|
|
2218
|
+
if left:
|
|
2219
|
+
return 1
|
|
2220
|
+
right = evaluate_arithmetic_sync(ctx, expr.right)
|
|
2221
|
+
return 1 if right else 0
|
|
2222
|
+
elif op == ",":
|
|
2223
|
+
# Comma operator: evaluate both, return right
|
|
2224
|
+
evaluate_arithmetic_sync(ctx, expr.left)
|
|
2225
|
+
return evaluate_arithmetic_sync(ctx, expr.right)
|
|
2226
|
+
else:
|
|
2227
|
+
left = evaluate_arithmetic_sync(ctx, expr.left)
|
|
2228
|
+
right = evaluate_arithmetic_sync(ctx, expr.right)
|
|
1019
2229
|
if op == "+":
|
|
1020
2230
|
return left + right
|
|
1021
2231
|
elif op == "-":
|
|
@@ -1023,10 +2233,18 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1023
2233
|
elif op == "*":
|
|
1024
2234
|
return left * right
|
|
1025
2235
|
elif op == "/":
|
|
1026
|
-
|
|
2236
|
+
if right == 0:
|
|
2237
|
+
raise ValueError("division by 0")
|
|
2238
|
+
# C-style truncation toward zero (not Python floor division)
|
|
2239
|
+
return int(left / right)
|
|
1027
2240
|
elif op == "%":
|
|
1028
|
-
|
|
2241
|
+
if right == 0:
|
|
2242
|
+
raise ValueError("division by 0")
|
|
2243
|
+
# C-style modulo: sign follows dividend
|
|
2244
|
+
return int(left - int(left / right) * right)
|
|
1029
2245
|
elif op == "**":
|
|
2246
|
+
if right < 0:
|
|
2247
|
+
raise ValueError("exponent less than 0")
|
|
1030
2248
|
return left ** right
|
|
1031
2249
|
elif op == "<":
|
|
1032
2250
|
return 1 if left < right else 0
|
|
@@ -1040,10 +2258,6 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1040
2258
|
return 1 if left == right else 0
|
|
1041
2259
|
elif op == "!=":
|
|
1042
2260
|
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
2261
|
elif op == "&":
|
|
1048
2262
|
return left & right
|
|
1049
2263
|
elif op == "|":
|
|
@@ -1054,9 +2268,6 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1054
2268
|
return left << right
|
|
1055
2269
|
elif op == ">>":
|
|
1056
2270
|
return left >> right
|
|
1057
|
-
elif op == ",":
|
|
1058
|
-
# Comma operator: evaluate both, return right
|
|
1059
|
-
return right
|
|
1060
2271
|
elif expr.type == "ArithUnary":
|
|
1061
2272
|
op = expr.operator
|
|
1062
2273
|
# Handle increment/decrement specially (need variable name)
|
|
@@ -1105,15 +2316,32 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1105
2316
|
# Handle compound assignments: = += -= *= /= %= <<= >>= &= |= ^=
|
|
1106
2317
|
op = getattr(expr, 'operator', '=')
|
|
1107
2318
|
var_name = getattr(expr, 'variable', None) or getattr(expr, 'name', None)
|
|
2319
|
+
subscript = getattr(expr, 'subscript', None)
|
|
2320
|
+
string_key = getattr(expr, 'string_key', None)
|
|
1108
2321
|
rhs = evaluate_arithmetic_sync(ctx, expr.value)
|
|
1109
2322
|
|
|
2323
|
+
# Determine the storage key (for arrays, use arr_idx format)
|
|
2324
|
+
store_key = var_name
|
|
2325
|
+
if subscript is not None and var_name:
|
|
2326
|
+
idx = evaluate_arithmetic_sync(ctx, subscript)
|
|
2327
|
+
store_key = f"{var_name}_{idx}"
|
|
2328
|
+
# Mark as array if not already
|
|
2329
|
+
if f"{var_name}__is_array" not in ctx.state.env:
|
|
2330
|
+
ctx.state.env[f"{var_name}__is_array"] = "indexed"
|
|
2331
|
+
elif string_key is not None and var_name:
|
|
2332
|
+
store_key = f"{var_name}_{string_key}"
|
|
2333
|
+
if f"{var_name}__is_array" not in ctx.state.env:
|
|
2334
|
+
ctx.state.env[f"{var_name}__is_array"] = "assoc"
|
|
2335
|
+
|
|
1110
2336
|
if op == '=':
|
|
1111
2337
|
value = rhs
|
|
1112
2338
|
else:
|
|
1113
2339
|
# Get current value for compound operators
|
|
1114
2340
|
current = 0
|
|
1115
|
-
if
|
|
1116
|
-
val =
|
|
2341
|
+
if store_key:
|
|
2342
|
+
val = ctx.state.env.get(store_key, "")
|
|
2343
|
+
if not val and var_name and subscript is None and string_key is None:
|
|
2344
|
+
val = get_variable(ctx, var_name, False)
|
|
1117
2345
|
try:
|
|
1118
2346
|
current = int(val) if val else 0
|
|
1119
2347
|
except ValueError:
|
|
@@ -1126,9 +2354,13 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1126
2354
|
elif op == '*=':
|
|
1127
2355
|
value = current * rhs
|
|
1128
2356
|
elif op == '/=':
|
|
1129
|
-
|
|
2357
|
+
if rhs == 0:
|
|
2358
|
+
raise ValueError("division by 0")
|
|
2359
|
+
value = int(current / rhs)
|
|
1130
2360
|
elif op == '%=':
|
|
1131
|
-
|
|
2361
|
+
if rhs == 0:
|
|
2362
|
+
raise ValueError("division by 0")
|
|
2363
|
+
value = int(current - int(current / rhs) * rhs)
|
|
1132
2364
|
elif op == '<<=':
|
|
1133
2365
|
value = current << rhs
|
|
1134
2366
|
elif op == '>>=':
|
|
@@ -1142,15 +2374,326 @@ def evaluate_arithmetic_sync(ctx: "InterpreterContext", expr) -> int:
|
|
|
1142
2374
|
else:
|
|
1143
2375
|
value = rhs
|
|
1144
2376
|
|
|
1145
|
-
if
|
|
1146
|
-
ctx.state.env[
|
|
2377
|
+
if store_key:
|
|
2378
|
+
ctx.state.env[store_key] = str(value)
|
|
1147
2379
|
return value
|
|
1148
2380
|
elif expr.type == "ArithGroup":
|
|
1149
2381
|
return evaluate_arithmetic_sync(ctx, expr.expression)
|
|
2382
|
+
elif expr.type == "ArithNested":
|
|
2383
|
+
# Nested arithmetic expansion: $((expr)) within arithmetic
|
|
2384
|
+
if expr.expression:
|
|
2385
|
+
return evaluate_arithmetic_sync(ctx, expr.expression)
|
|
2386
|
+
return 0
|
|
2387
|
+
elif expr.type == "ArithArrayElement":
|
|
2388
|
+
# Array element access: arr[idx]
|
|
2389
|
+
arr_name = expr.array
|
|
2390
|
+
if expr.string_key is not None:
|
|
2391
|
+
# Associative array
|
|
2392
|
+
val = ctx.state.env.get(f"{arr_name}_{expr.string_key}", "")
|
|
2393
|
+
elif expr.index is not None:
|
|
2394
|
+
idx = evaluate_arithmetic_sync(ctx, expr.index)
|
|
2395
|
+
val = ctx.state.env.get(f"{arr_name}_{idx}", "")
|
|
2396
|
+
else:
|
|
2397
|
+
val = ""
|
|
2398
|
+
return _parse_arith_value(val)
|
|
2399
|
+
elif expr.type == "ArithConcat":
|
|
2400
|
+
# Concatenation of parts forming a single numeric value
|
|
2401
|
+
result_str = ""
|
|
2402
|
+
for part in expr.parts:
|
|
2403
|
+
result_str += str(evaluate_arithmetic_sync(ctx, part))
|
|
2404
|
+
return _parse_arith_value(result_str)
|
|
2405
|
+
elif expr.type == "ArithDynamicBase":
|
|
2406
|
+
# Dynamic base constant: ${base}#value
|
|
2407
|
+
base_str = get_variable(ctx, expr.base_expr, False)
|
|
2408
|
+
try:
|
|
2409
|
+
base = int(base_str)
|
|
2410
|
+
if 2 <= base <= 64:
|
|
2411
|
+
return _parse_base_n_value(expr.value, base)
|
|
2412
|
+
except (ValueError, TypeError):
|
|
2413
|
+
pass
|
|
2414
|
+
return 0
|
|
2415
|
+
elif expr.type == "ArithDynamicNumber":
|
|
2416
|
+
# Dynamic number prefix: ${zero}11 or ${zero}xAB
|
|
2417
|
+
prefix = get_variable(ctx, expr.prefix, False)
|
|
2418
|
+
full = prefix + expr.suffix
|
|
2419
|
+
return _parse_arith_value(full)
|
|
2420
|
+
elif expr.type in ("ArithBracedExpansion", "ArithCommandSubst"):
|
|
2421
|
+
# These need async handling - in sync mode, try basic resolution
|
|
2422
|
+
if expr.type == "ArithBracedExpansion":
|
|
2423
|
+
content = expr.content
|
|
2424
|
+
val = _expand_braced_param_sync(ctx, content)
|
|
2425
|
+
return _parse_arith_value(val)
|
|
2426
|
+
# Command substitution can't be done synchronously
|
|
2427
|
+
return 0
|
|
2428
|
+
elif expr.type in ("ArithDoubleSubscript", "ArithNumberSubscript"):
|
|
2429
|
+
# Invalid syntax
|
|
2430
|
+
return 0
|
|
1150
2431
|
return 0
|
|
1151
2432
|
|
|
1152
2433
|
|
|
1153
2434
|
async def evaluate_arithmetic(ctx: "InterpreterContext", expr) -> int:
|
|
1154
|
-
"""Evaluate an arithmetic expression asynchronously.
|
|
1155
|
-
|
|
1156
|
-
|
|
2435
|
+
"""Evaluate an arithmetic expression asynchronously.
|
|
2436
|
+
|
|
2437
|
+
Handles command substitution and parameter expansion within arithmetic.
|
|
2438
|
+
"""
|
|
2439
|
+
if not expr or not hasattr(expr, 'type'):
|
|
2440
|
+
return 0
|
|
2441
|
+
|
|
2442
|
+
if expr.type == "ArithNumber":
|
|
2443
|
+
return expr.value
|
|
2444
|
+
elif expr.type == "ArithVariable":
|
|
2445
|
+
name = expr.name
|
|
2446
|
+
# Handle dynamic base constants
|
|
2447
|
+
if "#" in name and not name.startswith("$"):
|
|
2448
|
+
hash_pos = name.index("#")
|
|
2449
|
+
base_part = name[:hash_pos]
|
|
2450
|
+
value_part = name[hash_pos + 1:]
|
|
2451
|
+
if base_part.startswith("$"):
|
|
2452
|
+
base_var = base_part[1:]
|
|
2453
|
+
if base_var.startswith("{") and base_var.endswith("}"):
|
|
2454
|
+
base_var = base_var[1:-1]
|
|
2455
|
+
base_str = get_variable(ctx, base_var, False)
|
|
2456
|
+
else:
|
|
2457
|
+
base_str = get_variable(ctx, base_part, False)
|
|
2458
|
+
if not base_str:
|
|
2459
|
+
base_str = base_part
|
|
2460
|
+
try:
|
|
2461
|
+
base = int(base_str)
|
|
2462
|
+
if 2 <= base <= 64:
|
|
2463
|
+
return _parse_base_n_value(value_part, base)
|
|
2464
|
+
except (ValueError, TypeError):
|
|
2465
|
+
pass
|
|
2466
|
+
# Handle $((expr)) nested arithmetic in variable name
|
|
2467
|
+
if name.startswith("$((") and name.endswith("))"):
|
|
2468
|
+
inner = name[3:-2]
|
|
2469
|
+
from ..parser.parser import Parser
|
|
2470
|
+
parser = Parser()
|
|
2471
|
+
inner_expr = parser._parse_arithmetic_expression(inner)
|
|
2472
|
+
return await evaluate_arithmetic(ctx, inner_expr)
|
|
2473
|
+
# Handle $(cmd) command substitution in variable name
|
|
2474
|
+
if name.startswith("$(") and name.endswith(")") and not name.startswith("$(("):
|
|
2475
|
+
cmd = name[2:-1]
|
|
2476
|
+
if ctx.exec_fn:
|
|
2477
|
+
result = await ctx.exec_fn(cmd, None, None)
|
|
2478
|
+
val = result.stdout.rstrip("\n")
|
|
2479
|
+
return _parse_arith_value(val)
|
|
2480
|
+
return 0
|
|
2481
|
+
# Handle ${...} parameter expansion
|
|
2482
|
+
if name.startswith("${") and name.endswith("}"):
|
|
2483
|
+
inner = name[2:-1]
|
|
2484
|
+
val = _expand_braced_param_sync(ctx, inner)
|
|
2485
|
+
return _parse_arith_value(val)
|
|
2486
|
+
# Handle $var
|
|
2487
|
+
if name.startswith("$") and not name.startswith("$("):
|
|
2488
|
+
var_name = name[1:]
|
|
2489
|
+
if var_name.startswith("{") and var_name.endswith("}"):
|
|
2490
|
+
var_name = var_name[1:-1]
|
|
2491
|
+
val = get_variable(ctx, var_name, False)
|
|
2492
|
+
return _parse_arith_value(val)
|
|
2493
|
+
val = get_variable(ctx, name, False)
|
|
2494
|
+
return _parse_arith_value(val)
|
|
2495
|
+
elif expr.type == "ArithBinary":
|
|
2496
|
+
op = expr.operator
|
|
2497
|
+
if op == "&&":
|
|
2498
|
+
left = await evaluate_arithmetic(ctx, expr.left)
|
|
2499
|
+
if not left:
|
|
2500
|
+
return 0
|
|
2501
|
+
right = await evaluate_arithmetic(ctx, expr.right)
|
|
2502
|
+
return 1 if right else 0
|
|
2503
|
+
elif op == "||":
|
|
2504
|
+
left = await evaluate_arithmetic(ctx, expr.left)
|
|
2505
|
+
if left:
|
|
2506
|
+
return 1
|
|
2507
|
+
right = await evaluate_arithmetic(ctx, expr.right)
|
|
2508
|
+
return 1 if right else 0
|
|
2509
|
+
elif op == ",":
|
|
2510
|
+
await evaluate_arithmetic(ctx, expr.left)
|
|
2511
|
+
return await evaluate_arithmetic(ctx, expr.right)
|
|
2512
|
+
else:
|
|
2513
|
+
left = await evaluate_arithmetic(ctx, expr.left)
|
|
2514
|
+
right = await evaluate_arithmetic(ctx, expr.right)
|
|
2515
|
+
if op == "+":
|
|
2516
|
+
return left + right
|
|
2517
|
+
elif op == "-":
|
|
2518
|
+
return left - right
|
|
2519
|
+
elif op == "*":
|
|
2520
|
+
return left * right
|
|
2521
|
+
elif op == "/":
|
|
2522
|
+
if right == 0:
|
|
2523
|
+
raise ValueError("division by 0")
|
|
2524
|
+
return int(left / right)
|
|
2525
|
+
elif op == "%":
|
|
2526
|
+
if right == 0:
|
|
2527
|
+
raise ValueError("division by 0")
|
|
2528
|
+
return int(left - int(left / right) * right)
|
|
2529
|
+
elif op == "**":
|
|
2530
|
+
if right < 0:
|
|
2531
|
+
raise ValueError("exponent less than 0")
|
|
2532
|
+
return left ** right
|
|
2533
|
+
elif op == "<":
|
|
2534
|
+
return 1 if left < right else 0
|
|
2535
|
+
elif op == ">":
|
|
2536
|
+
return 1 if left > right else 0
|
|
2537
|
+
elif op == "<=":
|
|
2538
|
+
return 1 if left <= right else 0
|
|
2539
|
+
elif op == ">=":
|
|
2540
|
+
return 1 if left >= right else 0
|
|
2541
|
+
elif op == "==":
|
|
2542
|
+
return 1 if left == right else 0
|
|
2543
|
+
elif op == "!=":
|
|
2544
|
+
return 1 if left != right else 0
|
|
2545
|
+
elif op == "&":
|
|
2546
|
+
return left & right
|
|
2547
|
+
elif op == "|":
|
|
2548
|
+
return left | right
|
|
2549
|
+
elif op == "^":
|
|
2550
|
+
return left ^ right
|
|
2551
|
+
elif op == "<<":
|
|
2552
|
+
return left << right
|
|
2553
|
+
elif op == ">>":
|
|
2554
|
+
return left >> right
|
|
2555
|
+
elif expr.type == "ArithUnary":
|
|
2556
|
+
op = expr.operator
|
|
2557
|
+
if op in ("++", "--"):
|
|
2558
|
+
if hasattr(expr.operand, 'name'):
|
|
2559
|
+
var_name = expr.operand.name
|
|
2560
|
+
val = get_variable(ctx, var_name, False)
|
|
2561
|
+
try:
|
|
2562
|
+
current = int(val) if val else 0
|
|
2563
|
+
except ValueError:
|
|
2564
|
+
current = 0
|
|
2565
|
+
new_val = current + 1 if op == "++" else current - 1
|
|
2566
|
+
array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', var_name)
|
|
2567
|
+
if array_match:
|
|
2568
|
+
arr_name = array_match.group(1)
|
|
2569
|
+
subscript = array_match.group(2)
|
|
2570
|
+
idx = _eval_array_subscript(ctx, subscript)
|
|
2571
|
+
ctx.state.env[f"{arr_name}_{idx}"] = str(new_val)
|
|
2572
|
+
else:
|
|
2573
|
+
ctx.state.env[var_name] = str(new_val)
|
|
2574
|
+
return new_val if expr.prefix else current
|
|
2575
|
+
else:
|
|
2576
|
+
operand = await evaluate_arithmetic(ctx, expr.operand)
|
|
2577
|
+
return operand + 1 if op == "++" else operand - 1
|
|
2578
|
+
operand = await evaluate_arithmetic(ctx, expr.operand)
|
|
2579
|
+
if op == "-":
|
|
2580
|
+
return -operand
|
|
2581
|
+
elif op == "+":
|
|
2582
|
+
return operand
|
|
2583
|
+
elif op == "!":
|
|
2584
|
+
return 0 if operand else 1
|
|
2585
|
+
elif op == "~":
|
|
2586
|
+
return ~operand
|
|
2587
|
+
elif expr.type == "ArithTernary":
|
|
2588
|
+
cond = await evaluate_arithmetic(ctx, expr.condition)
|
|
2589
|
+
if cond:
|
|
2590
|
+
return await evaluate_arithmetic(ctx, expr.consequent)
|
|
2591
|
+
else:
|
|
2592
|
+
return await evaluate_arithmetic(ctx, expr.alternate)
|
|
2593
|
+
elif expr.type == "ArithAssignment":
|
|
2594
|
+
op = getattr(expr, 'operator', '=')
|
|
2595
|
+
var_name = getattr(expr, 'variable', None) or getattr(expr, 'name', None)
|
|
2596
|
+
subscript = getattr(expr, 'subscript', None)
|
|
2597
|
+
string_key = getattr(expr, 'string_key', None)
|
|
2598
|
+
rhs = await evaluate_arithmetic(ctx, expr.value)
|
|
2599
|
+
|
|
2600
|
+
store_key = var_name
|
|
2601
|
+
if subscript is not None and var_name:
|
|
2602
|
+
idx = await evaluate_arithmetic(ctx, subscript)
|
|
2603
|
+
store_key = f"{var_name}_{idx}"
|
|
2604
|
+
if f"{var_name}__is_array" not in ctx.state.env:
|
|
2605
|
+
ctx.state.env[f"{var_name}__is_array"] = "indexed"
|
|
2606
|
+
elif string_key is not None and var_name:
|
|
2607
|
+
store_key = f"{var_name}_{string_key}"
|
|
2608
|
+
if f"{var_name}__is_array" not in ctx.state.env:
|
|
2609
|
+
ctx.state.env[f"{var_name}__is_array"] = "assoc"
|
|
2610
|
+
|
|
2611
|
+
if op == '=':
|
|
2612
|
+
value = rhs
|
|
2613
|
+
else:
|
|
2614
|
+
current = 0
|
|
2615
|
+
if store_key:
|
|
2616
|
+
val = ctx.state.env.get(store_key, "")
|
|
2617
|
+
if not val and var_name and subscript is None and string_key is None:
|
|
2618
|
+
val = get_variable(ctx, var_name, False)
|
|
2619
|
+
try:
|
|
2620
|
+
current = int(val) if val else 0
|
|
2621
|
+
except ValueError:
|
|
2622
|
+
current = 0
|
|
2623
|
+
if op == '+=':
|
|
2624
|
+
value = current + rhs
|
|
2625
|
+
elif op == '-=':
|
|
2626
|
+
value = current - rhs
|
|
2627
|
+
elif op == '*=':
|
|
2628
|
+
value = current * rhs
|
|
2629
|
+
elif op == '/=':
|
|
2630
|
+
if rhs == 0:
|
|
2631
|
+
raise ValueError("division by 0")
|
|
2632
|
+
value = int(current / rhs)
|
|
2633
|
+
elif op == '%=':
|
|
2634
|
+
if rhs == 0:
|
|
2635
|
+
raise ValueError("division by 0")
|
|
2636
|
+
value = int(current - int(current / rhs) * rhs)
|
|
2637
|
+
elif op == '<<=':
|
|
2638
|
+
value = current << rhs
|
|
2639
|
+
elif op == '>>=':
|
|
2640
|
+
value = current >> rhs
|
|
2641
|
+
elif op == '&=':
|
|
2642
|
+
value = current & rhs
|
|
2643
|
+
elif op == '|=':
|
|
2644
|
+
value = current | rhs
|
|
2645
|
+
elif op == '^=':
|
|
2646
|
+
value = current ^ rhs
|
|
2647
|
+
else:
|
|
2648
|
+
value = rhs
|
|
2649
|
+
|
|
2650
|
+
if store_key:
|
|
2651
|
+
ctx.state.env[store_key] = str(value)
|
|
2652
|
+
return value
|
|
2653
|
+
elif expr.type == "ArithGroup":
|
|
2654
|
+
return await evaluate_arithmetic(ctx, expr.expression)
|
|
2655
|
+
elif expr.type == "ArithNested":
|
|
2656
|
+
if expr.expression:
|
|
2657
|
+
return await evaluate_arithmetic(ctx, expr.expression)
|
|
2658
|
+
return 0
|
|
2659
|
+
elif expr.type == "ArithCommandSubst":
|
|
2660
|
+
# Execute command and parse result as integer
|
|
2661
|
+
if ctx.exec_fn:
|
|
2662
|
+
result = await ctx.exec_fn(expr.command, None, None)
|
|
2663
|
+
val = result.stdout.rstrip("\n")
|
|
2664
|
+
return _parse_arith_value(val)
|
|
2665
|
+
return 0
|
|
2666
|
+
elif expr.type == "ArithBracedExpansion":
|
|
2667
|
+
val = _expand_braced_param_sync(ctx, expr.content)
|
|
2668
|
+
return _parse_arith_value(val)
|
|
2669
|
+
elif expr.type == "ArithArrayElement":
|
|
2670
|
+
arr_name = expr.array
|
|
2671
|
+
if expr.string_key is not None:
|
|
2672
|
+
val = ctx.state.env.get(f"{arr_name}_{expr.string_key}", "")
|
|
2673
|
+
elif expr.index is not None:
|
|
2674
|
+
idx = await evaluate_arithmetic(ctx, expr.index)
|
|
2675
|
+
val = ctx.state.env.get(f"{arr_name}_{idx}", "")
|
|
2676
|
+
else:
|
|
2677
|
+
val = ""
|
|
2678
|
+
return _parse_arith_value(val)
|
|
2679
|
+
elif expr.type == "ArithConcat":
|
|
2680
|
+
result_str = ""
|
|
2681
|
+
for part in expr.parts:
|
|
2682
|
+
result_str += str(await evaluate_arithmetic(ctx, part))
|
|
2683
|
+
return _parse_arith_value(result_str)
|
|
2684
|
+
elif expr.type == "ArithDynamicBase":
|
|
2685
|
+
base_str = get_variable(ctx, expr.base_expr, False)
|
|
2686
|
+
try:
|
|
2687
|
+
base = int(base_str)
|
|
2688
|
+
if 2 <= base <= 64:
|
|
2689
|
+
return _parse_base_n_value(expr.value, base)
|
|
2690
|
+
except (ValueError, TypeError):
|
|
2691
|
+
pass
|
|
2692
|
+
return 0
|
|
2693
|
+
elif expr.type == "ArithDynamicNumber":
|
|
2694
|
+
prefix = get_variable(ctx, expr.prefix, False)
|
|
2695
|
+
full = prefix + expr.suffix
|
|
2696
|
+
return _parse_arith_value(full)
|
|
2697
|
+
elif expr.type in ("ArithDoubleSubscript", "ArithNumberSubscript"):
|
|
2698
|
+
return 0
|
|
2699
|
+
return 0
|