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.
Files changed (47) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +112 -7
  4. just_bash/commands/cat/cat.py +5 -1
  5. just_bash/commands/echo/echo.py +33 -1
  6. just_bash/commands/grep/grep.py +30 -1
  7. just_bash/commands/od/od.py +144 -30
  8. just_bash/commands/printf/printf.py +289 -87
  9. just_bash/commands/pwd/pwd.py +32 -2
  10. just_bash/commands/read/read.py +243 -64
  11. just_bash/commands/readlink/readlink.py +3 -9
  12. just_bash/commands/registry.py +24 -0
  13. just_bash/commands/rmdir/__init__.py +5 -0
  14. just_bash/commands/rmdir/rmdir.py +160 -0
  15. just_bash/commands/sed/sed.py +142 -31
  16. just_bash/commands/stat/stat.py +9 -0
  17. just_bash/commands/time/__init__.py +5 -0
  18. just_bash/commands/time/time.py +74 -0
  19. just_bash/commands/touch/touch.py +118 -8
  20. just_bash/commands/whoami/__init__.py +5 -0
  21. just_bash/commands/whoami/whoami.py +18 -0
  22. just_bash/fs/in_memory_fs.py +22 -0
  23. just_bash/fs/overlay_fs.py +14 -0
  24. just_bash/interpreter/__init__.py +1 -1
  25. just_bash/interpreter/builtins/__init__.py +2 -0
  26. just_bash/interpreter/builtins/control.py +4 -8
  27. just_bash/interpreter/builtins/declare.py +321 -24
  28. just_bash/interpreter/builtins/getopts.py +163 -0
  29. just_bash/interpreter/builtins/let.py +2 -2
  30. just_bash/interpreter/builtins/local.py +71 -5
  31. just_bash/interpreter/builtins/misc.py +22 -6
  32. just_bash/interpreter/builtins/readonly.py +38 -10
  33. just_bash/interpreter/builtins/set.py +58 -8
  34. just_bash/interpreter/builtins/test.py +136 -19
  35. just_bash/interpreter/builtins/unset.py +62 -10
  36. just_bash/interpreter/conditionals.py +29 -4
  37. just_bash/interpreter/control_flow.py +61 -17
  38. just_bash/interpreter/expansion.py +1647 -104
  39. just_bash/interpreter/interpreter.py +424 -70
  40. just_bash/interpreter/types.py +263 -2
  41. just_bash/parser/__init__.py +2 -0
  42. just_bash/parser/lexer.py +295 -26
  43. just_bash/parser/parser.py +523 -64
  44. just_bash/types.py +11 -0
  45. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  46. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/RECORD +47 -40
  47. {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
- # Handle numeric or variable subscript
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 == "@" or 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
- return str(random.randint(0, 32767))
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
- # Check for array subscript: arr[idx]
111
- array_match = re.match(r'^([a-zA-Z_][a-zA-Z0-9_]*)\[(.+)\]$', name)
112
- if array_match:
113
- array_name, subscript = array_match.groups()
114
- if subscript == "@" or subscript == "*":
115
- # Get all array elements
116
- elements = get_array_elements(ctx, array_name)
117
- return " ".join(v for _, v in elements)
118
- else:
119
- # Single element
120
- try:
121
- idx = int(subscript)
122
- except ValueError:
123
- # Try to evaluate as variable
124
- idx_val = env.get(subscript, "0")
125
- try:
126
- idx = int(idx_val)
127
- except ValueError:
128
- idx = 0
129
- return env.get(f"{array_name}_{idx}", "")
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 name_0, name_1, etc.
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.endswith("__length"):
152
- try:
153
- idx = int(key[len(prefix):])
154
- elements.append((idx, value))
155
- except ValueError:
156
- pass
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
- elements.sort(key=lambda x: x[0])
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
- return str(evaluate_arithmetic_sync(ctx, expr))
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
- return str(await evaluate_arithmetic(ctx, expr))
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
- is_unset = parameter not in ctx.state.env
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
- match = re.search(regex_pattern + "$", value)
511
- if match:
512
- return value[:match.start()]
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
- match = re.match(regex_pattern, value)
516
- if match:
517
- return value[match.end():]
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 == "PatternReplace":
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.replace_all
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
- if replace_all:
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 - escape special chars and wrap in quotes
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 - for now just return value
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 if array, readonly, etc.
591
- attrs = []
592
- if ctx.state.env.get(f"{parameter}__is_array") == "indexed":
593
- attrs.append("a")
594
- elif ctx.state.env.get(f"{parameter}__is_array") == "associative":
595
- attrs.append("A")
596
- readonly_set = ctx.state.env.get("__readonly__", "").split()
597
- if parameter in readonly_set:
598
- attrs.append("r")
599
- return "".join(attrs)
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 for associative arrays
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 the word
723
- value = await expand_word_async(ctx, word)
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
- # For unquoted words, perform IFS word splitting
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
- # Check for glob patterns first
728
- if any(c in value for c in "*?["):
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 = _split_on_ifs(value, ifs)
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": has_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
- # Check if this part has glob characters
908
- if not any(c in part for c in "*?["):
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 fnmatch.fnmatch(entry, part):
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 pattern.startswith("/") and results:
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
- try:
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
- return left // right if right != 0 else 0
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
- return left % right if right != 0 else 0
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 var_name:
1116
- val = get_variable(ctx, var_name, False)
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
- value = current // rhs if rhs != 0 else 0
2357
+ if rhs == 0:
2358
+ raise ValueError("division by 0")
2359
+ value = int(current / rhs)
1130
2360
  elif op == '%=':
1131
- value = current % rhs if rhs != 0 else 0
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 var_name:
1146
- ctx.state.env[var_name] = str(value)
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
- # For now, use sync version
1156
- return evaluate_arithmetic_sync(ctx, expr)
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