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
@@ -14,76 +14,73 @@ class PrintfCommand:
14
14
  if not args:
15
15
  return ExecResult(
16
16
  stdout="",
17
- stderr="printf: usage: printf format [arguments]\n",
17
+ stderr="printf: usage: printf [-v var] format [arguments]\n",
18
18
  exit_code=2,
19
19
  )
20
20
 
21
- format_str = args[0]
22
- arguments = args[1:]
21
+ # Parse -v option and -- end-of-options
22
+ var_name = None
23
+ format_start = 0
24
+ if len(args) >= 2 and args[0] == "-v":
25
+ var_name = args[1]
26
+ format_start = 2
27
+ if format_start < len(args) and args[format_start] == "--":
28
+ format_start += 1
29
+
30
+ if len(args) <= format_start:
31
+ return ExecResult(
32
+ stdout="",
33
+ stderr="printf: usage: printf [-v var] format [arguments]\n",
34
+ exit_code=2,
35
+ )
36
+
37
+ format_str = args[format_start]
38
+ arguments = args[format_start + 1:]
23
39
 
24
40
  try:
25
41
  output = self._format(format_str, arguments)
42
+
43
+ if var_name is not None:
44
+ # Assign to variable instead of printing
45
+ ctx.env[var_name] = output
46
+ return ExecResult(stdout="", stderr="", exit_code=0)
47
+
26
48
  return ExecResult(stdout=output, stderr="", exit_code=0)
27
49
  except ValueError as e:
28
50
  return ExecResult(stdout="", stderr=f"printf: {e}\n", exit_code=1)
29
51
 
30
52
  def _format(self, format_str: str, arguments: list[str]) -> str:
31
- """Format the string with arguments."""
53
+ """Format the string with arguments.
54
+
55
+ Supports format reuse: if there are more arguments than format specifiers,
56
+ the format string is reused for the remaining arguments.
57
+ """
32
58
  result = []
33
59
  arg_index = 0
60
+
61
+ # Continue formatting until all arguments are consumed
62
+ while True:
63
+ start_arg_index = arg_index
64
+ formatted, arg_index = self._format_once(format_str, arguments, arg_index)
65
+ result.append(formatted)
66
+
67
+ # If no arguments were consumed or all arguments are consumed, stop
68
+ if arg_index == start_arg_index or arg_index >= len(arguments):
69
+ break
70
+
71
+ return "".join(result)
72
+
73
+ def _format_once(self, format_str: str, arguments: list[str], arg_index: int) -> tuple[str, int]:
74
+ """Format the string once, returning formatted string and new arg index."""
75
+ result = []
34
76
  i = 0
35
77
 
36
78
  while i < len(format_str):
37
79
  if format_str[i] == "\\" and i + 1 < len(format_str):
38
80
  # Handle escape sequences
39
- escape_char = format_str[i + 1]
40
- if escape_char == "n":
41
- result.append("\n")
42
- elif escape_char == "t":
43
- result.append("\t")
44
- elif escape_char == "r":
45
- result.append("\r")
46
- elif escape_char == "\\":
47
- result.append("\\")
48
- elif escape_char == "a":
49
- result.append("\a")
50
- elif escape_char == "b":
51
- result.append("\b")
52
- elif escape_char == "f":
53
- result.append("\f")
54
- elif escape_char == "v":
55
- result.append("\v")
56
- elif escape_char == "e" or escape_char == "E":
57
- result.append("\x1b")
58
- elif escape_char == "0":
59
- # Octal escape
60
- octal = ""
61
- j = i + 2
62
- while j < len(format_str) and len(octal) < 3 and format_str[j] in "01234567":
63
- octal += format_str[j]
64
- j += 1
65
- if octal:
66
- result.append(chr(int(octal, 8)))
67
- i = j
68
- continue
69
- else:
70
- result.append("\0")
71
- elif escape_char == "x":
72
- # Hex escape
73
- hex_digits = ""
74
- j = i + 2
75
- while j < len(format_str) and len(hex_digits) < 2 and format_str[j] in "0123456789abcdefABCDEF":
76
- hex_digits += format_str[j]
77
- j += 1
78
- if hex_digits:
79
- result.append(chr(int(hex_digits, 16)))
80
- i = j
81
- continue
82
- else:
83
- result.append(escape_char)
84
- else:
85
- result.append(escape_char)
86
- i += 2
81
+ escape_result, consumed = self._process_escape(format_str, i)
82
+ result.append(escape_result)
83
+ i += consumed
87
84
  elif format_str[i] == "%" and i + 1 < len(format_str):
88
85
  # Handle format specifiers
89
86
  if format_str[i + 1] == "%":
@@ -91,12 +88,47 @@ class PrintfCommand:
91
88
  i += 2
92
89
  continue
93
90
 
94
- # Parse format specifier
95
- spec_match = re.match(r"-?(\d+)?(\.\d+)?([diouxXeEfFgGsbc])", format_str[i + 1:])
91
+ # Parse format specifier with full support
92
+ # Pattern: %[flags][width][.precision]specifier
93
+ # Flags: -, +, space, #, 0
94
+ # Width: number or *
95
+ # Precision: .number or .*
96
+ spec_pattern = r"([-+# 0]*)(\*|\d+)?(?:\.(\*|\d*))?([diouxXeEfFgGsbcq])"
97
+ spec_match = re.match(spec_pattern, format_str[i + 1:])
98
+
96
99
  if spec_match:
97
- spec = spec_match.group(0)
98
- full_spec = "%" + spec
99
- spec_type = spec_match.group(3)
100
+ flags = spec_match.group(1) or ""
101
+ width_spec = spec_match.group(2)
102
+ precision_spec = spec_match.group(3)
103
+ spec_type = spec_match.group(4)
104
+
105
+ # Handle * for width
106
+ width = None
107
+ if width_spec == "*":
108
+ if arg_index < len(arguments):
109
+ try:
110
+ width = int(arguments[arg_index])
111
+ except ValueError:
112
+ width = 0
113
+ arg_index += 1
114
+ else:
115
+ width = 0
116
+ elif width_spec:
117
+ width = int(width_spec)
118
+
119
+ # Handle * for precision
120
+ precision = None
121
+ if precision_spec == "*":
122
+ if arg_index < len(arguments):
123
+ try:
124
+ precision = int(arguments[arg_index])
125
+ except ValueError:
126
+ precision = 0
127
+ arg_index += 1
128
+ else:
129
+ precision = 0
130
+ elif precision_spec is not None:
131
+ precision = int(precision_spec) if precision_spec else 0
100
132
 
101
133
  # Get argument
102
134
  if arg_index < len(arguments):
@@ -106,24 +138,10 @@ class PrintfCommand:
106
138
  arg = ""
107
139
 
108
140
  # Format based on type
109
- try:
110
- if spec_type in "diouxX":
111
- val = int(arg) if arg else 0
112
- result.append(full_spec % val)
113
- elif spec_type in "eEfFgG":
114
- val = float(arg) if arg else 0.0
115
- result.append(full_spec % val)
116
- elif spec_type == "s":
117
- result.append(full_spec % arg)
118
- elif spec_type == "c":
119
- result.append(arg[0] if arg else "")
120
- elif spec_type == "b":
121
- # %b is like %s but interprets escapes
122
- result.append(self._process_escapes(arg))
123
- except (ValueError, TypeError):
124
- result.append(full_spec % 0 if spec_type in "diouxXeEfFgG" else "")
125
-
126
- i += 1 + len(spec)
141
+ formatted = self._format_specifier(spec_type, arg, flags, width, precision)
142
+ result.append(formatted)
143
+
144
+ i += 1 + len(spec_match.group(0))
127
145
  else:
128
146
  result.append(format_str[i])
129
147
  i += 1
@@ -131,26 +149,210 @@ class PrintfCommand:
131
149
  result.append(format_str[i])
132
150
  i += 1
133
151
 
152
+ return "".join(result), arg_index
153
+
154
+ def _format_specifier(self, spec_type: str, arg: str, flags: str, width: int | None, precision: int | None) -> str:
155
+ """Format a single specifier."""
156
+ try:
157
+ if spec_type == "q":
158
+ # Shell quoting
159
+ return self._shell_quote(arg)
160
+ elif spec_type in "diouxX":
161
+ val = self._parse_numeric_arg(arg)
162
+ fmt = self._build_format_string(spec_type, flags, width, precision)
163
+ return fmt % val
164
+ elif spec_type in "eEfFgG":
165
+ val = float(arg) if arg else 0.0
166
+ fmt = self._build_format_string(spec_type, flags, width, precision)
167
+ return fmt % val
168
+ elif spec_type == "s":
169
+ fmt = self._build_format_string(spec_type, flags, width, precision)
170
+ return fmt % arg
171
+ elif spec_type == "c":
172
+ return arg[0] if arg else ""
173
+ elif spec_type == "b":
174
+ # %b is like %s but interprets escapes
175
+ processed = self._process_escapes(arg)
176
+ if width is not None:
177
+ if "-" in flags:
178
+ return processed.ljust(width)
179
+ else:
180
+ return processed.rjust(width)
181
+ return processed
182
+ else:
183
+ return ""
184
+ except (ValueError, TypeError):
185
+ if spec_type in "diouxXeEfFgG":
186
+ return "0"
187
+ return ""
188
+
189
+ def _parse_numeric_arg(self, arg: str) -> int:
190
+ """Parse a numeric argument, handling hex, octal, and character notation."""
191
+ if not arg:
192
+ return 0
193
+ # Character notation: 'c or "c
194
+ if len(arg) >= 2 and arg[0] in ("'", '"'):
195
+ return ord(arg[1])
196
+ # Handle sign
197
+ s = arg.strip()
198
+ sign = 1
199
+ if s.startswith("-"):
200
+ sign = -1
201
+ s = s[1:]
202
+ elif s.startswith("+"):
203
+ s = s[1:]
204
+ try:
205
+ # Hex: 0x or 0X
206
+ if s.startswith("0x") or s.startswith("0X"):
207
+ return sign * int(s, 16)
208
+ # Octal: leading 0 followed by digits
209
+ if len(s) > 1 and s[0] == "0" and all(c in "01234567" for c in s[1:]):
210
+ return sign * int(s, 8)
211
+ return sign * int(s)
212
+ except ValueError:
213
+ return 0
214
+
215
+ def _build_format_string(self, spec_type: str, flags: str, width: int | None, precision: int | None) -> str:
216
+ """Build a Python format string from components."""
217
+ fmt = "%"
218
+ fmt += flags
219
+ if width is not None:
220
+ fmt += str(abs(width))
221
+ if width < 0:
222
+ # Negative width means left-justify
223
+ fmt = "%-" + fmt[1:]
224
+ if precision is not None:
225
+ fmt += f".{precision}"
226
+ fmt += spec_type
227
+ return fmt
228
+
229
+ def _shell_quote(self, s: str) -> str:
230
+ """Quote a string for shell use."""
231
+ if not s:
232
+ return "''"
233
+
234
+ # Check if quoting is needed
235
+ safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-")
236
+ if all(c in safe_chars for c in s):
237
+ return s
238
+
239
+ # Use $'...' format for strings with special chars
240
+ result = ["$'"]
241
+ for c in s:
242
+ if c == "'":
243
+ result.append("\\'")
244
+ elif c == "\\":
245
+ result.append("\\\\")
246
+ elif c == "\n":
247
+ result.append("\\n")
248
+ elif c == "\t":
249
+ result.append("\\t")
250
+ elif c == "\r":
251
+ result.append("\\r")
252
+ elif ord(c) < 32 or ord(c) > 126:
253
+ result.append(f"\\x{ord(c):02x}")
254
+ else:
255
+ result.append(c)
256
+ result.append("'")
134
257
  return "".join(result)
135
258
 
259
+ def _process_escape(self, s: str, i: int) -> tuple[str, int]:
260
+ """Process an escape sequence starting at position i.
261
+
262
+ Returns (result_string, characters_consumed).
263
+ """
264
+ if i + 1 >= len(s):
265
+ return ("\\", 1)
266
+
267
+ escape_char = s[i + 1]
268
+ escape_map = {
269
+ "n": "\n",
270
+ "t": "\t",
271
+ "r": "\r",
272
+ "\\": "\\",
273
+ "a": "\a",
274
+ "b": "\b",
275
+ "f": "\f",
276
+ "v": "\v",
277
+ "e": "\x1b",
278
+ "E": "\x1b",
279
+ }
280
+
281
+ if escape_char in escape_map:
282
+ return (escape_map[escape_char], 2)
283
+ elif escape_char in "01234567":
284
+ # Octal escape: \NNN - first digit plus up to 2 more (3 total)
285
+ octal = escape_char
286
+ j = i + 2
287
+ while j < len(s) and len(octal) < 3 and s[j] in "01234567":
288
+ octal += s[j]
289
+ j += 1
290
+ return (chr(int(octal, 8) & 0xFF), j - i)
291
+ elif escape_char == "x":
292
+ # Hex escape - collect consecutive \xHH sequences and try UTF-8 decoding
293
+ hex_bytes = []
294
+ j = i
295
+ while j < len(s) and s[j:j+2] == "\\x":
296
+ hex_digits = ""
297
+ k = j + 2
298
+ while k < len(s) and len(hex_digits) < 2 and s[k] in "0123456789abcdefABCDEF":
299
+ hex_digits += s[k]
300
+ k += 1
301
+ if hex_digits:
302
+ hex_bytes.append(int(hex_digits, 16))
303
+ j = k
304
+ else:
305
+ break
306
+
307
+ if hex_bytes:
308
+ # Try UTF-8 decoding first
309
+ byte_data = bytes(hex_bytes)
310
+ try:
311
+ decoded = byte_data.decode("utf-8")
312
+ return (decoded, j - i)
313
+ except UnicodeDecodeError:
314
+ # Fall back to Latin-1 (1:1 byte to codepoint)
315
+ return (byte_data.decode("latin-1"), j - i)
316
+ else:
317
+ return (escape_char, 2)
318
+ elif escape_char == "u":
319
+ # Unicode escape \uHHHH
320
+ hex_digits = ""
321
+ j = i + 2
322
+ while j < len(s) and len(hex_digits) < 4 and s[j] in "0123456789abcdefABCDEF":
323
+ hex_digits += s[j]
324
+ j += 1
325
+ if hex_digits:
326
+ try:
327
+ return (chr(int(hex_digits, 16)), j - i)
328
+ except ValueError:
329
+ return (escape_char, 2)
330
+ return (escape_char, 2)
331
+ elif escape_char == "U":
332
+ # Unicode escape \UHHHHHHHH
333
+ hex_digits = ""
334
+ j = i + 2
335
+ while j < len(s) and len(hex_digits) < 8 and s[j] in "0123456789abcdefABCDEF":
336
+ hex_digits += s[j]
337
+ j += 1
338
+ if hex_digits:
339
+ try:
340
+ return (chr(int(hex_digits, 16)), j - i)
341
+ except ValueError:
342
+ return (escape_char, 2)
343
+ return (escape_char, 2)
344
+ else:
345
+ return (escape_char, 2)
346
+
136
347
  def _process_escapes(self, s: str) -> str:
137
348
  """Process escape sequences in a string."""
138
349
  result = []
139
350
  i = 0
140
351
  while i < len(s):
141
352
  if s[i] == "\\" and i + 1 < len(s):
142
- c = s[i + 1]
143
- if c == "n":
144
- result.append("\n")
145
- elif c == "t":
146
- result.append("\t")
147
- elif c == "r":
148
- result.append("\r")
149
- elif c == "\\":
150
- result.append("\\")
151
- else:
152
- result.append(c)
153
- i += 2
353
+ escaped, consumed = self._process_escape(s, i)
354
+ result.append(escaped)
355
+ i += consumed
154
356
  else:
155
357
  result.append(s[i])
156
358
  i += 1
@@ -19,5 +19,35 @@ class PwdCommand:
19
19
 
20
20
  async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
21
21
  """Execute the pwd command."""
22
- # For now, just return cwd (ignore -L/-P options in virtual fs)
23
- return ExecResult(stdout=f"{ctx.cwd}\n", stderr="", exit_code=0)
22
+ physical = False
23
+
24
+ # Parse arguments
25
+ for arg in args:
26
+ if arg == "-P":
27
+ physical = True
28
+ elif arg == "-L":
29
+ physical = False
30
+ elif arg.startswith("-"):
31
+ # Check for combined flags like -LP or -PL
32
+ for c in arg[1:]:
33
+ if c == "P":
34
+ physical = True
35
+ elif c == "L":
36
+ physical = False
37
+ else:
38
+ return ExecResult(
39
+ stdout="",
40
+ stderr=f"pwd: invalid option -- '{c}'\n",
41
+ exit_code=1,
42
+ )
43
+
44
+ if physical:
45
+ # Resolve symlinks in cwd
46
+ try:
47
+ resolved = await ctx.fs.realpath(ctx.cwd)
48
+ return ExecResult(stdout=f"{resolved}\n", stderr="", exit_code=0)
49
+ except (FileNotFoundError, OSError):
50
+ return ExecResult(stdout=f"{ctx.cwd}\n", stderr="", exit_code=0)
51
+ else:
52
+ # Return logical path (cwd as-is)
53
+ return ExecResult(stdout=f"{ctx.cwd}\n", stderr="", exit_code=0)