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,455 @@
1
+ """
2
+ ReadWriteFs Implementation
3
+
4
+ A filesystem wrapper that provides direct access to the real filesystem.
5
+ All operations are delegated to the actual OS filesystem with path translation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ import aiofiles # type: ignore[import-untyped]
18
+ import aiofiles.os # type: ignore[import-untyped]
19
+
20
+ from ..types import FsStat
21
+
22
+
23
+ @dataclass
24
+ class DirentEntry:
25
+ """Directory entry information."""
26
+
27
+ name: str
28
+ is_file: bool = False
29
+ is_directory: bool = False
30
+ is_symbolic_link: bool = False
31
+
32
+
33
+ @dataclass
34
+ class ReadWriteFsOptions:
35
+ """Options for ReadWriteFs."""
36
+
37
+ root: str
38
+ """Root directory on the real filesystem. All operations are relative to this."""
39
+
40
+
41
+ class ReadWriteFs:
42
+ """
43
+ Direct wrapper around the real filesystem.
44
+
45
+ Provides an IFileSystem-compatible interface that operates on actual files.
46
+ All virtual paths are translated to real paths under the configured root.
47
+ """
48
+
49
+ def __init__(self, options: ReadWriteFsOptions) -> None:
50
+ """
51
+ Initialize the filesystem.
52
+
53
+ Args:
54
+ options: Configuration options including the root directory
55
+
56
+ Raises:
57
+ FileNotFoundError: If root directory does not exist
58
+ NotADirectoryError: If root is not a directory
59
+ """
60
+ root_path = Path(options.root)
61
+
62
+ if not root_path.exists():
63
+ raise FileNotFoundError(
64
+ f"ENOENT: no such file or directory, root '{options.root}'"
65
+ )
66
+
67
+ if not root_path.is_dir():
68
+ raise NotADirectoryError(
69
+ f"ENOTDIR: not a directory, root '{options.root}'"
70
+ )
71
+
72
+ self._root = root_path.resolve()
73
+
74
+ def _normalize_path(self, path: str) -> str:
75
+ """Normalize a virtual path (resolve ., .., trailing slashes)."""
76
+ if not path or path == "/":
77
+ return "/"
78
+
79
+ # Remove trailing slash
80
+ if path.endswith("/") and path != "/":
81
+ path = path[:-1]
82
+
83
+ # Ensure starts with /
84
+ if not path.startswith("/"):
85
+ path = "/" + path
86
+
87
+ # Resolve . and ..
88
+ parts = path.split("/")
89
+ resolved: list[str] = []
90
+
91
+ for part in parts:
92
+ if part == "" or part == ".":
93
+ continue
94
+ elif part == "..":
95
+ if resolved:
96
+ resolved.pop()
97
+ else:
98
+ resolved.append(part)
99
+
100
+ return "/" + "/".join(resolved) if resolved else "/"
101
+
102
+ def _to_real_path(self, virtual_path: str) -> Path:
103
+ """Convert a virtual path to a real filesystem path."""
104
+ normalized = self._normalize_path(virtual_path)
105
+
106
+ if normalized == "/":
107
+ return self._root
108
+
109
+ # Strip leading / and join with root
110
+ relative = normalized[1:] # Remove leading /
111
+ return self._root / relative
112
+
113
+ def _dirname(self, path: str) -> str:
114
+ """Get the directory name of a path."""
115
+ normalized = self._normalize_path(path)
116
+ if normalized == "/":
117
+ return "/"
118
+ last_slash = normalized.rfind("/")
119
+ return "/" if last_slash == 0 else normalized[:last_slash]
120
+
121
+ def _ensure_parent_dirs(self, path: str) -> None:
122
+ """Ensure all parent directories exist on the real filesystem."""
123
+ real_path = self._to_real_path(path)
124
+ real_path.parent.mkdir(parents=True, exist_ok=True)
125
+
126
+ async def read_file(self, path: str, encoding: str = "utf-8") -> str:
127
+ """Read file contents as string."""
128
+ real_path = self._to_real_path(path)
129
+
130
+ if not real_path.exists():
131
+ raise FileNotFoundError(
132
+ f"ENOENT: no such file or directory, open '{path}'"
133
+ )
134
+
135
+ if real_path.is_dir():
136
+ raise IsADirectoryError(
137
+ f"EISDIR: illegal operation on a directory, read '{path}'"
138
+ )
139
+
140
+ async with aiofiles.open(real_path, "r", encoding=encoding) as f:
141
+ content: str = await f.read()
142
+ return content
143
+
144
+ async def read_file_bytes(self, path: str) -> bytes:
145
+ """Read file contents as bytes."""
146
+ real_path = self._to_real_path(path)
147
+
148
+ if not real_path.exists():
149
+ raise FileNotFoundError(
150
+ f"ENOENT: no such file or directory, open '{path}'"
151
+ )
152
+
153
+ if real_path.is_dir():
154
+ raise IsADirectoryError(
155
+ f"EISDIR: illegal operation on a directory, read '{path}'"
156
+ )
157
+
158
+ async with aiofiles.open(real_path, "rb") as f:
159
+ content: bytes = await f.read()
160
+ return content
161
+
162
+ async def write_file(
163
+ self,
164
+ path: str,
165
+ content: str | bytes,
166
+ encoding: str = "utf-8",
167
+ ) -> None:
168
+ """Write content to file."""
169
+ self._ensure_parent_dirs(path)
170
+ real_path = self._to_real_path(path)
171
+
172
+ if isinstance(content, str):
173
+ async with aiofiles.open(real_path, "w", encoding=encoding) as f:
174
+ await f.write(content)
175
+ else:
176
+ async with aiofiles.open(real_path, "wb") as f:
177
+ await f.write(content)
178
+
179
+ async def append_file(
180
+ self,
181
+ path: str,
182
+ content: str | bytes,
183
+ encoding: str = "utf-8",
184
+ ) -> None:
185
+ """Append content to file."""
186
+ self._ensure_parent_dirs(path)
187
+ real_path = self._to_real_path(path)
188
+
189
+ if isinstance(content, str):
190
+ async with aiofiles.open(real_path, "a", encoding=encoding) as f:
191
+ await f.write(content)
192
+ else:
193
+ async with aiofiles.open(real_path, "ab") as f:
194
+ await f.write(content)
195
+
196
+ async def exists(self, path: str) -> bool:
197
+ """Check if path exists."""
198
+ real_path = self._to_real_path(path)
199
+ return real_path.exists()
200
+
201
+ async def is_file(self, path: str) -> bool:
202
+ """Check if path is a file."""
203
+ real_path = self._to_real_path(path)
204
+ return real_path.is_file()
205
+
206
+ async def is_directory(self, path: str) -> bool:
207
+ """Check if path is a directory."""
208
+ real_path = self._to_real_path(path)
209
+ return real_path.is_dir()
210
+
211
+ async def stat(self, path: str) -> FsStat:
212
+ """Get file/directory stats (follows symlinks)."""
213
+ real_path = self._to_real_path(path)
214
+
215
+ if not real_path.exists():
216
+ raise FileNotFoundError(
217
+ f"ENOENT: no such file or directory, stat '{path}'"
218
+ )
219
+
220
+ stat_result = real_path.stat()
221
+
222
+ return FsStat(
223
+ is_file=real_path.is_file(),
224
+ is_directory=real_path.is_dir(),
225
+ is_symbolic_link=False, # stat follows symlinks
226
+ mode=stat_result.st_mode & 0o777,
227
+ size=stat_result.st_size,
228
+ mtime=stat_result.st_mtime,
229
+ nlink=stat_result.st_nlink,
230
+ )
231
+
232
+ async def lstat(self, path: str) -> FsStat:
233
+ """Get file/directory stats (does not follow final symlink)."""
234
+ real_path = self._to_real_path(path)
235
+
236
+ if not real_path.exists() and not real_path.is_symlink():
237
+ raise FileNotFoundError(
238
+ f"ENOENT: no such file or directory, lstat '{path}'"
239
+ )
240
+
241
+ stat_result = real_path.lstat()
242
+ is_symlink = real_path.is_symlink()
243
+
244
+ return FsStat(
245
+ is_file=not is_symlink and real_path.is_file(),
246
+ is_directory=not is_symlink and real_path.is_dir(),
247
+ is_symbolic_link=is_symlink,
248
+ mode=stat_result.st_mode & 0o777,
249
+ size=stat_result.st_size,
250
+ mtime=stat_result.st_mtime,
251
+ nlink=stat_result.st_nlink,
252
+ )
253
+
254
+ async def mkdir(self, path: str, recursive: bool = False) -> None:
255
+ """Create a directory."""
256
+ real_path = self._to_real_path(path)
257
+
258
+ if real_path.exists():
259
+ if real_path.is_file():
260
+ raise OSError(f"EEXIST: file already exists, mkdir '{path}'")
261
+ if not recursive:
262
+ raise OSError(f"EEXIST: directory already exists, mkdir '{path}'")
263
+ return
264
+
265
+ if recursive:
266
+ real_path.mkdir(parents=True, exist_ok=True)
267
+ else:
268
+ if not real_path.parent.exists():
269
+ raise OSError(f"ENOENT: no such file or directory, mkdir '{path}'")
270
+ real_path.mkdir()
271
+
272
+ async def readdir(self, path: str) -> list[str]:
273
+ """List directory contents."""
274
+ entries = await self.readdir_with_file_types(path)
275
+ return [e.name for e in entries]
276
+
277
+ async def readdir_with_file_types(self, path: str) -> list[DirentEntry]:
278
+ """List directory contents with type information."""
279
+ real_path = self._to_real_path(path)
280
+
281
+ if not real_path.exists():
282
+ raise FileNotFoundError(
283
+ f"ENOENT: no such file or directory, scandir '{path}'"
284
+ )
285
+
286
+ if not real_path.is_dir():
287
+ raise NotADirectoryError(
288
+ f"ENOTDIR: not a directory, scandir '{path}'"
289
+ )
290
+
291
+ entries: list[DirentEntry] = []
292
+ for item in real_path.iterdir():
293
+ entries.append(
294
+ DirentEntry(
295
+ name=item.name,
296
+ is_file=item.is_file(),
297
+ is_directory=item.is_dir(),
298
+ is_symbolic_link=item.is_symlink(),
299
+ )
300
+ )
301
+
302
+ return sorted(entries, key=lambda e: e.name)
303
+
304
+ async def rm(
305
+ self, path: str, recursive: bool = False, force: bool = False
306
+ ) -> None:
307
+ """Remove a file or directory."""
308
+ real_path = self._to_real_path(path)
309
+
310
+ if not real_path.exists():
311
+ if force:
312
+ return
313
+ raise FileNotFoundError(
314
+ f"ENOENT: no such file or directory, rm '{path}'"
315
+ )
316
+
317
+ if real_path.is_dir():
318
+ if recursive:
319
+ shutil.rmtree(real_path)
320
+ else:
321
+ # Check if empty
322
+ if any(real_path.iterdir()):
323
+ raise OSError(f"ENOTEMPTY: directory not empty, rm '{path}'")
324
+ real_path.rmdir()
325
+ else:
326
+ real_path.unlink()
327
+
328
+ async def cp(self, src: str, dest: str, recursive: bool = False) -> None:
329
+ """Copy a file or directory."""
330
+ src_real = self._to_real_path(src)
331
+ dest_real = self._to_real_path(dest)
332
+
333
+ if not src_real.exists():
334
+ raise FileNotFoundError(
335
+ f"ENOENT: no such file or directory, cp '{src}'"
336
+ )
337
+
338
+ if src_real.is_dir():
339
+ if not recursive:
340
+ raise IsADirectoryError(f"EISDIR: is a directory, cp '{src}'")
341
+ shutil.copytree(src_real, dest_real)
342
+ else:
343
+ self._ensure_parent_dirs(dest)
344
+ shutil.copy2(src_real, dest_real)
345
+
346
+ async def mv(self, src: str, dest: str) -> None:
347
+ """Move a file or directory."""
348
+ src_real = self._to_real_path(src)
349
+ dest_real = self._to_real_path(dest)
350
+
351
+ if not src_real.exists():
352
+ raise FileNotFoundError(
353
+ f"ENOENT: no such file or directory, mv '{src}'"
354
+ )
355
+
356
+ self._ensure_parent_dirs(dest)
357
+ shutil.move(str(src_real), str(dest_real))
358
+
359
+ async def chmod(self, path: str, mode: int) -> None:
360
+ """Change file/directory permissions."""
361
+ real_path = self._to_real_path(path)
362
+
363
+ if not real_path.exists():
364
+ raise FileNotFoundError(
365
+ f"ENOENT: no such file or directory, chmod '{path}'"
366
+ )
367
+
368
+ real_path.chmod(mode)
369
+
370
+ async def symlink(self, target: str, link_path: str) -> None:
371
+ """Create a symbolic link."""
372
+ link_real = self._to_real_path(link_path)
373
+
374
+ if link_real.exists() or link_real.is_symlink():
375
+ raise FileExistsError(
376
+ f"EEXIST: file already exists, symlink '{link_path}'"
377
+ )
378
+
379
+ self._ensure_parent_dirs(link_path)
380
+
381
+ # Convert target to real path for the symlink
382
+ target_real = self._to_real_path(target)
383
+ link_real.symlink_to(target_real)
384
+
385
+ async def link(self, existing_path: str, new_path: str) -> None:
386
+ """Create a hard link."""
387
+ existing_real = self._to_real_path(existing_path)
388
+ new_real = self._to_real_path(new_path)
389
+
390
+ if not existing_real.exists():
391
+ raise FileNotFoundError(
392
+ f"ENOENT: no such file or directory, link '{existing_path}'"
393
+ )
394
+
395
+ if not existing_real.is_file():
396
+ raise PermissionError(
397
+ f"EPERM: operation not permitted, link '{existing_path}'"
398
+ )
399
+
400
+ if new_real.exists():
401
+ raise FileExistsError(
402
+ f"EEXIST: file already exists, link '{new_path}'"
403
+ )
404
+
405
+ self._ensure_parent_dirs(new_path)
406
+ new_real.hardlink_to(existing_real)
407
+
408
+ async def readlink(self, path: str) -> str:
409
+ """Read the target of a symbolic link."""
410
+ real_path = self._to_real_path(path)
411
+
412
+ if not real_path.exists() and not real_path.is_symlink():
413
+ raise FileNotFoundError(
414
+ f"ENOENT: no such file or directory, readlink '{path}'"
415
+ )
416
+
417
+ if not real_path.is_symlink():
418
+ raise OSError(f"EINVAL: invalid argument, readlink '{path}'")
419
+
420
+ # Get the target and convert back to virtual path
421
+ target_real = real_path.readlink()
422
+
423
+ # If target is relative, keep it relative
424
+ if not target_real.is_absolute():
425
+ return str(target_real)
426
+
427
+ # If target is under our root, convert to virtual path
428
+ try:
429
+ relative = target_real.relative_to(self._root)
430
+ return "/" + str(relative)
431
+ except ValueError:
432
+ # Target is outside root, return as-is
433
+ return str(target_real)
434
+
435
+ def resolve_path(self, base: str, path: str) -> str:
436
+ """Resolve a path relative to a base."""
437
+ if path.startswith("/"):
438
+ return self._normalize_path(path)
439
+ combined = f"/{path}" if base == "/" else f"{base}/{path}"
440
+ return self._normalize_path(combined)
441
+
442
+ def get_all_paths(self) -> list[str]:
443
+ """Get all paths in the filesystem (useful for debugging/glob)."""
444
+ paths: list[str] = ["/"]
445
+
446
+ for root, dirs, files in os.walk(self._root):
447
+ rel_root = Path(root).relative_to(self._root)
448
+ virtual_root = "/" + str(rel_root) if str(rel_root) != "." else ""
449
+
450
+ for d in dirs:
451
+ paths.append(f"{virtual_root}/{d}")
452
+ for f in files:
453
+ paths.append(f"{virtual_root}/{f}")
454
+
455
+ return paths
@@ -0,0 +1,37 @@
1
+ """Interpreter module for just-bash."""
2
+
3
+ from .interpreter import Interpreter
4
+ from .types import InterpreterContext, InterpreterState, ShellOptions
5
+ from .errors import (
6
+ InterpreterError,
7
+ ExitError,
8
+ ReturnError,
9
+ BreakError,
10
+ ContinueError,
11
+ ErrexitError,
12
+ NounsetError,
13
+ BadSubstitutionError,
14
+ ArithmeticError,
15
+ ExecutionLimitError,
16
+ SubshellExitError,
17
+ is_scope_exit_error,
18
+ )
19
+
20
+ __all__ = [
21
+ "Interpreter",
22
+ "InterpreterContext",
23
+ "InterpreterState",
24
+ "ShellOptions",
25
+ "InterpreterError",
26
+ "ExitError",
27
+ "ReturnError",
28
+ "BreakError",
29
+ "ContinueError",
30
+ "ErrexitError",
31
+ "NounsetError",
32
+ "BadSubstitutionError",
33
+ "ArithmeticError",
34
+ "ExecutionLimitError",
35
+ "SubshellExitError",
36
+ "is_scope_exit_error",
37
+ ]
@@ -0,0 +1,92 @@
1
+ """Builtin commands for the shell.
2
+
3
+ These are shell builtins that need direct access to InterpreterContext
4
+ to modify interpreter state (environment, cwd, options, etc.).
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, Optional, Callable, Awaitable
8
+
9
+ from .test import handle_test, handle_bracket
10
+ from .cd import handle_cd
11
+ from .export import handle_export
12
+ from .set import handle_set, handle_shift
13
+ from .unset import handle_unset
14
+ from .local import handle_local
15
+ from .source import handle_source, handle_eval
16
+ from .control import handle_break, handle_continue, handle_return, handle_exit
17
+ from .declare import handle_declare
18
+ from .mapfile import handle_mapfile
19
+ from .let import handle_let
20
+ from .readonly import handle_readonly
21
+ from .shopt import handle_shopt
22
+ from .alias import handle_alias, handle_unalias
23
+ from .misc import (
24
+ handle_colon,
25
+ handle_true,
26
+ handle_false,
27
+ handle_type,
28
+ handle_command,
29
+ handle_builtin,
30
+ handle_exec,
31
+ handle_wait,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from ..types import InterpreterContext
36
+ from ...types import ExecResult
37
+
38
+
39
+ # Map of builtin names to their handler functions
40
+ BUILTINS: dict[str, Callable[["InterpreterContext", list[str]], Awaitable["ExecResult"]]] = {
41
+ "test": handle_test,
42
+ "[": handle_bracket,
43
+ "cd": handle_cd,
44
+ "export": handle_export,
45
+ "set": handle_set,
46
+ "shift": handle_shift,
47
+ "unset": handle_unset,
48
+ "local": handle_local,
49
+ "source": handle_source,
50
+ ".": handle_source,
51
+ "eval": handle_eval,
52
+ "break": handle_break,
53
+ "continue": handle_continue,
54
+ "return": handle_return,
55
+ "exit": handle_exit,
56
+ "declare": handle_declare,
57
+ "typeset": handle_declare, # typeset is alias for declare
58
+ "mapfile": handle_mapfile,
59
+ "readarray": handle_mapfile, # readarray is alias for mapfile
60
+ "let": handle_let,
61
+ "readonly": handle_readonly,
62
+ "shopt": handle_shopt,
63
+ "alias": handle_alias,
64
+ "unalias": handle_unalias,
65
+ ":": handle_colon,
66
+ "true": handle_true,
67
+ "false": handle_false,
68
+ "type": handle_type,
69
+ "command": handle_command,
70
+ "builtin": handle_builtin,
71
+ "exec": handle_exec,
72
+ "wait": handle_wait,
73
+ }
74
+
75
+
76
+ __all__ = [
77
+ "BUILTINS",
78
+ "handle_test",
79
+ "handle_cd",
80
+ "handle_export",
81
+ "handle_set",
82
+ "handle_shift",
83
+ "handle_unset",
84
+ "handle_local",
85
+ "handle_source",
86
+ "handle_eval",
87
+ "handle_break",
88
+ "handle_continue",
89
+ "handle_return",
90
+ "handle_exit",
91
+ "handle_declare",
92
+ ]