just-bash 0.1.5__py3-none-any.whl → 0.1.8__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.
@@ -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
@@ -60,6 +84,7 @@ class AwkState:
60
84
  output: str = ""
61
85
  next_record: bool = False
62
86
  exit_program: bool = False
87
+ rng: random.Random = field(default_factory=random.Random)
63
88
 
64
89
 
65
90
  class AwkCommand:
@@ -596,6 +621,17 @@ class AwkCommand:
596
621
  pass
597
622
  return
598
623
 
624
+ # Handle match() as a statement (for side effects on RSTART/RLENGTH)
625
+ if stmt.startswith("match("):
626
+ # Just evaluate it - _eval_expr will set RSTART/RLENGTH
627
+ self._eval_expr(stmt, state, line, fields)
628
+ return
629
+
630
+ # Handle srand() as a statement
631
+ if stmt.startswith("srand(") or stmt == "srand()":
632
+ self._eval_expr(stmt, state, line, fields)
633
+ return
634
+
599
635
  # Handle assignment
600
636
  if "=" in stmt and not stmt.startswith("if") and "==" not in stmt and "!=" not in stmt:
601
637
  # Handle += -= *= /=
@@ -773,7 +809,6 @@ class AwkCommand:
773
809
  if expr.startswith("sqrt("):
774
810
  match = re.match(r"sqrt\((.+)\)", expr)
775
811
  if match:
776
- import math
777
812
  arg = self._eval_expr(match.group(1), state, line, fields)
778
813
  try:
779
814
  return math.sqrt(float(arg))
@@ -784,7 +819,6 @@ class AwkCommand:
784
819
  if expr.startswith("sin("):
785
820
  match = re.match(r"sin\((.+)\)", expr)
786
821
  if match:
787
- import math
788
822
  arg = self._eval_expr(match.group(1), state, line, fields)
789
823
  try:
790
824
  return math.sin(float(arg))
@@ -795,7 +829,6 @@ class AwkCommand:
795
829
  if expr.startswith("cos("):
796
830
  match = re.match(r"cos\((.+)\)", expr)
797
831
  if match:
798
- import math
799
832
  arg = self._eval_expr(match.group(1), state, line, fields)
800
833
  try:
801
834
  return math.cos(float(arg))
@@ -806,7 +839,6 @@ class AwkCommand:
806
839
  if expr.startswith("log("):
807
840
  match = re.match(r"log\((.+)\)", expr)
808
841
  if match:
809
- import math
810
842
  arg = self._eval_expr(match.group(1), state, line, fields)
811
843
  try:
812
844
  return math.log(float(arg))
@@ -817,7 +849,6 @@ class AwkCommand:
817
849
  if expr.startswith("exp("):
818
850
  match = re.match(r"exp\((.+)\)", expr)
819
851
  if match:
820
- import math
821
852
  arg = self._eval_expr(match.group(1), state, line, fields)
822
853
  try:
823
854
  return math.exp(float(arg))
@@ -845,6 +876,104 @@ class AwkCommand:
845
876
  return len(parts)
846
877
  return 0
847
878
 
879
+ # rand() - return random number 0 <= n < 1
880
+ if expr == "rand()":
881
+ return state.rng.random()
882
+
883
+ # srand([seed]) - seed the random number generator
884
+ if expr.startswith("srand(") or expr == "srand()":
885
+ match = re.match(r"srand\(([^)]*)\)", expr)
886
+ if match:
887
+ seed_str = match.group(1).strip()
888
+ if seed_str:
889
+ seed = self._eval_expr(seed_str, state, line, fields)
890
+ try:
891
+ state.rng.seed(int(float(seed)))
892
+ except (ValueError, TypeError):
893
+ state.rng.seed(int(time.time()))
894
+ else:
895
+ state.rng.seed(int(time.time()))
896
+ return 0 # srand returns previous seed, but we just return 0
897
+
898
+ # sprintf(fmt, args...) - return formatted string
899
+ if expr.startswith("sprintf("):
900
+ match = re.match(r"sprintf\((.+)\)", expr)
901
+ if match:
902
+ args = self._split_args(match.group(1))
903
+ if args:
904
+ fmt = str(self._eval_expr(args[0], state, line, fields))
905
+ values = [self._eval_expr(a, state, line, fields) for a in args[1:]]
906
+ return self._format_string(fmt, values)
907
+ return ""
908
+
909
+ # match(s, r) - return position of regex match, set RSTART and RLENGTH
910
+ if expr.startswith("match("):
911
+ match_call = re.match(r"match\((.+)\)", expr)
912
+ if match_call:
913
+ args = self._split_args(match_call.group(1))
914
+ if len(args) >= 2:
915
+ s = str(self._eval_expr(args[0], state, line, fields))
916
+ pattern = args[1].strip()
917
+ # Handle /regex/ syntax
918
+ if pattern.startswith("/") and pattern.endswith("/"):
919
+ pattern = pattern[1:-1]
920
+ try:
921
+ regex_match = re.search(pattern, s)
922
+ if regex_match:
923
+ pos = regex_match.start() + 1 # 1-based
924
+ length = regex_match.end() - regex_match.start()
925
+ state.variables["RSTART"] = pos
926
+ state.variables["RLENGTH"] = length
927
+ return pos
928
+ else:
929
+ state.variables["RSTART"] = 0
930
+ state.variables["RLENGTH"] = -1
931
+ return 0
932
+ except re.error:
933
+ state.variables["RSTART"] = 0
934
+ state.variables["RLENGTH"] = -1
935
+ return 0
936
+ return 0
937
+
938
+ # atan2(y, x) - arctangent of y/x
939
+ if expr.startswith("atan2("):
940
+ match = re.match(r"atan2\((.+)\)", expr)
941
+ if match:
942
+ args = self._split_args(match.group(1))
943
+ if len(args) >= 2:
944
+ y = self._eval_expr(args[0], state, line, fields)
945
+ x = self._eval_expr(args[1], state, line, fields)
946
+ try:
947
+ return math.atan2(float(y), float(x))
948
+ except (ValueError, TypeError):
949
+ return 0
950
+ return 0
951
+
952
+ # systime() - return current epoch timestamp
953
+ if expr == "systime()":
954
+ return int(time.time())
955
+
956
+ # strftime(fmt, timestamp) - format timestamp as string
957
+ if expr.startswith("strftime("):
958
+ match = re.match(r"strftime\((.+)\)", expr)
959
+ if match:
960
+ args = self._split_args(match.group(1))
961
+ if args:
962
+ fmt = str(self._eval_expr(args[0], state, line, fields))
963
+ if len(args) >= 2:
964
+ timestamp = self._eval_expr(args[1], state, line, fields)
965
+ try:
966
+ timestamp = int(float(timestamp))
967
+ except (ValueError, TypeError):
968
+ timestamp = int(time.time())
969
+ else:
970
+ timestamp = int(time.time())
971
+ try:
972
+ return time.strftime(fmt, time.localtime(timestamp))
973
+ except (ValueError, OSError):
974
+ return ""
975
+ return ""
976
+
848
977
  # Arithmetic - check for operators (including with spaces like "2 + 3")
849
978
  for op in ["+", "-", "*", "/", "%"]:
850
979
  if op in expr:
@@ -1010,17 +1139,70 @@ class AwkCommand:
1010
1139
  self, stmt: str, state: AwkState, fields: list[str], line: str
1011
1140
  ) -> None:
1012
1141
  """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)
1142
+ # Find the condition by matching balanced parentheses
1143
+ if not stmt.startswith("if"):
1144
+ return
1145
+
1146
+ # Find opening paren
1147
+ paren_start = stmt.find("(")
1148
+ if paren_start == -1:
1149
+ return
1150
+
1151
+ # Find matching closing paren
1152
+ depth = 1
1153
+ pos = paren_start + 1
1154
+ while pos < len(stmt) and depth > 0:
1155
+ if stmt[pos] == "(":
1156
+ depth += 1
1157
+ elif stmt[pos] == ")":
1158
+ depth -= 1
1159
+ pos += 1
1160
+
1161
+ if depth != 0:
1162
+ return
1163
+
1164
+ condition = stmt[paren_start + 1:pos - 1]
1165
+ rest = stmt[pos:].strip()
1166
+
1167
+ # Check for braced then-action
1168
+ if rest.startswith("{"):
1169
+ # Find matching closing brace
1170
+ brace_depth = 1
1171
+ brace_pos = 1
1172
+ while brace_pos < len(rest) and brace_depth > 0:
1173
+ if rest[brace_pos] == "{":
1174
+ brace_depth += 1
1175
+ elif rest[brace_pos] == "}":
1176
+ brace_depth -= 1
1177
+ brace_pos += 1
1178
+
1179
+ then_action = rest[1:brace_pos - 1]
1180
+ after_then = rest[brace_pos:].strip()
1181
+
1182
+ # Check for else
1183
+ else_action = None
1184
+ if after_then.startswith("else"):
1185
+ else_rest = after_then[4:].strip()
1186
+ if else_rest.startswith("{"):
1187
+ # Find matching brace for else
1188
+ brace_depth = 1
1189
+ brace_pos = 1
1190
+ while brace_pos < len(else_rest) and brace_depth > 0:
1191
+ if else_rest[brace_pos] == "{":
1192
+ brace_depth += 1
1193
+ elif else_rest[brace_pos] == "}":
1194
+ brace_depth -= 1
1195
+ brace_pos += 1
1196
+ else_action = else_rest[1:brace_pos - 1]
1019
1197
 
1020
1198
  if self._eval_condition(condition, state, fields, line):
1021
1199
  self._execute_action(then_action, state, fields, line)
1022
1200
  elif else_action:
1023
1201
  self._execute_action(else_action, state, fields, line)
1202
+ else:
1203
+ # No braces - rest is the statement
1204
+ if self._eval_condition(condition, state, fields, line):
1205
+ self._execute_statement(rest, state, fields, line)
1024
1206
 
1025
1207
  def _execute_for(
1026
1208
  self, stmt: str, state: AwkState, fields: list[str], line: str
@@ -1166,3 +1348,61 @@ class AwkCommand:
1166
1348
  return str(int(n))
1167
1349
  return str(n)
1168
1350
  return str(n)
1351
+
1352
+ def _format_string(self, fmt: str, values: list[Any]) -> str:
1353
+ """Format a string using printf-style format specifiers."""
1354
+ result = ""
1355
+ i = 0
1356
+ val_idx = 0
1357
+
1358
+ while i < len(fmt):
1359
+ if fmt[i] == "\\" and i + 1 < len(fmt):
1360
+ c = fmt[i + 1]
1361
+ if c == "n":
1362
+ result += "\n"
1363
+ elif c == "t":
1364
+ result += "\t"
1365
+ elif c == "\\":
1366
+ result += "\\"
1367
+ else:
1368
+ result += c
1369
+ i += 2
1370
+ elif fmt[i] == "%" and i + 1 < len(fmt):
1371
+ # Parse format spec
1372
+ j = i + 1
1373
+ while j < len(fmt) and fmt[j] in "-+0 #":
1374
+ j += 1
1375
+ while j < len(fmt) and fmt[j].isdigit():
1376
+ j += 1
1377
+ if j < len(fmt) and fmt[j] == ".":
1378
+ j += 1
1379
+ while j < len(fmt) and fmt[j].isdigit():
1380
+ j += 1
1381
+ if j < len(fmt):
1382
+ spec = fmt[i:j + 1]
1383
+ conv = fmt[j]
1384
+ if conv == "%":
1385
+ result += "%"
1386
+ elif val_idx < len(values):
1387
+ val = values[val_idx]
1388
+ val_idx += 1
1389
+ try:
1390
+ if conv in "diouxX":
1391
+ result += spec % int(float(val))
1392
+ elif conv in "eEfFgG":
1393
+ result += spec % float(val)
1394
+ elif conv == "s":
1395
+ result += spec % str(val)
1396
+ else:
1397
+ result += spec % val
1398
+ except (ValueError, TypeError):
1399
+ result += str(val)
1400
+ i = j + 1
1401
+ else:
1402
+ result += fmt[i]
1403
+ i += 1
1404
+ else:
1405
+ result += fmt[i]
1406
+ i += 1
1407
+
1408
+ return result
@@ -203,8 +203,18 @@ class GrepCommand:
203
203
  if pattern:
204
204
  # If pattern was set, it's actually a file
205
205
  files.insert(0, pattern)
206
- pattern = "|".join(f"({p})" for p in patterns)
207
- elif pattern is None:
206
+ # Convert each pattern from BRE before combining (if not ERE mode)
207
+ if not extended_regexp and not fixed_strings:
208
+ converted = [self._bre_to_python_regex(p) for p in patterns]
209
+ pattern = "|".join(f"({p})" for p in converted)
210
+ else:
211
+ pattern = "|".join(f"({p})" for p in patterns)
212
+ # Mark as already converted
213
+ patterns_already_converted = True
214
+ else:
215
+ patterns_already_converted = False
216
+
217
+ if pattern is None and not patterns:
208
218
  return ExecResult(
209
219
  stdout="",
210
220
  stderr="grep: pattern not specified\n",
@@ -220,6 +230,10 @@ class GrepCommand:
220
230
  if fixed_strings:
221
231
  # Escape all regex metacharacters
222
232
  pattern = re.escape(pattern)
233
+ elif not extended_regexp and not patterns_already_converted:
234
+ # Convert BRE (Basic Regular Expression) to Python regex
235
+ pattern = self._bre_to_python_regex(pattern)
236
+
223
237
  if word_regexp:
224
238
  pattern = r'\b' + pattern + r'\b'
225
239
  if line_regexp:
@@ -382,6 +396,101 @@ class GrepCommand:
382
396
  exit_code = 0 if found_match else 1
383
397
  return ExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
384
398
 
399
+ def _bre_to_python_regex(self, pattern: str) -> str:
400
+ """Convert BRE (Basic Regular Expression) to Python regex.
401
+
402
+ In BRE:
403
+ - \\| is alternation, | is literal
404
+ - \\+ is one-or-more, + is literal
405
+ - \\? is zero-or-one, ? is literal
406
+ - \\( \\) is grouping, ( ) are literal
407
+ - \\{ \\} is repetition, { } are literal
408
+ - \\< \\> is word boundary
409
+
410
+ In Python regex (like ERE):
411
+ - | is alternation, \\| is literal
412
+ - + is one-or-more, \\+ is literal
413
+ - etc.
414
+ """
415
+ result = []
416
+ i = 0
417
+ while i < len(pattern):
418
+ if pattern[i] == '\\' and i + 1 < len(pattern):
419
+ next_char = pattern[i + 1]
420
+ if next_char == '|':
421
+ # BRE \| -> Python |
422
+ result.append('|')
423
+ i += 2
424
+ elif next_char == '+':
425
+ # BRE \+ -> Python +
426
+ result.append('+')
427
+ i += 2
428
+ elif next_char == '?':
429
+ # BRE \? -> Python ?
430
+ result.append('?')
431
+ i += 2
432
+ elif next_char == '(':
433
+ # BRE \( -> Python (
434
+ result.append('(')
435
+ i += 2
436
+ elif next_char == ')':
437
+ # BRE \) -> Python )
438
+ result.append(')')
439
+ i += 2
440
+ elif next_char == '{':
441
+ # BRE \{ -> Python {
442
+ result.append('{')
443
+ i += 2
444
+ elif next_char == '}':
445
+ # BRE \} -> Python }
446
+ result.append('}')
447
+ i += 2
448
+ elif next_char == '<':
449
+ # BRE \< (word start) -> Python \b
450
+ result.append(r'\b')
451
+ i += 2
452
+ elif next_char == '>':
453
+ # BRE \> (word end) -> Python \b
454
+ result.append(r'\b')
455
+ i += 2
456
+ else:
457
+ # Other escapes pass through as-is
458
+ result.append(pattern[i:i + 2])
459
+ i += 2
460
+ elif pattern[i] == '|':
461
+ # BRE literal | -> Python \|
462
+ result.append(r'\|')
463
+ i += 1
464
+ elif pattern[i] == '+':
465
+ # BRE literal + -> Python \+
466
+ result.append(r'\+')
467
+ i += 1
468
+ elif pattern[i] == '?':
469
+ # BRE literal ? -> Python \?
470
+ result.append(r'\?')
471
+ i += 1
472
+ elif pattern[i] == '(':
473
+ # BRE literal ( -> Python \(
474
+ result.append(r'\(')
475
+ i += 1
476
+ elif pattern[i] == ')':
477
+ # BRE literal ) -> Python \)
478
+ result.append(r'\)')
479
+ i += 1
480
+ elif pattern[i] == '{':
481
+ # BRE literal { -> Python \{
482
+ result.append(r'\{')
483
+ i += 1
484
+ elif pattern[i] == '}':
485
+ # BRE literal } -> Python \}
486
+ result.append(r'\}')
487
+ i += 1
488
+ else:
489
+ result.append(pattern[i])
490
+ i += 1
491
+
492
+ return ''.join(result)
493
+
385
494
  async def _get_files_recursive(self, ctx: CommandContext, path: str) -> list[str]:
386
495
  """Get all files in a directory recursively."""
387
496
  files = []
@@ -113,6 +113,7 @@ COMMAND_NAMES = [
113
113
  "timeout",
114
114
  "seq",
115
115
  "expr",
116
+ "shuf",
116
117
  # Checksums
117
118
  "md5sum",
118
119
  "sha1sum",
@@ -186,6 +187,7 @@ _command_loaders: list[LazyCommandDef] = [
186
187
  LazyCommandDef(name="date", load=lambda: _load_date()),
187
188
  LazyCommandDef(name="seq", load=lambda: _load_seq()),
188
189
  LazyCommandDef(name="expr", load=lambda: _load_expr()),
190
+ LazyCommandDef(name="shuf", load=lambda: _load_shuf()),
189
191
  # JSON processing
190
192
  LazyCommandDef(name="jq", load=lambda: _load_jq()),
191
193
  # High priority commands
@@ -389,6 +391,12 @@ async def _load_expr() -> Command:
389
391
  return ExprCommand()
390
392
 
391
393
 
394
+ async def _load_shuf() -> Command:
395
+ """Load the shuf command."""
396
+ from .shuf.shuf import ShufCommand
397
+ return ShufCommand()
398
+
399
+
392
400
  async def _load_uniq() -> Command:
393
401
  """Load the uniq command."""
394
402
  from .uniq.uniq import UniqCommand
@@ -0,0 +1,5 @@
1
+ """Shuf command."""
2
+
3
+ from .shuf import ShufCommand
4
+
5
+ __all__ = ["ShufCommand"]
@@ -0,0 +1,242 @@
1
+ """Shuf command implementation.
2
+
3
+ Usage: shuf [OPTION]... [FILE]
4
+ or: shuf -e [OPTION]... [ARG]...
5
+ or: shuf -i LO-HI [OPTION]...
6
+
7
+ Write a random permutation of the input lines to standard output.
8
+
9
+ Options:
10
+ -e, --echo treat each ARG as an input line
11
+ -i, --input-range=LO-HI treat each number LO through HI as an input line
12
+ -n, --head-count=COUNT output at most COUNT lines
13
+ -o, --output=FILE write result to FILE instead of standard output
14
+ -r, --repeat output lines can be repeated (requires -n)
15
+ --random-source=FILE get random bytes from FILE
16
+ """
17
+
18
+ import random
19
+ from ...types import CommandContext, ExecResult
20
+
21
+
22
+ class ShufCommand:
23
+ """The shuf command."""
24
+
25
+ name = "shuf"
26
+
27
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
28
+ """Execute the shuf command."""
29
+ # Parse arguments
30
+ echo_mode = False
31
+ input_range = None
32
+ head_count = None
33
+ output_file = None
34
+ repeat = False
35
+ random_source = None
36
+ input_args: list[str] = []
37
+ input_file = None
38
+
39
+ i = 0
40
+ while i < len(args):
41
+ arg = args[i]
42
+
43
+ if arg in ("-e", "--echo"):
44
+ echo_mode = True
45
+ elif arg in ("-r", "--repeat"):
46
+ repeat = True
47
+ elif arg == "-n":
48
+ if i + 1 >= len(args):
49
+ return ExecResult(
50
+ stdout="",
51
+ stderr="shuf: option requires an argument -- 'n'\n",
52
+ exit_code=1,
53
+ )
54
+ i += 1
55
+ try:
56
+ head_count = int(args[i])
57
+ if head_count < 0:
58
+ raise ValueError()
59
+ except ValueError:
60
+ return ExecResult(
61
+ stdout="",
62
+ stderr=f"shuf: invalid line count: '{args[i]}'\n",
63
+ exit_code=1,
64
+ )
65
+ elif arg.startswith("-n"):
66
+ try:
67
+ head_count = int(arg[2:])
68
+ if head_count < 0:
69
+ raise ValueError()
70
+ except ValueError:
71
+ return ExecResult(
72
+ stdout="",
73
+ stderr=f"shuf: invalid line count: '{arg[2:]}'\n",
74
+ exit_code=1,
75
+ )
76
+ elif arg.startswith("--head-count="):
77
+ try:
78
+ head_count = int(arg[13:])
79
+ if head_count < 0:
80
+ raise ValueError()
81
+ except ValueError:
82
+ return ExecResult(
83
+ stdout="",
84
+ stderr=f"shuf: invalid line count: '{arg[13:]}'\n",
85
+ exit_code=1,
86
+ )
87
+ elif arg == "-i":
88
+ if i + 1 >= len(args):
89
+ return ExecResult(
90
+ stdout="",
91
+ stderr="shuf: option requires an argument -- 'i'\n",
92
+ exit_code=1,
93
+ )
94
+ i += 1
95
+ input_range = args[i]
96
+ elif arg.startswith("-i"):
97
+ input_range = arg[2:]
98
+ elif arg.startswith("--input-range="):
99
+ input_range = arg[14:]
100
+ elif arg == "-o":
101
+ if i + 1 >= len(args):
102
+ return ExecResult(
103
+ stdout="",
104
+ stderr="shuf: option requires an argument -- 'o'\n",
105
+ exit_code=1,
106
+ )
107
+ i += 1
108
+ output_file = args[i]
109
+ elif arg.startswith("-o"):
110
+ output_file = arg[2:]
111
+ elif arg.startswith("--output="):
112
+ output_file = arg[9:]
113
+ elif arg.startswith("--random-source="):
114
+ random_source = arg[16:]
115
+ elif arg == "--random-source":
116
+ if i + 1 >= len(args):
117
+ return ExecResult(
118
+ stdout="",
119
+ stderr="shuf: option requires an argument -- 'random-source'\n",
120
+ exit_code=1,
121
+ )
122
+ i += 1
123
+ random_source = args[i]
124
+ elif arg.startswith("-") and len(arg) > 1 and arg != "-":
125
+ return ExecResult(
126
+ stdout="",
127
+ stderr=f"shuf: invalid option -- '{arg[1]}'\n",
128
+ exit_code=1,
129
+ )
130
+ else:
131
+ if echo_mode:
132
+ input_args.append(arg)
133
+ elif input_file is None:
134
+ input_file = arg
135
+ else:
136
+ input_args.append(arg)
137
+
138
+ i += 1
139
+
140
+ # Set up random generator
141
+ rng = random.Random()
142
+ if random_source:
143
+ try:
144
+ path = ctx.fs.resolve_path(ctx.cwd, random_source)
145
+ seed_data = await ctx.fs.read_file(path)
146
+ # Use hash of file content as seed
147
+ rng.seed(hash(seed_data))
148
+ except FileNotFoundError:
149
+ return ExecResult(
150
+ stdout="",
151
+ stderr=f"shuf: {random_source}: No such file or directory\n",
152
+ exit_code=1,
153
+ )
154
+
155
+ # Get input lines
156
+ lines: list[str] = []
157
+
158
+ if input_range:
159
+ # Parse range LO-HI
160
+ if "-" not in input_range:
161
+ return ExecResult(
162
+ stdout="",
163
+ stderr=f"shuf: invalid input range: '{input_range}'\n",
164
+ exit_code=1,
165
+ )
166
+ parts = input_range.split("-", 1)
167
+ try:
168
+ lo = int(parts[0])
169
+ hi = int(parts[1])
170
+ except ValueError:
171
+ return ExecResult(
172
+ stdout="",
173
+ stderr=f"shuf: invalid input range: '{input_range}'\n",
174
+ exit_code=1,
175
+ )
176
+ if lo > hi:
177
+ return ExecResult(
178
+ stdout="",
179
+ stderr=f"shuf: invalid input range: '{input_range}'\n",
180
+ exit_code=1,
181
+ )
182
+ lines = [str(n) for n in range(lo, hi + 1)]
183
+ elif echo_mode:
184
+ lines = input_args
185
+ else:
186
+ # Read from file or stdin
187
+ if input_file:
188
+ try:
189
+ path = ctx.fs.resolve_path(ctx.cwd, input_file)
190
+ content = await ctx.fs.read_file(path)
191
+ except FileNotFoundError:
192
+ return ExecResult(
193
+ stdout="",
194
+ stderr=f"shuf: {input_file}: No such file or directory\n",
195
+ exit_code=1,
196
+ )
197
+ else:
198
+ content = ctx.stdin
199
+
200
+ if content:
201
+ # Split into lines, preserving empty lines but removing final newline
202
+ if content.endswith("\n"):
203
+ content = content[:-1]
204
+ if content:
205
+ lines = content.split("\n")
206
+
207
+ # Handle empty input
208
+ if not lines:
209
+ if output_file:
210
+ path = ctx.fs.resolve_path(ctx.cwd, output_file)
211
+ await ctx.fs.write_file(path, "")
212
+ return ExecResult(stdout="", stderr="", exit_code=0)
213
+
214
+ # Generate output
215
+ output_lines: list[str] = []
216
+
217
+ if repeat:
218
+ # With repeat, we can output more lines than input
219
+ count = head_count if head_count is not None else len(lines)
220
+ for _ in range(count):
221
+ output_lines.append(rng.choice(lines))
222
+ else:
223
+ # Shuffle and optionally limit
224
+ shuffled = lines.copy()
225
+ rng.shuffle(shuffled)
226
+ if head_count is not None:
227
+ output_lines = shuffled[:head_count]
228
+ else:
229
+ output_lines = shuffled
230
+
231
+ # Build output
232
+ output = "\n".join(output_lines)
233
+ if output:
234
+ output += "\n"
235
+
236
+ # Write to file or stdout
237
+ if output_file:
238
+ path = ctx.fs.resolve_path(ctx.cwd, output_file)
239
+ await ctx.fs.write_file(path, output)
240
+ return ExecResult(stdout="", stderr="", exit_code=0)
241
+
242
+ return ExecResult(stdout=output, stderr="", exit_code=0)
@@ -155,6 +155,9 @@ class OverlayFs:
155
155
 
156
156
  def _is_under_mount(self, path: str) -> bool:
157
157
  """Check if a normalized path is under the mount point."""
158
+ # Special case: root mount point means all paths are under it
159
+ if self._mount_point == "/":
160
+ return True
158
161
  return path == self._mount_point or path.startswith(self._mount_point + "/")
159
162
 
160
163
  def _to_real_path(self, virtual_path: str) -> Path | None:
@@ -172,7 +175,11 @@ class OverlayFs:
172
175
  return self._root
173
176
 
174
177
  # Strip mount point prefix
175
- relative = normalized[len(self._mount_point) + 1:] # +1 for the /
178
+ # Special case: when mount_point is "/", just strip the leading "/"
179
+ if self._mount_point == "/":
180
+ relative = normalized[1:] # Just strip the leading /
181
+ else:
182
+ relative = normalized[len(self._mount_point) + 1:] # +1 for the /
176
183
  return self._root / relative
177
184
 
178
185
  def _is_deleted(self, path: str) -> bool:
@@ -543,7 +543,9 @@ class Interpreter:
543
543
  if node.name is None:
544
544
  return _ok()
545
545
 
546
- # Process redirections for heredocs
546
+ # Process redirections for heredocs and input redirections
547
+ from ..ast.types import WordNode
548
+
547
549
  for redir in node.redirections:
548
550
  if redir.operator in ("<<", "<<-"):
549
551
  # Here-document: the target should be a HereDocNode
@@ -557,6 +559,17 @@ class Interpreter:
557
559
  lines = heredoc_content.split("\n")
558
560
  heredoc_content = "\n".join(line.lstrip("\t") for line in lines)
559
561
  stdin = heredoc_content
562
+ elif redir.operator == "<":
563
+ # Input redirection: read file content into stdin
564
+ if redir.target is not None and isinstance(redir.target, WordNode):
565
+ target_path = await expand_word_async(self._ctx, redir.target)
566
+ target_path = self._fs.resolve_path(self._state.cwd, target_path)
567
+ try:
568
+ stdin = await self._fs.read_file(target_path)
569
+ except FileNotFoundError:
570
+ return _failure(f"bash: {target_path}: No such file or directory\n")
571
+ except IsADirectoryError:
572
+ return _failure(f"bash: {target_path}: Is a directory\n")
560
573
 
561
574
  try:
562
575
  # Expand command name
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: just-bash
3
- Version: 0.1.5
3
+ Version: 0.1.8
4
4
  Summary: A pure Python bash interpreter with in-memory virtual filesystem
5
5
  Project-URL: Homepage, https://github.com/dbreunig/just-bash-py
6
6
  Project-URL: Repository, https://github.com/dbreunig/just-bash-py
@@ -6,11 +6,11 @@ just_bash/ast/__init__.py,sha256=yItXk_RIPHj3Op6JKBKjxdyZaxW_1lTg5d-L-KHgDLc,457
6
6
  just_bash/ast/factory.py,sha256=TPlE5uT2XAqViKiHtJILw5vpeqrAKRUfzLQAwIb19y4,8268
7
7
  just_bash/ast/types.py,sha256=o8MdwIirjAcA1HgvQaOmrK5iwSWpYOlvvnRdkbvTBck,26574
8
8
  just_bash/commands/__init__.py,sha256=sKNb1FjmMY56svg4BJuVgm0ljhbpVl_tMKcx3jFNdE8,523
9
- just_bash/commands/registry.py,sha256=djwBIfuPqU7OK5JX6dzYG-bJHuVLBStH9b8K-hKzFHk,21962
9
+ just_bash/commands/registry.py,sha256=luqpSlvVD4TxhoG1sqEVKzCdt0sez8qYH6-Cb5KreqU,22168
10
10
  just_bash/commands/argv/__init__.py,sha256=QrGig0hlCcxkOYGzqWsw_bWVOFwxxLLeukU7wAiGDp4,85
11
11
  just_bash/commands/argv/argv.py,sha256=Qbn7gIjplMA3HSB7scnLd0wOerjcwuTfg_m5HmyTYIE,560
12
12
  just_bash/commands/awk/__init__.py,sha256=ZpgMK6A0QsApd3GyCIvpkmL3iPf7FaUPav13bdUsZhk,89
13
- just_bash/commands/awk/awk.py,sha256=Rzi14vZ36K2SIdTvIl_T7Nf0OxEumZ_x86vSZAMnnCg,40913
13
+ just_bash/commands/awk/awk.py,sha256=_xA7b2eKvAaxF6VXdkc1gni4nMLWwGWSJ-ScFdKUO10,50164
14
14
  just_bash/commands/base64/__init__.py,sha256=0hKkKgCi7m_TDz83hd3KaFJgjmBDjIqfLx7bctx98xA,101
15
15
  just_bash/commands/base64/base64.py,sha256=UtzE0e-3MAYm_61anfI3Vxm36UlOB4X9fobl9ySV8s0,4927
16
16
  just_bash/commands/basename/__init__.py,sha256=mEapj7eo70RrVGjwhVj9S9idWrBxptOPu6uV6z_cteo,94
@@ -58,7 +58,7 @@ just_bash/commands/find/find.py,sha256=I2Qmn3cSUvyiVfdh2XIYJqACpe2ggiPdxnNhPlN2d
58
58
  just_bash/commands/fold/__init__.py,sha256=R0PCq0oBJ5PWEXIeRz-M1vuNkoRdvWLaSSiXyEx9nKQ,78
59
59
  just_bash/commands/fold/fold.py,sha256=QWGehi7vdVhcAGxaAZ4lWYHAuWquduhFb515zpKL09M,5417
60
60
  just_bash/commands/grep/__init__.py,sha256=Ho400PkJRLXBf5Rt7tYF4uiS8Dbv4VhWcfkQNzqgxB4,153
61
- just_bash/commands/grep/grep.py,sha256=AaA99kpS8Kel9v_M-liLL2I1RRVIe4zowg351VopFck,16607
61
+ just_bash/commands/grep/grep.py,sha256=GKmcSSKAjNnwwuAaNEBYPQsjTp_Rj1rB3g6mffQS5Uc,20599
62
62
  just_bash/commands/head/__init__.py,sha256=UTgOtL29ZxbqIKjEXC0EjaxmlnWD8dYFFbW4YruJCDI,93
63
63
  just_bash/commands/head/head.py,sha256=41Ib8Z5uIBiiISWB-My9CAK7nDfpQJnnS7vkRwkA2yo,6083
64
64
  just_bash/commands/help/__init__.py,sha256=F-NY6nt_1vpSm4qPDd93-muE6Pz_K68wy3-_PpMNvlE,78
@@ -108,6 +108,8 @@ just_bash/commands/seq/__init__.py,sha256=WoS9bcCPBCi4kEAwqmUSyxo-1ekumaJ-QFJYLN
108
108
  just_bash/commands/seq/seq.py,sha256=cBfVJqbZMmXXXUA2FcXDJk8H2yZpTXqKLgy6-f5vciE,6485
109
109
  just_bash/commands/shell/__init__.py,sha256=7FUmyHPZv-ujikzHzks8SRTPYScfs9vr_9KJ8Ny72XM,189
110
110
  just_bash/commands/shell/shell.py,sha256=NEuSVfXz-L-0L2ev6M5ir4S9z6TvEN8-mIJgSlV1wjg,6753
111
+ just_bash/commands/shuf/__init__.py,sha256=0xLdf5wRMK4vfOoCwDWdOisHy09PGuJnkIp0oRnBhjM,78
112
+ just_bash/commands/shuf/shuf.py,sha256=BDGkvKqYvAq1I6ZGetBfvOzsaSjznwfIc2vPZ4yhoSs,8523
111
113
  just_bash/commands/sleep/__init__.py,sha256=c2BuhU78G38aj6zOZwplWhtqmbf0ydfv8y_cIdc9N3I,82
112
114
  just_bash/commands/sleep/sleep.py,sha256=tKXLGndy43nDgT-gO5jLnclkmMY_k2mU7cZh3Hxcb2M,1710
113
115
  just_bash/commands/sort/__init__.py,sha256=97Zp6z7YGf6HLvhVYsN9atRXUDfCTxr7GrfsQmY0fBY,93
@@ -153,14 +155,14 @@ just_bash/commands/yq/yq.py,sha256=Av6QQSHOvV5pp11lgvK22bJR0SEzQNuNkVvXbznc5a0,2
153
155
  just_bash/fs/__init__.py,sha256=hEDypfPi7T7tHOIyf97JOcoryKjd9fF_XAShDUyGj58,636
154
156
  just_bash/fs/in_memory_fs.py,sha256=I7pnAYyrZVTEBr05XV3cbetENAUgnqW7D-rt93RFmQQ,21347
155
157
  just_bash/fs/mountable_fs.py,sha256=xhYLRllN1mCScL4tG8q0JXyanEntyZLxMYK6hvc3Pg4,17774
156
- just_bash/fs/overlay_fs.py,sha256=WHbefQBVlIYVTKlJ7s_qdoVE2qYib1Y99bTcaHKDFxE,30405
158
+ just_bash/fs/overlay_fs.py,sha256=9ZKCw_xtH8EFXvrs1kz2sDtyf73HoVJYLgSdKGyxEIQ,30733
157
159
  just_bash/fs/read_write_fs.py,sha256=7auXvArIuKlmbQGxPNSpV8tR48tJp4mfqUhrBNEpbXc,14813
158
160
  just_bash/interpreter/__init__.py,sha256=ud2zeA0Ly0rMjhzY9zEOnLzukUi7FnJYJ0ZCMnTEuhA,790
159
161
  just_bash/interpreter/conditionals.py,sha256=wRMRiEMevhJom6KJbYC8Rxb7mfQdOmUG3cOJk8iQXDk,11169
160
162
  just_bash/interpreter/control_flow.py,sha256=k-ZDCXslQ4EJc9N1GbvDmfzPNoavpL36qZFgifLJ6A4,11997
161
163
  just_bash/interpreter/errors.py,sha256=Nz_dbvGxCybfnxFSTxKD5QIEQBz0Y6SrRA4KDhq2n20,3585
162
164
  just_bash/interpreter/expansion.py,sha256=Yc-yKt16MjsjFrdYNJ-e1JdN_B1jS5G85JvRmv6TuIM,41584
163
- just_bash/interpreter/interpreter.py,sha256=nAv9WQvGnEy-h9Si6f_Uo2TZ4f2e0o8ows1WW1E3Je8,29933
165
+ just_bash/interpreter/interpreter.py,sha256=LnQEuSHJoCY1B5hdp8WzQkh_vfjsVw04xyfNwwaObHE,30719
164
166
  just_bash/interpreter/types.py,sha256=t8v7sRSVf0_sVmXWKxAV3x1pi2vzun3yi97qtq8wanY,3992
165
167
  just_bash/interpreter/builtins/__init__.py,sha256=uGh2b8ij3DQ4oxYodRyfMhOWTFdRHPa3wuqMfOBw_ms,2461
166
168
  just_bash/interpreter/builtins/alias.py,sha256=b575DQKcNbT2uNK-7YBsPEyNZ54-YjjduUaoQ6-z150,4017
@@ -188,6 +190,6 @@ just_bash/query_engine/parser.py,sha256=pHw-il3AoaIbuvL9MBuxgv4BVsPCJjTl3OHEZmqe
188
190
  just_bash/query_engine/tokenizer.py,sha256=PfaHJ8Y8N_KkFCiti7iGP4Le4A2JjAhg3OtAMJuNIBo,10161
189
191
  just_bash/query_engine/types.py,sha256=zhUj2FvClAANpWvcvXSyvWBxT-g2jAczCiE7kNQzHrM,7272
190
192
  just_bash/query_engine/builtins/__init__.py,sha256=Rs7TjJ0rjFmL9GyQXBbU4H0k9WLbgDlT31_mhpd-1aw,40315
191
- just_bash-0.1.5.dist-info/METADATA,sha256=xWtbxsqvTCCCk1AZR-K8n81mxinzYx3hPL8VXZBieZQ,12809
192
- just_bash-0.1.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
193
- just_bash-0.1.5.dist-info/RECORD,,
193
+ just_bash-0.1.8.dist-info/METADATA,sha256=wItRiamHJQtxPWo9XJqw5AnxLaba052xs-ORi-YuKQM,12809
194
+ just_bash-0.1.8.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
195
+ just_bash-0.1.8.dist-info/RECORD,,