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.
- just_bash/ast/factory.py +3 -1
- just_bash/bash.py +28 -6
- just_bash/commands/awk/awk.py +362 -17
- just_bash/commands/cat/cat.py +5 -1
- just_bash/commands/echo/echo.py +33 -1
- just_bash/commands/grep/grep.py +141 -3
- just_bash/commands/od/od.py +144 -30
- just_bash/commands/printf/printf.py +289 -87
- just_bash/commands/pwd/pwd.py +32 -2
- just_bash/commands/read/read.py +243 -64
- just_bash/commands/readlink/readlink.py +3 -9
- just_bash/commands/registry.py +32 -0
- just_bash/commands/rmdir/__init__.py +5 -0
- just_bash/commands/rmdir/rmdir.py +160 -0
- just_bash/commands/sed/sed.py +142 -31
- just_bash/commands/shuf/__init__.py +5 -0
- just_bash/commands/shuf/shuf.py +242 -0
- just_bash/commands/stat/stat.py +9 -0
- just_bash/commands/time/__init__.py +5 -0
- just_bash/commands/time/time.py +74 -0
- just_bash/commands/touch/touch.py +118 -8
- just_bash/commands/whoami/__init__.py +5 -0
- just_bash/commands/whoami/whoami.py +18 -0
- just_bash/fs/in_memory_fs.py +22 -0
- just_bash/fs/overlay_fs.py +22 -1
- just_bash/interpreter/__init__.py +1 -1
- just_bash/interpreter/builtins/__init__.py +2 -0
- just_bash/interpreter/builtins/control.py +4 -8
- just_bash/interpreter/builtins/declare.py +321 -24
- just_bash/interpreter/builtins/getopts.py +163 -0
- just_bash/interpreter/builtins/let.py +2 -2
- just_bash/interpreter/builtins/local.py +71 -5
- just_bash/interpreter/builtins/misc.py +22 -6
- just_bash/interpreter/builtins/readonly.py +38 -10
- just_bash/interpreter/builtins/set.py +58 -8
- just_bash/interpreter/builtins/test.py +136 -19
- just_bash/interpreter/builtins/unset.py +62 -10
- just_bash/interpreter/conditionals.py +29 -4
- just_bash/interpreter/control_flow.py +61 -17
- just_bash/interpreter/expansion.py +1647 -104
- just_bash/interpreter/interpreter.py +436 -69
- just_bash/interpreter/types.py +263 -2
- just_bash/parser/__init__.py +2 -0
- just_bash/parser/lexer.py +295 -26
- just_bash/parser/parser.py +523 -64
- just_bash/types.py +11 -0
- {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
- {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/RECORD +49 -40
- {just_bash-0.1.5.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
just_bash/interpreter/types.py
CHANGED
|
@@ -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:
|
|
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:
|
just_bash/parser/__init__.py
CHANGED
|
@@ -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",
|