just-bash 0.1.5__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 (49) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +362 -17
  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 +141 -3
  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 +32 -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/shuf/__init__.py +5 -0
  17. just_bash/commands/shuf/shuf.py +242 -0
  18. just_bash/commands/stat/stat.py +9 -0
  19. just_bash/commands/time/__init__.py +5 -0
  20. just_bash/commands/time/time.py +74 -0
  21. just_bash/commands/touch/touch.py +118 -8
  22. just_bash/commands/whoami/__init__.py +5 -0
  23. just_bash/commands/whoami/whoami.py +18 -0
  24. just_bash/fs/in_memory_fs.py +22 -0
  25. just_bash/fs/overlay_fs.py +22 -1
  26. just_bash/interpreter/__init__.py +1 -1
  27. just_bash/interpreter/builtins/__init__.py +2 -0
  28. just_bash/interpreter/builtins/control.py +4 -8
  29. just_bash/interpreter/builtins/declare.py +321 -24
  30. just_bash/interpreter/builtins/getopts.py +163 -0
  31. just_bash/interpreter/builtins/let.py +2 -2
  32. just_bash/interpreter/builtins/local.py +71 -5
  33. just_bash/interpreter/builtins/misc.py +22 -6
  34. just_bash/interpreter/builtins/readonly.py +38 -10
  35. just_bash/interpreter/builtins/set.py +58 -8
  36. just_bash/interpreter/builtins/test.py +136 -19
  37. just_bash/interpreter/builtins/unset.py +62 -10
  38. just_bash/interpreter/conditionals.py +29 -4
  39. just_bash/interpreter/control_flow.py +61 -17
  40. just_bash/interpreter/expansion.py +1647 -104
  41. just_bash/interpreter/interpreter.py +436 -69
  42. just_bash/interpreter/types.py +263 -2
  43. just_bash/parser/__init__.py +2 -0
  44. just_bash/parser/lexer.py +295 -26
  45. just_bash/parser/parser.py +523 -64
  46. just_bash/types.py +11 -0
  47. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  48. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/RECORD +49 -40
  49. {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
just_bash/ast/factory.py CHANGED
@@ -78,6 +78,7 @@ def simple_command(
78
78
  args: Sequence[WordNode] | None = None,
79
79
  assignments: Sequence[AssignmentNode] | None = None,
80
80
  redirections: Sequence[RedirectionNode] | None = None,
81
+ line: int | None = None,
81
82
  ) -> SimpleCommandNode:
82
83
  """Create a simple command node."""
83
84
  return SimpleCommandNode(
@@ -85,6 +86,7 @@ def simple_command(
85
86
  args=tuple(args) if args else (),
86
87
  assignments=tuple(assignments) if assignments else (),
87
88
  redirections=tuple(redirections) if redirections else (),
89
+ line=line,
88
90
  )
89
91
 
90
92
 
@@ -205,7 +207,7 @@ def for_node(
205
207
  """Create a for node."""
206
208
  return ForNode(
207
209
  variable=variable,
208
- words=tuple(words) if words else None,
210
+ words=tuple(words) if words is not None else None,
209
211
  body=tuple(body),
210
212
  redirections=tuple(redirections) if redirections else (),
211
213
  )
just_bash/bash.py CHANGED
@@ -28,8 +28,8 @@ import nest_asyncio # type: ignore[import-untyped]
28
28
 
29
29
  from .commands import create_command_registry
30
30
  from .fs import InMemoryFs
31
- from .interpreter import Interpreter, InterpreterState, ShellOptions
32
- from .parser import parse
31
+ from .interpreter import ExitError, Interpreter, InterpreterState, ShellOptions, VariableStore
32
+ from .parser import parse, unescape_html_entities
33
33
  from .types import (
34
34
  Command,
35
35
  ExecResult,
@@ -59,6 +59,7 @@ class Bash:
59
59
  errexit: bool = False,
60
60
  pipefail: bool = False,
61
61
  nounset: bool = False,
62
+ unescape_html: bool = True,
62
63
  ):
63
64
  """Initialize the Bash interpreter.
64
65
 
@@ -73,6 +74,9 @@ class Bash:
73
74
  errexit: Enable errexit (set -e) mode.
74
75
  pipefail: Enable pipefail mode.
75
76
  nounset: Enable nounset (set -u) mode.
77
+ unescape_html: Unescape HTML entities in operator positions (default True).
78
+ This helps LLM-generated commands work correctly when they contain
79
+ &lt; instead of <, &gt; instead of >, etc.
76
80
  """
77
81
  # Set up filesystem
78
82
  if fs is not None:
@@ -89,15 +93,21 @@ class Bash:
89
93
  # Set up network config
90
94
  self._network = network
91
95
 
96
+ # Set up HTML unescaping
97
+ self._unescape_html = unescape_html
98
+
92
99
  # Set up initial state
93
- default_env = {
100
+ default_env = VariableStore({
94
101
  "PATH": "/usr/local/bin:/usr/bin:/bin",
95
102
  "HOME": "/home/user",
96
103
  "USER": "user",
97
104
  "SHELL": "/bin/bash",
98
105
  "PWD": cwd,
99
106
  "?": "0",
100
- }
107
+ "SHLVL": "1",
108
+ "BASH_VERSION": "5.0.0(1)-release",
109
+ "OPTIND": "1",
110
+ })
101
111
  if env:
102
112
  default_env.update(env)
103
113
 
@@ -152,6 +162,10 @@ class Bash:
152
162
  Returns:
153
163
  ExecResult with stdout, stderr, exit_code, and final env.
154
164
  """
165
+ # Preprocess HTML entities if enabled
166
+ if self._unescape_html:
167
+ script = unescape_html_entities(script)
168
+
155
169
  # Parse the script
156
170
  ast = parse(script)
157
171
 
@@ -163,7 +177,15 @@ class Bash:
163
177
  self._interpreter.state.env["PWD"] = cwd
164
178
 
165
179
  # Execute
166
- return await self._interpreter.execute_script(ast)
180
+ try:
181
+ return await self._interpreter.execute_script(ast)
182
+ except ExitError as error:
183
+ return ExecResult(
184
+ stdout=error.stdout,
185
+ stderr=error.stderr,
186
+ exit_code=error.exit_code,
187
+ env=dict(self._interpreter.state.env),
188
+ )
167
189
 
168
190
  def run(
169
191
  self,
@@ -208,7 +230,7 @@ class Bash:
208
230
  commands=self._commands,
209
231
  limits=self._limits,
210
232
  state=InterpreterState(
211
- env=dict(self._initial_state.env),
233
+ env=self._initial_state.env.copy() if isinstance(self._initial_state.env, VariableStore) else VariableStore(self._initial_state.env),
212
234
  cwd=self._initial_state.cwd,
213
235
  previous_dir=self._initial_state.previous_dir,
214
236
  options=ShellOptions(
@@ -22,6 +22,8 @@ Variables:
22
22
  FS field separator
23
23
  OFS output field separator
24
24
  ORS output record separator
25
+ RSTART start of match (set by match())
26
+ RLENGTH length of match (set by match())
25
27
 
26
28
  Built-in functions:
27
29
  length(s) string length
@@ -30,13 +32,35 @@ Built-in functions:
30
32
  split(s,a,fs) split s into array a
31
33
  sub(r,s) substitute first match
32
34
  gsub(r,s) substitute all matches
35
+ match(s,r) find regex r in s, set RSTART/RLENGTH
33
36
  tolower(s) convert to lowercase
34
37
  toupper(s) convert to uppercase
35
- printf(fmt,args...) formatted print
38
+ sprintf(fmt,args...) return formatted string
39
+ printf(fmt,args...) formatted print
36
40
  print print current line
41
+
42
+ Math functions:
43
+ int(x) truncate to integer
44
+ sqrt(x) square root
45
+ sin(x) sine
46
+ cos(x) cosine
47
+ log(x) natural logarithm
48
+ exp(x) exponential
49
+ atan2(y,x) arctangent of y/x
50
+
51
+ Random functions:
52
+ rand() random number 0 <= n < 1
53
+ srand([seed]) seed random generator
54
+
55
+ Time functions:
56
+ systime() current epoch timestamp
57
+ strftime(fmt,ts) format timestamp
37
58
  """
38
59
 
60
+ import math
61
+ import random
39
62
  import re
63
+ import time
40
64
  from dataclasses import dataclass, field
41
65
  from typing import Any
42
66
  from ...types import CommandContext, ExecResult
@@ -50,6 +74,7 @@ class AwkRule:
50
74
  action: str
51
75
  is_regex: bool = False
52
76
  regex: re.Pattern | None = None
77
+ negate: bool = False # ! pattern negation
53
78
 
54
79
 
55
80
  @dataclass
@@ -60,6 +85,7 @@ class AwkState:
60
85
  output: str = ""
61
86
  next_record: bool = False
62
87
  exit_program: bool = False
88
+ rng: random.Random = field(default_factory=random.Random)
63
89
 
64
90
 
65
91
  class AwkCommand:
@@ -262,12 +288,27 @@ class AwkCommand:
262
288
  is_regex = False
263
289
  regex = None
264
290
 
291
+ negate_pattern = False
292
+
265
293
  if program[pos:].startswith("BEGIN"):
266
294
  pattern = "BEGIN"
267
295
  pos += 5
268
296
  elif program[pos:].startswith("END"):
269
297
  pattern = "END"
270
298
  pos += 3
299
+ elif program[pos] == "!" and pos + 1 < len(program) and program[pos + 1] == "/":
300
+ # Negated regex pattern
301
+ negate_pattern = True
302
+ pos += 1 # skip '!', now pos is at '/'
303
+ end = self._find_regex_end(program, pos + 1)
304
+ if end != -1:
305
+ pattern = program[pos + 1:end]
306
+ is_regex = True
307
+ try:
308
+ regex = re.compile(pattern)
309
+ except re.error as e:
310
+ raise ValueError(f"invalid regex: {e}")
311
+ pos = end + 1
271
312
  elif program[pos] == "/":
272
313
  # Regex pattern
273
314
  end = self._find_regex_end(program, pos + 1)
@@ -324,10 +365,10 @@ class AwkCommand:
324
365
  pos += 1
325
366
 
326
367
  action = program[start:pos - 1].strip()
327
- rules.append(AwkRule(pattern=pattern, action=action, is_regex=is_regex, regex=regex))
368
+ rules.append(AwkRule(pattern=pattern, action=action, is_regex=is_regex, regex=regex, negate=negate_pattern))
328
369
  else:
329
370
  # Default action is print $0
330
- rules.append(AwkRule(pattern=pattern, action="print", is_regex=is_regex, regex=regex))
371
+ rules.append(AwkRule(pattern=pattern, action="print", is_regex=is_regex, regex=regex, negate=negate_pattern))
331
372
 
332
373
  return rules
333
374
 
@@ -349,7 +390,8 @@ class AwkCommand:
349
390
  return True
350
391
 
351
392
  if rule.is_regex and rule.regex:
352
- return bool(rule.regex.search(line))
393
+ result = bool(rule.regex.search(line))
394
+ return not result if rule.negate else result
353
395
 
354
396
  # Expression pattern
355
397
  pattern = rule.pattern
@@ -468,7 +510,15 @@ class AwkCommand:
468
510
  if current.strip():
469
511
  statements.append(current.strip())
470
512
 
471
- return statements
513
+ # Merge 'else' parts back into their preceding 'if' statement
514
+ merged = []
515
+ for stmt in statements:
516
+ if stmt.startswith("else") and merged and merged[-1].lstrip().startswith("if"):
517
+ merged[-1] = merged[-1] + "; " + stmt
518
+ else:
519
+ merged.append(stmt)
520
+
521
+ return merged
472
522
 
473
523
  def _execute_statement(
474
524
  self, stmt: str, state: AwkState, fields: list[str], line: str
@@ -596,6 +646,28 @@ class AwkCommand:
596
646
  pass
597
647
  return
598
648
 
649
+ # Handle delete statement
650
+ if stmt.startswith("delete "):
651
+ target = stmt[7:].strip()
652
+ match = re.match(r"(\w+)\[(.+)\]", target)
653
+ if match:
654
+ arr_name = match.group(1)
655
+ idx = self._eval_expr(match.group(2).strip(), state, line, fields)
656
+ key = f"{arr_name}[{idx}]"
657
+ state.variables.pop(key, None)
658
+ return
659
+
660
+ # Handle match() as a statement (for side effects on RSTART/RLENGTH)
661
+ if stmt.startswith("match("):
662
+ # Just evaluate it - _eval_expr will set RSTART/RLENGTH
663
+ self._eval_expr(stmt, state, line, fields)
664
+ return
665
+
666
+ # Handle srand() as a statement
667
+ if stmt.startswith("srand(") or stmt == "srand()":
668
+ self._eval_expr(stmt, state, line, fields)
669
+ return
670
+
599
671
  # Handle assignment
600
672
  if "=" in stmt and not stmt.startswith("if") and "==" not in stmt and "!=" not in stmt:
601
673
  # Handle += -= *= /=
@@ -621,6 +693,16 @@ class AwkCommand:
621
693
  state.variables[var] = current / val if val != 0 else 0
622
694
  return
623
695
 
696
+ # Array element assignment: arr[idx] = val
697
+ match = re.match(r"(\w+)\[(.+?)\]\s*=\s*(.+)", stmt)
698
+ if match:
699
+ arr_name = match.group(1)
700
+ idx = self._eval_expr(match.group(2).strip(), state, line, fields)
701
+ val = self._eval_expr(match.group(3).strip(), state, line, fields)
702
+ key = f"{arr_name}[{idx}]"
703
+ state.variables[key] = val
704
+ return
705
+
624
706
  # Simple assignment
625
707
  match = re.match(r"(\w+)\s*=\s*(.+)", stmt)
626
708
  if match:
@@ -639,6 +721,10 @@ class AwkCommand:
639
721
  fields.append("")
640
722
  if field_num > 0:
641
723
  fields[field_num - 1] = str(val)
724
+ # Reconstruct $0 from modified fields
725
+ ofs = state.variables.get("OFS", " ")
726
+ state.variables["__line__"] = ofs.join(fields)
727
+ state.variables["NF"] = len(fields)
642
728
  return
643
729
 
644
730
  # Handle increment/decrement
@@ -773,7 +859,6 @@ class AwkCommand:
773
859
  if expr.startswith("sqrt("):
774
860
  match = re.match(r"sqrt\((.+)\)", expr)
775
861
  if match:
776
- import math
777
862
  arg = self._eval_expr(match.group(1), state, line, fields)
778
863
  try:
779
864
  return math.sqrt(float(arg))
@@ -784,7 +869,6 @@ class AwkCommand:
784
869
  if expr.startswith("sin("):
785
870
  match = re.match(r"sin\((.+)\)", expr)
786
871
  if match:
787
- import math
788
872
  arg = self._eval_expr(match.group(1), state, line, fields)
789
873
  try:
790
874
  return math.sin(float(arg))
@@ -795,7 +879,6 @@ class AwkCommand:
795
879
  if expr.startswith("cos("):
796
880
  match = re.match(r"cos\((.+)\)", expr)
797
881
  if match:
798
- import math
799
882
  arg = self._eval_expr(match.group(1), state, line, fields)
800
883
  try:
801
884
  return math.cos(float(arg))
@@ -806,7 +889,6 @@ class AwkCommand:
806
889
  if expr.startswith("log("):
807
890
  match = re.match(r"log\((.+)\)", expr)
808
891
  if match:
809
- import math
810
892
  arg = self._eval_expr(match.group(1), state, line, fields)
811
893
  try:
812
894
  return math.log(float(arg))
@@ -817,7 +899,6 @@ class AwkCommand:
817
899
  if expr.startswith("exp("):
818
900
  match = re.match(r"exp\((.+)\)", expr)
819
901
  if match:
820
- import math
821
902
  arg = self._eval_expr(match.group(1), state, line, fields)
822
903
  try:
823
904
  return math.exp(float(arg))
@@ -845,6 +926,104 @@ class AwkCommand:
845
926
  return len(parts)
846
927
  return 0
847
928
 
929
+ # rand() - return random number 0 <= n < 1
930
+ if expr == "rand()":
931
+ return state.rng.random()
932
+
933
+ # srand([seed]) - seed the random number generator
934
+ if expr.startswith("srand(") or expr == "srand()":
935
+ match = re.match(r"srand\(([^)]*)\)", expr)
936
+ if match:
937
+ seed_str = match.group(1).strip()
938
+ if seed_str:
939
+ seed = self._eval_expr(seed_str, state, line, fields)
940
+ try:
941
+ state.rng.seed(int(float(seed)))
942
+ except (ValueError, TypeError):
943
+ state.rng.seed(int(time.time()))
944
+ else:
945
+ state.rng.seed(int(time.time()))
946
+ return 0 # srand returns previous seed, but we just return 0
947
+
948
+ # sprintf(fmt, args...) - return formatted string
949
+ if expr.startswith("sprintf("):
950
+ match = re.match(r"sprintf\((.+)\)", expr)
951
+ if match:
952
+ args = self._split_args(match.group(1))
953
+ if args:
954
+ fmt = str(self._eval_expr(args[0], state, line, fields))
955
+ values = [self._eval_expr(a, state, line, fields) for a in args[1:]]
956
+ return self._format_string(fmt, values)
957
+ return ""
958
+
959
+ # match(s, r) - return position of regex match, set RSTART and RLENGTH
960
+ if expr.startswith("match("):
961
+ match_call = re.match(r"match\((.+)\)", expr)
962
+ if match_call:
963
+ args = self._split_args(match_call.group(1))
964
+ if len(args) >= 2:
965
+ s = str(self._eval_expr(args[0], state, line, fields))
966
+ pattern = args[1].strip()
967
+ # Handle /regex/ syntax
968
+ if pattern.startswith("/") and pattern.endswith("/"):
969
+ pattern = pattern[1:-1]
970
+ try:
971
+ regex_match = re.search(pattern, s)
972
+ if regex_match:
973
+ pos = regex_match.start() + 1 # 1-based
974
+ length = regex_match.end() - regex_match.start()
975
+ state.variables["RSTART"] = pos
976
+ state.variables["RLENGTH"] = length
977
+ return pos
978
+ else:
979
+ state.variables["RSTART"] = 0
980
+ state.variables["RLENGTH"] = -1
981
+ return 0
982
+ except re.error:
983
+ state.variables["RSTART"] = 0
984
+ state.variables["RLENGTH"] = -1
985
+ return 0
986
+ return 0
987
+
988
+ # atan2(y, x) - arctangent of y/x
989
+ if expr.startswith("atan2("):
990
+ match = re.match(r"atan2\((.+)\)", expr)
991
+ if match:
992
+ args = self._split_args(match.group(1))
993
+ if len(args) >= 2:
994
+ y = self._eval_expr(args[0], state, line, fields)
995
+ x = self._eval_expr(args[1], state, line, fields)
996
+ try:
997
+ return math.atan2(float(y), float(x))
998
+ except (ValueError, TypeError):
999
+ return 0
1000
+ return 0
1001
+
1002
+ # systime() - return current epoch timestamp
1003
+ if expr == "systime()":
1004
+ return int(time.time())
1005
+
1006
+ # strftime(fmt, timestamp) - format timestamp as string
1007
+ if expr.startswith("strftime("):
1008
+ match = re.match(r"strftime\((.+)\)", expr)
1009
+ if match:
1010
+ args = self._split_args(match.group(1))
1011
+ if args:
1012
+ fmt = str(self._eval_expr(args[0], state, line, fields))
1013
+ if len(args) >= 2:
1014
+ timestamp = self._eval_expr(args[1], state, line, fields)
1015
+ try:
1016
+ timestamp = int(float(timestamp))
1017
+ except (ValueError, TypeError):
1018
+ timestamp = int(time.time())
1019
+ else:
1020
+ timestamp = int(time.time())
1021
+ try:
1022
+ return time.strftime(fmt, time.localtime(timestamp))
1023
+ except (ValueError, OSError):
1024
+ return ""
1025
+ return ""
1026
+
848
1027
  # Arithmetic - check for operators (including with spaces like "2 + 3")
849
1028
  for op in ["+", "-", "*", "/", "%"]:
850
1029
  if op in expr:
@@ -1010,29 +1189,137 @@ class AwkCommand:
1010
1189
  self, stmt: str, state: AwkState, fields: list[str], line: str
1011
1190
  ) -> None:
1012
1191
  """Execute an if statement."""
1013
- # Parse: if (condition) { action } [else { action }]
1014
- match = re.match(r"if\s*\((.+?)\)\s*\{(.+?)\}(?:\s*else\s*\{(.+?)\})?", stmt, re.DOTALL)
1015
- if match:
1016
- condition = match.group(1)
1017
- then_action = match.group(2)
1018
- else_action = match.group(3)
1192
+ # Find the condition by matching balanced parentheses
1193
+ if not stmt.startswith("if"):
1194
+ return
1195
+
1196
+ # Find opening paren
1197
+ paren_start = stmt.find("(")
1198
+ if paren_start == -1:
1199
+ return
1200
+
1201
+ # Find matching closing paren
1202
+ depth = 1
1203
+ pos = paren_start + 1
1204
+ while pos < len(stmt) and depth > 0:
1205
+ if stmt[pos] == "(":
1206
+ depth += 1
1207
+ elif stmt[pos] == ")":
1208
+ depth -= 1
1209
+ pos += 1
1210
+
1211
+ if depth != 0:
1212
+ return
1213
+
1214
+ condition = stmt[paren_start + 1:pos - 1]
1215
+ rest = stmt[pos:].strip()
1216
+
1217
+ # Check for braced then-action
1218
+ if rest.startswith("{"):
1219
+ # Find matching closing brace
1220
+ brace_depth = 1
1221
+ brace_pos = 1
1222
+ while brace_pos < len(rest) and brace_depth > 0:
1223
+ if rest[brace_pos] == "{":
1224
+ brace_depth += 1
1225
+ elif rest[brace_pos] == "}":
1226
+ brace_depth -= 1
1227
+ brace_pos += 1
1228
+
1229
+ then_action = rest[1:brace_pos - 1]
1230
+ after_then = rest[brace_pos:].strip()
1231
+
1232
+ # Check for else
1233
+ else_action = None
1234
+ if after_then.startswith("else"):
1235
+ else_rest = after_then[4:].strip()
1236
+ if else_rest.startswith("{"):
1237
+ # Find matching brace for else
1238
+ brace_depth = 1
1239
+ brace_pos = 1
1240
+ while brace_pos < len(else_rest) and brace_depth > 0:
1241
+ if else_rest[brace_pos] == "{":
1242
+ brace_depth += 1
1243
+ elif else_rest[brace_pos] == "}":
1244
+ brace_depth -= 1
1245
+ brace_pos += 1
1246
+ else_action = else_rest[1:brace_pos - 1]
1019
1247
 
1020
1248
  if self._eval_condition(condition, state, fields, line):
1021
1249
  self._execute_action(then_action, state, fields, line)
1022
1250
  elif else_action:
1023
1251
  self._execute_action(else_action, state, fields, line)
1252
+ else:
1253
+ # No braces - check for else clause separated by ;
1254
+ then_action = rest
1255
+ else_action = None
1256
+
1257
+ # Look for "; else" pattern (not inside strings)
1258
+ else_idx = -1
1259
+ in_str = False
1260
+ esc = False
1261
+ for ci in range(len(rest)):
1262
+ if esc:
1263
+ esc = False
1264
+ continue
1265
+ if rest[ci] == "\\":
1266
+ esc = True
1267
+ continue
1268
+ if rest[ci] == '"':
1269
+ in_str = not in_str
1270
+ continue
1271
+ if not in_str and rest[ci] == ";" and rest[ci + 1:].lstrip().startswith("else"):
1272
+ else_idx = ci
1273
+ break
1274
+
1275
+ if else_idx != -1:
1276
+ then_action = rest[:else_idx].strip()
1277
+ else_part = rest[else_idx + 1:].strip()
1278
+ if else_part.startswith("else"):
1279
+ else_action = else_part[4:].strip()
1280
+
1281
+ if self._eval_condition(condition, state, fields, line):
1282
+ self._execute_statement(then_action, state, fields, line)
1283
+ elif else_action:
1284
+ self._execute_statement(else_action, state, fields, line)
1024
1285
 
1025
1286
  def _execute_for(
1026
1287
  self, stmt: str, state: AwkState, fields: list[str], line: str
1027
1288
  ) -> None:
1028
1289
  """Execute a for statement."""
1290
+ # Parse: for (var in array) body
1291
+ match = re.match(r"for\s*\(\s*(\w+)\s+in\s+(\w+)\s*\)\s*(.*)", stmt, re.DOTALL)
1292
+ if match:
1293
+ var = match.group(1)
1294
+ arr_name = match.group(2)
1295
+ body = match.group(3).strip()
1296
+ if body.startswith("{") and body.endswith("}"):
1297
+ body = body[1:-1]
1298
+
1299
+ # Find all keys for this array
1300
+ keys = []
1301
+ prefix = f"{arr_name}["
1302
+ for k in state.variables:
1303
+ if isinstance(k, str) and k.startswith(prefix) and k.endswith("]"):
1304
+ keys.append(k[len(prefix):-1])
1305
+
1306
+ for key in keys:
1307
+ state.variables[var] = key
1308
+ self._execute_action(body, state, fields, line)
1309
+ return
1310
+
1029
1311
  # Parse: for (init; condition; update) { action }
1030
1312
  match = re.match(r"for\s*\((.+?);(.+?);(.+?)\)\s*\{(.+?)\}", stmt, re.DOTALL)
1313
+ if not match:
1314
+ # Try without braces: for (init; condition; update) statement
1315
+ match = re.match(r"for\s*\((.+?);(.+?);(.+?)\)\s*(.*)", stmt, re.DOTALL)
1031
1316
  if match:
1032
1317
  init = match.group(1).strip()
1033
1318
  condition = match.group(2).strip()
1034
1319
  update = match.group(3).strip()
1035
- action = match.group(4)
1320
+ action = match.group(4).strip()
1321
+ if action.startswith("{") and action.endswith("}"):
1322
+ action = action[1:-1]
1036
1323
 
1037
1324
  # Execute init
1038
1325
  self._execute_statement(init, state, fields, line)
@@ -1166,3 +1453,61 @@ class AwkCommand:
1166
1453
  return str(int(n))
1167
1454
  return str(n)
1168
1455
  return str(n)
1456
+
1457
+ def _format_string(self, fmt: str, values: list[Any]) -> str:
1458
+ """Format a string using printf-style format specifiers."""
1459
+ result = ""
1460
+ i = 0
1461
+ val_idx = 0
1462
+
1463
+ while i < len(fmt):
1464
+ if fmt[i] == "\\" and i + 1 < len(fmt):
1465
+ c = fmt[i + 1]
1466
+ if c == "n":
1467
+ result += "\n"
1468
+ elif c == "t":
1469
+ result += "\t"
1470
+ elif c == "\\":
1471
+ result += "\\"
1472
+ else:
1473
+ result += c
1474
+ i += 2
1475
+ elif fmt[i] == "%" and i + 1 < len(fmt):
1476
+ # Parse format spec
1477
+ j = i + 1
1478
+ while j < len(fmt) and fmt[j] in "-+0 #":
1479
+ j += 1
1480
+ while j < len(fmt) and fmt[j].isdigit():
1481
+ j += 1
1482
+ if j < len(fmt) and fmt[j] == ".":
1483
+ j += 1
1484
+ while j < len(fmt) and fmt[j].isdigit():
1485
+ j += 1
1486
+ if j < len(fmt):
1487
+ spec = fmt[i:j + 1]
1488
+ conv = fmt[j]
1489
+ if conv == "%":
1490
+ result += "%"
1491
+ elif val_idx < len(values):
1492
+ val = values[val_idx]
1493
+ val_idx += 1
1494
+ try:
1495
+ if conv in "diouxX":
1496
+ result += spec % int(float(val))
1497
+ elif conv in "eEfFgG":
1498
+ result += spec % float(val)
1499
+ elif conv == "s":
1500
+ result += spec % str(val)
1501
+ else:
1502
+ result += spec % val
1503
+ except (ValueError, TypeError):
1504
+ result += str(val)
1505
+ i = j + 1
1506
+ else:
1507
+ result += fmt[i]
1508
+ i += 1
1509
+ else:
1510
+ result += fmt[i]
1511
+ i += 1
1512
+
1513
+ return result
@@ -117,8 +117,12 @@ class CatCommand:
117
117
 
118
118
  for file in files:
119
119
  try:
120
- if file == "-":
120
+ if file == "-" or file == "/dev/stdin":
121
121
  content = ctx.stdin
122
+ elif file == "/dev/stdout":
123
+ content = ""
124
+ elif file == "/dev/stderr":
125
+ content = ""
122
126
  else:
123
127
  # Resolve path
124
128
  path = ctx.fs.resolve_path(ctx.cwd, file)