termish 0.2.0__tar.gz → 0.2.2__tar.gz

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 (68) hide show
  1. {termish-0.2.0/termish.egg-info → termish-0.2.2}/PKG-INFO +3 -3
  2. {termish-0.2.0 → termish-0.2.2}/README.md +2 -2
  3. {termish-0.2.0 → termish-0.2.2}/pyproject.toml +1 -1
  4. termish-0.2.2/termish/interpreter/commands/_regex.py +113 -0
  5. termish-0.2.2/termish/interpreter/commands/conditional.py +210 -0
  6. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/filesystem.py +113 -89
  7. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/search.py +26 -68
  8. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/sed.py +27 -15
  9. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/core.py +52 -11
  10. termish-0.2.2/termish/interpreter/pathglob.py +140 -0
  11. {termish-0.2.0 → termish-0.2.2}/termish/parser.py +4 -3
  12. {termish-0.2.0 → termish-0.2.2/termish.egg-info}/PKG-INFO +3 -3
  13. {termish-0.2.0 → termish-0.2.2}/termish.egg-info/SOURCES.txt +7 -0
  14. {termish-0.2.0 → termish-0.2.2}/termish.egg-info/top_level.txt +1 -0
  15. termish-0.2.2/tests/test_bre.py +136 -0
  16. termish-0.2.2/tests/test_conditional.py +92 -0
  17. termish-0.2.2/tests/test_dev_null.py +65 -0
  18. {termish-0.2.0 → termish-0.2.2}/tests/test_expansion.py +2 -1
  19. {termish-0.2.0 → termish-0.2.2}/tests/test_grep.py +18 -12
  20. {termish-0.2.0 → termish-0.2.2}/tests/test_parser.py +11 -0
  21. termish-0.2.2/tests/test_pathglob.py +152 -0
  22. {termish-0.2.0 → termish-0.2.2}/tests/test_sed.py +8 -2
  23. {termish-0.2.0 → termish-0.2.2}/LICENSE +0 -0
  24. {termish-0.2.0 → termish-0.2.2}/setup.cfg +0 -0
  25. {termish-0.2.0 → termish-0.2.2}/termish/__init__.py +0 -0
  26. {termish-0.2.0 → termish-0.2.2}/termish/ast.py +0 -0
  27. {termish-0.2.0 → termish-0.2.2}/termish/context.py +0 -0
  28. {termish-0.2.0 → termish-0.2.2}/termish/errors.py +0 -0
  29. {termish-0.2.0 → termish-0.2.2}/termish/fs/__init__.py +0 -0
  30. {termish-0.2.0 → termish-0.2.2}/termish/fs/conformance.py +0 -0
  31. {termish-0.2.0 → termish-0.2.2}/termish/fs/memory.py +0 -0
  32. {termish-0.2.0 → termish-0.2.2}/termish/fs/protocol.py +0 -0
  33. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/__init__.py +0 -0
  34. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/__init__.py +0 -0
  35. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/_argparse.py +0 -0
  36. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/_util.py +0 -0
  37. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/archive.py +0 -0
  38. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/control.py +0 -0
  39. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/diff.py +0 -0
  40. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/file.py +0 -0
  41. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/io.py +0 -0
  42. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/jq.py +0 -0
  43. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/meta.py +0 -0
  44. {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/text.py +0 -0
  45. {termish-0.2.0 → termish-0.2.2}/termish/jq/__init__.py +0 -0
  46. {termish-0.2.0 → termish-0.2.2}/termish/jq/ast.py +0 -0
  47. {termish-0.2.0 → termish-0.2.2}/termish/jq/eval.py +0 -0
  48. {termish-0.2.0 → termish-0.2.2}/termish/jq/parser.py +0 -0
  49. {termish-0.2.0 → termish-0.2.2}/termish/py.typed +0 -0
  50. {termish-0.2.0 → termish-0.2.2}/termish/quote_masker.py +0 -0
  51. {termish-0.2.0 → termish-0.2.2}/termish.egg-info/dependency_links.txt +0 -0
  52. {termish-0.2.0 → termish-0.2.2}/termish.egg-info/requires.txt +0 -0
  53. {termish-0.2.0 → termish-0.2.2}/tests/test_archive.py +0 -0
  54. {termish-0.2.0 → termish-0.2.2}/tests/test_binary.py +0 -0
  55. {termish-0.2.0 → termish-0.2.2}/tests/test_command_injection.py +0 -0
  56. {termish-0.2.0 → termish-0.2.2}/tests/test_control.py +0 -0
  57. {termish-0.2.0 → termish-0.2.2}/tests/test_file.py +0 -0
  58. {termish-0.2.0 → termish-0.2.2}/tests/test_find.py +0 -0
  59. {termish-0.2.0 → termish-0.2.2}/tests/test_fs_conformance.py +0 -0
  60. {termish-0.2.0 → termish-0.2.2}/tests/test_heredoc.py +0 -0
  61. {termish-0.2.0 → termish-0.2.2}/tests/test_interpreter.py +0 -0
  62. {termish-0.2.0 → termish-0.2.2}/tests/test_jq.py +0 -0
  63. {termish-0.2.0 → termish-0.2.2}/tests/test_memory_fs.py +0 -0
  64. {termish-0.2.0 → termish-0.2.2}/tests/test_monkeyfs_compat.py +0 -0
  65. {termish-0.2.0 → termish-0.2.2}/tests/test_printf.py +0 -0
  66. {termish-0.2.0 → termish-0.2.2}/tests/test_quote_masker.py +0 -0
  67. {termish-0.2.0 → termish-0.2.2}/tests/test_stderr.py +0 -0
  68. {termish-0.2.0 → termish-0.2.2}/tests/test_text_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termish
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Virtual terminal with shell-like commands over a pluggable filesystem.
5
5
  Author: ashenfad
6
6
  License-Expression: MIT
@@ -46,7 +46,7 @@ Parses and executes shell scripts (pipelines, redirects, semicolons) against any
46
46
  - **Variable expansion** -- `$?` (last exit code), `$VAR` / `${VAR}` from an env dict; expands in unquoted and double-quoted contexts, literal in single quotes
47
47
  - **Binary-safe pipelines** -- pipes and redirects carry bytes, so `cat bin.dat > copy.dat` is byte-identical and `cat file.gz | zcat` works; text decoding happens at each command's own boundary, and the returned transcript is decoded once for display
48
48
  - **Terminal-faithful transcript** -- stderr diagnostics appear in the returned output when execution continues past a failure (`cmd; next`, `cmd || rescue`), like a real terminal screen; a failure with nothing after it raises `TerminalError`. Stderr redirects are honored: `2>file` captures, `2>/dev/null` suppresses, `2>&1` merges into the pipe (`cmd 2>&1 | head` works)
49
- - **37 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, true, false, basename, dirname, ...
49
+ - **39 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, test/[, true, false, basename, dirname, ...
50
50
  - **Custom commands** -- inject your own command handlers alongside builtins; injected commands override builtins and compose in pipelines
51
51
  - **jq engine** -- built-in jq filter parser and evaluator (field access, pipes, functions, conditionals)
52
52
  - **Pluggable filesystem** -- `FileSystem` is a `typing.Protocol`; any object with the right methods works
@@ -203,7 +203,7 @@ That is not a coincidence, and it is a promise: termish's `FileSystem` protocol
203
203
  | Meta | `xargs` |
204
204
  | JSON | `jq` |
205
205
  | Inspection | `file` |
206
- | Control | `true`, `false` |
206
+ | Control | `true`, `false`, `test` / `[` |
207
207
 
208
208
  ## Development
209
209
 
@@ -10,7 +10,7 @@ Parses and executes shell scripts (pipelines, redirects, semicolons) against any
10
10
  - **Variable expansion** -- `$?` (last exit code), `$VAR` / `${VAR}` from an env dict; expands in unquoted and double-quoted contexts, literal in single quotes
11
11
  - **Binary-safe pipelines** -- pipes and redirects carry bytes, so `cat bin.dat > copy.dat` is byte-identical and `cat file.gz | zcat` works; text decoding happens at each command's own boundary, and the returned transcript is decoded once for display
12
12
  - **Terminal-faithful transcript** -- stderr diagnostics appear in the returned output when execution continues past a failure (`cmd; next`, `cmd || rescue`), like a real terminal screen; a failure with nothing after it raises `TerminalError`. Stderr redirects are honored: `2>file` captures, `2>/dev/null` suppresses, `2>&1` merges into the pipe (`cmd 2>&1 | head` works)
13
- - **37 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, true, false, basename, dirname, ...
13
+ - **39 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, test/[, true, false, basename, dirname, ...
14
14
  - **Custom commands** -- inject your own command handlers alongside builtins; injected commands override builtins and compose in pipelines
15
15
  - **jq engine** -- built-in jq filter parser and evaluator (field access, pipes, functions, conditionals)
16
16
  - **Pluggable filesystem** -- `FileSystem` is a `typing.Protocol`; any object with the right methods works
@@ -167,7 +167,7 @@ That is not a coincidence, and it is a promise: termish's `FileSystem` protocol
167
167
  | Meta | `xargs` |
168
168
  | JSON | `jq` |
169
169
  | Inspection | `file` |
170
- | Control | `true`, `false` |
170
+ | Control | `true`, `false`, `test` / `[` |
171
171
 
172
172
  ## Development
173
173
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "termish"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Virtual terminal with shell-like commands over a pluggable filesystem."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,113 @@
1
+ r"""POSIX basic regular expressions (BRE), read into Python ``re`` syntax.
2
+
3
+ ``grep`` and ``sed`` without ``-E`` take BRE, the syntax agents write
4
+ when they write GNU commands, and its escaping is roughly the reverse of
5
+ Python's: ``\(``, ``\{``, ``\|``, ``\+`` and ``\?`` are operators while
6
+ their bare forms are literal characters. Handed to ``re`` unchanged, the
7
+ operators read as literals, so ``s/a\+/X/`` matched nothing and still
8
+ exited 0, and a bare ``(`` was a syntax error rather than a parenthesis.
9
+
10
+ With ``-E`` a pattern is already in the syntax ``re`` speaks and is
11
+ passed through untouched; this module is for the other case.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ #: Escaped in BRE they are operators; bare, they are literal characters.
17
+ _SWAPPED = "(){}|+?"
18
+
19
+
20
+ def bre_to_python(pattern: str) -> str:
21
+ r"""Translate a POSIX BRE ``pattern`` into a Python ``re`` pattern.
22
+
23
+ Outside a bracket expression:
24
+
25
+ - ``\( \) \{ \} \| \+ \?`` become ``( ) { } | + ?``, and their bare
26
+ forms become escaped literals.
27
+ - ``*`` is literal where nothing precedes it: at the start of the
28
+ pattern, of a group, or of an alternative (and after a leading
29
+ ``^``).
30
+ - ``^`` anchors only at those same starting points and ``$`` only at
31
+ the end of the pattern, of a group or of an alternative; anywhere
32
+ else each is a literal character.
33
+ - ``\<`` and ``\>`` (GNU word boundaries) become ``\b``.
34
+ - Every other escape — ``\1``-``\9``, ``\.``, ``\*``, ``\w`` — is
35
+ kept as it is, since it means the same to ``re``.
36
+
37
+ Inside ``[...]`` a backslash is a literal character, as in BRE, and
38
+ a ``]`` right after ``[`` or ``[^`` is a member rather than the end.
39
+ """
40
+ out: list[str] = []
41
+ i = 0
42
+ n = len(pattern)
43
+ # True where nothing precedes this position in the current
44
+ # alternative: '*' is literal here and '^' anchors.
45
+ at_start = True
46
+ while i < n:
47
+ ch = pattern[i]
48
+ if ch == "[":
49
+ i = _bracket(pattern, i, out)
50
+ at_start = False
51
+ continue
52
+ if ch == "\\":
53
+ if i + 1 >= n:
54
+ out.append("\\\\") # a trailing backslash is literal
55
+ i += 1
56
+ continue
57
+ nxt = pattern[i + 1]
58
+ i += 2
59
+ if nxt in _SWAPPED:
60
+ out.append(nxt)
61
+ at_start = nxt in "(|"
62
+ elif nxt in "<>":
63
+ out.append(r"\b")
64
+ at_start = False
65
+ else:
66
+ out.append("\\" + nxt)
67
+ at_start = False
68
+ continue
69
+ i += 1
70
+ if ch in _SWAPPED:
71
+ out.append("\\" + ch)
72
+ at_start = False
73
+ elif ch == "*" and at_start:
74
+ out.append(r"\*")
75
+ at_start = False
76
+ elif ch == "^":
77
+ # An anchor leaves the position a start: '^*' is a literal '*'.
78
+ out.append("^" if at_start else r"\^")
79
+ elif ch == "$":
80
+ rest = pattern[i:]
81
+ ends = not rest or rest.startswith(("\\)", "\\|"))
82
+ out.append("$" if ends else r"\$")
83
+ at_start = False
84
+ else:
85
+ out.append(ch)
86
+ at_start = False
87
+ return "".join(out)
88
+
89
+
90
+ def _bracket(pattern: str, start: int, out: list[str]) -> int:
91
+ """Copy the bracket expression at ``start`` into ``out``, with its
92
+ backslashes made literal; return the position after it.
93
+
94
+ An unterminated bracket is copied as far as it goes, for ``re`` to
95
+ refuse in its own words.
96
+ """
97
+ n = len(pattern)
98
+ out.append("[")
99
+ i = start + 1
100
+ if i < n and pattern[i] == "^":
101
+ out.append("^")
102
+ i += 1
103
+ if i < n and pattern[i] == "]":
104
+ out.append(r"\]")
105
+ i += 1
106
+ while i < n:
107
+ ch = pattern[i]
108
+ i += 1
109
+ if ch == "]":
110
+ out.append("]")
111
+ return i
112
+ out.append("\\\\" if ch == "\\" else ch)
113
+ return i
@@ -0,0 +1,210 @@
1
+ """``test`` and ``[``: the conditionals agents write before ``&&`` and ``||``.
2
+
3
+ ``[ -f x ] && ...``, ``test -d out || mkdir out`` and ``[ -z "$VAR" ]``
4
+ are how a script asks a question, and without them each is
5
+ ``command not found``. The subset is the POSIX one agents use:
6
+
7
+ - file tests: ``-e`` ``-f`` ``-d`` ``-s`` ``-r`` ``-w`` ``-x``
8
+ - string tests: ``-z`` ``-n``, ``=`` (and bash's ``==``) ``!=``, and a bare
9
+ string, true when it is not empty
10
+ - integer tests: ``-eq`` ``-ne`` ``-lt`` ``-le`` ``-gt`` ``-ge``
11
+ - ``!``, ``-a``, ``-o`` and parentheses
12
+
13
+ True exits 0, false exits 1 with nothing written, and a malformed
14
+ expression or a non-integer operand exits 2 with a diagnostic, as in
15
+ bash. Nothing is ever written to stdout.
16
+
17
+ The filesystem has no permission bits, so ``-r`` and ``-w`` are true
18
+ for any path that exists (a read-only mount still answers true: the
19
+ protocol cannot say otherwise without writing), and ``-x`` is true for
20
+ a directory, which can be entered, and false for a file, which nothing
21
+ here can run. Out of scope: ``[[ ]]``, ``-nt``/``-ot``, ``-L``, pattern
22
+ matching.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+
29
+ from termish.context import CommandContext, CommandResult
30
+ from termish.fs import FileSystem
31
+
32
+ from ._util import resolve_path
33
+
34
+ _UNARY = {"-e", "-f", "-d", "-s", "-r", "-w", "-x", "-z", "-n"}
35
+ _BINARY = {"=", "==", "!=", "-eq", "-ne", "-lt", "-le", "-gt", "-ge"}
36
+ _INTEGER = re.compile(r"^\s*[+-]?\d+\s*$")
37
+ #: bash's integers are signed 64-bit; past either end is not an integer.
38
+ _INT_MIN, _INT_MAX = -(2**63), 2**63 - 1
39
+
40
+
41
+ class _Usage(Exception):
42
+ """A malformed expression or a bad operand: exit 2, with this text."""
43
+
44
+
45
+ def test_cmd(ctx: CommandContext) -> CommandResult | None:
46
+ """``test EXPRESSION`` — exit 0 when it holds, 1 when it does not."""
47
+ return _run("test", list(ctx.args), ctx.fs)
48
+
49
+
50
+ def bracket_cmd(ctx: CommandContext) -> CommandResult | None:
51
+ """``[ EXPRESSION ]`` — ``test``, with a closing ``]`` required."""
52
+ args = list(ctx.args)
53
+ if not args or args[-1] != "]":
54
+ return CommandResult(exit_code=2, stderr="[: missing `]'")
55
+ return _run("[", args[:-1], ctx.fs)
56
+
57
+
58
+ def _run(name: str, args: list[str], fs: FileSystem) -> CommandResult | None:
59
+ try:
60
+ held = _evaluate(args, fs)
61
+ except _Usage as e:
62
+ return CommandResult(exit_code=2, stderr=f"{name}: {e}")
63
+ return None if held else CommandResult(exit_code=1, stderr="")
64
+
65
+
66
+ def _evaluate(args: list[str], fs: FileSystem) -> bool:
67
+ """POSIX reads one to four arguments by their count, which is what
68
+ makes ``[ -n ]`` (a bare string) and ``[ ! = x ]`` mean what they do;
69
+ longer expressions go to the general parser."""
70
+ n = len(args)
71
+ if n == 0:
72
+ return False
73
+ if n == 1:
74
+ return args[0] != ""
75
+ if n == 2:
76
+ if args[0] == "!":
77
+ return not _evaluate(args[1:], fs)
78
+ if args[0] in _UNARY:
79
+ return _unary(args[0], args[1], fs)
80
+ raise _Usage(f"{args[0]}: unary operator expected")
81
+ if n == 3:
82
+ if args[1] in _BINARY:
83
+ return _binary(args[0], args[1], args[2])
84
+ if args[0] == "!":
85
+ return not _evaluate(args[1:], fs)
86
+ if args[0] == "(" and args[2] == ")":
87
+ return _evaluate(args[1:2], fs)
88
+ if args[1] in ("-a", "-o"):
89
+ return _Parser(args, fs).parse()
90
+ raise _Usage(f"{args[1]}: binary operator expected")
91
+ if n == 4:
92
+ if args[0] == "!":
93
+ return not _evaluate(args[1:], fs)
94
+ if args[0] == "(" and args[3] == ")":
95
+ return _evaluate(args[1:3], fs)
96
+ return _Parser(args, fs).parse()
97
+
98
+
99
+ class _Parser:
100
+ """``-o`` binds loosest, then ``-a``, then ``!``; a primary is a
101
+ parenthesised expression, a unary test, a binary test or a bare
102
+ string."""
103
+
104
+ def __init__(self, args: list[str], fs: FileSystem) -> None:
105
+ self.args = args
106
+ self.fs = fs
107
+ self.i = 0
108
+
109
+ def parse(self) -> bool:
110
+ value = self._or()
111
+ if self.i < len(self.args):
112
+ raise _Usage(f"{self.args[self.i]}: unexpected argument")
113
+ return value
114
+
115
+ def _peek(self, ahead: int = 0) -> str | None:
116
+ j = self.i + ahead
117
+ return self.args[j] if j < len(self.args) else None
118
+
119
+ def _or(self) -> bool:
120
+ value = self._and()
121
+ while self._peek() == "-o":
122
+ self.i += 1
123
+ right = self._and()
124
+ value = value or right
125
+ return value
126
+
127
+ def _and(self) -> bool:
128
+ value = self._not()
129
+ while self._peek() == "-a":
130
+ self.i += 1
131
+ right = self._not()
132
+ value = value and right
133
+ return value
134
+
135
+ def _not(self) -> bool:
136
+ if self._peek() == "!":
137
+ self.i += 1
138
+ return not self._not()
139
+ return self._primary()
140
+
141
+ def _primary(self) -> bool:
142
+ tok = self._peek()
143
+ if tok is None:
144
+ raise _Usage("argument expected")
145
+ if tok == "(":
146
+ self.i += 1
147
+ value = self._or()
148
+ if self._peek() != ")":
149
+ raise _Usage("')' expected")
150
+ self.i += 1
151
+ return value
152
+ if tok in _UNARY and self._peek(1) is not None:
153
+ self.i += 2
154
+ return _unary(tok, self.args[self.i - 1], self.fs)
155
+ if self._peek(1) in _BINARY and self._peek(2) is not None:
156
+ left, op, right = self.args[self.i : self.i + 3]
157
+ self.i += 3
158
+ return _binary(left, op, right)
159
+ self.i += 1
160
+ return tok != ""
161
+
162
+
163
+ def _unary(op: str, operand: str, fs: FileSystem) -> bool:
164
+ if op == "-z":
165
+ return operand == ""
166
+ if op == "-n":
167
+ return operand != ""
168
+ if operand == "":
169
+ # No path at all, not the current directory: an unset variable
170
+ # in `[ -d "$OUT" ]` must not test whatever the shell stands in.
171
+ return False
172
+ path = resolve_path(operand, fs)
173
+ if not fs.exists(path):
174
+ return False
175
+ if op in ("-e", "-r", "-w"):
176
+ return True
177
+ if op == "-f":
178
+ return fs.isfile(path)
179
+ if op == "-d":
180
+ return fs.isdir(path)
181
+ if op == "-x":
182
+ return fs.isdir(path)
183
+ # -s: exists and is not empty; a directory counts as not empty, as a
184
+ # real directory's own size does
185
+ if fs.isdir(path):
186
+ return True
187
+ return fs.stat(path).size > 0
188
+
189
+
190
+ def _binary(left: str, op: str, right: str) -> bool:
191
+ if op in ("=", "=="):
192
+ return left == right
193
+ if op == "!=":
194
+ return left != right
195
+ a, b = _integer(left), _integer(right)
196
+ return {
197
+ "-eq": a == b,
198
+ "-ne": a != b,
199
+ "-lt": a < b,
200
+ "-le": a <= b,
201
+ "-gt": a > b,
202
+ "-ge": a >= b,
203
+ }[op]
204
+
205
+
206
+ def _integer(text: str) -> int:
207
+ value = int(text.strip()) if _INTEGER.match(text) else None
208
+ if value is None or not _INT_MIN <= value <= _INT_MAX:
209
+ raise _Usage(f"{text}: integer expression expected")
210
+ return value
@@ -3,6 +3,7 @@ Filesystem commands for the terminal interpreter.
3
3
  """
4
4
 
5
5
  import posixpath
6
+ from typing import Any
6
7
 
7
8
  from termish.context import CommandContext, CommandResult
8
9
  from termish.errors import TerminalError
@@ -91,108 +92,131 @@ def ls(ctx: CommandContext) -> CommandResult | None:
91
92
  if unknown:
92
93
  raise TerminalError(f"ls: unknown option: {unknown[0]}")
93
94
 
94
- for i, path in enumerate(parsed.paths):
95
- if len(parsed.paths) > 1:
96
- stdout.write(f"{path}:\n")
97
-
98
- try:
99
- # -d: list directories themselves, not their contents
100
- if parsed.directory and fs.isdir(path):
101
- if parsed.l:
102
- meta = fs.stat(path)
103
- if parsed.human_readable:
104
- size = _human_readable_size(meta.size).rjust(6)
105
- else:
106
- size = str(meta.size).rjust(8)
107
- time = (
108
- meta.modified_at[:16].replace("T", " ")
109
- if meta.modified_at
110
- else " " * 16
111
- )
112
- stdout.write(f"drw-r--r-- 1 agent agent {size} {time} {path}\n")
113
- else:
114
- stdout.write(f"{path}\n")
115
- continue
116
-
117
- # Check if it is a file first
118
- if fs.isfile(path):
119
- if parsed.l:
120
- # List detailed for file
121
- meta = fs.stat(path)
95
+ def long_line(path: str, type_char: str) -> str:
96
+ meta = fs.stat(path)
97
+ if parsed.human_readable:
98
+ size = _human_readable_size(meta.size).rjust(6)
99
+ else:
100
+ size = str(meta.size).rjust(8)
101
+ time = meta.modified_at[:16].replace("T", " ") if meta.modified_at else " " * 16
102
+ return f"{type_char}rw-r--r-- 1 agent agent {size} {time} {path}\n"
103
+
104
+ def list_dir(path: str) -> None:
105
+ needs_detailed = parsed.l or parsed.t or parsed.S or parsed.classify
106
+ if needs_detailed:
107
+ items = fs.list_detailed(path, recursive=parsed.R)
108
+ if not parsed.a:
109
+ items = [i for i in items if not i.name.startswith(".")]
110
+ if parsed.S:
111
+ items = sorted(items, key=lambda x: x.size, reverse=True)
112
+ elif parsed.t:
113
+ items = sorted(items, key=lambda x: x.modified_at or "", reverse=True)
114
+ if parsed.r:
115
+ items = list(reversed(items))
116
+
117
+ if parsed.l:
118
+ for item in items:
119
+ type_char = "d" if item.is_dir else "-"
122
120
  if parsed.human_readable:
123
- size = _human_readable_size(meta.size).rjust(6)
121
+ size = _human_readable_size(item.size).rjust(6)
124
122
  else:
125
- size = str(meta.size).rjust(8)
123
+ size = str(item.size).rjust(8)
126
124
  time = (
127
- meta.modified_at[:16].replace("T", " ")
128
- if meta.modified_at
125
+ item.modified_at[:16].replace("T", " ")
126
+ if item.modified_at
129
127
  else " " * 16
130
128
  )
131
- stdout.write(f"-rw-r--r-- 1 agent agent {size} {time} {path}\n")
132
- else:
133
- stdout.write(f"{path}\n")
134
- continue
135
-
136
- needs_detailed = parsed.l or parsed.t or parsed.S or parsed.classify
137
- if needs_detailed:
138
- items = fs.list_detailed(path, recursive=parsed.R)
139
- if not parsed.a:
140
- items = [i for i in items if not i.name.startswith(".")]
141
- if parsed.S:
142
- items = sorted(items, key=lambda x: x.size, reverse=True)
143
- elif parsed.t:
144
- items = sorted(
145
- items, key=lambda x: x.modified_at or "", reverse=True
129
+ suffix = "/" if parsed.classify and item.is_dir else ""
130
+ stdout.write(
131
+ f"{type_char}rw-r--r-- 1 agent agent {size} {time} {item.path}{suffix}\n"
146
132
  )
147
- if parsed.r:
148
- items = list(reversed(items))
149
-
150
- if parsed.l:
151
- for item in items:
152
- type_char = "d" if item.is_dir else "-"
153
- if parsed.human_readable:
154
- size = _human_readable_size(item.size).rjust(6)
155
- else:
156
- size = str(item.size).rjust(8)
157
- time = (
158
- item.modified_at[:16].replace("T", " ")
159
- if item.modified_at
160
- else " " * 16
161
- )
162
- suffix = "/" if parsed.classify and item.is_dir else ""
163
- stdout.write(
164
- f"{type_char}rw-r--r-- 1 agent agent {size} {time} {item.path}{suffix}\n"
165
- )
166
- else:
167
- filtered = [
168
- item.path + ("/" if parsed.classify and item.is_dir else "")
169
- for item in items
170
- ]
171
- if filtered:
172
- stdout.write("\n".join(filtered) + "\n")
173
133
  else:
174
- items_str = fs.list(path, recursive=parsed.R)
175
134
  filtered = [
176
- p
177
- for p in items_str
178
- if parsed.a or not p.split("/")[-1].startswith(".")
135
+ item.path + ("/" if parsed.classify and item.is_dir else "")
136
+ for item in items
179
137
  ]
180
- if parsed.r:
181
- filtered = list(reversed(filtered))
182
138
  if filtered:
183
139
  stdout.write("\n".join(filtered) + "\n")
140
+ else:
141
+ items_str = fs.list(path, recursive=parsed.R)
142
+ filtered = [
143
+ p for p in items_str if parsed.a or not p.split("/")[-1].startswith(".")
144
+ ]
145
+ if parsed.r:
146
+ filtered = list(reversed(filtered))
147
+ if filtered:
148
+ stdout.write("\n".join(filtered) + "\n")
149
+
150
+ # GNU's order: an operand that is missing is reported and the rest
151
+ # are listed; file operands (and, with -d, directories as entries)
152
+ # come first, by name and with no header; then each directory, by
153
+ # name, under a "dir:" header whenever there is more than one
154
+ # operand, with a blank line before every group after the first.
155
+ missing: list[str] = []
156
+ files: list[str] = []
157
+ dirs: list[str] = []
158
+ for path in parsed.paths:
159
+ try:
160
+ if fs.isdir(path):
161
+ (files if parsed.directory else dirs).append(path)
162
+ elif fs.isfile(path) or fs.exists(path):
163
+ files.append(path)
164
+ else:
165
+ missing.append(path)
166
+ except Exception:
167
+ missing.append(path)
168
+
169
+ def operand_order(paths: list[str]) -> list[str]:
170
+ """Operands in the order their entries would be: by name, or by
171
+ size or time (largest and newest first) under -S and -t, and
172
+ reversed under -r."""
173
+ if parsed.S or parsed.t:
174
+
175
+ def key(p: str) -> Any:
176
+ try:
177
+ meta = fs.stat(p)
178
+ except Exception:
179
+ return 0 if parsed.S else ""
180
+ return meta.size if parsed.S else (meta.modified_at or "")
181
+
182
+ paths = sorted(sorted(paths), key=key, reverse=True)
183
+ else:
184
+ paths = sorted(paths)
185
+ return list(reversed(paths)) if parsed.r else paths
184
186
 
185
- except FileNotFoundError:
186
- raise TerminalError(
187
- f"ls: cannot access '{path}': No such file or directory"
188
- )
189
- except NotADirectoryError:
190
- raise TerminalError(f"ls: cannot access '{path}': Not a directory")
191
- except Exception as e:
192
- raise TerminalError(f"ls: {e}")
187
+ files = operand_order(files)
188
+ dirs = operand_order(dirs)
189
+ errors = [f"ls: cannot access '{p}': No such file or directory" for p in missing]
190
+ if not files and not dirs:
191
+ raise TerminalError("\n".join(errors))
192
+
193
+ try:
194
+ for path in files:
195
+ if parsed.l:
196
+ stdout.write(long_line(path, "d" if fs.isdir(path) else "-"))
197
+ else:
198
+ stdout.write(f"{path}\n")
199
+ headers = len(parsed.paths) > 1
200
+ for k, path in enumerate(dirs):
201
+ if files or k:
202
+ stdout.write("\n")
203
+ if headers:
204
+ stdout.write(f"{path}:\n")
205
+ list_dir(path)
206
+ except FileNotFoundError as e:
207
+ raise TerminalError(
208
+ f"ls: cannot access '{e.filename or e}': No such file or directory"
209
+ )
210
+ except NotADirectoryError as e:
211
+ raise TerminalError(f"ls: cannot access '{e.filename or e}': Not a directory")
212
+ except TerminalError:
213
+ raise
214
+ except Exception as e:
215
+ raise TerminalError(f"ls: {e}")
193
216
 
194
- if i < len(parsed.paths) - 1:
195
- stdout.write("\n")
217
+ if errors:
218
+ return CommandResult(exit_code=2, stderr="\n".join(errors) + "\n")
219
+ return None
196
220
 
197
221
 
198
222
  def touch(ctx: CommandContext) -> CommandResult | None: