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,265 @@
1
1
  """Interpreter types for just-bash."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from dataclasses import dataclass, field
4
6
  from typing import TYPE_CHECKING, Callable, Optional, Awaitable
5
7
 
6
8
  if TYPE_CHECKING:
9
+ import random
7
10
  from ..ast.types import FunctionDefNode, ScriptNode, StatementNode, CommandNode
8
11
  from ..types import ExecResult, IFileSystem, Command, ExecutionLimits
9
12
 
10
13
 
14
+ @dataclass
15
+ class VariableMetadata:
16
+ """Per-variable metadata that can't be represented in the flat env dict."""
17
+
18
+ attributes: set[str] = field(default_factory=set)
19
+ """Variable attributes: r=readonly, x=export, i=integer, l=lowercase,
20
+ u=uppercase, n=nameref, t=trace."""
21
+
22
+ nameref_target: str | None = None
23
+ """For namerefs (declare -n), the target variable name."""
24
+
25
+
26
+ class VariableStore(dict):
27
+ """Dict subclass that adds metadata tracking for variables.
28
+
29
+ Inherits from dict so all existing code that uses env as dict[str, str]
30
+ continues to work unchanged. Adds a parallel _metadata dict for
31
+ per-variable metadata (attributes, nameref targets) that can't be
32
+ represented in the flat key-value store.
33
+ """
34
+
35
+ _metadata: dict[str, VariableMetadata]
36
+ _local_meta_scopes: list[dict[str, VariableMetadata | None]]
37
+
38
+ def __init__(self, *args, **kwargs):
39
+ super().__init__(*args, **kwargs)
40
+ self._metadata = {}
41
+ self._local_meta_scopes = []
42
+
43
+ def get_metadata(self, name: str) -> VariableMetadata:
44
+ """Get or create metadata for a variable."""
45
+ if name not in self._metadata:
46
+ self._metadata[name] = VariableMetadata()
47
+ return self._metadata[name]
48
+
49
+ def has_metadata(self, name: str) -> bool:
50
+ """Check if a variable has metadata."""
51
+ return name in self._metadata
52
+
53
+ def resolve_nameref(self, name: str, max_depth: int = 10) -> str:
54
+ """Resolve nameref chain, returning the final variable name.
55
+
56
+ If name is not a nameref, returns name unchanged.
57
+ Detects cycles and raises ValueError.
58
+ """
59
+ visited: set[str] = set()
60
+ current = name
61
+ for _ in range(max_depth):
62
+ meta = self._metadata.get(current)
63
+ if meta and "n" in meta.attributes and meta.nameref_target:
64
+ if meta.nameref_target in visited:
65
+ raise ValueError(
66
+ f"{name}: circular name reference"
67
+ )
68
+ visited.add(current)
69
+ current = meta.nameref_target
70
+ else:
71
+ return current
72
+ raise ValueError(f"{name}: nameref chain too long")
73
+
74
+ def is_nameref(self, name: str) -> bool:
75
+ """Check if a variable is a nameref."""
76
+ meta = self._metadata.get(name)
77
+ return meta is not None and "n" in meta.attributes
78
+
79
+ def is_readonly(self, name: str) -> bool:
80
+ """Check if a variable is readonly."""
81
+ meta = self._metadata.get(name)
82
+ return meta is not None and "r" in meta.attributes
83
+
84
+ def set_attribute(self, name: str, attr: str) -> None:
85
+ """Set an attribute on a variable."""
86
+ self.get_metadata(name).attributes.add(attr)
87
+
88
+ def remove_attribute(self, name: str, attr: str) -> None:
89
+ """Remove an attribute from a variable."""
90
+ meta = self._metadata.get(name)
91
+ if meta:
92
+ meta.attributes.discard(attr)
93
+
94
+ def get_attributes(self, name: str) -> set[str]:
95
+ """Get all attributes for a variable."""
96
+ meta = self._metadata.get(name)
97
+ return set(meta.attributes) if meta else set()
98
+
99
+ def set_nameref(self, name: str, target: str) -> None:
100
+ """Set a variable as a nameref pointing to target."""
101
+ meta = self.get_metadata(name)
102
+ meta.attributes.add("n")
103
+ meta.nameref_target = target
104
+
105
+ def clear_nameref(self, name: str) -> None:
106
+ """Remove nameref from a variable."""
107
+ meta = self._metadata.get(name)
108
+ if meta:
109
+ meta.attributes.discard("n")
110
+ meta.nameref_target = None
111
+
112
+ def push_local_meta_scope(self) -> None:
113
+ """Push a new local metadata scope (for function calls)."""
114
+ self._local_meta_scopes.append({})
115
+
116
+ def pop_local_meta_scope(self) -> dict[str, VariableMetadata | None]:
117
+ """Pop and return the top local metadata scope."""
118
+ return self._local_meta_scopes.pop()
119
+
120
+ def save_metadata_in_scope(self, name: str) -> None:
121
+ """Save variable's current metadata in the current local scope."""
122
+ if self._local_meta_scopes:
123
+ scope = self._local_meta_scopes[-1]
124
+ if name not in scope:
125
+ meta = self._metadata.get(name)
126
+ if meta:
127
+ scope[name] = VariableMetadata(
128
+ attributes=set(meta.attributes),
129
+ nameref_target=meta.nameref_target,
130
+ )
131
+ else:
132
+ scope[name] = None
133
+
134
+ def restore_metadata_from_scope(
135
+ self, scope: dict[str, VariableMetadata | None]
136
+ ) -> None:
137
+ """Restore metadata from a saved local scope."""
138
+ for name, saved_meta in scope.items():
139
+ if saved_meta is None:
140
+ self._metadata.pop(name, None)
141
+ else:
142
+ self._metadata[name] = saved_meta
143
+
144
+ def copy(self) -> VariableStore:
145
+ """Create a shallow copy that includes metadata."""
146
+ new = VariableStore(super().copy())
147
+ new._metadata = {
148
+ k: VariableMetadata(
149
+ attributes=set(v.attributes),
150
+ nameref_target=v.nameref_target,
151
+ )
152
+ for k, v in self._metadata.items()
153
+ }
154
+ return new
155
+
156
+ def to_env_dict(self) -> dict[str, str]:
157
+ """Return a plain dict copy (for CommandContext, ExecResult)."""
158
+ return dict(self)
159
+
160
+
161
+ @dataclass
162
+ class FDEntry:
163
+ """A file descriptor entry in the FD table."""
164
+
165
+ content: str = ""
166
+ """Accumulated content for this FD."""
167
+
168
+ mode: str = "r"
169
+ """Mode: 'r' (read), 'w' (write), 'a' (append), 'rw' (read/write)."""
170
+
171
+ path: str | None = None
172
+ """If opened to a file, the path."""
173
+
174
+ is_closed: bool = False
175
+ """Whether this FD has been explicitly closed."""
176
+
177
+ dup_of: int | None = None
178
+ """If this FD was created by duplication, the source FD number."""
179
+
180
+
181
+ class FDTable:
182
+ """File descriptor table for the interpreter.
183
+
184
+ Manages FDs 0 (stdin), 1 (stdout), 2 (stderr), and custom FDs (3+).
185
+ The FD table is persistent across commands and can be modified by
186
+ exec redirections.
187
+ """
188
+
189
+ _fds: dict[int, FDEntry]
190
+
191
+ def __init__(self):
192
+ self._fds = {
193
+ 0: FDEntry(mode="r"), # stdin
194
+ 1: FDEntry(mode="w"), # stdout
195
+ 2: FDEntry(mode="w"), # stderr
196
+ }
197
+
198
+ def open(self, fd: int, path: str, mode: str = "w") -> None:
199
+ """Open a file descriptor to a path."""
200
+ self._fds[fd] = FDEntry(mode=mode, path=path)
201
+
202
+ def close(self, fd: int) -> None:
203
+ """Close a file descriptor."""
204
+ if fd in self._fds:
205
+ self._fds[fd] = FDEntry(is_closed=True)
206
+
207
+ def dup(self, src_fd: int, dst_fd: int) -> None:
208
+ """Duplicate src_fd onto dst_fd (like 2>&1)."""
209
+ if src_fd in self._fds:
210
+ src = self._fds[src_fd]
211
+ self._fds[dst_fd] = FDEntry(
212
+ content=src.content,
213
+ mode=src.mode,
214
+ path=src.path,
215
+ dup_of=src_fd,
216
+ )
217
+
218
+ def write(self, fd: int, data: str) -> None:
219
+ """Write data to a file descriptor."""
220
+ if fd not in self._fds:
221
+ self._fds[fd] = FDEntry(mode="w")
222
+ entry = self._fds[fd]
223
+ if entry.mode == "a":
224
+ entry.content += data
225
+ else:
226
+ entry.content = data
227
+
228
+ def read(self, fd: int) -> str:
229
+ """Read from a file descriptor."""
230
+ if fd in self._fds:
231
+ return self._fds[fd].content
232
+ return ""
233
+
234
+ def get_path(self, fd: int) -> str | None:
235
+ """Get the file path for an FD, if any."""
236
+ if fd in self._fds:
237
+ return self._fds[fd].path
238
+ return None
239
+
240
+ def is_open(self, fd: int) -> bool:
241
+ """Check if an FD is open."""
242
+ return fd in self._fds and not self._fds[fd].is_closed
243
+
244
+ def is_redirected(self, fd: int) -> bool:
245
+ """Check if an FD has been redirected to a file."""
246
+ return fd in self._fds and self._fds[fd].path is not None
247
+
248
+ def clone(self) -> FDTable:
249
+ """Create a deep copy of the FD table."""
250
+ new = FDTable()
251
+ new._fds = {
252
+ fd: FDEntry(
253
+ content=entry.content,
254
+ mode=entry.mode,
255
+ path=entry.path,
256
+ is_closed=entry.is_closed,
257
+ )
258
+ for fd, entry in self._fds.items()
259
+ }
260
+ return new
261
+
262
+
11
263
  @dataclass
12
264
  class ShellOptions:
13
265
  """Shell options (set -e, etc.)."""
@@ -32,8 +284,8 @@ class ShellOptions:
32
284
  class InterpreterState:
33
285
  """Mutable state maintained by the interpreter."""
34
286
 
35
- env: dict[str, str] = field(default_factory=dict)
36
- """Environment variables."""
287
+ env: VariableStore = field(default_factory=VariableStore)
288
+ """Environment variables (VariableStore is a dict subclass)."""
37
289
 
38
290
  cwd: str = "/home/user"
39
291
  """Current working directory."""
@@ -65,6 +317,9 @@ class InterpreterState:
65
317
  start_time: float = 0.0
66
318
  """Time when shell started (for $SECONDS)."""
67
319
 
320
+ seconds_reset_time: Optional[float] = None
321
+ """Time when SECONDS was reset (for SECONDS=n assignment)."""
322
+
68
323
  last_background_pid: int = 0
69
324
  """PID of last background job (for $!)."""
70
325
 
@@ -98,6 +353,12 @@ class InterpreterState:
98
353
  associative_arrays: set[str] = field(default_factory=set)
99
354
  """Set of associative array variable names."""
100
355
 
356
+ fd_table: FDTable = field(default_factory=FDTable)
357
+ """File descriptor table for persistent FD redirections."""
358
+
359
+ random_generator: Optional["random.Random"] = field(default=None, repr=False)
360
+ """Random number generator for $RANDOM (seeded when RANDOM=n is assigned)."""
361
+
101
362
 
102
363
  @dataclass
103
364
  class InterpreterContext:
@@ -9,6 +9,7 @@ from .lexer import (
9
9
  is_valid_name,
10
10
  is_valid_assignment_lhs,
11
11
  RESERVED_WORDS,
12
+ unescape_html_entities,
12
13
  )
13
14
  from .parser import (
14
15
  Parser,
@@ -29,6 +30,7 @@ __all__ = [
29
30
  "is_valid_name",
30
31
  "is_valid_assignment_lhs",
31
32
  "RESERVED_WORDS",
33
+ "unescape_html_entities",
32
34
  # Parser
33
35
  "Parser",
34
36
  "ParseException",