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
@@ -1,13 +1,14 @@
1
1
  """Read command implementation.
2
2
 
3
- Usage: read [-r] [-d delim] [-n nchars] [-p prompt] [-t timeout] [name ...]
3
+ Usage: read [-r] [-d delim] [-n nchars] [-N nchars] [-p prompt] [-t timeout] [name ...]
4
4
 
5
5
  Read a line from stdin and split it into fields.
6
6
 
7
7
  Options:
8
8
  -r Do not treat backslash as escape character
9
9
  -d delim Use delim as line delimiter instead of newline
10
- -n nchars Read only nchars characters
10
+ -n nchars Read at most nchars characters
11
+ -N nchars Read exactly nchars characters (no IFS splitting, ignores delimiters)
11
12
  -p prompt Output the string prompt before reading
12
13
  -t timeout Time out after timeout seconds
13
14
 
@@ -28,7 +29,9 @@ class ReadCommand:
28
29
  raw_mode = False
29
30
  delimiter = "\n"
30
31
  nchars = None
32
+ no_split = False # -N mode: no IFS splitting
31
33
  array_name = None # -a option
34
+ fd_num = None # -u option
32
35
  var_names = []
33
36
 
34
37
  i = 0
@@ -41,7 +44,9 @@ class ReadCommand:
41
44
  array_name = args[i]
42
45
  elif arg == "-d" and i + 1 < len(args):
43
46
  i += 1
44
- delimiter = args[i]
47
+ delimiter = args[i] if args[i] else "\0"
48
+ elif arg.startswith("-d") and len(arg) > 2:
49
+ delimiter = arg[2:]
45
50
  elif arg == "-n" and i + 1 < len(args):
46
51
  i += 1
47
52
  try:
@@ -52,33 +57,99 @@ class ReadCommand:
52
57
  stderr=f"bash: read: {args[i]}: invalid number\n",
53
58
  exit_code=1,
54
59
  )
60
+ elif arg.startswith("-n") and len(arg) > 2:
61
+ try:
62
+ nchars = int(arg[2:])
63
+ except ValueError:
64
+ return ExecResult(
65
+ stdout="",
66
+ stderr=f"bash: read: {arg[2:]}: invalid number\n",
67
+ exit_code=1,
68
+ )
69
+ elif arg == "-N" and i + 1 < len(args):
70
+ i += 1
71
+ try:
72
+ nchars = int(args[i])
73
+ except ValueError:
74
+ return ExecResult(
75
+ stdout="",
76
+ stderr=f"bash: read: {args[i]}: invalid number\n",
77
+ exit_code=1,
78
+ )
79
+ no_split = True
80
+ delimiter = "" # -N ignores delimiters
81
+ elif arg.startswith("-N") and len(arg) > 2:
82
+ try:
83
+ nchars = int(arg[2:])
84
+ except ValueError:
85
+ return ExecResult(
86
+ stdout="",
87
+ stderr=f"bash: read: {arg[2:]}: invalid number\n",
88
+ exit_code=1,
89
+ )
90
+ no_split = True
91
+ delimiter = "" # -N ignores delimiters
92
+ elif arg == "-u" and i + 1 < len(args):
93
+ i += 1
94
+ try:
95
+ fd_num = int(args[i])
96
+ except ValueError:
97
+ return ExecResult(
98
+ stdout="",
99
+ stderr=f"bash: read: {args[i]}: invalid file descriptor\n",
100
+ exit_code=1,
101
+ )
102
+ elif arg.startswith("-u") and len(arg) > 2:
103
+ try:
104
+ fd_num = int(arg[2:])
105
+ except ValueError:
106
+ return ExecResult(
107
+ stdout="",
108
+ stderr=f"bash: read: {arg[2:]}: invalid file descriptor\n",
109
+ exit_code=1,
110
+ )
55
111
  elif arg == "-p" and i + 1 < len(args):
56
- # Prompt option - we ignore it since we can't prompt
57
112
  i += 1
58
113
  elif arg == "-t" and i + 1 < len(args):
59
- # Timeout option - we ignore it
60
114
  i += 1
61
115
  elif arg.startswith("-"):
62
- # Unknown option - ignore for compatibility
63
116
  pass
64
117
  else:
65
118
  var_names.append(arg)
66
119
  i += 1
67
120
 
68
- # Default variable is REPLY
121
+ # Track whether we're using default REPLY
122
+ use_reply = not var_names and not array_name
69
123
  if not var_names:
70
124
  var_names = ["REPLY"]
71
125
 
72
- # Get input from stdin
73
- stdin = ctx.stdin or ""
126
+ # Get input from stdin or custom FD
127
+ if fd_num is not None and fd_num >= 3:
128
+ fd_contents = getattr(ctx, 'fd_contents', {})
129
+ stdin = fd_contents.get(fd_num, "")
130
+ else:
131
+ stdin = ctx.stdin or ""
132
+
133
+ # Determine if input was properly terminated by delimiter
134
+ # (affects exit code: 1 if no terminating delimiter found)
135
+ eof_reached = False
136
+ if not stdin:
137
+ eof_reached = True
138
+ elif no_split:
139
+ # -N mode: EOF if fewer chars available than requested
140
+ if nchars is not None and len(stdin) < nchars:
141
+ eof_reached = True
142
+ elif delimiter not in stdin:
143
+ eof_reached = True
74
144
 
75
145
  # Find the line to read
76
- if delimiter == "\n":
77
- # Standard line reading
146
+ if delimiter == "":
147
+ # -N mode: read raw bytes, no delimiter processing
148
+ line = stdin
149
+ elif delimiter == "\n":
78
150
  lines = stdin.split("\n")
79
151
  line = lines[0] if lines else ""
80
152
  else:
81
- # Custom delimiter
82
153
  parts = stdin.split(delimiter)
83
154
  line = parts[0] if parts else ""
84
155
 
@@ -86,99 +157,207 @@ class ReadCommand:
86
157
  if nchars is not None:
87
158
  line = line[:nchars]
88
159
 
89
- # Process backslash escapes if not in raw mode
90
- if not raw_mode:
91
- # Handle backslash-newline continuation (remove them)
160
+ # Process backslash escapes if not in raw mode and not -N mode
161
+ if not raw_mode and not no_split:
92
162
  line = line.replace("\\\n", "")
93
- # Handle other escapes
94
163
  result = []
95
- i = 0
96
- while i < len(line):
97
- if line[i] == "\\" and i + 1 < len(line):
98
- # Escape the next character
99
- result.append(line[i + 1])
100
- i += 2
164
+ ci = 0
165
+ while ci < len(line):
166
+ if line[ci] == "\\" and ci + 1 < len(line):
167
+ result.append(line[ci + 1])
168
+ ci += 2
101
169
  else:
102
- result.append(line[i])
103
- i += 1
170
+ result.append(line[ci])
171
+ ci += 1
104
172
  line = "".join(result)
105
173
 
106
- # Split on IFS
174
+ # Get IFS
107
175
  ifs = ctx.env.get("IFS", " \t\n")
108
- if ifs:
109
- # Split on IFS characters
110
- words = self._split_on_ifs(line, ifs)
111
- else:
112
- # Empty IFS - no splitting
113
- words = [line] if line else []
114
176
 
115
177
  # Handle -a option (read into array)
116
178
  if array_name:
179
+ # Split on IFS for array assignment
180
+ if no_split:
181
+ words = [line] if line else []
182
+ elif ifs:
183
+ words = self._split_on_ifs(line, ifs)
184
+ else:
185
+ words = [line] if line else []
186
+
117
187
  # Clear existing array elements
118
188
  prefix = f"{array_name}_"
119
189
  to_remove = [k for k in ctx.env if k.startswith(prefix) and not k.startswith(f"{array_name}__")]
120
190
  for k in to_remove:
121
191
  del ctx.env[k]
122
192
 
123
- # Mark as array
124
193
  ctx.env[f"{array_name}__is_array"] = "indexed"
125
194
 
126
- # Store each word as array element
127
195
  for idx, word in enumerate(words):
128
196
  ctx.env[f"{array_name}_{idx}"] = word
129
197
 
130
- exit_code = 0 if stdin else 1
131
- return ExecResult(stdout="", stderr="", exit_code=exit_code)
198
+ return ExecResult(stdout="", stderr="", exit_code=1 if eof_reached else 0)
132
199
 
133
200
  # Assign to variables
134
- for i, var in enumerate(var_names):
135
- if i < len(words):
136
- if i == len(var_names) - 1:
137
- # Last variable gets all remaining words
138
- ctx.env[var] = " ".join(words[i:])
139
- else:
140
- ctx.env[var] = words[i]
201
+ if no_split:
202
+ # -N mode: no IFS splitting at all
203
+ if len(var_names) == 1:
204
+ ctx.env[var_names[0]] = line
141
205
  else:
142
- ctx.env[var] = ""
206
+ # Even with multiple vars, -N doesn't split
207
+ ctx.env[var_names[0]] = line
208
+ for v in var_names[1:]:
209
+ ctx.env[v] = ""
210
+ elif use_reply or len(var_names) == 1:
211
+ # Single variable or REPLY: no IFS splitting
212
+ # But strip leading/trailing IFS whitespace
213
+ stripped = self._strip_ifs_whitespace(line, ifs)
214
+ ctx.env[var_names[0]] = stripped
215
+ elif not ifs:
216
+ # Empty IFS: no splitting
217
+ ctx.env[var_names[0]] = line
218
+ for v in var_names[1:]:
219
+ ctx.env[v] = ""
220
+ else:
221
+ # Multiple variables: split on IFS
222
+ self._assign_split_vars(line, var_names, ifs, ctx)
223
+
224
+ return ExecResult(stdout="", stderr="", exit_code=1 if eof_reached else 0)
225
+
226
+ def _strip_ifs_whitespace(self, value: str, ifs: str) -> str:
227
+ """Strip leading and trailing IFS whitespace characters."""
228
+ if not ifs:
229
+ return value
230
+ ifs_ws = set(c for c in ifs if c in " \t\n")
231
+ if not ifs_ws:
232
+ return value
233
+ # Strip leading
234
+ start = 0
235
+ while start < len(value) and value[start] in ifs_ws:
236
+ start += 1
237
+ # Strip trailing
238
+ end = len(value)
239
+ while end > start and value[end - 1] in ifs_ws:
240
+ end -= 1
241
+ return value[start:end]
242
+
243
+ def _assign_split_vars(self, line: str, var_names: list[str], ifs: str, ctx: CommandContext) -> None:
244
+ """Split line on IFS and assign to multiple variables.
245
+
246
+ The last variable gets the remainder of the line (preserving
247
+ original separators from the input).
248
+ """
249
+ ifs_ws = set(c for c in ifs if c in " \t\n")
250
+ ifs_nonws = set(c for c in ifs if c not in " \t\n")
251
+
252
+ # We need to track positions in the original line so the last
253
+ # variable gets the remainder from the original string
254
+ num_vars = len(var_names)
255
+ words = []
256
+ pos = 0
257
+
258
+ # Skip leading IFS whitespace
259
+ while pos < len(line) and line[pos] in ifs_ws:
260
+ pos += 1
143
261
 
144
- # Return success if we read something, failure if EOF
145
- exit_code = 0 if stdin else 1
146
- return ExecResult(stdout="", stderr="", exit_code=exit_code)
262
+ for var_idx in range(num_vars):
263
+ if pos >= len(line):
264
+ # No more input - set remaining vars to empty
265
+ for vi in range(var_idx, num_vars):
266
+ ctx.env[var_names[vi]] = ""
267
+ return
268
+
269
+ if var_idx == num_vars - 1:
270
+ # Last variable: gets the rest of the line, with trailing
271
+ # IFS whitespace stripped
272
+ remainder = line[pos:]
273
+ # Strip trailing IFS whitespace
274
+ end = len(remainder)
275
+ while end > 0 and remainder[end - 1] in ifs_ws:
276
+ end -= 1
277
+ ctx.env[var_names[var_idx]] = remainder[:end]
278
+ return
279
+
280
+ # Collect next word
281
+ word_start = pos
282
+ while pos < len(line) and line[pos] not in ifs_ws and line[pos] not in ifs_nonws:
283
+ pos += 1
284
+
285
+ word = line[word_start:pos]
286
+ ctx.env[var_names[var_idx]] = word
287
+
288
+ # Skip IFS delimiters between words
289
+ # Whitespace IFS chars: skip all consecutive
290
+ # Non-whitespace IFS chars: each one is a delimiter
291
+ # Whitespace around non-whitespace is part of the delimiter
292
+ if pos < len(line):
293
+ # Skip leading whitespace
294
+ while pos < len(line) and line[pos] in ifs_ws:
295
+ pos += 1
296
+ # If we hit a non-whitespace delimiter, consume it
297
+ if pos < len(line) and line[pos] in ifs_nonws:
298
+ pos += 1
299
+ # Skip trailing whitespace after non-ws delimiter
300
+ while pos < len(line) and line[pos] in ifs_ws:
301
+ pos += 1
302
+
303
+ # Shouldn't reach here, but just in case
304
+ for vi in range(len(words), num_vars):
305
+ if var_names[vi] not in ctx.env:
306
+ ctx.env[var_names[vi]] = ""
147
307
 
148
308
  def _split_on_ifs(self, value: str, ifs: str) -> list[str]:
149
- """Split a string on IFS characters."""
309
+ """Split a string on IFS characters.
310
+
311
+ Follows bash IFS splitting rules:
312
+ - IFS whitespace (space, tab, newline): leading/trailing stripped,
313
+ consecutive act as single separator
314
+ - IFS non-whitespace: each occurrence is a separator, consecutive
315
+ produce empty fields
316
+ - Mixed: whitespace adjacent to non-whitespace is part of the delimiter
317
+ """
150
318
  if not value:
151
319
  return []
152
320
 
153
- # Identify IFS whitespace vs non-whitespace
154
- ifs_whitespace = "".join(c for c in ifs if c in " \t\n")
321
+ ifs_ws = set(c for c in ifs if c in " \t\n")
322
+ ifs_nonws = set(c for c in ifs if c not in " \t\n")
155
323
 
156
- # Simple split for whitespace-only IFS
157
- if ifs == ifs_whitespace:
324
+ # Whitespace-only IFS: simple split (strips leading/trailing, merges consecutive)
325
+ if not ifs_nonws:
158
326
  return value.split()
159
327
 
160
- # Complex case with non-whitespace delimiters
161
328
  result = []
162
329
  current = []
163
- i = 0
164
- while i < len(value):
165
- c = value[i]
166
- if c in ifs_whitespace:
330
+ pos = 0
331
+
332
+ # Skip leading IFS whitespace
333
+ while pos < len(value) and value[pos] in ifs_ws:
334
+ pos += 1
335
+
336
+ while pos < len(value):
337
+ c = value[pos]
338
+ if c in ifs_nonws:
339
+ # Non-whitespace delimiter: always produces field boundary
340
+ result.append("".join(current))
341
+ current = []
342
+ pos += 1
343
+ # Skip trailing IFS whitespace after non-ws delimiter
344
+ while pos < len(value) and value[pos] in ifs_ws:
345
+ pos += 1
346
+ elif c in ifs_ws:
347
+ # IFS whitespace
167
348
  if current:
168
349
  result.append("".join(current))
169
350
  current = []
170
351
  # Skip consecutive whitespace
171
- while i < len(value) and value[i] in ifs_whitespace:
172
- i += 1
173
- elif c in ifs:
174
- # Non-whitespace delimiter
175
- result.append("".join(current))
176
- current = []
177
- i += 1
352
+ while pos < len(value) and value[pos] in ifs_ws:
353
+ pos += 1
354
+ # If next char is non-ws delimiter, it's part of this delimiter run
355
+ # (don't start a new field yet - the non-ws handler will do it)
178
356
  else:
179
357
  current.append(c)
180
- i += 1
358
+ pos += 1
181
359
 
360
+ # Add last field if non-empty
182
361
  if current:
183
362
  result.append("".join(current))
184
363
 
@@ -55,15 +55,9 @@ class ReadlinkCommand:
55
55
  stat = await ctx.fs.lstat(resolved)
56
56
 
57
57
  if canonicalize:
58
- # Return the canonical path
59
- if stat.is_symbolic_link:
60
- target = await ctx.fs.readlink(resolved)
61
- if not target.startswith("/"):
62
- import os
63
- target = ctx.fs.resolve_path(os.path.dirname(resolved), target)
64
- stdout_parts.append(target)
65
- else:
66
- stdout_parts.append(resolved)
58
+ # Return the canonical path (fully resolved)
59
+ canonical = await ctx.fs.realpath(resolved)
60
+ stdout_parts.append(canonical)
67
61
  else:
68
62
  # Only works on symlinks
69
63
  if stat.is_symbolic_link:
@@ -127,6 +127,9 @@ COMMAND_NAMES = [
127
127
  "tac",
128
128
  "hostname",
129
129
  "od",
130
+ "rmdir",
131
+ "time",
132
+ "whoami",
130
133
  # Testing utilities
131
134
  "argv.py",
132
135
  # Builtins
@@ -243,6 +246,9 @@ _command_loaders: list[LazyCommandDef] = [
243
246
  LazyCommandDef(name="comm", load=lambda: _load_comm()),
244
247
  LazyCommandDef(name="strings", load=lambda: _load_strings()),
245
248
  LazyCommandDef(name="od", load=lambda: _load_od()),
249
+ LazyCommandDef(name="rmdir", load=lambda: _load_rmdir()),
250
+ LazyCommandDef(name="time", load=lambda: _load_time()),
251
+ LazyCommandDef(name="whoami", load=lambda: _load_whoami()),
246
252
  # Testing utilities
247
253
  LazyCommandDef(name="argv.py", load=lambda: _load_argv()),
248
254
  # Builtins
@@ -697,6 +703,24 @@ async def _load_od() -> Command:
697
703
  return OdCommand()
698
704
 
699
705
 
706
+ async def _load_rmdir() -> Command:
707
+ """Load the rmdir command."""
708
+ from .rmdir.rmdir import RmdirCommand
709
+ return RmdirCommand()
710
+
711
+
712
+ async def _load_time() -> Command:
713
+ """Load the time command."""
714
+ from .time.time import TimeCommand
715
+ return TimeCommand()
716
+
717
+
718
+ async def _load_whoami() -> Command:
719
+ """Load the whoami command."""
720
+ from .whoami.whoami import WhoamiCommand
721
+ return WhoamiCommand()
722
+
723
+
700
724
  async def _load_argv() -> Command:
701
725
  """Load the argv.py command."""
702
726
  from .argv.argv import ArgvCommand
@@ -0,0 +1,5 @@
1
+ """Rmdir command."""
2
+
3
+ from .rmdir import RmdirCommand
4
+
5
+ __all__ = ["RmdirCommand"]
@@ -0,0 +1,160 @@
1
+ """Rmdir command implementation.
2
+
3
+ Usage: rmdir [OPTION]... DIRECTORY...
4
+
5
+ Remove empty directories.
6
+
7
+ Options:
8
+ -p, --parents remove DIRECTORY and its ancestors
9
+ -v, --verbose output a diagnostic for every directory processed
10
+ """
11
+
12
+ from ...types import CommandContext, ExecResult
13
+
14
+
15
+ class RmdirCommand:
16
+ """The rmdir command."""
17
+
18
+ name = "rmdir"
19
+
20
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
21
+ """Execute the rmdir command."""
22
+ parents = False
23
+ verbose = False
24
+ directories: list[str] = []
25
+
26
+ # Parse arguments
27
+ i = 0
28
+ while i < len(args):
29
+ arg = args[i]
30
+ if arg == "--":
31
+ directories.extend(args[i + 1:])
32
+ break
33
+ elif arg.startswith("--"):
34
+ if arg == "--parents":
35
+ parents = True
36
+ elif arg == "--verbose":
37
+ verbose = True
38
+ else:
39
+ return ExecResult(
40
+ stdout="",
41
+ stderr=f"rmdir: unrecognized option '{arg}'\n",
42
+ exit_code=1,
43
+ )
44
+ elif arg.startswith("-") and arg != "-":
45
+ for c in arg[1:]:
46
+ if c == "p":
47
+ parents = True
48
+ elif c == "v":
49
+ verbose = True
50
+ else:
51
+ return ExecResult(
52
+ stdout="",
53
+ stderr=f"rmdir: invalid option -- '{c}'\n",
54
+ exit_code=1,
55
+ )
56
+ else:
57
+ directories.append(arg)
58
+ i += 1
59
+
60
+ if not directories:
61
+ return ExecResult(
62
+ stdout="",
63
+ stderr="rmdir: missing operand\n",
64
+ exit_code=1,
65
+ )
66
+
67
+ stdout = ""
68
+ stderr = ""
69
+ exit_code = 0
70
+
71
+ for directory in directories:
72
+ result = await self._rmdir_one(
73
+ ctx, directory, parents, verbose
74
+ )
75
+ stdout += result.stdout
76
+ stderr += result.stderr
77
+ if result.exit_code != 0:
78
+ exit_code = result.exit_code
79
+
80
+ return ExecResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
81
+
82
+ async def _rmdir_one(
83
+ self,
84
+ ctx: CommandContext,
85
+ directory: str,
86
+ parents: bool,
87
+ verbose: bool,
88
+ ) -> ExecResult:
89
+ """Remove a single directory (and optionally its parents)."""
90
+ path = ctx.fs.resolve_path(ctx.cwd, directory)
91
+ stdout = ""
92
+ stderr = ""
93
+
94
+ # Get list of directories to remove
95
+ dirs_to_remove = [path]
96
+ if parents:
97
+ # Add parent directories up to root
98
+ current = path
99
+ while True:
100
+ parent = self._dirname(current)
101
+ if parent == current or parent == "/":
102
+ break
103
+ dirs_to_remove.append(parent)
104
+ current = parent
105
+
106
+ # Try to remove directories
107
+ for dir_path in dirs_to_remove:
108
+ try:
109
+ # Check if path exists
110
+ try:
111
+ stat = await ctx.fs.stat(dir_path)
112
+ except FileNotFoundError:
113
+ return ExecResult(
114
+ stdout=stdout,
115
+ stderr=stderr + f"rmdir: failed to remove '{dir_path}': No such file or directory\n",
116
+ exit_code=1,
117
+ )
118
+
119
+ if not stat.is_directory:
120
+ return ExecResult(
121
+ stdout=stdout,
122
+ stderr=stderr + f"rmdir: failed to remove '{dir_path}': Not a directory\n",
123
+ exit_code=1,
124
+ )
125
+
126
+ # Check if directory is empty
127
+ contents = await ctx.fs.readdir(dir_path)
128
+ if contents:
129
+ return ExecResult(
130
+ stdout=stdout,
131
+ stderr=stderr + f"rmdir: failed to remove '{dir_path}': Directory not empty\n",
132
+ exit_code=1,
133
+ )
134
+
135
+ # Remove the directory
136
+ await ctx.fs.rm(dir_path)
137
+
138
+ if verbose:
139
+ stdout += f"rmdir: removing directory, '{dir_path}'\n"
140
+
141
+ except OSError as e:
142
+ return ExecResult(
143
+ stdout=stdout,
144
+ stderr=stderr + f"rmdir: failed to remove '{dir_path}': {e}\n",
145
+ exit_code=1,
146
+ )
147
+
148
+ return ExecResult(stdout=stdout, stderr=stderr, exit_code=0)
149
+
150
+ def _dirname(self, path: str) -> str:
151
+ """Get the directory name of a path."""
152
+ if path == "/":
153
+ return "/"
154
+ path = path.rstrip("/")
155
+ last_slash = path.rfind("/")
156
+ if last_slash == -1:
157
+ return "."
158
+ if last_slash == 0:
159
+ return "/"
160
+ return path[:last_slash]