just-bash 0.1.5__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 (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,623 @@
1
+ """Find command implementation.
2
+
3
+ Usage: find [path...] [expression]
4
+
5
+ Search for files in a directory hierarchy.
6
+
7
+ Note: -user and -group predicates are not implemented as they are not
8
+ applicable in an in-memory virtual filesystem designed for sandboxed execution.
9
+
10
+ Options:
11
+ -name PATTERN match file name (shell glob)
12
+ -iname PATTERN like -name but case insensitive
13
+ -type TYPE file type (f=file, d=directory, l=symlink)
14
+ -size N[cwbkMG] file size (+ for greater, - for less)
15
+ -mtime N modification time in days (+ older, - newer)
16
+ -newer FILE newer than FILE
17
+ -path PATTERN match full path
18
+ -regex PATTERN match path with regex
19
+ -maxdepth N descend at most N levels
20
+ -mindepth N do not apply tests at levels less than N
21
+ -empty match empty files/directories
22
+ -perm MODE match permission bits
23
+
24
+ Actions:
25
+ -print print path (default)
26
+ -print0 print path with null terminator
27
+ -delete delete matched files
28
+ -exec CMD {} ; execute command
29
+
30
+ Operators:
31
+ -and, -a logical AND (implicit)
32
+ -or, -o logical OR
33
+ -not, ! logical NOT
34
+ ( expr ) grouping
35
+ """
36
+
37
+ import fnmatch
38
+ import re
39
+ import time
40
+ from dataclasses import dataclass
41
+ from typing import Any
42
+ from ...types import CommandContext, ExecResult
43
+
44
+
45
+ @dataclass
46
+ class Expression:
47
+ """A find expression."""
48
+
49
+ type: str # "predicate", "and", "or", "not", "group"
50
+ predicate: str = ""
51
+ value: Any = None
52
+ left: "Expression | None" = None
53
+ right: "Expression | None" = None
54
+ inner: "Expression | None" = None
55
+
56
+
57
+ class FindCommand:
58
+ """The find command."""
59
+
60
+ name = "find"
61
+
62
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
63
+ """Execute the find command."""
64
+ paths: list[str] = []
65
+ i = 0
66
+ maxdepth = -1
67
+ mindepth = 0
68
+
69
+ # Parse paths (before first -option or !)
70
+ while i < len(args):
71
+ arg = args[i]
72
+ if arg.startswith("-") or arg == "!" or arg == "(":
73
+ break
74
+ paths.append(arg)
75
+ i += 1
76
+
77
+ # Default to current directory
78
+ if not paths:
79
+ paths = ["."]
80
+
81
+ # Parse depth options first
82
+ j = i
83
+ while j < len(args):
84
+ if args[j] == "-maxdepth" and j + 1 < len(args):
85
+ try:
86
+ maxdepth = int(args[j + 1])
87
+ except ValueError:
88
+ return ExecResult(
89
+ stdout="",
90
+ stderr=f"find: invalid argument '{args[j + 1]}' to '-maxdepth'\n",
91
+ exit_code=1,
92
+ )
93
+ elif args[j] == "-mindepth" and j + 1 < len(args):
94
+ try:
95
+ mindepth = int(args[j + 1])
96
+ except ValueError:
97
+ return ExecResult(
98
+ stdout="",
99
+ stderr=f"find: invalid argument '{args[j + 1]}' to '-mindepth'\n",
100
+ exit_code=1,
101
+ )
102
+ j += 1
103
+
104
+ # Parse expression
105
+ expr_args = args[i:]
106
+ # Remove depth options from expression parsing
107
+ filtered_args = []
108
+ j = 0
109
+ while j < len(expr_args):
110
+ if expr_args[j] in ("-maxdepth", "-mindepth"):
111
+ j += 2
112
+ else:
113
+ filtered_args.append(expr_args[j])
114
+ j += 1
115
+
116
+ try:
117
+ expr, action = self._parse_expression(filtered_args)
118
+ except ValueError as e:
119
+ return ExecResult(
120
+ stdout="",
121
+ stderr=f"find: {e}\n",
122
+ exit_code=1,
123
+ )
124
+
125
+ # Default action is -print
126
+ if action is None:
127
+ action = ("print", None)
128
+
129
+ # Execute find
130
+ output = ""
131
+ stderr = ""
132
+ exit_code = 0
133
+
134
+ for path in paths:
135
+ try:
136
+ resolved = ctx.fs.resolve_path(ctx.cwd, path)
137
+ result, err = await self._find_recursive(
138
+ ctx, resolved, path, expr, action, 0, maxdepth, mindepth
139
+ )
140
+ output += result
141
+ stderr += err
142
+ except FileNotFoundError:
143
+ stderr += f"find: '{path}': No such file or directory\n"
144
+ exit_code = 1
145
+
146
+ if exit_code == 0 and stderr:
147
+ exit_code = 1
148
+
149
+ return ExecResult(stdout=output, stderr=stderr, exit_code=exit_code)
150
+
151
+ def _parse_expression(
152
+ self, args: list[str]
153
+ ) -> tuple[Expression | None, tuple[str, Any] | None]:
154
+ """Parse find expression from arguments."""
155
+ if not args:
156
+ return None, None
157
+
158
+ expr, pos, action = self._parse_or(args, 0)
159
+ return expr, action
160
+
161
+ def _parse_or(
162
+ self, args: list[str], pos: int
163
+ ) -> tuple[Expression | None, int, tuple[str, Any] | None]:
164
+ """Parse OR expression."""
165
+ left, pos, action = self._parse_and(args, pos)
166
+
167
+ while pos < len(args) and args[pos] in ("-or", "-o"):
168
+ pos += 1
169
+ right, pos, act2 = self._parse_and(args, pos)
170
+ if act2:
171
+ action = act2
172
+ left = Expression(type="or", left=left, right=right)
173
+
174
+ return left, pos, action
175
+
176
+ def _parse_and(
177
+ self, args: list[str], pos: int
178
+ ) -> tuple[Expression | None, int, tuple[str, Any] | None]:
179
+ """Parse AND expression."""
180
+ left, pos, action = self._parse_not(args, pos)
181
+
182
+ while pos < len(args) and (args[pos] in ("-and", "-a") or (
183
+ args[pos] not in ("-or", "-o", ")") and not args[pos].startswith("-print") and args[pos] != "-delete"
184
+ )):
185
+ if args[pos] in ("-and", "-a"):
186
+ pos += 1
187
+ right, pos, act2 = self._parse_not(args, pos)
188
+ if act2:
189
+ action = act2
190
+ if right:
191
+ left = Expression(type="and", left=left, right=right)
192
+
193
+ return left, pos, action
194
+
195
+ def _parse_not(
196
+ self, args: list[str], pos: int
197
+ ) -> tuple[Expression | None, int, tuple[str, Any] | None]:
198
+ """Parse NOT expression."""
199
+ if pos < len(args) and args[pos] in ("-not", "!"):
200
+ pos += 1
201
+ inner, pos, action = self._parse_primary(args, pos)
202
+ return Expression(type="not", inner=inner), pos, action
203
+
204
+ return self._parse_primary(args, pos)
205
+
206
+ def _parse_primary(
207
+ self, args: list[str], pos: int
208
+ ) -> tuple[Expression | None, int, tuple[str, Any] | None]:
209
+ """Parse primary expression."""
210
+ if pos >= len(args):
211
+ return None, pos, None
212
+
213
+ action = None
214
+ arg = args[pos]
215
+
216
+ # Grouping
217
+ if arg == "(":
218
+ pos += 1
219
+ expr, pos, action = self._parse_or(args, pos)
220
+ if pos < len(args) and args[pos] == ")":
221
+ pos += 1
222
+ return Expression(type="group", inner=expr), pos, action
223
+
224
+ # Actions
225
+ if arg == "-print":
226
+ pos += 1
227
+ return None, pos, ("print", None)
228
+
229
+ if arg == "-print0":
230
+ pos += 1
231
+ return None, pos, ("print0", None)
232
+
233
+ if arg == "-delete":
234
+ pos += 1
235
+ return None, pos, ("delete", None)
236
+
237
+ if arg == "-exec":
238
+ # Find the terminator
239
+ cmd_parts = []
240
+ pos += 1
241
+ while pos < len(args) and args[pos] not in (";", "+"):
242
+ cmd_parts.append(args[pos])
243
+ pos += 1
244
+ if pos < len(args):
245
+ pos += 1
246
+ return None, pos, ("exec", cmd_parts)
247
+
248
+ # Predicates
249
+ if arg in ("-name", "-iname"):
250
+ if pos + 1 >= len(args):
251
+ raise ValueError(f"missing argument to '{arg}'")
252
+ pattern = args[pos + 1]
253
+ return (
254
+ Expression(
255
+ type="predicate", predicate=arg[1:], value=pattern
256
+ ),
257
+ pos + 2,
258
+ action,
259
+ )
260
+
261
+ if arg == "-type":
262
+ if pos + 1 >= len(args):
263
+ raise ValueError("missing argument to '-type'")
264
+ type_val = args[pos + 1]
265
+ return (
266
+ Expression(type="predicate", predicate="type", value=type_val),
267
+ pos + 2,
268
+ action,
269
+ )
270
+
271
+ if arg == "-size":
272
+ if pos + 1 >= len(args):
273
+ raise ValueError("missing argument to '-size'")
274
+ size_spec = args[pos + 1]
275
+ return (
276
+ Expression(type="predicate", predicate="size", value=size_spec),
277
+ pos + 2,
278
+ action,
279
+ )
280
+
281
+ if arg == "-mtime":
282
+ if pos + 1 >= len(args):
283
+ raise ValueError("missing argument to '-mtime'")
284
+ mtime_val = args[pos + 1]
285
+ return (
286
+ Expression(type="predicate", predicate="mtime", value=mtime_val),
287
+ pos + 2,
288
+ action,
289
+ )
290
+
291
+ if arg == "-newer":
292
+ if pos + 1 >= len(args):
293
+ raise ValueError("missing argument to '-newer'")
294
+ return (
295
+ Expression(type="predicate", predicate="newer", value=args[pos + 1]),
296
+ pos + 2,
297
+ action,
298
+ )
299
+
300
+ if arg in ("-path", "-ipath"):
301
+ if pos + 1 >= len(args):
302
+ raise ValueError(f"missing argument to '{arg}'")
303
+ return (
304
+ Expression(type="predicate", predicate=arg[1:], value=args[pos + 1]),
305
+ pos + 2,
306
+ action,
307
+ )
308
+
309
+ if arg in ("-regex", "-iregex"):
310
+ if pos + 1 >= len(args):
311
+ raise ValueError(f"missing argument to '{arg}'")
312
+ return (
313
+ Expression(type="predicate", predicate=arg[1:], value=args[pos + 1]),
314
+ pos + 2,
315
+ action,
316
+ )
317
+
318
+ if arg == "-empty":
319
+ return (
320
+ Expression(type="predicate", predicate="empty"),
321
+ pos + 1,
322
+ action,
323
+ )
324
+
325
+ if arg == "-perm":
326
+ if pos + 1 >= len(args):
327
+ raise ValueError("missing argument to '-perm'")
328
+ return (
329
+ Expression(type="predicate", predicate="perm", value=args[pos + 1]),
330
+ pos + 2,
331
+ action,
332
+ )
333
+
334
+ if arg.startswith("-"):
335
+ raise ValueError(f"unknown predicate '{arg}'")
336
+
337
+ # Skip unknown args
338
+ return None, pos + 1, action
339
+
340
+ async def _find_recursive(
341
+ self,
342
+ ctx: CommandContext,
343
+ abs_path: str,
344
+ display_path: str,
345
+ expr: Expression | None,
346
+ action: tuple[str, Any],
347
+ depth: int,
348
+ maxdepth: int,
349
+ mindepth: int,
350
+ ) -> tuple[str, str]:
351
+ """Recursively find files."""
352
+ output = ""
353
+ stderr = ""
354
+
355
+ if maxdepth >= 0 and depth > maxdepth:
356
+ return output, stderr
357
+
358
+ try:
359
+ stat = await ctx.fs.stat(abs_path)
360
+ except FileNotFoundError:
361
+ return output, f"find: '{display_path}': No such file or directory\n"
362
+
363
+ # Check if expression matches (only at mindepth or deeper)
364
+ if depth >= mindepth:
365
+ matches = await self._evaluate(ctx, abs_path, display_path, stat, expr)
366
+
367
+ if matches:
368
+ act_name, act_val = action
369
+
370
+ if act_name == "print":
371
+ output += display_path + "\n"
372
+ elif act_name == "print0":
373
+ output += display_path + "\0"
374
+ elif act_name == "delete":
375
+ try:
376
+ await ctx.fs.rm(abs_path, recursive=stat.is_directory)
377
+ except Exception as e:
378
+ stderr += f"find: cannot delete '{display_path}': {e}\n"
379
+ elif act_name == "exec":
380
+ # Execute command with {} replaced by path
381
+ if act_val:
382
+ # Replace {} with the current path
383
+ cmd_parts = [p.replace("{}", abs_path) for p in act_val]
384
+ # Build command string
385
+ cmd_str = " ".join(cmd_parts)
386
+ # Execute via the context's exec function
387
+ try:
388
+ result = await ctx.exec(cmd_str, {"cwd": ctx.cwd})
389
+ output += result.stdout
390
+ stderr += result.stderr
391
+ except Exception as e:
392
+ stderr += f"find: -exec failed: {e}\n"
393
+
394
+ # Recurse into directories
395
+ if stat.is_directory and (maxdepth < 0 or depth < maxdepth):
396
+ try:
397
+ entries = await ctx.fs.readdir(abs_path)
398
+ for entry in sorted(entries):
399
+ child_abs = abs_path.rstrip("/") + "/" + entry
400
+ child_display = display_path.rstrip("/") + "/" + entry
401
+ child_out, child_err = await self._find_recursive(
402
+ ctx, child_abs, child_display, expr, action, depth + 1, maxdepth, mindepth
403
+ )
404
+ output += child_out
405
+ stderr += child_err
406
+ except Exception:
407
+ pass
408
+
409
+ return output, stderr
410
+
411
+ async def _evaluate(
412
+ self,
413
+ ctx: CommandContext,
414
+ abs_path: str,
415
+ display_path: str,
416
+ stat: Any,
417
+ expr: Expression | None,
418
+ ) -> bool:
419
+ """Evaluate an expression against a file."""
420
+ if expr is None:
421
+ return True
422
+
423
+ if expr.type == "and":
424
+ left = await self._evaluate(ctx, abs_path, display_path, stat, expr.left)
425
+ if not left:
426
+ return False
427
+ return await self._evaluate(ctx, abs_path, display_path, stat, expr.right)
428
+
429
+ elif expr.type == "or":
430
+ left = await self._evaluate(ctx, abs_path, display_path, stat, expr.left)
431
+ if left:
432
+ return True
433
+ return await self._evaluate(ctx, abs_path, display_path, stat, expr.right)
434
+
435
+ elif expr.type == "not":
436
+ return not await self._evaluate(ctx, abs_path, display_path, stat, expr.inner)
437
+
438
+ elif expr.type == "group":
439
+ return await self._evaluate(ctx, abs_path, display_path, stat, expr.inner)
440
+
441
+ elif expr.type == "predicate":
442
+ return await self._evaluate_predicate(ctx, abs_path, display_path, stat, expr)
443
+
444
+ return True
445
+
446
+ async def _evaluate_predicate(
447
+ self,
448
+ ctx: CommandContext,
449
+ abs_path: str,
450
+ display_path: str,
451
+ stat: dict,
452
+ expr: Expression,
453
+ ) -> bool:
454
+ """Evaluate a single predicate."""
455
+ pred = expr.predicate
456
+ value = expr.value
457
+
458
+ # Get basename for name matching
459
+ basename = abs_path.rsplit("/", 1)[-1]
460
+
461
+ if pred == "name":
462
+ return fnmatch.fnmatch(basename, value)
463
+
464
+ elif pred == "iname":
465
+ return fnmatch.fnmatch(basename.lower(), value.lower())
466
+
467
+ elif pred == "type":
468
+ if value == "f":
469
+ return stat.is_file
470
+ elif value == "d":
471
+ return stat.is_directory
472
+ elif value == "l":
473
+ return stat.is_symbolic_link
474
+ return False
475
+
476
+ elif pred == "size":
477
+ return self._match_size(stat.size, value)
478
+
479
+ elif pred == "mtime":
480
+ now = time.time()
481
+ days = (now - stat.mtime) / 86400
482
+ return self._match_numeric(days, value)
483
+
484
+ elif pred == "newer":
485
+ try:
486
+ ref_path = ctx.fs.resolve_path(ctx.cwd, value)
487
+ ref_stat = await ctx.fs.stat(ref_path)
488
+ return stat.mtime > ref_stat.mtime
489
+ except FileNotFoundError:
490
+ return False
491
+
492
+ elif pred == "path":
493
+ return fnmatch.fnmatch(display_path, value)
494
+
495
+ elif pred == "ipath":
496
+ return fnmatch.fnmatch(display_path.lower(), value.lower())
497
+
498
+ elif pred == "regex":
499
+ try:
500
+ return bool(re.search(value, display_path))
501
+ except re.error:
502
+ return False
503
+
504
+ elif pred == "iregex":
505
+ try:
506
+ return bool(re.search(value, display_path, re.IGNORECASE))
507
+ except re.error:
508
+ return False
509
+
510
+ elif pred == "empty":
511
+ if stat.is_directory:
512
+ try:
513
+ entries = await ctx.fs.readdir(abs_path)
514
+ return len(entries) == 0
515
+ except Exception:
516
+ return False
517
+ else:
518
+ return stat.size == 0
519
+
520
+ elif pred == "perm":
521
+ return self._match_perm(stat.mode, value)
522
+
523
+ return True
524
+
525
+ def _match_size(self, size: int, spec: str) -> bool:
526
+ """Match file size against specification."""
527
+ if not spec:
528
+ return True
529
+
530
+ # Parse +/- prefix
531
+ compare = "eq"
532
+ if spec[0] == "+":
533
+ compare = "gt"
534
+ spec = spec[1:]
535
+ elif spec[0] == "-":
536
+ compare = "lt"
537
+ spec = spec[1:]
538
+
539
+ # Parse unit suffix
540
+ unit = 512 # default is 512-byte blocks
541
+ if spec and spec[-1] in "cwbkMG":
542
+ suffix = spec[-1]
543
+ spec = spec[:-1]
544
+ if suffix == "c":
545
+ unit = 1
546
+ elif suffix == "w":
547
+ unit = 2
548
+ elif suffix == "b":
549
+ unit = 512
550
+ elif suffix == "k":
551
+ unit = 1024
552
+ elif suffix == "M":
553
+ unit = 1024 * 1024
554
+ elif suffix == "G":
555
+ unit = 1024 * 1024 * 1024
556
+
557
+ try:
558
+ n = int(spec)
559
+ except ValueError:
560
+ return True
561
+
562
+ target = n * unit
563
+
564
+ if compare == "eq":
565
+ return size == target
566
+ elif compare == "gt":
567
+ return size > target
568
+ elif compare == "lt":
569
+ return size < target
570
+
571
+ return True
572
+
573
+ def _match_numeric(self, actual: float, spec: str) -> bool:
574
+ """Match numeric value against +N/-N/N specification."""
575
+ if not spec:
576
+ return True
577
+
578
+ compare = "eq"
579
+ if spec[0] == "+":
580
+ compare = "gt"
581
+ spec = spec[1:]
582
+ elif spec[0] == "-":
583
+ compare = "lt"
584
+ spec = spec[1:]
585
+
586
+ try:
587
+ n = int(spec)
588
+ except ValueError:
589
+ return True
590
+
591
+ if compare == "eq":
592
+ return int(actual) == n
593
+ elif compare == "gt":
594
+ return actual > n
595
+ elif compare == "lt":
596
+ return actual < n
597
+
598
+ return True
599
+
600
+ def _match_perm(self, mode: int, spec: str) -> bool:
601
+ """Match permission mode."""
602
+ if not spec:
603
+ return True
604
+
605
+ # Handle exact match
606
+ exact = True
607
+ if spec.startswith("-"):
608
+ exact = False
609
+ spec = spec[1:]
610
+ elif spec.startswith("/"):
611
+ # Any bit match
612
+ exact = False
613
+ spec = spec[1:]
614
+
615
+ try:
616
+ perm = int(spec, 8)
617
+ except ValueError:
618
+ return True
619
+
620
+ if exact:
621
+ return (mode & 0o7777) == perm
622
+ else:
623
+ return (mode & perm) == perm
@@ -0,0 +1,5 @@
1
+ """Fold command."""
2
+
3
+ from .fold import FoldCommand
4
+
5
+ __all__ = ["FoldCommand"]