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
@@ -860,6 +860,20 @@ class OverlayFs:
860
860
  )
861
861
 
862
862
  target = real_path.readlink()
863
+ # Convert absolute real paths back to virtual paths to prevent leaking
864
+ if target.is_absolute():
865
+ # Resolve the target to handle symlinks in the path
866
+ # (e.g., macOS /var -> /private/var)
867
+ import os
868
+ resolved_target = Path(os.path.realpath(str(target)))
869
+ try:
870
+ relative = resolved_target.relative_to(self._root)
871
+ if self._mount_point == "/":
872
+ return f"/{relative}"
873
+ return f"{self._mount_point}/{relative}"
874
+ except ValueError:
875
+ # Target is outside the overlay root
876
+ pass
863
877
  return str(target)
864
878
 
865
879
  def resolve_path(self, base: str, path: str) -> str:
@@ -1,7 +1,7 @@
1
1
  """Interpreter module for just-bash."""
2
2
 
3
3
  from .interpreter import Interpreter
4
- from .types import InterpreterContext, InterpreterState, ShellOptions
4
+ from .types import InterpreterContext, InterpreterState, ShellOptions, VariableStore
5
5
  from .errors import (
6
6
  InterpreterError,
7
7
  ExitError,
@@ -20,6 +20,7 @@ from .let import handle_let
20
20
  from .readonly import handle_readonly
21
21
  from .shopt import handle_shopt
22
22
  from .alias import handle_alias, handle_unalias
23
+ from .getopts import handle_getopts
23
24
  from .misc import (
24
25
  handle_colon,
25
26
  handle_true,
@@ -62,6 +63,7 @@ BUILTINS: dict[str, Callable[["InterpreterContext", list[str]], Awaitable["ExecR
62
63
  "shopt": handle_shopt,
63
64
  "alias": handle_alias,
64
65
  "unalias": handle_unalias,
66
+ "getopts": handle_getopts,
65
67
  ":": handle_colon,
66
68
  "true": handle_true,
67
69
  "false": handle_false,
@@ -27,11 +27,9 @@ async def handle_break(ctx: "InterpreterContext", args: list[str]) -> "ExecResul
27
27
  if levels < 1:
28
28
  levels = 1
29
29
  except ValueError:
30
- from ...types import ExecResult
31
- return ExecResult(
32
- stdout="",
30
+ raise ExitError(
31
+ 128,
33
32
  stderr=f"bash: break: {args[0]}: numeric argument required\n",
34
- exit_code=1,
35
33
  )
36
34
 
37
35
  # Check if we're in a loop
@@ -61,11 +59,9 @@ async def handle_continue(ctx: "InterpreterContext", args: list[str]) -> "ExecRe
61
59
  if levels < 1:
62
60
  levels = 1
63
61
  except ValueError:
64
- from ...types import ExecResult
65
- return ExecResult(
66
- stdout="",
62
+ raise ExitError(
63
+ 128,
67
64
  stderr=f"bash: continue: {args[0]}: numeric argument required\n",
68
- exit_code=1,
69
65
  )
70
66
 
71
67
  # Check if we're in a loop
@@ -23,7 +23,7 @@ import re
23
23
  from typing import TYPE_CHECKING, Optional
24
24
 
25
25
  if TYPE_CHECKING:
26
- from ..types import InterpreterContext
26
+ from ..types import InterpreterContext, VariableStore
27
27
  from ...types import ExecResult
28
28
 
29
29
 
@@ -102,6 +102,10 @@ async def handle_declare(ctx: "InterpreterContext", args: list[str]) -> "ExecRes
102
102
 
103
103
  i += 1
104
104
 
105
+ # Handle -f or -F options (functions)
106
+ if options["function"] or options["func_names"]:
107
+ return _handle_functions(ctx, names, options)
108
+
105
109
  # Print mode: show variable declarations
106
110
  if options["print"]:
107
111
  return _print_declarations(ctx, names, options)
@@ -137,6 +141,32 @@ async def handle_declare(ctx: "InterpreterContext", args: list[str]) -> "ExecRes
137
141
  exit_code = 1
138
142
  continue
139
143
 
144
+ # Handle nameref declaration
145
+ from ..types import VariableStore
146
+ if options["nameref"] and isinstance(ctx.state.env, VariableStore):
147
+ if value_str is not None:
148
+ ctx.state.env.set_nameref(name, value_str)
149
+ else:
150
+ # declare -n without value just marks as nameref type
151
+ ctx.state.env.set_attribute(name, "n")
152
+ continue
153
+
154
+ # Handle readonly attribute
155
+ if options["readonly"] and isinstance(ctx.state.env, VariableStore):
156
+ ctx.state.env.set_attribute(name, "r")
157
+ ctx.state.readonly_vars.add(name) if hasattr(ctx.state, 'readonly_vars') else None
158
+ if value_str is not None:
159
+ ctx.state.env[name] = value_str
160
+ continue
161
+
162
+ # Handle export attribute
163
+ if options["export"]:
164
+ if isinstance(ctx.state.env, VariableStore):
165
+ ctx.state.env.set_attribute(name, "x")
166
+ if value_str is not None:
167
+ ctx.state.env[name] = value_str
168
+ continue
169
+
140
170
  # Handle array declaration
141
171
  if options["array"] or options["assoc"]:
142
172
  # Initialize array if not already set
@@ -157,6 +187,16 @@ async def handle_declare(ctx: "InterpreterContext", args: list[str]) -> "ExecRes
157
187
  ctx.state.env[f"{name}_0"] = value_str
158
188
  else:
159
189
  # Regular variable
190
+ # Set attributes via metadata
191
+ from ..types import VariableStore
192
+ if isinstance(ctx.state.env, VariableStore):
193
+ if options["integer"]:
194
+ ctx.state.env.set_attribute(name, "i")
195
+ if options["lowercase"]:
196
+ ctx.state.env.set_attribute(name, "l")
197
+ if options["uppercase"]:
198
+ ctx.state.env.set_attribute(name, "u")
199
+
160
200
  if value_str is not None:
161
201
  # Apply transformations
162
202
  if options["integer"]:
@@ -177,7 +217,7 @@ async def handle_declare(ctx: "InterpreterContext", args: list[str]) -> "ExecRes
177
217
  else:
178
218
  ctx.state.env[name] = value_str
179
219
  elif name not in ctx.state.env:
180
- # Declare without value - just set type info
220
+ # Declare without value - just set type info (legacy compat)
181
221
  if options["integer"]:
182
222
  ctx.state.env[f"{name}__is_integer"] = "1"
183
223
  if options["lowercase"]:
@@ -271,8 +311,10 @@ def _parse_array_assignment(ctx: "InterpreterContext", name: str, inner: str, is
271
311
 
272
312
 
273
313
  def _eval_integer(expr: str, ctx: "InterpreterContext") -> int:
274
- """Evaluate a simple integer expression."""
275
- # Handle variable references
314
+ """Evaluate an integer expression for declare -i."""
315
+ from ..expansion import evaluate_arithmetic_sync
316
+ from ...parser.parser import Parser
317
+
276
318
  expr = expr.strip()
277
319
 
278
320
  # Try direct integer
@@ -289,48 +331,303 @@ def _eval_integer(expr: str, ctx: "InterpreterContext") -> int:
289
331
  except ValueError:
290
332
  return 0
291
333
 
334
+ # Try arithmetic expression evaluation
335
+ try:
336
+ parser = Parser()
337
+ arith_expr = parser._parse_arith_comma(expr)
338
+ return evaluate_arithmetic_sync(ctx, arith_expr)
339
+ except Exception:
340
+ pass
341
+
292
342
  return 0
293
343
 
294
344
 
345
+ def _get_attr_flags(env, name: str, ctx: "InterpreterContext") -> str:
346
+ """Get the declare attribute flags for a variable."""
347
+ from ..types import VariableStore
348
+ flags = ""
349
+ if isinstance(env, VariableStore):
350
+ attrs = env.get_attributes(name)
351
+ for attr in "rxilunt":
352
+ if attr in attrs:
353
+ flags += attr
354
+ elif name in ctx.state.readonly_vars:
355
+ flags += "r"
356
+ # Check array type
357
+ is_array = env.get(f"{name}__is_array")
358
+ if is_array == "assoc":
359
+ flags += "A"
360
+ elif is_array:
361
+ flags += "a"
362
+ return flags
363
+
364
+
365
+ def _format_array_decl(env, name: str, flags: str) -> str:
366
+ """Format an array variable declaration."""
367
+ is_assoc = env.get(f"{name}__is_array") == "assoc"
368
+ prefix = f"{name}_"
369
+ elements = []
370
+ for key in sorted(env.keys()):
371
+ if key.startswith(prefix) and not key.startswith(f"{name}__"):
372
+ idx_part = key[len(prefix):]
373
+ val = env[key]
374
+ elements.append((idx_part, val))
375
+
376
+ if not is_assoc:
377
+ # Sort indexed arrays by numeric index
378
+ try:
379
+ elements.sort(key=lambda x: int(x[0]))
380
+ except ValueError:
381
+ pass
382
+
383
+ flag_str = f"-{flags}" if flags else "-a"
384
+ pairs = " ".join(f'[{idx}]="{val}"' for idx, val in elements)
385
+ if elements:
386
+ return f"declare {flag_str} {name}=({pairs})"
387
+ else:
388
+ return f"declare {flag_str} {name}=()"
389
+
390
+
391
+ def _format_var_decl(env, name: str, ctx: "InterpreterContext") -> str | None:
392
+ """Format a single variable declaration. Returns None if not found."""
393
+ from ..types import VariableStore
394
+ flags = _get_attr_flags(env, name, ctx)
395
+
396
+ # Check for nameref
397
+ if isinstance(env, VariableStore) and env.is_nameref(name):
398
+ meta = env._metadata.get(name)
399
+ target = meta.nameref_target if meta else ""
400
+ flag_str = f"-{flags}" if flags else "-n"
401
+ return f'declare {flag_str} {name}="{target}"'
402
+
403
+ # Check for array
404
+ is_array = env.get(f"{name}__is_array")
405
+ if is_array:
406
+ return _format_array_decl(env, name, flags)
407
+
408
+ # Regular variable
409
+ if name in env:
410
+ val = env[name]
411
+ flag_str = f"-{flags}" if flags else "--"
412
+ return f'declare {flag_str} {name}="{val}"'
413
+
414
+ return None
415
+
416
+
295
417
  def _print_declarations(ctx: "InterpreterContext", names: list[str], options: dict) -> "ExecResult":
296
418
  """Print variable declarations."""
297
419
  lines = []
420
+ env = ctx.state.env
421
+ exit_code = 0
298
422
 
299
423
  if not names:
300
- # Print all matching variables
301
- for name in sorted(ctx.state.env.keys()):
302
- if name.startswith("_") or "__" in name:
424
+ # Print all variables
425
+ # Collect unique variable names (skip internal keys)
426
+ seen = set()
427
+ for key in sorted(env.keys()):
428
+ if "__" in key:
429
+ # Extract base name from array keys like arr_0, arr__is_array
430
+ continue
431
+ if key.startswith("_"):
432
+ continue
433
+ if key in ("?", "#", "$", "!", "-", "*", "@"):
303
434
  continue
304
- if name in ("?", "#", "$", "!", "-", "*", "@"):
435
+ # Skip positional params and single-digit keys
436
+ if key.isdigit():
305
437
  continue
306
438
 
307
- val = ctx.state.env[name]
308
- lines.append(f'declare -- {name}="{val}"')
439
+ seen.add(key)
440
+
441
+ # Also add arrays
442
+ for key in env.keys():
443
+ if key.endswith("__is_array"):
444
+ arr_name = key[:-len("__is_array")]
445
+ if not arr_name.startswith("_"):
446
+ seen.add(arr_name)
447
+
448
+ for name in sorted(seen):
449
+ decl = _format_var_decl(env, name, ctx)
450
+ if decl:
451
+ lines.append(decl)
309
452
  else:
310
453
  for name in names:
311
- if name in ctx.state.env:
312
- val = ctx.state.env[name]
313
- lines.append(f'declare -- {name}="{val}"')
454
+ decl = _format_var_decl(env, name, ctx)
455
+ if decl:
456
+ lines.append(decl)
314
457
  else:
315
- # Check if it's an array
316
- is_array = ctx.state.env.get(f"{name}__is_array")
317
- if is_array:
318
- lines.append(f'declare -{("A" if is_array == "assoc" else "a")} {name}')
458
+ # Variable not found
459
+ lines_err = f"bash: declare: {name}: not found\n"
460
+ exit_code = 1
319
461
 
320
- return _result("\n".join(lines) + "\n" if lines else "", "", 0)
462
+ stdout = "\n".join(lines) + "\n" if lines else ""
463
+ stderr = ""
464
+ if exit_code != 0 and not lines:
465
+ stderr = f"bash: declare: {names[0]}: not found\n" if names else ""
466
+ return _result(stdout, stderr, exit_code)
467
+
468
+
469
+ def _handle_functions(ctx: "InterpreterContext", names: list[str], options: dict) -> "ExecResult":
470
+ """Handle declare -f (list function bodies) and -F (list function names)."""
471
+ functions = ctx.state.functions
472
+ lines = []
473
+ exit_code = 0
474
+
475
+ if options["func_names"]:
476
+ # -F: list function names only
477
+ if names:
478
+ for name in names:
479
+ if name in functions:
480
+ lines.append(f"declare -f {name}")
481
+ else:
482
+ exit_code = 1
483
+ else:
484
+ for name in sorted(functions.keys()):
485
+ lines.append(f"declare -f {name}")
486
+ else:
487
+ # -f: list function definitions
488
+ if names:
489
+ for name in names:
490
+ if name in functions:
491
+ lines.append(_format_function_def(name, functions[name]))
492
+ else:
493
+ exit_code = 1
494
+ else:
495
+ for name in sorted(functions.keys()):
496
+ lines.append(_format_function_def(name, functions[name]))
497
+
498
+ # If also -p, treat as print mode for functions
499
+ if options["print"] and not options["func_names"]:
500
+ # declare -fp: same as declare -f
501
+ pass
502
+
503
+ stdout = "\n".join(lines) + "\n" if lines else ""
504
+ return _result(stdout, "", exit_code)
505
+
506
+
507
+ def _format_function_def(name: str, func_node) -> str:
508
+ """Format a function definition for output."""
509
+ # Try to reconstruct the function body from the AST
510
+ # For simplicity, output a placeholder that matches bash format
511
+ body = _ast_to_source(func_node.body) if func_node.body else " :"
512
+ return f"{name} () \n{{\n{body}\n}}"
513
+
514
+
515
+ def _ast_to_source(node) -> str:
516
+ """Best-effort AST to source code conversion."""
517
+ # This is a simplified reconstruction - bash's declare -f does perfect roundtrip
518
+ # but we can't easily reconstruct from AST without storing the original source
519
+ from ...ast.types import GroupNode, SimpleCommandNode, StatementNode
520
+ parts = []
521
+
522
+ body_stmts = []
523
+ if hasattr(node, 'body'):
524
+ body_stmts = node.body
525
+ elif hasattr(node, 'statements'):
526
+ body_stmts = node.statements
527
+
528
+ if not body_stmts:
529
+ return " :"
530
+
531
+ for stmt in body_stmts:
532
+ line = _stmt_to_source(stmt)
533
+ if line:
534
+ parts.append(f" {line}")
535
+
536
+ return "\n".join(parts) if parts else " :"
537
+
538
+
539
+ def _stmt_to_source(node) -> str:
540
+ """Convert a statement node to source."""
541
+ from ...ast.types import SimpleCommandNode
542
+ if hasattr(node, 'pipelines'):
543
+ cmd_parts = []
544
+ for i, pipeline in enumerate(node.pipelines):
545
+ if i > 0 and i - 1 < len(node.operators):
546
+ cmd_parts.append(node.operators[i - 1])
547
+ pipeline_str = _pipeline_to_source(pipeline)
548
+ cmd_parts.append(pipeline_str)
549
+ return " ".join(cmd_parts)
550
+ return ""
551
+
552
+
553
+ def _pipeline_to_source(node) -> str:
554
+ """Convert a pipeline node to source."""
555
+ parts = []
556
+ for i, cmd in enumerate(node.commands):
557
+ if i > 0:
558
+ parts.append("|")
559
+ parts.append(_cmd_to_source(cmd))
560
+ result = " ".join(parts)
561
+ if node.negated:
562
+ result = f"! {result}"
563
+ return result
564
+
565
+
566
+ def _cmd_to_source(node) -> str:
567
+ """Convert a command node to source."""
568
+ from ...ast.types import SimpleCommandNode
569
+ if isinstance(node, SimpleCommandNode) or (hasattr(node, 'type') and node.type == "SimpleCommand"):
570
+ parts = []
571
+ if node.name:
572
+ parts.append(_word_to_source(node.name))
573
+ for arg in node.args:
574
+ parts.append(_word_to_source(arg))
575
+ return " ".join(parts)
576
+ return ""
577
+
578
+
579
+ def _word_to_source(word) -> str:
580
+ """Convert a word node to approximate source."""
581
+ parts = []
582
+ for part in word.parts:
583
+ if hasattr(part, 'value'):
584
+ parts.append(part.value)
585
+ elif hasattr(part, 'parts'):
586
+ inner = ""
587
+ for p in part.parts:
588
+ if hasattr(p, 'value'):
589
+ inner += p.value
590
+ elif hasattr(p, 'parameter'):
591
+ inner += f"${p.parameter}"
592
+ if part.type == "DoubleQuoted":
593
+ parts.append(f'"{inner}"')
594
+ elif part.type == "SingleQuoted":
595
+ parts.append(f"'{inner}'")
596
+ else:
597
+ parts.append(inner)
598
+ elif hasattr(part, 'parameter'):
599
+ parts.append(f"${part.parameter}")
600
+ return "".join(parts)
321
601
 
322
602
 
323
603
  def _list_variables(ctx: "InterpreterContext", options: dict) -> "ExecResult":
324
604
  """List variables with matching attributes."""
605
+ env = ctx.state.env
325
606
  lines = []
326
607
 
327
- for name in sorted(ctx.state.env.keys()):
328
- if name.startswith("_") or "__" in name:
608
+ # Collect unique variable names
609
+ seen = set()
610
+ for key in sorted(env.keys()):
611
+ if "__" in key:
329
612
  continue
330
- if name in ("?", "#", "$", "!", "-", "*", "@"):
613
+ if key.startswith("_"):
331
614
  continue
332
-
333
- val = ctx.state.env[name]
334
- lines.append(f'declare -- {name}="{val}"')
615
+ if key in ("?", "#", "$", "!", "-", "*", "@"):
616
+ continue
617
+ if key.isdigit():
618
+ continue
619
+ seen.add(key)
620
+
621
+ # Also add arrays
622
+ for key in env.keys():
623
+ if key.endswith("__is_array"):
624
+ arr_name = key[:-len("__is_array")]
625
+ if not arr_name.startswith("_"):
626
+ seen.add(arr_name)
627
+
628
+ for name in sorted(seen):
629
+ decl = _format_var_decl(env, name, ctx)
630
+ if decl:
631
+ lines.append(decl)
335
632
 
336
633
  return _result("\n".join(lines) + "\n" if lines else "", "", 0)
@@ -0,0 +1,163 @@
1
+ """Getopts builtin implementation.
2
+
3
+ Usage: getopts optstring name [args]
4
+
5
+ Parse positional parameters as options.
6
+
7
+ The optstring contains the option letters to be recognized.
8
+ If a letter is followed by a colon, the option requires an argument.
9
+ """
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from ..types import InterpreterContext
15
+ from ...types import ExecResult
16
+
17
+
18
+ async def handle_getopts(ctx: "InterpreterContext", args: list[str]) -> "ExecResult":
19
+ """Execute the getopts builtin."""
20
+ from ...types import ExecResult
21
+
22
+ if len(args) < 2:
23
+ return ExecResult(
24
+ stdout="",
25
+ stderr="bash: getopts: usage: getopts optstring name [arg ...]\n",
26
+ exit_code=2,
27
+ )
28
+
29
+ optstring = args[0]
30
+ name = args[1]
31
+
32
+ # Validate variable name
33
+ import re
34
+ valid_name = bool(re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', name))
35
+
36
+ # Get the arguments to parse (from args[2:] or positional params)
37
+ if len(args) > 2:
38
+ parse_args = args[2:]
39
+ else:
40
+ # Use positional parameters
41
+ parse_args = []
42
+ i = 1
43
+ while str(i) in ctx.state.env:
44
+ parse_args.append(ctx.state.env[str(i)])
45
+ i += 1
46
+
47
+ # Get current OPTIND (1-based)
48
+ try:
49
+ optind = int(ctx.state.env.get("OPTIND", "1"))
50
+ except ValueError:
51
+ optind = 1
52
+
53
+ # Check if we've exhausted all arguments
54
+ if optind > len(parse_args):
55
+ ctx.state.env[name] = "?"
56
+ return ExecResult(stdout="", stderr="", exit_code=1)
57
+
58
+ current_arg = parse_args[optind - 1]
59
+
60
+ # Check if current arg is an option
61
+ if not current_arg.startswith("-") or current_arg == "-" or current_arg == "--":
62
+ ctx.state.env[name] = "?"
63
+ if current_arg == "--":
64
+ ctx.state.env["OPTIND"] = str(optind + 1)
65
+ return ExecResult(stdout="", stderr="", exit_code=1)
66
+
67
+ # Get the option character
68
+ # Handle multi-character processing within a single -abc argument
69
+ optchar_idx_key = "__getopts_charpos"
70
+ try:
71
+ charpos = int(ctx.state.env.get(optchar_idx_key, "1"))
72
+ except ValueError:
73
+ charpos = 1
74
+
75
+ if charpos >= len(current_arg):
76
+ # Move to next argument
77
+ optind += 1
78
+ charpos = 1
79
+ if optind > len(parse_args):
80
+ ctx.state.env[name] = "?"
81
+ ctx.state.env["OPTIND"] = str(optind)
82
+ ctx.state.env.pop(optchar_idx_key, None)
83
+ return ExecResult(stdout="", stderr="", exit_code=1)
84
+ current_arg = parse_args[optind - 1]
85
+ if not current_arg.startswith("-") or current_arg == "-" or current_arg == "--":
86
+ ctx.state.env[name] = "?"
87
+ ctx.state.env["OPTIND"] = str(optind + (1 if current_arg == "--" else 0))
88
+ ctx.state.env.pop(optchar_idx_key, None)
89
+ return ExecResult(stdout="", stderr="", exit_code=1)
90
+
91
+ opt_char = current_arg[charpos]
92
+
93
+ # Check if this option is valid
94
+ opt_idx = optstring.find(opt_char)
95
+ stderr = ""
96
+
97
+ if opt_idx == -1:
98
+ # Invalid option
99
+ ctx.state.env[name] = "?"
100
+ if optstring.startswith(":"):
101
+ # Silent mode: OPTARG = the invalid option character
102
+ ctx.state.env["OPTARG"] = opt_char
103
+ else:
104
+ # Normal mode: OPTARG is unset, print error
105
+ ctx.state.env.pop("OPTARG", None)
106
+ stderr = f"bash: getopts: illegal option -- {opt_char}\n"
107
+
108
+ # Advance position
109
+ if charpos + 1 < len(current_arg):
110
+ ctx.state.env[optchar_idx_key] = str(charpos + 1)
111
+ else:
112
+ ctx.state.env["OPTIND"] = str(optind + 1)
113
+ ctx.state.env.pop(optchar_idx_key, None)
114
+
115
+ return ExecResult(stdout="", stderr=stderr, exit_code=0)
116
+
117
+ # Valid option - check if it requires an argument
118
+ needs_arg = opt_idx + 1 < len(optstring) and optstring[opt_idx + 1] == ":"
119
+
120
+ if needs_arg:
121
+ # Check for argument
122
+ if charpos + 1 < len(current_arg):
123
+ # Argument is the rest of this token
124
+ ctx.state.env["OPTARG"] = current_arg[charpos + 1:]
125
+ ctx.state.env["OPTIND"] = str(optind + 1)
126
+ ctx.state.env.pop(optchar_idx_key, None)
127
+ elif optind < len(parse_args):
128
+ # Argument is the next token
129
+ ctx.state.env["OPTARG"] = parse_args[optind]
130
+ ctx.state.env["OPTIND"] = str(optind + 2)
131
+ ctx.state.env.pop(optchar_idx_key, None)
132
+ else:
133
+ # Missing argument
134
+ if optstring.startswith(":"):
135
+ ctx.state.env[name] = ":"
136
+ ctx.state.env["OPTARG"] = opt_char
137
+ else:
138
+ ctx.state.env[name] = "?"
139
+ ctx.state.env.pop("OPTARG", None)
140
+ stderr = f"bash: getopts: option requires an argument -- {opt_char}\n"
141
+ ctx.state.env["OPTIND"] = str(optind + 1)
142
+ ctx.state.env.pop(optchar_idx_key, None)
143
+ return ExecResult(stdout="", stderr=stderr, exit_code=0)
144
+ else:
145
+ # No argument needed
146
+ ctx.state.env.pop("OPTARG", None)
147
+ if charpos + 1 < len(current_arg):
148
+ # More options in this token
149
+ ctx.state.env[optchar_idx_key] = str(charpos + 1)
150
+ else:
151
+ # Move to next argument
152
+ ctx.state.env["OPTIND"] = str(optind + 1)
153
+ ctx.state.env.pop(optchar_idx_key, None)
154
+
155
+ if valid_name:
156
+ ctx.state.env[name] = opt_char
157
+ return ExecResult(stdout="", stderr=stderr, exit_code=0)
158
+ else:
159
+ return ExecResult(
160
+ stdout="",
161
+ stderr=stderr + f"bash: getopts: `{name}': not a valid identifier\n",
162
+ exit_code=1,
163
+ )
@@ -23,7 +23,7 @@ async def handle_let(
23
23
  ctx: "InterpreterContext", args: list[str]
24
24
  ) -> "ExecResult":
25
25
  """Execute the let builtin."""
26
- from ..expansion import evaluate_arithmetic_sync
26
+ from ..expansion import evaluate_arithmetic
27
27
  from ...parser.parser import Parser
28
28
 
29
29
  if not args:
@@ -36,7 +36,7 @@ async def handle_let(
36
36
  try:
37
37
  # Parse and evaluate the arithmetic expression
38
38
  arith_expr = parser._parse_arithmetic_expression(expr_str)
39
- last_value = evaluate_arithmetic_sync(ctx, arith_expr)
39
+ last_value = await evaluate_arithmetic(ctx, arith_expr)
40
40
  except Exception as e:
41
41
  return _result("", f"bash: let: {expr_str}: syntax error\n", 1)
42
42