Compare commits

..

53 Commits

Author SHA1 Message Date
ef1d92a108 Fix screensaver idle timeout overflow by using 1s poll intervals with deadline tracking instead of direct millisecond conversion 2026-03-21 03:54:43 -04:00
898002812e Restructure Nix options to nest shopts under line/core/prompt submodules matching shopt key paths 2026-03-21 03:27:25 -04:00
627489905e added autocmd hooks for screensaver commands
added a landing page for the help command
2026-03-21 03:06:55 -04:00
42647ffda8 Refactor: extract history search, completion, and keymap handling into separate methods; support prefix matching for help topics 2026-03-21 02:14:47 -04:00
1c42a36810 chore: extracted some logic in ShedVi into helpers, cleaned up compiler warnings 2026-03-21 01:19:55 -04:00
4b07990fc5 Bump to 0.6.1: add Ctrl-D/U half-screen scrolling, Ctrl-G position info, and status messages 2026-03-20 18:44:43 -04:00
939888e579 Bump version to 0.6.0 and add viewport scrolling to the line editor 2026-03-20 16:09:02 -04:00
d83cda616b Replace hand-rolled glob_to_regex with fnmatch_regex crate and remove unused mut 2026-03-20 13:43:38 -04:00
6f44759deb reimplemented ex-mode widget/function execution 2026-03-20 12:36:57 -04:00
392506d414 finished linebuf refactor, all tests passing 2026-03-20 00:19:21 -04:00
c4ba89f83a more work on linebuf reimpl 2026-03-19 19:45:54 -04:00
22113cbdfd reimplemented visual mode and text objects 2026-03-19 17:12:22 -04:00
406fd57b5a reimplemented incremental autosuggestion acceptance 2026-03-19 01:11:13 -04:00
4a82f29231 progress on linebuf refactor 2026-03-18 23:52:23 -04:00
7c8a418f96 work started on refactoring LineBuf 2026-03-18 15:34:12 -04:00
782a3820da added global shedrc option configuration to the nixos module
extracted options and rc file renderer into their own files
2026-03-17 01:47:42 -04:00
b0325b6bbb Add Candidate type for case-insensitive completion, shopt_group macro, escape fixes, and vi mode tweaks 2026-03-17 01:25:55 -04:00
bce6cd10f7 Merge branch 'main' of github.com:km-clay/shed 2026-03-16 23:32:00 -04:00
ac8940f936 Implement = (equalize/auto-indent) verb, fix dedent indexing, remove unimplemented screen-line motions, and clean up unreachable match arms 2026-03-16 23:31:54 -04:00
3705986169 Update README
Update README
2026-03-16 19:10:47 -04:00
db3f1b5108 Propagate SIGINT from foreground jobs to interrupt shell loops, add SIGUSR1 for async prompt refresh, and support SHED_HPAGER override 2026-03-16 19:08:38 -04:00
958dad9942 implemented ex mode :w/:e commands
implemented tab completion and history search for the ex mode prompt as well

fixed paths not expanding correctly in ex mode command arguments
2026-03-16 18:15:01 -04:00
ec9795c781 implemented read command for ex mode 2026-03-16 01:53:49 -04:00
bcc4a87e10 implemented PIPESTATUS variable from bash. puts all exit codes from last pipeline into an array. 2026-03-15 23:32:57 -04:00
067b4f6184 Implement sourcing for shedenv and shed_profile, and also check /etc/shed for global shedrc/shed_profile/shedenv files 2026-03-15 23:02:11 -04:00
7e2763bb80 Implemented the -s flag for reading commands from stdin 2026-03-15 22:27:54 -04:00
99b9440ee1 Implemented the 'help' builtin, and support for :h <topic> in ex mode
:h is an alias for the 'help' builtin.

'help' takes a single argument and tries to find a suitable match among the files in '$SHED_HPATH'

if a match is found, this file is opened in your pager

calling the 'help' builtin using :h in ex mode will preserve your current pending line
2026-03-15 18:18:53 -04:00
f6a3935bcb implement tilde expansion for ~user and ~uid using nix User lookups 2026-03-15 11:30:40 -04:00
1f9d59b546 fixed ss3 escape code parsing, added a cursor mode reset that triggers on child exit 2026-03-15 11:11:35 -04:00
101d8434f8 fixed heredocs using the same expansion pathway as regular strings
implemented backtick command subs

deferred heredoc expansion until redir time instead of parse time

implemented "$*" expansions

function defs like 'func   ()  { }' now parse correctly

fixed conjunctions short circuiting instead of skipping
2026-03-15 10:49:24 -04:00
9bd9c66b92 implemented '<>' redirects, and the 'seek' builtin
'seek' is a wrapper around the lseek() syscall

added noclobber to core shopts and implemented '>|' redirection syntax

properly implemented fd close syntax

fixed saved fds being leaked into exec'd programs
2026-03-14 20:04:20 -04:00
5173e1908d heredocs and herestrings implemented
added more tests to the test suite
2026-03-14 13:40:00 -04:00
1f9c96f24e more improvements to auto indent depth tracking
added test cases for the auto indent/dedent feature
2026-03-14 01:14:30 -04:00
09024728f6 Add token-aware depth calculator for indentation, improve brace group error handling, and clean up warnings 2026-03-13 20:57:04 -04:00
307386ffc6 tightened up some logic with indenting and joining lines
added more linebuf tests

extracted all verb match arms into private methods on LineBuf
2026-03-13 19:24:30 -04:00
13227943c6 Add unit and integration tests for tab completion, fuzzy scoring, escaping, and wordbreak handling 2026-03-13 18:40:29 -04:00
a46ebe6868 Use COMP_WORDBREAKS for completion word breaking, fix cursor row in vi command mode, and append completion suffix instead of replacing full token 2026-03-13 11:18:57 -04:00
5500b081fe Strip escape markers from expanded patterns in parameter expansion operations 2026-03-12 09:20:07 -04:00
f279159873 tab completion and glob results are now properly escaped before being parsed 2026-03-11 18:48:07 -04:00
bb3db444db Add screensaver idle command support, autocd directory completion, and unused import cleanup 2026-03-10 12:20:40 -04:00
85e5fc2875 Fork non-command nodes for background jobs, fix interactive flag in child processes, and add empty variable test for [ builtin 2026-03-09 21:55:03 -04:00
ac429cbdf4 Fix crash when using vi visual selection on empty buffer 2026-03-08 00:36:46 -05:00
a464540fbe Implement SHLVL tracking, LINENO variable, and magic shell variables (SECONDS, EPOCHREALTIME, EPOCHSECONDS, RANDOM) 2026-03-08 00:30:22 -05:00
07d7015dd4 Add ! negation support, fix POSIX exit statuses, and improve vi emulation with comprehensive tests 2026-03-07 22:04:33 -05:00
490ce4571d added tests for the parser 2026-03-07 14:38:07 -05:00
a43f8a6dde implemented umask builtin 2026-03-07 02:09:32 -05:00
ae73969969 fixed compiler warnings 2026-03-07 00:37:51 -05:00
fe9fd5c797 Fix function $0 param, scope positional args locally, follow symlinks in completion, expose SHED_VI_MODE variable, and clean up test helpers 2026-03-07 00:23:05 -05:00
b137c38e92 completely rewrote test suite for top level src files and all builtin files 2026-03-06 23:42:14 -05:00
42b4120055 Add ulimit builtin and optimize shed -c to exec single commands directly without forking 2026-03-06 11:07:46 -05:00
8a7211d42e Expose completion/history metadata to autocmd hooks and add broader Var conversion impls 2026-03-06 02:08:28 -05:00
dc0ff23903 Merge branch 'main' of github.com:km-clay/shed 2026-03-06 00:46:54 -05:00
c8531fb384 Update README formatting 2026-03-05 20:10:06 -05:00
80 changed files with 18518 additions and 5838 deletions

6
.cargo/config.toml Normal file
View File

@@ -0,0 +1,6 @@
[env]
# we need to use one thread for tests
# so that they arent stepping on eachother's toes
# plus it matches the single-threaded behavior of the program
# more closely anyway
RUST_TEST_THREADS = "1"

135
Cargo.lock generated
View File

@@ -47,7 +47,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -158,18 +158,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
@@ -191,12 +179,6 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "env_filter"
version = "1.0.0"
@@ -233,7 +215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -242,6 +224,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fnmatch-regex"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f319c7da34eac5f0b8c7220a4afb2e1ddde0c24ae87c7435a8e36dcd62a43a3"
dependencies = [
"anyhow",
"itertools",
"regex",
]
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -307,18 +300,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "insta"
version = "1.46.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4"
dependencies = [
"console",
"once_cell",
"similar",
"tempfile",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -533,7 +514,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -592,14 +573,14 @@ dependencies = [
[[package]]
name = "shed"
version = "0.5.0"
version = "0.6.2"
dependencies = [
"ariadne",
"bitflags",
"clap",
"env_logger",
"fnmatch-regex",
"glob",
"insta",
"itertools",
"log",
"nix",
@@ -608,6 +589,7 @@ dependencies = [
"regex",
"scopeguard",
"serde_json",
"smallvec",
"tempfile",
"unicode-segmentation",
"unicode-width",
@@ -616,10 +598,10 @@ dependencies = [
]
[[package]]
name = "similar"
version = "2.7.0"
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "strsim"
@@ -648,7 +630,7 @@ dependencies = [
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
@@ -749,15 +731,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -767,70 +740,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -2,7 +2,7 @@
name = "shed"
description = "A linux shell written in rust"
publish = false
version = "0.5.0"
version = "0.6.2"
edition = "2024"
@@ -14,21 +14,36 @@ ariadne = "0.6.0"
bitflags = "2.8.0"
clap = { version = "4.5.38", features = ["derive"] }
env_logger = "0.11.9"
fnmatch-regex = "0.3.0"
glob = "0.3.2"
itertools = "0.14.0"
log = "0.4.29"
nix = { version = "0.29.0", features = ["uio", "term", "user", "hostname", "fs", "default", "signal", "process", "event", "ioctl", "poll"] }
nix = { version = "0.29.0", features = [
"uio",
"term",
"user",
"resource",
"hostname",
"fs",
"default",
"signal",
"process",
"event",
"ioctl",
"poll"
] }
rand = "0.10.0"
regex = "1.11.1"
scopeguard = "1.2.0"
serde_json = "1.0.149"
smallvec = { version = "1.15.1", features = ["write"] }
tempfile = "3.24.0"
unicode-segmentation = "1.12.0"
unicode-width = "0.2.0"
vte = "0.15"
yansi = "1.0.1"
[dev-dependencies]
insta = "1.42.2"
pretty_assertions = "1.4.1"
tempfile = "3.24.0"

View File

@@ -1,6 +1,8 @@
# shed
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing.
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing and general interactive UX improvements over existing shells.
<sub>btw if you don't use `vim` this probably isn't your shell</sub>
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/3945f663-a361-4418-bf20-0c4eaa2a36d2" />
@@ -8,7 +10,7 @@ A Linux shell written in Rust. The name is a nod to the original Unix utilities
### Line Editor
`shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt.
`shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt than conventional `vi` mode implementations.
- **Normal mode** - motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts
- **Insert mode** - insert, append, replace, with Ctrl+W word deletion and undo/redo
@@ -16,6 +18,8 @@ A Linux shell written in Rust. The name is a nod to the original Unix utilities
- **Real-time syntax highlighting** - commands, keywords, strings, variables, redirections, and operators are colored as you type
- **Tab completion** - context-aware completion for commands, file paths, and variables
---
### Prompt
The prompt string supports escape sequences for dynamic content:
@@ -38,8 +42,12 @@ gitbranch() { git branch --show-current 2>/dev/null; }
export PS1='\u@\h \W \@gitbranch \$ '
```
If `shed` receives `SIGUSR1` while in interactive mode, it will refresh and redraw the prompt. This can be used to create asynchronous, dynamic prompt content.
Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, similar to how the `-e` flag expands conventional escape sequences.
---
### I Can't Believe It's Not `fzf`!
`shed` comes with fuzzy completion and history searching out of the box. It has it's own internal fuzzyfinder implementation, so `fzf` is not a dependency.
@@ -47,6 +55,7 @@ Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, s
<img width="380" height="270" alt="shed_comp" src="https://github.com/user-attachments/assets/d317387e-4c33-406a-817f-1c183afab749" />
<img width="380" height="270" alt="shed_search" src="https://github.com/user-attachments/assets/5109eb14-5c33-46bb-ab39-33c60ca039a8" />
---
### Keymaps
@@ -69,6 +78,8 @@ Use `keymap --remove <keys>` to remove a binding.
Shell commands run via keymaps have read-write access to the line editor state through special variables: `$_BUFFER` (current line contents), `$_CURSOR` (cursor position), `$_ANCHOR` (visual selection anchor), and `$_KEYS` (inject key sequences back into the editor). Modifying these variables from within the command updates the editor when it returns.
---
### Autocmds
The `autocmd` builtin registers shell commands to run on specific events:
@@ -94,6 +105,8 @@ Available events:
Use `-p <pattern>` to filter by regex, and `-c` to clear all autocmds for an event. The pattern matched by `-p` changes by context, and not all autocmds have a pattern to match.
---
### Shell Language
shed's scripting language contains all of the essentials.
@@ -113,6 +126,8 @@ shed's scripting language contains all of the essentials.
- **Subshells** - `(...)` for isolated execution
- **Variable attributes** - `export`, `local`, `readonly`
---
### Job Control
- Background execution with `&`
@@ -120,6 +135,8 @@ shed's scripting language contains all of the essentials.
- `fg`, `bg`, `jobs`, `disown` with flags (`-l`, `-p`, `-r`, `-s`, `-h`, `-a`)
- Process group management and proper signal forwarding
---
### Configuration
Shell options are managed through `shopt`:
@@ -134,6 +151,8 @@ shopt core.max_hist=5000 # history size
The rc file is loaded from `~/.shedrc` on startup.
---
## Building
### Cargo

76
doc/arith.txt Normal file
View File

@@ -0,0 +1,76 @@
*arith* *arithmetic* *arithmetic-expansion*
#ARITHMETIC EXPANSION#
Arithmetic expansion evaluates a mathematical expression and substitutes
the result. The expression is subject to parameter expansion and command
substitution before evaluation.
`$((expression))`
Example:
`echo $((2 + 3))` # prints: 5
`x=$((width * height))`
==============================================================================
1. Operators *arith-operators*
The following operators are supported, listed from highest to lowest
precedence:
`( )` *arith-parens*
Grouping. Override default precedence.
Example:
`echo $(( (2+3) * 4 ))` # prints: 20
`*` `/` `%` *arith-muldivmod*
Multiplication, division, and modulo (remainder).
Example:
`echo $((10 / 3))` # prints: 3
`echo $((10 % 3))` # prints: 1
`+` `-` *arith-addsub*
Addition and subtraction.
Example:
`echo $((10 - 3 + 1))` # prints: 8
==============================================================================
2. Variables in Expressions *arith-variables*
Variables can be referenced by name inside arithmetic expressions.
They are expanded and converted to numbers.
`x=10`
`echo $(($x + 5))` # prints: 15
`echo $((x + 5))` # also works
If a variable is unset or not a valid number, an error is reported.
==============================================================================
3. Nesting *arith-nesting*
Arithmetic expressions can be nested with parentheses to any depth:
`echo $(( (1+2) * (3+4) ))` # prints: 21
Arithmetic expansion can also appear inside other expansions:
`echo "Total: $((price * qty))"`
==============================================================================
4. Whitespace *arith-whitespace*
Whitespace inside `$((...))` is ignored and can be used freely for
readability:
`echo $((2+3))` # prints: 5
`echo $(( 2 + 3 ))` # same result
==============================================================================
See also: |param| |redirect| |glob|

210
doc/autocmd.txt Normal file
View File

@@ -0,0 +1,210 @@
*autocmd* *autocmds* *hooks*
#AUTOCMDS#
Autocmds (automatic commands) execute shell commands in response to
specific events. They provide a hook system for customizing shell
behavior without modifying the shell itself.
==============================================================================
1. Registration *autocmd-register*
`autocmd [OPTIONS] {kind} {command}`
Register {command} to run whenever the event {kind} fires.
Options:
`-p {pattern}` *autocmd-pattern*
Only run this autocmd when {pattern} (a regex) matches the
event's context string. The context varies by event type:
for |autocmd-pre-cmd| it is the command being executed, for
|autocmd-post-change-dir| it is the target directory, etc.
`-c` *autocmd-clear*
Clear all autocmds of the specified {kind}. No command is
needed.
Examples:
`autocmd post-cmd 'echo "exit: $?"'`
`autocmd -p '^git' pre-cmd 'echo running git...'`
`autocmd -c pre-cmd`
==============================================================================
2. Event Kinds *autocmd-kinds*
2.1 Command Execution *autocmd-cmd-events*
`pre-cmd` *autocmd-pre-cmd*
Fires before a command is executed. The command string is
available for pattern matching.
`post-cmd` *autocmd-post-cmd*
Fires after a command finishes. The command string is available
for pattern matching.
2.2 Directory Changes *autocmd-dir-events*
`pre-change-dir` *autocmd-pre-change-dir*
Fires before `cd` changes the working directory. The target
directory is available for pattern matching.
Special variables:
`$_NEW_DIR` the directory being changed to
`$_OLD_DIR` the current directory (before the change)
`post-change-dir` *autocmd-post-change-dir*
Fires after a successful directory change.
Special variables:
`$_NEW_DIR` the new working directory
`$_OLD_DIR` the previous directory
2.3 Job Events *autocmd-job-events*
`on-job-finish` *autocmd-on-job-finish*
Fires when a background job completes. The job's command string
is available for pattern matching.
2.4 Prompt Events *autocmd-prompt-events*
`pre-prompt` *autocmd-pre-prompt*
Fires before the prompt is rendered. Useful for updating prompt
state.
`post-prompt` *autocmd-post-prompt*
Fires after the prompt is rendered.
2.5 Mode Change Events *autocmd-mode-events*
`pre-mode-change` *autocmd-pre-mode-change*
Fires before the vi editing mode changes. The `$SHED_VI_MODE`
variable still holds the old mode.
`post-mode-change` *autocmd-post-mode-change*
Fires after the vi editing mode changes. `$SHED_VI_MODE` reflects
the new mode.
2.6 History Events *autocmd-hist-events*
`on-history-open` *autocmd-on-history-open*
Fires when the fuzzy history search window opens.
Special variables:
`$_ENTRIES` array of all history entries
`$_NUM_ENTRIES` count of all entries
`$_MATCHES` array of currently matching entries
`$_NUM_MATCHES` count of matching entries
`$_SEARCH_STR` the current search string
`on-history-close` *autocmd-on-history-close*
Fires when the history search is dismissed without selecting.
`on-history-select` *autocmd-on-history-select*
Fires when a history entry is selected. The entry text is
available for pattern matching.
Special variables:
`$_HIST_ENTRY` the selected history entry
2.7 Completion Events *autocmd-comp-events*
`on-completion-start` *autocmd-on-completion-start*
Fires when the completion menu becomes visible.
Special variables:
`$_MATCHES` array of completion candidates
`$_NUM_MATCHES` count of candidates
`$_SEARCH_STR` the token being completed
`on-completion-cancel` *autocmd-on-completion-cancel*
Fires when the completion menu is dismissed without selecting.
`on-completion-select` *autocmd-on-completion-select*
Fires when a completion candidate is accepted. The candidate
is available for pattern matching.
Special variables:
`$_COMP_CANDIDATE` the selected completion candidate
2.8 Screensaver Events *autocmd-screensaver-events*
`on-screensaver-exec` *autocmd-on-screensaver-exec*
Fires when the screensaver activates after the idle timeout.
The screensaver command (see `shopt prompt.screensaver_cmd`) is
available for pattern matching.
`on-screensaver-return` *autocmd-on-screensaver-return*
Fires when the shell returns from the screensaver command.
The screensaver command is available for pattern matching.
2.9 Exit Event *autocmd-exit-event*
`on-exit` *autocmd-on-exit*
Fires when the shell is about to exit.
==============================================================================
3. Behavior *autocmd-behavior*
- Multiple autocmds can be registered for the same event kind. They
execute in registration order.
- If an autocmd command fails, the error is printed but subsequent
autocmds for the same event still run.
- Autocmds do not affect the shell's exit status (`$?`). The exit
status is saved before autocmd execution and restored afterward.
- Pattern matching uses Rust regex syntax. If an autocmd has no
pattern, it always fires for its event kind.
- Special variables (e.g. `$_NEW_DIR`) are only available within the
scope of the autocmd execution. They are not set globally.
==============================================================================
4. Examples *autocmd-examples*
Notify on directory change:
`autocmd post-change-dir 'echo "moved to $_NEW_DIR"'`
Run a linter only on git commands:
`autocmd -p '^git commit' post-cmd 'lint-check'`
Refresh prompt on mode change (for mode indicator):
`autocmd post-mode-change 'kill -USR1 $$'`
(SIGUSR1 can be used to remotely refresh the prompt. See |prompt|)
Log completed jobs:
`autocmd on-job-finish 'echo "job done" >> /tmp/jobs.log'`
Clean up on exit:
`autocmd on-exit 'rm -f /tmp/my-shell-*.tmp'`
==============================================================================
See also: |keybinds| |prompt| |ex|

109
doc/ex.txt Normal file
View File

@@ -0,0 +1,109 @@
*ex* *ex-mode* *ex-commands* *colon-commands*
#EX MODE#
Ex mode provides colon commands for operations that go beyond single-key
normal mode actions. Enter ex mode by pressing `:` in normal mode.
The command line supports full editing via insert mode, and has its own
command history navigable with `Up` and `Down`.
==============================================================================
1. Shell Commands *ex-shell*
`:!{cmd}` *ex-bang*
Execute {cmd} in the shell. The following special variables are
set during execution and can be read or modified:
`$_BUFFER` the current editor buffer contents
`$_CURSOR` the cursor position (flat byte index)
`$_ANCHOR` the visual selection anchor position
If the command modifies these variables, the editor state is
updated accordingly. This allows ex commands to programmatically
edit the buffer.
If the command sets `$_KEYS`, the value is fed back into the
editor as a key sequence.
Example:
`:!echo "$_BUFFER" | tr a-z A-Z > /tmp/out`
`:!_BUFFER=$(echo "$_BUFFER" | sort)`
==============================================================================
2. File Operations *ex-file*
`:r {file}` *ex-read*
Read the contents of {file} and insert them into the buffer at
the cursor position.
`:r !{cmd}` *ex-read-cmd*
Execute {cmd} and insert its output into the buffer.
`:w {file}` *ex-write*
Write the buffer contents to {file}. Creates the file if it does
not exist, or truncates it if it does.
`:w >> {file}` *ex-write-append*
Append the buffer contents to {file}.
`:w !{cmd}` *ex-write-cmd*
Pipe the buffer contents to {cmd} as stdin.
`:e {file}` *ex-edit*
Open {file} in the editor defined by `$EDITOR`. Requires the
`EDITOR` environment variable to be set.
Example:
`:e ~/.config/shed/shedrc`
==============================================================================
3. Buffer Operations *ex-buffer*
`:d` *ex-delete*
Delete the entire buffer.
`:y` *ex-yank*
Yank the entire buffer into the default register.
`:pu` *ex-put*
Put (paste) from the default register after the cursor.
==============================================================================
4. Other Commands *ex-other*
`:q` *ex-quit*
Quit the editor / exit the shell.
`:help {topic}` *ex-help*
Display help for {topic}. Runs the `help` builtin.
==============================================================================
5. Path Expansion *ex-paths*
File paths in ex commands are subject to variable expansion. You can
use environment variables in paths:
`:e $HOME/.config/shed/shedrc`
`:w ${TMPDIR}/output.txt`
==============================================================================
6. Ex Command History *ex-history*
Ex mode maintains its own command history, separate from the main
shell history. Navigate with `Up` and `Down` while in ex mode.
==============================================================================
See also: |keybinds| |autocmd| |prompt|

155
doc/glob.txt Normal file
View File

@@ -0,0 +1,155 @@
*glob* *globbing* *pathname-expansion* *filename-expansion*
#PATHNAME EXPANSION#
After word splitting, the shell scans each word for the characters `*`,
`?`, and `[`. If any appear (and are not quoted), the word is treated as a
pattern and replaced with an alphabetically sorted list of matching file
names. If no files match, the pattern is left unchanged.
==============================================================================
1. Wildcards *glob-wildcards*
`*` *glob-star*
Matches any string of zero or more characters, except that it does
not match a leading `.` (see |glob-dotglob|) or a `/`.
Example:
`echo *.txt` # all .txt files
`ls src/*.rs` # all .rs files in src/
`?` *glob-question*
Matches exactly one character, with the same restrictions as `*`.
Example:
`ls file?.txt` # file1.txt, fileA.txt, etc.
`[...]` *glob-bracket*
Matches any one of the enclosed characters. A range can be specified
with a hyphen.
`[abc]` matches `a`, `b`, or `c`
`[a-z]` matches any lowercase letter
`[0-9]` matches any digit
`[A-Za-z]` matches any letter
`[!...]` `[^...]` *glob-bracket-negate*
Matches any character NOT in the set.
Example:
`ls [!.]*.txt` # .txt files not starting with dot
`echo file[^0-9].txt` # files without a digit
==============================================================================
2. Hidden Files *glob-dotglob*
By default, patterns do not match files whose names begin with `.`
(hidden files). A leading dot must be matched explicitly:
`echo .*` # only hidden files
`echo .* *` # hidden and non-hidden files
The `dotglob` shell option changes this behavior:
`shopt core.dotglob true`
When enabled, `*` and `?` will also match files starting with `.`.
==============================================================================
3. Brace Expansion *brace* *brace-expansion*
Brace expansion is performed before globbing and generates multiple
words from a single pattern. It is not a POSIX feature.
`{a,b,c}` *brace-list*
Comma-separated list. Each item becomes a separate word.
Example:
`echo {a,b,c}` # prints: a b c
`echo file.{txt,log}` # prints: file.txt file.log
`mkdir -p src/{bin,lib}`
`{N..M}` *brace-range*
Numeric or character range.
Example:
`echo {1..5}` # prints: 1 2 3 4 5
`echo {a..f}` # prints: a b c d e f
`echo {5..1}` # prints: 5 4 3 2 1
`{N..M..S}` *brace-range-step*
Numeric range with step {S}.
Example:
`echo {0..10..2}` # prints: 0 2 4 6 8 10
`echo {1..20..5}` # prints: 1 6 11 16
`{01..10}` *brace-range-pad*
Zero-padded ranges. If either endpoint has leading zeros, all
generated values are padded to the same width.
Example:
`echo {01..05}` # prints: 01 02 03 04 05
`echo {001..3}` # prints: 001 002 003
Brace expansion can be nested and combined with other expansions:
`echo {a,b{1..3},c}` # prints: a b1 b2 b3 c
==============================================================================
4. Quoting and Escaping *glob-quoting*
Glob characters lose their special meaning when quoted:
`echo "*"` # prints literal *
`echo '*.txt'` # prints literal *.txt
`echo \*` # prints literal *
This is important when passing patterns to commands like `find` or
`grep` where you want the command (not the shell) to interpret the
pattern.
==============================================================================
5. Tilde Expansion *tilde* *tilde-expansion*
Tilde expansion is performed before pathname expansion.
`~` *tilde-home*
Expands to the value of `$HOME`.
`~/path` *tilde-home-path*
Expands `~` to `$HOME`, then appends the path.
Example:
`cd ~/projects`
`ls ~/.config`
`~user` *tilde-user*
Expands to the home directory of {user}.
Example:
`ls ~root` # /root
`cat ~nobody/.profile`
`~uid` *tilde-uid*
Expands to the home directory of the user with numeric uid {uid}.
This is a shed-specific extension.
Example:
`echo ~0` # /root (uid 0)
`echo ~1000` # first normal user's home
==============================================================================
See also: |param| |redirect| |arith|

44
doc/help.txt Normal file
View File

@@ -0,0 +1,44 @@
*help*
#SHED HELP#
Shed is an experimental UNIX command interpreter (shell) usable as both an interactive login shell and as a shell script command preprocessor. Shed combines the functionality of `bash` with the UX and customizability of `vim`. Shed seeks to improve the interactive shell experience by providing:
* Programmable, dynamic prompts
* A hackable line editor similar to `zsh`'s `zle`
* Custom tab completion
* An event hook system
* Fuzzy history search/tab completion
* And many more features
==============================================================================
1. Available Topics *help-topics*
`help arith` Arithmetic expansion: `$(( ))` syntax |arith|
`help autocmd` Autocmd hooks for shell events |autocmd|
`help ex` Ex mode (colon) commands |ex|
`help glob` Pathname expansion and wildcards |glob|
`help keybinds` Vi mode keys, motions, text objects |keybinds|
`help param` Parameter expansion: `${}` operators |param|
`help prompt` Prompt configuration, PS1, PSR, echo -p |prompt|
`help redirect` Redirection, pipes, heredocs |redirect|
==============================================================================
2. Navigating Help *help-nav*
`help {topic}` Open a help page by name. Prefix matches work,
so `help key` opens |keybinds|.
`help {tag}` Jump directly to a tagged section. Tags are the
words marked with `*asterisks*` in the help files.
Examples:
`help text-objects` jumps to |text-objects|
`help prompt-escapes` jumps to |prompt-escapes|
`help heredoc` jumps to |heredoc|
Cross-references like |prompt| are clickable tags. Search for them
with `help` followed by the reference name.
Inside the pager, use `/` to search and `q` to quit.
==============================================================================

461
doc/keybinds.txt Normal file
View File

@@ -0,0 +1,461 @@
*keybinds* *keys* *vi-mode* *keybindings*
#VI MODE KEYBINDINGS#
The line editor uses a vi-style modal editing system with six modes:
Normal, Insert, Visual, Replace, Ex (command), and Verbatim. The default
mode on startup is Insert.
==============================================================================
1. Normal Mode *normal-mode*
Normal mode is for navigating and manipulating text. Press `Esc` from
any other mode to return here.
1.1 Movement *normal-movement*
`h` `l` *key-h* *key-l*
Move left / right by one character.
`j` `k` *key-j* *key-k*
Move down / up by one line.
`w` `W` *key-w* *key-W*
Move to the start of the next word. `w` stops at punctuation
boundaries, `W` only stops at whitespace.
`b` `B` *key-b* *key-B*
Move to the start of the previous word.
`e` `E` *key-e* *key-E*
Move to the end of the next word.
`ge` `gE` *key-ge* *key-gE*
Move to the end of the previous word.
`0` *key-0*
Move to the start of the line.
`^` *key-caret*
Move to the first non-whitespace character on the line.
`$` *key-dollar*
Move to the end of the line.
`g_` *key-g_*
Move to the last non-whitespace character on the line.
`gg` *key-gg*
Move to the first line of the buffer.
`G` *key-G*
Move to the last line of the buffer.
`|` *key-bar*
Move to a specific column. `10|` moves to column 10.
`%` *key-percent*
Jump to the matching bracket: `()`, `[]`, `{}`.
`](` `[(` *key-paren-nav*
Jump to the next / previous unmatched parenthesis.
`]}` `[{` *key-brace-nav*
Jump to the next / previous unmatched brace.
1.2 Character Search *char-search*
`f{char}` *key-f*
Move forward to the next occurrence of {char} on the current line.
`F{char}` *key-F*
Move backward to the previous occurrence of {char}.
`t{char}` *key-t*
Move forward to just before {char}.
`T{char}` *key-T*
Move backward to just after {char}.
`;` *key-semicolon*
Repeat the last `f`, `F`, `t`, or `T` in the same direction.
`,` *key-comma*
Repeat the last `f`, `F`, `t`, or `T` in the reverse direction.
1.3 Scrolling *scrolling*
`Ctrl+D` *key-ctrl-d*
In normal mode, scroll down half a screen. See |viewport|.
In insert mode, `Ctrl+D` clears the buffer if there is any content, and exits the shell if there is not.
`Ctrl+U` *key-ctrl-u*
Scroll up half a screen.
`Ctrl+G` *key-ctrl-g*
Print current cursor position (line, column, total lines).
1.4 Operators *operators*
Operators take a {motion} or |text-object| to define the range they
act on. Double an operator to act on the whole line (e.g. `dd`, `>>`,
`==`).
`d{motion}` *key-d*
Delete the text covered by {motion}.
`dd` delete the whole line
`D` delete to end of line (same as `d$`)
`x` delete character under cursor (same as `dl`)
`X` delete character before cursor (same as `dh`)
`c{motion}` *key-c*
Delete the text covered by {motion} and enter insert mode.
`cc` change the whole line
`C` change to end of line (same as `c$`)
`s` change the character under cursor (same as `cl`)
`S` change the whole line (same as `cc`)
`y{motion}` *key-y*
Yank (copy) the text covered by {motion} into a register.
`yy` yank the whole line
`Y` yank the whole line
`>{motion}` *key-indent*
Indent the lines covered by {motion}.
`>>` indent the current line
`<{motion}` *key-dedent*
Dedent the lines covered by {motion}.
`<<` dedent the current line
`={motion}` *key-equalize*
Auto-indent the lines covered by {motion}. Uses the minimum
nesting depth of each line's start and end to determine the
correct indentation level.
`==` equalize the current line
`g~{motion}` *key-toggle-case*
Toggle the case of the text covered by {motion}.
`gu{motion}` *key-gu*
Convert the text covered by {motion} to lowercase.
`gU{motion}` *key-gU*
Convert the text covered by {motion} to uppercase.
1.5 Single-Key Actions *normal-actions*
`p` *key-p*
Paste from the register after the cursor.
`P` *key-P*
Paste from the register before the cursor.
`r{char}` *key-r*
Replace the character under the cursor with {char}. With a count,
replaces that many characters.
`~` *key-tilde*
Toggle the case of the character under the cursor and advance.
Accepts a count.
`J` *key-J*
Join the current line with the next line.
`u` *key-u*
Undo the last change.
`Ctrl+R` *key-ctrl-r*
Redo the last undone change.
`.` *key-dot*
Repeat the last editing command.
`Ctrl+A` *key-ctrl-a*
Increment the number under the cursor. Recognizes decimal,
hexadecimal (`0x`), binary (`0b`), and octal (`0o`) formats.
Preserves leading zeros and prefix.
`Ctrl+X` *key-ctrl-x*
Decrement the number under the cursor.
1.6 Entering Other Modes *mode-entry*
`i` insert before cursor *key-i*
`a` insert after cursor *key-a*
`I` insert at first non-whitespace *key-I*
`A` insert at end of line *key-A*
`o` open a new line below *key-o*
`O` open a new line above *key-O*
`R` enter replace mode *key-R*
`v` enter visual mode (character-wise) *key-v*
`V` enter visual mode (line-wise) *key-V-visual*
`gv` reselect last visual region *key-gv*
`:` enter ex mode *key-colon*
1.7 Registers *registers*
`"{reg}` *key-register*
Use register {reg} for the next delete, yank, or put. Registers
`a`-`z` store text; uppercase `A`-`Z` appends to the corresponding
lowercase register.
==============================================================================
2. Insert Mode *insert-mode*
Insert mode is for typing text. Characters are inserted at the cursor.
`Esc` return to normal mode
`Backspace` delete character before cursor
`Ctrl+H` same as Backspace
`Ctrl+W` delete word before cursor
`Delete` delete character under cursor
`Tab` trigger completion (see |completion|)
`Shift+Tab` trigger completion (reverse direction)
`Ctrl+R` open history search (see |history-search|)
`Ctrl+V` enter verbatim mode (insert literal key sequence)
`Enter` submit line or insert newline if input is incomplete
Arrow keys, Home, and End work as expected for navigation.
==============================================================================
3. Visual Mode *visual-mode*
Visual mode selects a region of text. Enter with `v` (character-wise)
or `V` (line-wise) from normal mode.
All normal-mode motions work to extend the selection. Operators act
on the selected region without needing a motion:
`d` `x` delete selection
`c` `s` `S` change selection (delete and enter insert mode)
`y` yank selection
`p` `P` paste, replacing selection
`>` `<` indent / dedent selection
`=` equalize selection
`~` toggle case of selection
`u` lowercase selection
`U` uppercase selection
`r{char}` replace every character in selection with {char}
`J` join selected lines
`o` `O` swap cursor and selection anchor
Press `Esc` to return to normal mode without acting.
==============================================================================
4. Replace Mode *replace-mode*
Replace mode overwrites existing characters as you type. Enter with
`R` from normal mode.
`Esc` return to normal mode
`Backspace` undo the last replacement
All other keys replace the character under the cursor and advance.
==============================================================================
5. Ex Mode *ex-mode-keys*
Ex mode accepts colon commands. Enter with `:` from normal mode.
See |ex| for available commands.
`Enter` execute the command
`Esc` cancel and return to normal mode
`Ctrl+C` clear the command line
`Up` `Down` navigate ex command history
==============================================================================
6. Text Objects *text-objects*
Text objects define a range of text based on structure. They are used
with operators: `d`, `c`, `y`, etc. Each has an "inner" (`i`) and
"around" (`a`) variant.
`iw` `aw` *obj-word*
Word (punctuation-delimited). `aw` includes trailing whitespace.
`iW` `aW` *obj-WORD*
WORD (whitespace-delimited). `aW` includes trailing whitespace.
`i"` `a"` *obj-dquote*
Double-quoted string.
`i'` `a'` *obj-squote*
Single-quoted string.
`` i` `` `` a` `` *obj-backtick*
Backtick-quoted string.
`i)` `a)` `ib` `ab` *obj-paren*
Parenthesized block.
`i]` `a]` *obj-bracket*
Square-bracketed block.
`i}` `a}` `iB` `aB` *obj-brace*
Brace-delimited block.
`i<` `a<` *obj-angle*
Angle-bracketed block.
`it` `at` *obj-tag*
XML/HTML tag block.
`is` `as` *obj-sentence*
Sentence.
`ip` `ap` *obj-paragraph*
Paragraph (separated by blank lines).
==============================================================================
7. Counts *counts*
Most motions, operators, and actions accept a numeric count prefix:
`3j` move down 3 lines
`2dw` delete 2 words
`5>>` indent 5 lines
`10l` move 10 characters right
When both the operator and the motion have counts, they are
multiplied: `2d3w` deletes 6 words.
==============================================================================
8. Viewport *viewport*
When the buffer is taller than the terminal, the editor displays a
scrolling viewport. The viewport follows the cursor and respects the
`line.scroll_offset` shopt (minimum lines visible above/below the cursor).
`Ctrl+D` scroll down half a screen
`Ctrl+U` scroll up half a screen
`Ctrl+G` display current position in the buffer
Line numbers in the left margin reflect actual buffer positions, not
viewport-relative indices.
==============================================================================
9. User-Defined Keymaps *keymaps* *keymap*
Custom key bindings are created with the `keymap` command:
`keymap [flags] {keys} {action}`
Flags select the mode(s) the binding applies to:
`-n` normal mode
`-i` insert mode
`-v` visual mode
`-x` ex mode
`-o` operator-pending mode
`-r` replace mode
Keys and actions use angle-bracket notation for special keys:
`<CR>` Enter `<Esc>` Escape
`<Tab>` Tab `<BS>` Backspace
`<Del>` Delete `<Space>` Space
`<Up>` `<Down>` `<Left>` `<Right>` Arrow keys
`<Home>` `<End>` Home / End
`<F1>` - `<F12>` Function keys
`<CMD>` Enter ex mode
`<Leader>` Leader key (set via `shopt prompt.leader`)
Modifier prefixes:
`C-` Control `A-` `M-` Alt / Meta
`S-` Shift
Examples:
`keymap -n <Leader>w ':w<CR>'` Leader+w writes to file
`keymap -i jk '<Esc>'` jk exits insert mode
`keymap -n <C-n> ':!mycmd<CR>'` Ctrl+n runs a shell command
To remove a binding:
`keymap --remove {keys}`
==============================================================================
10. Completion *completion*
`Tab` start or cycle through completion candidates (forward)
`Shift+Tab` cycle backward
When the completion menu is visible, `Tab` and arrow keys navigate
candidates. `Enter` accepts the selected candidate. `Esc` dismisses
the menu.
==============================================================================
11. History Search *history-search*
`Ctrl+R` open fuzzy history search (from insert or ex mode)
Type to filter history entries. `Enter` accepts the selected entry.
`Esc` dismisses the search.
==============================================================================
See also: |ex| |autocmd| |prompt|

197
doc/param.txt Normal file
View File

@@ -0,0 +1,197 @@
*param* *parameter-expansion* *param-expansion*
#PARAMETER EXPANSION#
The shell provides several forms of parameter expansion for working with
variables. In each form, {word} is subject to tilde expansion, parameter
expansion, command substitution, and arithmetic expansion.
If {parameter} is unset or null, the behavior depends on the operator used.
"Unset" means the variable has never been assigned. "Null" means the variable
is set but its value is the empty string.
==============================================================================
1. Basic Forms *param-basic*
`$var` Value of {var}
`${var}` Same, with explicit braces (needed for `${var}foo`)
Braces are required when {var} is followed by characters that could be part
of the name, or when using any of the operators below.
==============================================================================
2. Default Values *param-default*
`${var:-word}` *param-default-val*
Use default value. If {var} is unset or null, expand to {word}.
Otherwise, expand to the value of {var}.
Example:
`name=${1:-world}`
`echo "hello $name"` # prints "hello world" if \$1 is unset
`${var-word}` *param-default-nonnull*
Like `:-` but only substitutes {word} if {var} is completely unset,
not if it is null.
==============================================================================
3. Assign Defaults *param-assign*
`${var:=word}` *param-assign-val*
Assign default value. If {var} is unset or null, assign {word} to
{var} and then expand to the new value.
Note: This cannot be used with positional parameters or special
parameters.
Example:
`echo ${cache:=/tmp/cache}` # sets and uses \$cache
`${var=word}` *param-assign-nonnull*
Like `:=` but only assigns if {var} is completely unset.
==============================================================================
4. Error on Unset *param-error*
`${var:?word}` *param-error-val*
Display error. If {var} is unset or null, print {word} to stderr
and exit (in a non-interactive shell). If {word} is omitted, a
default message is printed.
Example:
`input=${1:?usage: myscript \<filename\>}`
`${var?word}` *param-error-nonnull*
Like `:?` but only errors if {var} is completely unset.
==============================================================================
5. Alternate Value *param-alt*
`${var:+word}` *param-alt-val*
Use alternate value. If {var} is unset or null, expand to nothing.
Otherwise, expand to {word}.
Example:
`echo ${verbose:+--verbose}` # flag only if \$verbose is set
`${var+word}` *param-alt-nonnull*
Like `:+` but substitutes {word} only if {var} is set (even if null).
==============================================================================
6. String Length *param-length*
`${#var}` *param-strlen*
Expands to the length of the value of {var} in characters.
Example:
`str="hello"`
`echo ${#str}` # prints 5
==============================================================================
7. Substring Removal *param-substring*
`${var#pattern}` *param-trim-short-left*
Remove shortest matching prefix. Removes the shortest match of
{pattern} from the beginning of the value of {var}.
`${var##pattern}` *param-trim-long-left*
Remove longest matching prefix.
Example:
`path="/home/user/file.txt"`
`echo ${path##*/}` # prints "file.txt"
`${var%pattern}` *param-trim-short-right*
Remove shortest matching suffix. Removes the shortest match of
{pattern} from the end of the value of {var}.
`${var%%pattern}` *param-trim-long-right*
Remove longest matching suffix.
Example:
`file="archive.tar.gz"`
`echo ${file%%.*}` # prints "archive"
`echo ${file%.*}` # prints "archive.tar"
==============================================================================
8. Search and Replace *param-replace*
`${var/pattern/replacement}` *param-replace-first*
Replace first match. Replaces the first occurrence of {pattern}
in the value of {var} with {replacement}.
`${var//pattern/replacement}` *param-replace-all*
Replace all matches.
Example:
`str="hello world"`
`echo ${str/o/0}` # prints "hell0 world"
`echo ${str//o/0}` # prints "hell0 w0rld"
`${var/#pattern/replacement}` *param-replace-prefix*
Replace if matching at the beginning.
`${var/%pattern/replacement}` *param-replace-suffix*
Replace if matching at the end.
==============================================================================
9. Case Modification *param-case*
`${var^}` *param-upper-first*
Uppercase the first character of {var}.
`${var^^}` *param-upper-all*
Uppercase all characters.
`${var,}` *param-lower-first*
Lowercase the first character of {var}.
`${var,,}` *param-lower-all*
Lowercase all characters.
Example:
`name="john doe"`
`echo ${name^}` # prints "John doe"
`echo ${name^^}` # prints "JOHN DOE"
==============================================================================
10. Substrings *param-slice*
`${var:offset}` *param-slice-from*
Substring starting at {offset} (0-indexed).
`${var:offset:length}` *param-slice-range*
Substring of {length} characters starting at {offset}.
Negative offsets count from the end (note the space before the minus
to distinguish from `:-`):
`str="hello world"`
`echo ${str: -5}` # prints "world"
`echo ${str:0:5}` # prints "hello"
==============================================================================
See also: |redirect| |glob| |arith|

229
doc/prompt.txt Normal file
View File

@@ -0,0 +1,229 @@
*prompt* *ps1* *psr* *prompt-expansion*
#PROMPT#
The shell displays a configurable prompt before each command. Two prompt
strings are available: PS1 (the main prompt) and PSR (an optional
right-aligned prompt).
==============================================================================
1. Setting the Prompt *prompt-set*
The prompt is controlled by the `PS1` and `PSR` environment variables.
Set them in your shell configuration:
`PS1='\u@\h:\W\$ '`
`PSR='$SHED_VI_MODE'`
Prompts are re-expanded before each display, so command substitutions
and functions are evaluated every time.
==============================================================================
2. Escape Sequences *prompt-escapes*
The following backslash escapes are recognized in PS1 and PSR:
`\u` *prompt-user*
The current username (from $USER).
`\h` *prompt-host*
The hostname (from $HOST).
`\w` *prompt-pwd*
The current working directory, with $HOME replaced by `~`.
Example:
`/home/user/projects` -> `~/projects`
`\W` *prompt-pwd-short*
Truncated working directory. Shows only the last N path segments,
where N is controlled by `shopt prompt.trunc_prompt_path`
(default: 4).
Example (with trunc_prompt_path=2):
`/home/user/projects/myapp` -> `projects/myapp`
`\s` *prompt-shell*
The shell name: `shed`.
`\$` *prompt-symbol*
`#` if the effective UID is 0 (root), `$` otherwise.
`\j` *prompt-jobs*
The number of background jobs currently managed by the shell.
`\t` *prompt-runtime-ms*
The runtime of the last command in milliseconds.
`\T` *prompt-runtime-fmt*
The runtime of the last command in human-readable format
(e.g. `1m 23s 456ms`).
`\n` *prompt-newline*
A literal newline. Use this to create multi-line prompts.
`\r` *prompt-return*
A literal carriage return.
`\\` *prompt-backslash*
A literal backslash.
`\e[...` *prompt-ansi*
An ANSI escape sequence. The sequence starts with `\e[` and ends
at the first letter character. Used for colors and formatting.
Common codes:
`\e[0m` reset all attributes
`\e[1m` bold
`\e[3m` italic
`\e[4m` underline
`\e[31m` red foreground
`\e[32m` green foreground
`\e[33m` yellow foreground
`\e[34m` blue foreground
`\e[35m` magenta foreground
`\e[36m` cyan foreground
`\e[1;32m` bold green
Example:
`PS1='\e[1;32m\u\e[0m@\e[34m\h\e[0m:\w\$ '`
==============================================================================
3. Functions in Prompts *prompt-functions*
`\@{funcname}` *prompt-func*
`\@funcname`
Call a shell function and insert its output. The function must
be defined before the prompt is expanded. If the function does
not exist, the literal sequence is displayed.
Example:
`git_branch() { git branch --show-current 2>/dev/null; }`
`PS1='\u@\h (\@git_branch)\$ '`
This allows dynamic prompt content that updates on every command.
==============================================================================
4. Right Prompt (PSR) *prompt-right* *psr*
The PSR variable defines an optional right-aligned prompt displayed
on the last line of PS1. It supports the same escape sequences as PS1.
PSR is only displayed when it fits: if the combined width of the
input line and PSR exceeds the terminal width, PSR is hidden.
PSR is restricted to a single line. If it contains newlines, only the
first line is used.
`PSR='$SHED_VI_MODE'`
`PSR='\T'` # show last command runtime on the right
==============================================================================
5. Multi-Line Prompts *prompt-multiline*
PS1 may span multiple lines using `\n`. The editor tracks line
positions for proper cursor movement and redrawing.
`PS1='\e[1m\u@\h\e[0m\n\W\$ '`
This displays the username and hostname on the first line, and the
working directory and prompt symbol on the second.
==============================================================================
6. echo -p *echo-prompt*
The `echo` builtin accepts a `-p` flag that enables prompt-style
expansion on its arguments. All prompt escape sequences listed above
are recognized.
`echo -p '\u'` # prints the current username
`echo -p '\W'` # prints the truncated working directory
`echo -p '\e[31mred\e[0m'` # prints "red" in red
The `-p` flag can be combined with `-n` (no trailing newline) and
`-e` (interpret escape sequences like `\n` and `\t`). When both `-e`
and `-p` are used, prompt expansion runs first, then escape sequence
interpretation.
==============================================================================
7. Prompt Options (shopt) *prompt-options*
The following options under `shopt prompt.*` affect prompt behavior:
`prompt.trunc_prompt_path` *opt-trunc-path*
Maximum number of path segments shown by `\W`. Default: 4.
`prompt.highlight` *opt-highlight*
Enable syntax highlighting in the input line. Default: true.
`prompt.auto_indent` *opt-auto-indent*
Automatically indent new lines to match the current nesting
depth. Default: true.
`prompt.linebreak_on_incomplete` *opt-linebreak*
Insert a newline when Enter is pressed on an incomplete command
(e.g. unclosed quotes or pipes). Default: true.
`prompt.line_numbers` *opt-line-numbers*
Display line numbers in the left margin for multi-line buffers.
Default: true.
`prompt.leader` *opt-leader*
The leader key sequence for |keymaps|. Default: `" "` (space).
`prompt.comp_limit` *opt-comp-limit*
Maximum number of completion candidates to display. Default: 100.
`prompt.completion_ignore_case` *opt-comp-case*
Case-insensitive tab completion. Default: false.
==============================================================================
8. Special Variables *prompt-variables*
`SHED_VI_MODE` *var-vi-mode*
Set automatically before each prompt to the current vi mode name:
`NORMAL`, `INSERT`, `VISUAL`, `COMMAND`, `REPLACE`, `SEARCH`, or
`COMPLETE`. Useful in PSR or prompt functions.
Example:
`PSR='$SHED_VI_MODE'`
==============================================================================
9. Remote Refresh (SIGUSR1) *prompt-sigusr1*
Sending `SIGUSR1` to the shell process causes it to re-expand and
redraw the prompt. This is useful for updating the prompt from
external processes (e.g. a background script that detects a state
change).
`kill -USR1 $$`
Combined with prompt functions (see |prompt-func|), this allows the
prompt to reflect changes that happen outside the shell's normal
command cycle.
==============================================================================
See also: |keybinds| |autocmd| |ex|

181
doc/redirect.txt Normal file
View File

@@ -0,0 +1,181 @@
*redirect* *redirection* *redir*
#REDIRECTION#
Redirections allow you to control where a command reads its input from and
where it sends its output. A redirection applies to a specific file
descriptor; if no descriptor number is given, output redirections default
to stdout (fd 1) and input redirections default to stdin (fd 0).
==============================================================================
1. Output Redirection *redir-output*
`command > file` *redir-out*
Redirect stdout to {file}, creating it if it does not exist or
truncating it if it does.
Example:
`echo hello > out.txt`
`ls 2> errors.txt` # redirect stderr
`command >| file` *redir-out-force*
Like `>` but overrides the {noclobber} option. If {noclobber} is set,
`>` will refuse to overwrite an existing file; `>|` forces the
overwrite.
`command >> file` *redir-append*
Append stdout to {file}, creating it if it does not exist.
Example:
`echo line >> log.txt`
==============================================================================
2. Input Redirection *redir-input*
`command < file` *redir-in*
Redirect {file} to stdin.
Example:
`sort < unsorted.txt`
==============================================================================
3. Read-Write Redirection *redir-readwrite*
`command <> file` *redir-rw*
Open {file} for both reading and writing on the specified file
descriptor (default fd 0). The file is created if it does not exist
but is not truncated.
Useful with the `seek` builtin for random-access file operations.
Example:
`exec 3<> data.bin`
`seek 3 0 set` # seek to beginning
==============================================================================
4. File Descriptor Duplication *redir-dup*
`command N>&M` *redir-dup-out*
Duplicate output file descriptor {M} onto {N}. After this, writing
to fd {N} goes to the same place as fd {M}.
Example:
`command > out.txt 2>&1` # stderr goes where stdout goes
`command N<&M` *redir-dup-in*
Duplicate input file descriptor {M} onto {N}.
`command N>&-` *redir-close-out*
`command N<&-` *redir-close-in*
Close file descriptor {N}.
Example:
`exec 3>&-` # close fd 3
==============================================================================
5. Pipelines *redir-pipe*
`command1 | command2` *pipe*
Connect stdout of {command1} to stdin of {command2}. Both commands
run concurrently.
Example:
`cat file.txt | grep pattern | sort`
`command1 |& command2` *pipe-and*
Connect both stdout and stderr of {command1} to stdin of {command2}.
Equivalent to `command1 2>&1 | command2`.
==============================================================================
6. Here Documents *heredoc*
`command << DELIM` *redir-heredoc*
Read input from the script body until a line containing only {DELIM}
is found. The text between is fed to stdin of {command}.
Parameter expansion, command substitution, and arithmetic expansion
are performed in the body unless the delimiter is quoted.
Example:
`cat << EOF`
`Hello $USER`
`EOF`
`command << 'DELIM'` *redir-heredoc-literal*
Quoting the delimiter (single or double quotes) suppresses all
expansion in the heredoc body. The text is passed literally.
Example:
`cat << 'EOF'`
`This $variable is not expanded`
`EOF`
`command <<- DELIM` *redir-heredoc-indent*
Like `<<` but strips leading tab characters from each line of the
body and from the closing delimiter. This allows heredocs to be
indented for readability without affecting the content.
Example:
`if true; then`
` cat <<- EOF`
` indented content`
` EOF`
`fi`
==============================================================================
7. Here Strings *herestring*
`command <<< word` *redir-herestring*
Feed {word} as a single string to stdin of {command}, with a
trailing newline appended. {word} is subject to the usual expansions.
Example:
`read first rest <<< "hello world"`
`bc <<< "2 + 2"`
==============================================================================
8. File Descriptor Numbers *redir-fd*
Any redirection operator can be prefixed with a file descriptor number:
`2> file` redirect stderr to file
`3< file` open file on fd 3
`4>> file` append to file on fd 4
`5<> file` open file read-write on fd 5
Standard file descriptors:
0 stdin
1 stdout
2 stderr
File descriptors 3 and above are available for general use with `exec`.
==============================================================================
9. Combining Redirections *redir-combine*
Multiple redirections can appear on a single command, processed left
to right:
`command > out.txt 2>&1` # stdout to file, stderr to same file
`command 2>&1 > out.txt` # different! stderr to terminal,
# stdout to file
Order matters: each redirection is applied in sequence.
==============================================================================
See also: |param| |glob| |arith|

View File

@@ -14,7 +14,7 @@
{
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "shed";
version = "0.5.0";
version = "0.6.2";
src = self;
@@ -22,9 +22,12 @@
lockFile = ./Cargo.lock;
};
doCheck = false;
passthru.shellPath = "/bin/shed";
checkPhase = ''
cargo test -- --test-threads=1
'';
meta = with pkgs.lib; {
description = "A Linux shell written in Rust";
homepage = "https://github.com/km-clay/shed";

View File

@@ -2,325 +2,14 @@
let
cfg = config.programs.shed;
boolToString = b:
if b then "true" else "false";
mkAutoCmd = cfg:
lib.concatLines (map (hook: "autocmd ${hook} ${lib.optionalString (cfg.pattern != null) "-p \"${cfg.pattern}\""} '${cfg.command}'") cfg.hooks);
mkFunctionDef = name: body:
let
indented = "\t" + lib.concatStringsSep "\n\t" (lib.splitString "\n" body);
in
''
${name}() {
${indented}
}'';
mkKeymapCmd = cfg: let
flags = "-${lib.concatStrings cfg.modes}";
keys = "'${cfg.keys}'";
action = "'${cfg.command}'";
in
"keymap ${flags} ${keys} ${action}";
mkCompleteCmd = name: cfg: let
flags = lib.concatStrings [
(lib.optionalString cfg.files " -f")
(lib.optionalString cfg.dirs " -d")
(lib.optionalString cfg.commands " -c")
(lib.optionalString cfg.variables " -v")
(lib.optionalString cfg.users " -u")
(lib.optionalString cfg.jobs " -j")
(lib.optionalString cfg.aliases " -a")
(lib.optionalString cfg.signals " -S")
(lib.optionalString cfg.noSpace " -n")
(lib.optionalString (cfg.function != null) " -F ${cfg.function}")
(lib.optionalString (cfg.fallback != "no") " -o ${cfg.fallback}")
(lib.optionalString (cfg.wordList != []) " -W '${lib.concatStringsSep " " cfg.wordList}'")
];
in "complete${flags} ${name}";
in
{
options.programs.shed = {
enable = lib.mkEnableOption "shed shell";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.shed;
description = "The shed package to use";
};
aliases = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Aliases to set when shed starts";
};
functions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Shell functions to set when shed starts";
};
autocmds = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
hooks = lib.mkOption {
type = lib.types.addCheck (lib.types.listOf (lib.types.enum [
"pre-cmd"
"post-cmd"
"pre-change-dir"
"post-change-dir"
"on-job-finish"
"pre-prompt"
"post-prompt"
"pre-mode-change"
"post-mode-change"
"on-exit"
"on-history-open"
"on-history-close"
"on-history-select"
"on-completion-start"
"on-completion-cancel"
"on-completion-select"
])) (list: list != []);
description = "The events that trigger this autocmd";
};
pattern = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "A regex pattern to use in the hook to determine whether it runs or not. What it's compared to differs by hook, for instance 'pre-change-dir' compares it to the new directory, pre-cmd compares it to the command, etc";
};
command = lib.mkOption {
type = lib.types.addCheck lib.types.str (cmd: cmd != "");
description = "The shell command to execute when the hook is triggered and the pattern (if provided) matches";
};
};
});
default = [];
description = "Custom autocmds to set when shed starts";
};
keymaps = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
modes = lib.mkOption {
type = lib.types.listOf (lib.types.enum [ "n" "i" "x" "v" "o" "r" ]);
default = [];
description = "The editing modes this keymap can be used in";
};
keys = lib.mkOption {
type = lib.types.str;
default = "";
description = "The sequence of keys that trigger this keymap";
};
command = lib.mkOption {
type = lib.types.str;
default = "";
description = "The sequence of characters to send to the line editor when the keymap is triggered.";
};
};
});
default = [];
description = "Custom keymaps to set when shed starts";
};
extraCompletion = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
files = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete file names in the current directory";
};
dirs = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete directory names in the current directory";
};
commands = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete executable commands in the PATH";
};
variables = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete variable names";
};
users = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete user names from /etc/passwd";
};
jobs = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete job names or pids from the current shell session";
};
aliases = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete alias names defined in the current shell session";
};
signals = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete signal names for commands like kill";
};
wordList = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Complete from a custom list of words";
};
function = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Complete using a custom shell function (should be defined in extraCompletionPreConfig)";
};
noSpace = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Don't append a space after completion";
};
fallback = lib.mkOption {
type = lib.types.enum [ "no" "default" "dirnames" ];
default = "no";
description = "Fallback behavior when no matches are found: 'no' means no fallback, 'default' means fall back to the default shell completion behavior, and 'directories' means fall back to completing directory names";
};
};
});
default = {};
description = "Additional completion scripts to source when shed starts (e.g. for custom tools or functions)";
};
environmentVars = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Environment variables to set when shed starts";
};
settings = {
dotGlob = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to include hidden files in glob patterns";
};
autocd = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to automatically change into directories when they are entered as commands";
};
historyIgnoresDupes = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to ignore duplicate entries in the command history";
};
maxHistoryEntries = lib.mkOption {
type = lib.types.int;
default = 10000;
description = "The maximum number of entries to keep in the command history";
};
interactiveComments = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to allow comments in interactive mode";
};
autoHistory = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to automatically add commands to the history as they are executed";
};
bellEnabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to allow shed to ring the terminal bell on certain events (e.g. command completion, errors, etc.)";
};
maxRecurseDepth = lib.mkOption {
type = lib.types.int;
default = 1000;
description = "The maximum depth to allow when recursively executing shell functions";
};
leaderKey = lib.mkOption {
type = lib.types.str;
default = "\\\\";
description = "The leader key to use for custom keymaps (e.g. if set to '\\\\', then a keymap with keys='x' would be triggered by '\\x')";
};
promptPathSegments = lib.mkOption {
type = lib.types.int;
default = 4;
description = "The maximum number of path segments to show in the prompt";
};
completionLimit = lib.mkOption {
type = lib.types.int;
default = 1000;
description = "The maximum number of completion candidates to show before truncating the list";
};
syntaxHighlighting = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable syntax highlighting in the shell";
};
linebreakOnIncomplete = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to automatically insert a newline when the input is incomplete";
};
extraPostConfig = lib.mkOption {
type = lib.types.str;
default = "";
description = "Additional configuration to append to the shed configuration file";
};
extraPreConfig = lib.mkOption {
type = lib.types.str;
default = "";
description = "Additional configuration to prepend to the shed configuration file";
};
};
};
options.programs.shed = import ./shed_opts.nix { inherit pkgs lib; };
config =
let
completeLines = lib.concatLines (lib.mapAttrsToList mkCompleteCmd cfg.extraCompletion);
keymapLines = lib.concatLines (map mkKeymapCmd cfg.keymaps);
functionLines = lib.concatLines (lib.mapAttrsToList mkFunctionDef cfg.functions);
autocmdLines = lib.concatLines (map mkAutoCmd cfg.autocmds);
in
lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
home.file.".shedrc".text = lib.concatLines [
cfg.settings.extraPreConfig
(lib.concatLines (lib.mapAttrsToList (name: value: "export ${name}=\"${value}\"") cfg.environmentVars))
(lib.concatLines (lib.mapAttrsToList (name: value: "alias ${name}=\"${value}\"") cfg.aliases))
(lib.concatLines [
"shopt core.dotglob=${boolToString cfg.settings.dotGlob}"
"shopt core.autocd=${boolToString cfg.settings.autocd}"
"shopt core.hist_ignore_dupes=${boolToString cfg.settings.historyIgnoresDupes}"
"shopt core.max_hist=${toString cfg.settings.maxHistoryEntries}"
"shopt core.interactive_comments=${boolToString cfg.settings.interactiveComments}"
"shopt core.auto_hist=${boolToString cfg.settings.autoHistory}"
"shopt core.bell_enabled=${boolToString cfg.settings.bellEnabled}"
"shopt core.max_recurse_depth=${toString cfg.settings.maxRecurseDepth}"
"shopt prompt.leader='${cfg.settings.leaderKey}'"
"shopt prompt.trunc_prompt_path=${toString cfg.settings.promptPathSegments}"
"shopt prompt.comp_limit=${toString cfg.settings.completionLimit}"
"shopt prompt.highlight=${boolToString cfg.settings.syntaxHighlighting}"
"shopt prompt.linebreak_on_incomplete=${boolToString cfg.settings.linebreakOnIncomplete}"
functionLines
completeLines
keymapLines
autocmdLines
])
cfg.settings.extraPostConfig
];
home.file.".shedrc".text = import ./render_rc.nix lib cfg;
};
}

View File

@@ -4,18 +4,11 @@ let
cfg = config.programs.shed;
in
{
options.programs.shed = {
enable = lib.mkEnableOption "shed shell";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.shed;
description = "The shed package to use";
};
};
options.programs.shed = import ./shed_opts.nix { inherit pkgs lib; };
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
environment.shells = [ cfg.package ];
environment.etc."shed/shedrc".text = import ./render_rc.nix lib cfg;
};
}

86
nix/render_rc.nix Normal file
View File

@@ -0,0 +1,86 @@
lib: cfg:
let
boolToString = b:
if b then "true" else "false";
mkAutoCmd = cfg:
lib.concatLines (map (hook: "autocmd ${hook} ${lib.optionalString (cfg.pattern != null) "-p \"${cfg.pattern}\""} '${cfg.command}'") cfg.hooks);
mkFunctionDef = name: body:
let
indented = "\t" + lib.concatStringsSep "\n\t" (lib.splitString "\n" body);
in
''
${name}() {
${indented}
}'';
mkKeymapCmd = cfg: let
flags = "-${lib.concatStrings cfg.modes}";
keys = "'${cfg.keys}'";
action = "'${cfg.command}'";
in
"keymap ${flags} ${keys} ${action}";
mkCompleteCmd = name: cfg: let
flags = lib.concatStrings [
(lib.optionalString cfg.files " -f")
(lib.optionalString cfg.dirs " -d")
(lib.optionalString cfg.commands " -c")
(lib.optionalString cfg.variables " -v")
(lib.optionalString cfg.users " -u")
(lib.optionalString cfg.jobs " -j")
(lib.optionalString cfg.aliases " -a")
(lib.optionalString cfg.signals " -S")
(lib.optionalString cfg.noSpace " -n")
(lib.optionalString (cfg.function != null) " -F ${cfg.function}")
(lib.optionalString (cfg.fallback != "no") " -o ${cfg.fallback}")
(lib.optionalString (cfg.wordList != []) " -W '${lib.concatStringsSep " " cfg.wordList}'")
];
in "complete${flags} ${name}";
completeLines = lib.concatLines (lib.mapAttrsToList mkCompleteCmd cfg.extraCompletion);
keymapLines = lib.concatLines (map mkKeymapCmd cfg.keymaps);
functionLines = lib.concatLines (lib.mapAttrsToList mkFunctionDef cfg.functions);
autocmdLines = lib.concatLines (map mkAutoCmd cfg.autocmds);
in
lib.concatLines [
cfg.extraPreConfig
(lib.concatLines (lib.mapAttrsToList (name: value: "export ${name}=\"${value}\"") cfg.environmentVars))
(lib.concatLines (lib.mapAttrsToList (name: value: "alias ${name}=\"${value}\"") cfg.aliases))
(lib.concatLines [
"shopt line.viewport_height=${toString cfg.shopts.line.viewport_height}"
"shopt line.scroll_offset=${toString cfg.shopts.line.scroll_offset}"
"shopt core.dotglob=${boolToString cfg.shopts.core.dotglob}"
"shopt core.autocd=${boolToString cfg.shopts.core.autocd}"
"shopt core.hist_ignore_dupes=${boolToString cfg.shopts.core.hist_ignore_dupes}"
"shopt core.max_hist=${toString cfg.shopts.core.max_hist}"
"shopt core.interactive_comments=${boolToString cfg.shopts.core.interactive_comments}"
"shopt core.auto_hist=${boolToString cfg.shopts.core.auto_hist}"
"shopt core.bell_enabled=${boolToString cfg.shopts.core.bell_enabled}"
"shopt core.max_recurse_depth=${toString cfg.shopts.core.max_recurse_depth}"
"shopt core.xpg_echo=${boolToString cfg.shopts.core.xpg_echo}"
"shopt core.noclobber=${boolToString cfg.shopts.core.noclobber}"
"shopt prompt.leader='${cfg.shopts.prompt.leader}'"
"shopt prompt.trunc_prompt_path=${toString cfg.shopts.prompt.trunc_prompt_path}"
"shopt prompt.comp_limit=${toString cfg.shopts.prompt.comp_limit}"
"shopt prompt.highlight=${boolToString cfg.shopts.prompt.highlight}"
"shopt prompt.linebreak_on_incomplete=${boolToString cfg.shopts.prompt.linebreak_on_incomplete}"
"shopt prompt.line_numbers=${boolToString cfg.shopts.prompt.line_numbers}"
"shopt prompt.screensaver_idle_time=${toString cfg.shopts.prompt.screensaver_idle_time}"
"shopt prompt.screensaver_cmd='${cfg.shopts.prompt.screensaver_cmd}'"
"shopt prompt.completion_ignore_case=${boolToString cfg.shopts.prompt.completion_ignore_case}"
"shopt prompt.auto_indent=${boolToString cfg.shopts.prompt.auto_indent}"
functionLines
completeLines
keymapLines
autocmdLines
])
cfg.extraPostConfig
]

316
nix/shed_opts.nix Normal file
View File

@@ -0,0 +1,316 @@
{ pkgs, lib }:
{
enable = lib.mkEnableOption "shed shell";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.shed;
description = "The shed package to use";
};
aliases = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Aliases to set when shed starts";
};
functions = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Shell functions to set when shed starts";
};
autocmds = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
hooks = lib.mkOption {
type = lib.types.addCheck (lib.types.listOf (lib.types.enum [
"pre-cmd"
"post-cmd"
"pre-change-dir"
"post-change-dir"
"on-job-finish"
"pre-prompt"
"post-prompt"
"pre-mode-change"
"post-mode-change"
"on-exit"
"on-history-open"
"on-history-close"
"on-history-select"
"on-completion-start"
"on-completion-cancel"
"on-completion-select"
])) (list: list != []);
description = "The events that trigger this autocmd";
};
pattern = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "A regex pattern to use in the hook to determine whether it runs or not. What it's compared to differs by hook, for instance 'pre-change-dir' compares it to the new directory, pre-cmd compares it to the command, etc";
};
command = lib.mkOption {
type = lib.types.addCheck lib.types.str (cmd: cmd != "");
description = "The shell command to execute when the hook is triggered and the pattern (if provided) matches";
};
};
});
default = [];
description = "Custom autocmds to set when shed starts";
};
keymaps = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
modes = lib.mkOption {
type = lib.types.listOf (lib.types.enum [ "n" "i" "x" "v" "o" "r" ]);
default = [];
description = "The editing modes this keymap can be used in";
};
keys = lib.mkOption {
type = lib.types.str;
default = "";
description = "The sequence of keys that trigger this keymap";
};
command = lib.mkOption {
type = lib.types.str;
default = "";
description = "The sequence of characters to send to the line editor when the keymap is triggered.";
};
};
});
default = [];
description = "Custom keymaps to set when shed starts";
};
extraCompletion = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule {
options = {
files = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete file names in the current directory";
};
dirs = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete directory names in the current directory";
};
commands = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete executable commands in the PATH";
};
variables = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete variable names";
};
users = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete user names from /etc/passwd";
};
jobs = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete job names or pids from the current shell session";
};
aliases = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete alias names defined in the current shell session";
};
signals = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Complete signal names for commands like kill";
};
wordList = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [];
description = "Complete from a custom list of words";
};
function = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Complete using a custom shell function (should be defined in extraCompletionPreConfig)";
};
noSpace = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Don't append a space after completion";
};
fallback = lib.mkOption {
type = lib.types.enum [ "no" "default" "dirnames" ];
default = "no";
description = "Fallback behavior when no matches are found: 'no' means no fallback, 'default' means fall back to the default shell completion behavior, and 'directories' means fall back to completing directory names";
};
};
});
default = {};
description = "Additional completion scripts to source when shed starts (e.g. for custom tools or functions)";
};
environmentVars = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = {};
description = "Environment variables to set when shed starts";
};
shopts = lib.mkOption {
type = lib.types.submodule {
options = {
line = lib.mkOption {
type = lib.types.submodule {
options = {
viewport_height = lib.mkOption {
type = lib.types.either lib.types.int lib.types.str;
default = "50%";
description = "Maximum viewport height for the line editor buffer";
};
scroll_offset = lib.mkOption {
type = lib.types.int;
default = 1;
description = "The minimum number of lines to keep visible above and below the cursor when scrolling (i.e. the 'scrolloff' option in vim)";
};
};
};
default = {};
description = "Settings related to the line editor (i.e. the 'shopt line.*' options)";
};
core = lib.mkOption {
type = lib.types.submodule {
options = {
dotglob = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to include hidden files in glob patterns";
};
autocd = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to automatically change into directories when they are entered as commands";
};
hist_ignore_dupes = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to ignore duplicate entries in the command history";
};
max_hist = lib.mkOption {
type = lib.types.int;
default = 10000;
description = "The maximum number of entries to keep in the command history";
};
interactive_comments = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to allow comments in interactive mode";
};
auto_hist = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to automatically add commands to the history as they are executed";
};
bell_enabled = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to allow shed to ring the terminal bell on certain events (e.g. command completion, errors, etc.)";
};
max_recurse_depth = lib.mkOption {
type = lib.types.int;
default = 1000;
description = "The maximum depth to allow when recursively executing shell functions";
};
xpg_echo = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to have the 'echo' builtin expand escape sequences like \\n and \\t (if false, it will print them verbatim)";
};
noclobber = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to prevent redirection from overwriting existing files by default (i.e. behave as if 'set -o noclobber' is always in effect)";
};
};
};
default = {};
description = "Core settings (i.e. the 'shopt core.*' options)";
};
prompt = lib.mkOption {
type = lib.types.submodule {
options = {
leader = lib.mkOption {
type = lib.types.str;
default = "\\\\";
description = "The leader key to use for custom keymaps (e.g. if set to '\\\\', then a keymap with keys='x' would be triggered by '\\x')";
};
trunc_prompt_path = lib.mkOption {
type = lib.types.int;
default = 4;
description = "The maximum number of path segments to show in the prompt";
};
comp_limit = lib.mkOption {
type = lib.types.int;
default = 1000;
description = "The maximum number of completion candidates to show before truncating the list";
};
highlight = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable syntax highlighting in the shell";
};
linebreak_on_incomplete = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to automatically insert a newline when the input is incomplete";
};
line_numbers = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to show line numbers in the prompt";
};
screensaver_cmd = lib.mkOption {
type = lib.types.str;
default = "";
description = "A shell command to execute after a period of inactivity (i.e. a custom screensaver)";
};
screensaver_idle_time = lib.mkOption {
type = lib.types.int;
default = 0;
description = "The amount of inactivity time in seconds before the screensaver command is executed";
};
completion_ignore_case = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to ignore case when completing commands and file names";
};
auto_indent = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to automatically indent new lines based on the previous line";
};
};
};
default = {};
description = "Settings related to the prompt (i.e. the 'shopt prompt.*' options)";
};
};
};
default = {};
};
extraPostConfig = lib.mkOption {
type = lib.types.str;
default = "";
description = "Additional configuration to append to the shed configuration file";
};
extraPreConfig = lib.mkOption {
type = lib.types.str;
default = "";
description = "Additional configuration to prepend to the shed configuration file";
};
}

View File

@@ -38,21 +38,32 @@ pub fn alias(node: Node) -> ShResult<()> {
write(stdout, alias_output.as_bytes())?; // Write it
} else {
for (arg, span) in argv {
if arg == "command" || arg == "builtin" {
let Some((name, body)) = arg.split_once('=') else {
let Some(alias) = read_logic(|l| l.get_alias(&arg)) else {
return Err(ShErr::at(
ShErrKind::SyntaxErr,
span,
"alias: Expected an assignment in alias args",
));
};
let alias_output = format!("{arg}='{alias}'");
let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, alias_output.as_bytes())?; // Write it
state::set_status(0);
return Ok(());
};
if name == "command" || name == "builtin" {
return Err(ShErr::at(
ShErrKind::ExecFail,
span,
format!("alias: Cannot assign alias to reserved name '{arg}'"),
format!(
"alias: Cannot assign alias to reserved name '{}'",
name.fg(next_color())
),
));
}
let Some((name, body)) = arg.split_once('=') else {
return Err(ShErr::at(
ShErrKind::SyntaxErr,
span,
"alias: Expected an assignment in alias args",
));
};
write_logic(|l| l.insert_alias(name, body, span.clone()));
}
}
@@ -60,6 +71,7 @@ pub fn alias(node: Node) -> ShResult<()> {
Ok(())
}
/// Remove one or more aliases by name
pub fn unalias(node: Node) -> ShResult<()> {
let NdRule::Command {
assignments: _,
@@ -103,3 +115,164 @@ pub fn unalias(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state::{self, read_logic};
use crate::testutil::{TestGuard, test_input};
use pretty_assertions::assert_eq;
#[test]
fn alias_set_and_expand() {
let guard = TestGuard::new();
test_input("alias ll='ls -la'").unwrap();
let alias = read_logic(|l| l.get_alias("ll"));
assert!(alias.is_some());
assert_eq!(alias.unwrap().body, "ls -la");
test_input("alias ll").unwrap();
let out = guard.read_output();
assert!(out.contains("ll"));
assert!(out.contains("ls -la"));
}
#[test]
fn alias_multiple() {
let _guard = TestGuard::new();
test_input("alias a='echo a' b='echo b'").unwrap();
assert_eq!(read_logic(|l| l.get_alias("a")).unwrap().body, "echo a");
assert_eq!(read_logic(|l| l.get_alias("b")).unwrap().body, "echo b");
}
#[test]
fn alias_overwrite() {
let _guard = TestGuard::new();
test_input("alias x='first'").unwrap();
test_input("alias x='second'").unwrap();
assert_eq!(read_logic(|l| l.get_alias("x")).unwrap().body, "second");
}
#[test]
fn alias_list_sorted() {
let guard = TestGuard::new();
test_input("alias z='zzz' a='aaa' m='mmm'").unwrap();
guard.read_output();
test_input("alias").unwrap();
let out = guard.read_output();
let lines: Vec<&str> = out.lines().collect();
assert!(lines.len() >= 3);
let a_pos = lines.iter().position(|l| l.contains("a =")).unwrap();
let m_pos = lines.iter().position(|l| l.contains("m =")).unwrap();
let z_pos = lines.iter().position(|l| l.contains("z =")).unwrap();
assert!(a_pos < m_pos);
assert!(m_pos < z_pos);
}
#[test]
fn alias_reserved_name_command() {
let _guard = TestGuard::new();
let result = test_input("alias command='something'");
assert!(result.is_err());
}
#[test]
fn alias_reserved_name_builtin() {
let _guard = TestGuard::new();
let result = test_input("alias builtin='something'");
assert!(result.is_err());
}
#[test]
fn alias_missing_equals() {
let _guard = TestGuard::new();
let result = test_input("alias noequals");
assert!(result.is_err());
}
#[test]
fn alias_expansion_in_command() {
let guard = TestGuard::new();
test_input("alias greet='echo hello'").unwrap();
guard.read_output();
test_input("greet").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\n");
}
#[test]
fn alias_expansion_with_args() {
let guard = TestGuard::new();
test_input("alias e='echo'").unwrap();
guard.read_output();
test_input("e foo bar").unwrap();
let out = guard.read_output();
assert_eq!(out, "foo bar\n");
}
#[test]
fn unalias_removes() {
let _guard = TestGuard::new();
test_input("alias tmp='something'").unwrap();
assert!(read_logic(|l| l.get_alias("tmp")).is_some());
test_input("unalias tmp").unwrap();
assert!(read_logic(|l| l.get_alias("tmp")).is_none());
}
#[test]
fn unalias_nonexistent() {
let _guard = TestGuard::new();
let result = test_input("unalias nosuchalias");
assert!(result.is_err());
}
#[test]
fn unalias_multiple() {
let _guard = TestGuard::new();
test_input("alias a='1' b='2' c='3'").unwrap();
test_input("unalias a c").unwrap();
assert!(read_logic(|l| l.get_alias("a")).is_none());
assert!(read_logic(|l| l.get_alias("b")).is_some());
assert!(read_logic(|l| l.get_alias("c")).is_none());
}
#[test]
fn unalias_no_args_lists() {
let guard = TestGuard::new();
test_input("alias x='hello'").unwrap();
guard.read_output();
test_input("unalias").unwrap();
let out = guard.read_output();
assert!(out.contains("x"));
assert!(out.contains("hello"));
}
#[test]
fn alias_empty_body() {
let _guard = TestGuard::new();
test_input("alias empty=''").unwrap();
let alias = read_logic(|l| l.get_alias("empty"));
assert!(alias.is_some());
assert_eq!(alias.unwrap().body, "");
}
#[test]
fn alias_status_zero() {
let _guard = TestGuard::new();
test_input("alias ok='true'").unwrap();
assert_eq!(state::get_status(), 0);
test_input("unalias ok").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -226,3 +226,266 @@ pub fn get_arr_op_opts(opts: Vec<Opt>) -> ShResult<ArrOpOpts> {
}
Ok(arr_op_opts)
}
#[cfg(test)]
mod tests {
use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
use crate::testutil::{TestGuard, test_input};
use std::collections::VecDeque;
fn set_arr(name: &str, elems: &[&str]) {
let arr = VecDeque::from_iter(elems.iter().map(|s| s.to_string()));
write_vars(|v| v.set_var(name, VarKind::Arr(arr), VarFlags::NONE)).unwrap();
}
fn get_arr(name: &str) -> Vec<String> {
read_vars(|v| v.get_arr_elems(name)).unwrap()
}
// ===================== push =====================
#[test]
fn push_to_existing_array() {
let _guard = TestGuard::new();
set_arr("arr", &["a", "b"]);
test_input("push arr c").unwrap();
assert_eq!(get_arr("arr"), vec!["a", "b", "c"]);
}
#[test]
fn push_creates_array() {
let _guard = TestGuard::new();
test_input("push newarr hello").unwrap();
assert_eq!(get_arr("newarr"), vec!["hello"]);
}
#[test]
fn push_multiple_values() {
let _guard = TestGuard::new();
set_arr("arr", &["a"]);
test_input("push arr b c d").unwrap();
assert_eq!(get_arr("arr"), vec!["a", "b", "c", "d"]);
}
#[test]
fn push_no_array_name() {
let _guard = TestGuard::new();
let result = test_input("push");
assert!(result.is_err());
}
// ===================== fpush =====================
#[test]
fn fpush_to_existing_array() {
let _guard = TestGuard::new();
set_arr("arr", &["b", "c"]);
test_input("fpush arr a").unwrap();
assert_eq!(get_arr("arr"), vec!["a", "b", "c"]);
}
#[test]
fn fpush_multiple_values() {
let _guard = TestGuard::new();
set_arr("arr", &["c"]);
test_input("fpush arr a b").unwrap();
// Each value is pushed to the front in order: c -> a,c -> b,a,c
assert_eq!(get_arr("arr"), vec!["b", "a", "c"]);
}
#[test]
fn fpush_creates_array() {
let _guard = TestGuard::new();
test_input("fpush newarr x").unwrap();
assert_eq!(get_arr("newarr"), vec!["x"]);
}
// ===================== pop =====================
#[test]
fn pop_removes_last() {
let guard = TestGuard::new();
set_arr("arr", &["a", "b", "c"]);
test_input("pop arr").unwrap();
let out = guard.read_output();
assert_eq!(out, "c\n");
assert_eq!(get_arr("arr"), vec!["a", "b"]);
}
#[test]
fn pop_with_count() {
let guard = TestGuard::new();
set_arr("arr", &["a", "b", "c", "d"]);
test_input("pop -c 2 arr").unwrap();
let out = guard.read_output();
assert_eq!(out, "d\nc\n");
assert_eq!(get_arr("arr"), vec!["a", "b"]);
}
#[test]
fn pop_into_variable() {
let _guard = TestGuard::new();
set_arr("arr", &["x", "y", "z"]);
test_input("pop -v result arr").unwrap();
let val = read_vars(|v| v.get_var("result"));
assert_eq!(val, "z");
assert_eq!(get_arr("arr"), vec!["x", "y"]);
}
#[test]
fn pop_empty_array_fails() {
let _guard = TestGuard::new();
set_arr("arr", &[]);
test_input("pop arr").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn pop_nonexistent_array() {
let _guard = TestGuard::new();
test_input("pop nosucharray").unwrap();
assert_eq!(state::get_status(), 1);
}
// ===================== fpop =====================
#[test]
fn fpop_removes_first() {
let guard = TestGuard::new();
set_arr("arr", &["a", "b", "c"]);
test_input("fpop arr").unwrap();
let out = guard.read_output();
assert_eq!(out, "a\n");
assert_eq!(get_arr("arr"), vec!["b", "c"]);
}
#[test]
fn fpop_with_count() {
let guard = TestGuard::new();
set_arr("arr", &["a", "b", "c", "d"]);
test_input("fpop -c 2 arr").unwrap();
let out = guard.read_output();
assert_eq!(out, "a\nb\n");
assert_eq!(get_arr("arr"), vec!["c", "d"]);
}
#[test]
fn fpop_into_variable() {
let _guard = TestGuard::new();
set_arr("arr", &["first", "second"]);
test_input("fpop -v result arr").unwrap();
let val = read_vars(|v| v.get_var("result"));
assert_eq!(val, "first");
assert_eq!(get_arr("arr"), vec!["second"]);
}
// ===================== rotate =====================
#[test]
fn rotate_left_default() {
let _guard = TestGuard::new();
set_arr("arr", &["a", "b", "c", "d"]);
test_input("rotate arr").unwrap();
assert_eq!(get_arr("arr"), vec!["b", "c", "d", "a"]);
}
#[test]
fn rotate_left_with_count() {
let _guard = TestGuard::new();
set_arr("arr", &["a", "b", "c", "d"]);
test_input("rotate -c 2 arr").unwrap();
assert_eq!(get_arr("arr"), vec!["c", "d", "a", "b"]);
}
#[test]
fn rotate_right() {
let _guard = TestGuard::new();
set_arr("arr", &["a", "b", "c", "d"]);
test_input("rotate -r arr").unwrap();
assert_eq!(get_arr("arr"), vec!["d", "a", "b", "c"]);
}
#[test]
fn rotate_right_with_count() {
let _guard = TestGuard::new();
set_arr("arr", &["a", "b", "c", "d"]);
test_input("rotate -r -c 2 arr").unwrap();
assert_eq!(get_arr("arr"), vec!["c", "d", "a", "b"]);
}
#[test]
fn rotate_count_exceeds_len() {
let _guard = TestGuard::new();
set_arr("arr", &["a", "b"]);
// count clamped to arr.len(), so rotate by 2 on len=2 is a no-op
test_input("rotate -c 5 arr").unwrap();
assert_eq!(get_arr("arr"), vec!["a", "b"]);
}
#[test]
fn rotate_single_element() {
let _guard = TestGuard::new();
set_arr("arr", &["only"]);
test_input("rotate arr").unwrap();
assert_eq!(get_arr("arr"), vec!["only"]);
}
// ===================== combined ops =====================
#[test]
fn push_then_pop_roundtrip() {
let guard = TestGuard::new();
set_arr("arr", &["a"]);
test_input("push arr b").unwrap();
test_input("pop arr").unwrap();
let out = guard.read_output();
assert_eq!(out, "b\n");
assert_eq!(get_arr("arr"), vec!["a"]);
}
#[test]
fn fpush_then_fpop_roundtrip() {
let guard = TestGuard::new();
set_arr("arr", &["a"]);
test_input("fpush arr z").unwrap();
test_input("fpop arr").unwrap();
let out = guard.read_output();
assert_eq!(out, "z\n");
assert_eq!(get_arr("arr"), vec!["a"]);
}
#[test]
fn pop_until_empty() {
let _guard = TestGuard::new();
set_arr("arr", &["x", "y"]);
test_input("pop arr").unwrap();
assert_eq!(state::get_status(), 0);
test_input("pop arr").unwrap();
assert_eq!(state::get_status(), 0);
test_input("pop arr").unwrap();
assert_eq!(state::get_status(), 1);
}
}

View File

@@ -111,3 +111,218 @@ pub fn autocmd(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state::{self, AutoCmdKind, read_logic};
use crate::testutil::{TestGuard, test_input};
// ===================== Registration =====================
#[test]
fn register_pre_cmd() {
let _guard = TestGuard::new();
test_input("autocmd pre-cmd 'echo hello'").unwrap();
let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd));
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].command, "echo hello");
assert!(cmds[0].pattern.is_none());
}
#[test]
fn register_post_cmd() {
let _guard = TestGuard::new();
test_input("autocmd post-cmd 'echo done'").unwrap();
let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd));
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].command, "echo done");
}
#[test]
fn register_multiple_same_kind() {
let _guard = TestGuard::new();
test_input("autocmd pre-cmd 'echo first'").unwrap();
test_input("autocmd pre-cmd 'echo second'").unwrap();
let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd));
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].command, "echo first");
assert_eq!(cmds[1].command, "echo second");
}
#[test]
fn register_different_kinds() {
let _guard = TestGuard::new();
test_input("autocmd pre-cmd 'echo pre'").unwrap();
test_input("autocmd post-cmd 'echo post'").unwrap();
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 1);
assert_eq!(
read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(),
1
);
}
// ===================== Pattern =====================
#[test]
fn register_with_pattern() {
let _guard = TestGuard::new();
test_input("autocmd -p '^git' pre-cmd 'echo git cmd'").unwrap();
let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd));
assert_eq!(cmds.len(), 1);
assert!(cmds[0].pattern.is_some());
let pat = cmds[0].pattern.as_ref().unwrap();
assert!(pat.is_match("git status"));
assert!(!pat.is_match("echo git"));
}
#[test]
fn invalid_regex_pattern() {
let _guard = TestGuard::new();
let result = test_input("autocmd -p '[invalid' pre-cmd 'echo bad'");
assert!(result.is_err());
}
// ===================== Clear =====================
#[test]
fn clear_autocmds() {
let _guard = TestGuard::new();
test_input("autocmd pre-cmd 'echo a'").unwrap();
test_input("autocmd pre-cmd 'echo b'").unwrap();
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 2);
test_input("autocmd -c pre-cmd").unwrap();
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 0);
}
#[test]
fn clear_only_affects_specified_kind() {
let _guard = TestGuard::new();
test_input("autocmd pre-cmd 'echo pre'").unwrap();
test_input("autocmd post-cmd 'echo post'").unwrap();
test_input("autocmd -c pre-cmd").unwrap();
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 0);
assert_eq!(
read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(),
1
);
}
#[test]
fn clear_empty_is_noop() {
let _guard = TestGuard::new();
// Clearing when nothing is registered should not error
test_input("autocmd -c pre-cmd").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== Error Cases =====================
#[test]
fn missing_kind() {
let _guard = TestGuard::new();
let result = test_input("autocmd");
assert!(result.is_err());
}
#[test]
fn invalid_kind() {
let _guard = TestGuard::new();
let result = test_input("autocmd not-a-real-kind 'echo hi'");
assert!(result.is_err());
}
#[test]
fn missing_command() {
let _guard = TestGuard::new();
let result = test_input("autocmd pre-cmd");
assert!(result.is_err());
}
// ===================== All valid kind strings =====================
#[test]
fn all_kinds_parse() {
let _guard = TestGuard::new();
let kinds = [
"pre-cmd",
"post-cmd",
"pre-change-dir",
"post-change-dir",
"on-job-finish",
"pre-prompt",
"post-prompt",
"pre-mode-change",
"post-mode-change",
"on-history-open",
"on-history-close",
"on-history-select",
"on-completion-start",
"on-completion-cancel",
"on-completion-select",
"on-exit",
];
for kind in kinds {
test_input(format!("autocmd {kind} 'true'")).unwrap();
}
}
// ===================== Execution =====================
#[test]
fn exec_fires_autocmd() {
let guard = TestGuard::new();
// Register a post-change-dir autocmd and trigger it via cd
test_input("autocmd post-change-dir 'echo changed'").unwrap();
guard.read_output();
test_input("cd /tmp").unwrap();
let out = guard.read_output();
assert!(out.contains("changed"));
}
#[test]
fn exec_with_pattern_match() {
let guard = TestGuard::new();
// Pattern that matches "cd" commands
test_input("autocmd -p '/tmp' post-change-dir 'echo matched'").unwrap();
guard.read_output();
test_input("cd /tmp").unwrap();
let out = guard.read_output();
assert!(out.contains("matched"));
}
#[test]
fn exec_with_pattern_no_match() {
let guard = TestGuard::new();
// Pattern that won't match /tmp
test_input("autocmd -p '^/usr' post-change-dir 'echo nope'").unwrap();
guard.read_output();
test_input("cd /tmp").unwrap();
let out = guard.read_output();
assert!(!out.contains("nope"));
}
#[test]
fn exec_preserves_status() {
let _guard = TestGuard::new();
// autocmd exec should restore the status code from before it ran
test_input("autocmd post-change-dir 'false'").unwrap();
test_input("true").unwrap();
assert_eq!(state::get_status(), 0);
test_input("cd /tmp").unwrap();
// cd itself succeeds, autocmd runs `false` but status should be
// restored to cd's success
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -75,3 +75,168 @@ pub fn cd(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
pub mod tests {
use std::env;
use std::fs;
use tempfile::TempDir;
use crate::state;
use crate::testutil::{TestGuard, test_input};
// ===================== Basic Navigation =====================
#[test]
fn cd_simple() {
let _g = TestGuard::new();
let old_dir = env::current_dir().unwrap();
let temp_dir = TempDir::new().unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let new_dir = env::current_dir().unwrap();
assert_ne!(old_dir, new_dir);
assert_eq!(
new_dir.display().to_string(),
temp_dir.path().display().to_string()
);
}
#[test]
fn cd_no_args_goes_home() {
let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
unsafe { env::set_var("HOME", temp_dir.path()) };
test_input("cd").unwrap();
let cwd = env::current_dir().unwrap();
assert_eq!(
cwd.display().to_string(),
temp_dir.path().display().to_string()
);
}
#[test]
fn cd_relative_path() {
let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
let sub = temp_dir.path().join("child");
fs::create_dir(&sub).unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap();
test_input("cd child").unwrap();
let cwd = env::current_dir().unwrap();
assert_eq!(cwd.display().to_string(), sub.display().to_string());
}
// ===================== Environment =====================
#[test]
fn cd_sets_pwd_env() {
let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let pwd = env::var("PWD").unwrap();
assert_eq!(pwd, env::current_dir().unwrap().display().to_string());
}
#[test]
fn cd_status_zero_on_success() {
let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== Error Cases =====================
#[test]
fn cd_nonexistent_dir_fails() {
let _g = TestGuard::new();
let result = test_input("cd /nonexistent_path_that_does_not_exist_xyz");
assert!(result.is_err());
}
#[test]
fn cd_file_not_directory_fails() {
let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("afile.txt");
fs::write(&file_path, "hello").unwrap();
let result = test_input(format!("cd {}", file_path.display()));
assert!(result.is_err());
}
// ===================== Multiple cd =====================
#[test]
fn cd_multiple_times() {
let _g = TestGuard::new();
let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap();
test_input(format!("cd {}", dir_a.path().display())).unwrap();
assert_eq!(
env::current_dir().unwrap().display().to_string(),
dir_a.path().display().to_string()
);
test_input(format!("cd {}", dir_b.path().display())).unwrap();
assert_eq!(
env::current_dir().unwrap().display().to_string(),
dir_b.path().display().to_string()
);
}
#[test]
fn cd_nested_subdirectories() {
let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
let deep = temp_dir.path().join("a").join("b").join("c");
fs::create_dir_all(&deep).unwrap();
test_input(format!("cd {}", deep.display())).unwrap();
assert_eq!(
env::current_dir().unwrap().display().to_string(),
deep.display().to_string()
);
}
// ===================== Autocmd Integration =====================
#[test]
fn cd_fires_post_change_dir_autocmd() {
let guard = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
test_input("autocmd post-change-dir 'echo cd-hook-fired'").unwrap();
guard.read_output();
test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let out = guard.read_output();
assert!(out.contains("cd-hook-fired"));
}
#[test]
fn cd_fires_pre_change_dir_autocmd() {
let guard = TestGuard::new();
let temp_dir = TempDir::new().unwrap();
test_input("autocmd pre-change-dir 'echo pre-cd'").unwrap();
guard.read_output();
test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let out = guard.read_output();
assert!(out.contains("pre-cd"));
}
}

View File

@@ -173,20 +173,24 @@ pub fn complete_builtin(node: Node) -> ShResult<()> {
if comp_opts.flags.contains(CompFlags::PRINT) {
if argv.is_empty() {
read_meta(|m| {
read_meta(|m| -> ShResult<()> {
let specs = m.comp_specs().values();
for spec in specs {
println!("{}", spec.source());
let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, spec.source().as_bytes())?;
}
})
Ok(())
})?;
} else {
read_meta(|m| {
read_meta(|m| -> ShResult<()> {
for (cmd, _) in &argv {
if let Some(spec) = m.comp_specs().get(cmd) {
println!("{}", spec.source());
let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, spec.source().as_bytes())?;
}
}
})
Ok(())
})?;
}
state::set_status(0);
@@ -309,3 +313,318 @@ pub fn get_comp_opts(opts: Vec<Opt>) -> ShResult<CompOpts> {
Ok(comp_opts)
}
#[cfg(test)]
mod tests {
use crate::state::{self, VarFlags, VarKind, read_meta, write_vars};
use crate::testutil::{TestGuard, test_input};
use std::fs;
use tempfile::TempDir;
// ===================== complete: Registration =====================
#[test]
fn complete_register_wordlist() {
let _g = TestGuard::new();
test_input("complete -W 'foo bar baz' mycmd").unwrap();
let spec = read_meta(|m| m.get_comp_spec("mycmd"));
assert!(spec.is_some());
}
#[test]
fn complete_register_files() {
let _g = TestGuard::new();
test_input("complete -f mycmd").unwrap();
let spec = read_meta(|m| m.get_comp_spec("mycmd"));
assert!(spec.is_some());
}
#[test]
fn complete_register_dirs() {
let _g = TestGuard::new();
test_input("complete -d mycmd").unwrap();
let spec = read_meta(|m| m.get_comp_spec("mycmd"));
assert!(spec.is_some());
}
#[test]
fn complete_register_multiple_commands() {
let _g = TestGuard::new();
test_input("complete -W 'x y' cmd1 cmd2").unwrap();
assert!(read_meta(|m| m.get_comp_spec("cmd1")).is_some());
assert!(read_meta(|m| m.get_comp_spec("cmd2")).is_some());
}
#[test]
fn complete_register_function() {
let _g = TestGuard::new();
test_input("complete -F _my_comp mycmd").unwrap();
let spec = read_meta(|m| m.get_comp_spec("mycmd"));
assert!(spec.is_some());
}
#[test]
fn complete_register_combined_flags() {
let _g = TestGuard::new();
test_input("complete -f -d -v mycmd").unwrap();
let spec = read_meta(|m| m.get_comp_spec("mycmd"));
assert!(spec.is_some());
}
#[test]
fn complete_overwrite_spec() {
let _g = TestGuard::new();
test_input("complete -W 'old' mycmd").unwrap();
test_input("complete -W 'new' mycmd").unwrap();
let spec = read_meta(|m| m.get_comp_spec("mycmd"));
assert!(spec.is_some());
// Verify the source reflects the latest registration
assert!(spec.unwrap().source().contains("new"));
}
#[test]
fn complete_no_command_fails() {
let _g = TestGuard::new();
let result = test_input("complete -W 'foo'");
assert!(result.is_err());
}
// ===================== complete -r: Removal =====================
#[test]
fn complete_remove_spec() {
let _g = TestGuard::new();
test_input("complete -W 'foo' mycmd").unwrap();
assert!(read_meta(|m| m.get_comp_spec("mycmd")).is_some());
test_input("complete -r mycmd").unwrap();
assert!(read_meta(|m| m.get_comp_spec("mycmd")).is_none());
}
#[test]
fn complete_remove_multiple() {
let _g = TestGuard::new();
test_input("complete -W 'a' cmd1").unwrap();
test_input("complete -W 'b' cmd2").unwrap();
test_input("complete -r cmd1 cmd2").unwrap();
assert!(read_meta(|m| m.get_comp_spec("cmd1")).is_none());
assert!(read_meta(|m| m.get_comp_spec("cmd2")).is_none());
}
#[test]
fn complete_remove_nonexistent_is_ok() {
let _g = TestGuard::new();
// Removing a spec that doesn't exist should not error
test_input("complete -r nosuchcmd").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== complete -p: Print =====================
#[test]
fn complete_print_specific() {
let guard = TestGuard::new();
test_input("complete -W 'alpha beta' mycmd").unwrap();
guard.read_output();
test_input("complete -p mycmd").unwrap();
let out = guard.read_output();
assert!(out.contains("mycmd"));
}
#[test]
fn complete_print_all() {
let guard = TestGuard::new();
// Clear any existing specs and register two
test_input("complete -W 'a' cmd1").unwrap();
test_input("complete -W 'b' cmd2").unwrap();
guard.read_output();
test_input("complete -p").unwrap();
let out = guard.read_output();
assert!(out.contains("cmd1"));
assert!(out.contains("cmd2"));
}
// ===================== complete -o: Option flags =====================
#[test]
fn complete_option_default() {
let _g = TestGuard::new();
test_input("complete -o default -W 'foo' mycmd").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn complete_option_dirnames() {
let _g = TestGuard::new();
test_input("complete -o dirnames -W 'foo' mycmd").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn complete_option_invalid() {
let _g = TestGuard::new();
let result = test_input("complete -o bogus -W 'foo' mycmd");
assert!(result.is_err());
}
// ===================== compgen -W: Word list =====================
#[test]
fn compgen_wordlist_no_prefix() {
let guard = TestGuard::new();
test_input("compgen -W 'alpha beta gamma'").unwrap();
let out = guard.read_output();
assert!(out.contains("alpha"));
assert!(out.contains("beta"));
assert!(out.contains("gamma"));
}
#[test]
fn compgen_wordlist_with_prefix() {
let guard = TestGuard::new();
test_input("compgen -W 'apple banana avocado' a").unwrap();
let out = guard.read_output();
assert!(out.contains("apple"));
assert!(out.contains("avocado"));
assert!(!out.contains("banana"));
}
#[test]
fn compgen_wordlist_no_match() {
let guard = TestGuard::new();
test_input("compgen -W 'foo bar baz' z").unwrap();
let out = guard.read_output();
assert!(out.trim().is_empty());
}
#[test]
fn compgen_wordlist_exact_match() {
let guard = TestGuard::new();
test_input("compgen -W 'hello help helm' hel").unwrap();
let out = guard.read_output();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
}
#[test]
fn compgen_wordlist_single_match() {
let guard = TestGuard::new();
test_input("compgen -W 'alpha beta gamma' g").unwrap();
let out = guard.read_output();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], "gamma");
}
// ===================== compgen -v: Variables =====================
#[test]
fn compgen_variables() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("TESTCOMPVAR", VarKind::Str("x".into()), VarFlags::NONE)).unwrap();
test_input("compgen -v TESTCOMP").unwrap();
let out = guard.read_output();
assert!(out.contains("TESTCOMPVAR"));
}
// ===================== compgen -a: Aliases =====================
#[test]
fn compgen_aliases() {
let guard = TestGuard::new();
test_input("alias testcompalias='echo hi'").unwrap();
guard.read_output();
test_input("compgen -a testcomp").unwrap();
let out = guard.read_output();
assert!(out.contains("testcompalias"));
}
// ===================== compgen -d: Directories =====================
#[test]
fn compgen_dirs() {
let guard = TestGuard::new();
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("subdir");
fs::create_dir(&sub).unwrap();
let prefix = format!("{}/", tmp.path().display());
test_input(format!("compgen -d {prefix}")).unwrap();
let out = guard.read_output();
assert!(out.contains("subdir"));
}
// ===================== compgen -f: Files =====================
#[test]
fn compgen_files() {
let guard = TestGuard::new();
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("testfile.txt"), "").unwrap();
fs::create_dir(tmp.path().join("testdir")).unwrap();
let prefix = format!("{}/test", tmp.path().display());
test_input(format!("compgen -f {prefix}")).unwrap();
let out = guard.read_output();
assert!(out.contains("testfile.txt"));
assert!(out.contains("testdir"));
}
// ===================== compgen -F: Completion function =====================
#[test]
fn compgen_function() {
let guard = TestGuard::new();
// Define a completion function that sets COMPREPLY
test_input("_mycomp() { COMPREPLY=(opt1 opt2 opt3); }").unwrap();
guard.read_output();
test_input("compgen -F _mycomp").unwrap();
let out = guard.read_output();
assert!(out.contains("opt1"));
assert!(out.contains("opt2"));
assert!(out.contains("opt3"));
}
// ===================== compgen: combined flags =====================
#[test]
fn compgen_wordlist_and_aliases() {
let guard = TestGuard::new();
test_input("alias testcga='true'").unwrap();
guard.read_output();
test_input("compgen -W 'testcgw' -a testcg").unwrap();
let out = guard.read_output();
assert!(out.contains("testcgw"));
assert!(out.contains("testcga"));
}
// ===================== Status =====================
#[test]
fn complete_status_zero() {
let _g = TestGuard::new();
test_input("complete -W 'x' mycmd").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn compgen_status_zero() {
let _g = TestGuard::new();
test_input("compgen -W 'hello'").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -11,6 +11,16 @@ use crate::{
state::{self, read_meta, write_meta},
};
pub fn truncate_home_path(path: String) -> String {
if let Ok(home) = env::var("HOME")
&& path.starts_with(&home)
{
let new = path.strip_prefix(&home).unwrap();
return format!("~{new}");
}
path.to_string()
}
enum StackIdx {
FromTop(usize),
FromBottom(usize),
@@ -23,18 +33,7 @@ fn print_dirs() -> ShResult<()> {
.into_iter()
.chain(dirs_iter)
.map(|d| d.to_string_lossy().to_string())
.map(|d| {
let Ok(home) = env::var("HOME") else {
return d;
};
if d.starts_with(&home) {
let new = d.strip_prefix(&home).unwrap();
format!("~{new}")
} else {
d
}
})
.map(truncate_home_path)
.collect::<Vec<_>>()
.join(" ");
@@ -378,19 +377,7 @@ pub fn dirs(node: Node) -> ShResult<()> {
.map(|d| d.to_string_lossy().to_string());
if abbreviate_home {
let Ok(home) = env::var("HOME") else {
return stack.collect();
};
stack
.map(|d| {
if d.starts_with(&home) {
let new = d.strip_prefix(&home).unwrap();
format!("~{new}")
} else {
d
}
})
.collect()
stack.map(truncate_home_path).collect()
} else {
stack.collect()
}
@@ -438,3 +425,201 @@ pub fn dirs(node: Node) -> ShResult<()> {
Ok(())
}
#[cfg(test)]
pub mod tests {
use crate::{
state::{self, read_meta},
testutil::{TestGuard, test_input},
};
use pretty_assertions::{assert_eq, assert_ne};
use std::{env, path::PathBuf};
use tempfile::TempDir;
#[test]
fn test_pushd_interactive() {
let g = TestGuard::new();
let current_dir = env::current_dir().unwrap();
test_input("pushd /tmp").unwrap();
let new_dir = env::current_dir().unwrap();
assert_ne!(new_dir, current_dir);
assert_eq!(new_dir, PathBuf::from("/tmp"));
let dir_stack = read_meta(|m| m.dirs().clone());
assert_eq!(dir_stack.len(), 1);
assert_eq!(dir_stack[0], current_dir);
let out = g.read_output();
let path = super::truncate_home_path(current_dir.to_string_lossy().to_string());
assert_eq!(out, format!("/tmp {path}\n"));
}
#[test]
fn test_popd_interactive() {
let g = TestGuard::new();
let current_dir = env::current_dir().unwrap();
let tempdir = TempDir::new().unwrap();
let tempdir_raw = tempdir.path().to_path_buf().to_string_lossy().to_string();
test_input(format!("pushd {tempdir_raw}")).unwrap();
let dir_stack = read_meta(|m| m.dirs().clone());
assert_eq!(dir_stack.len(), 1);
assert_eq!(dir_stack[0], current_dir);
assert_eq!(env::current_dir().unwrap(), tempdir.path());
g.read_output(); // consume output of pushd
test_input("popd").unwrap();
assert_eq!(env::current_dir().unwrap(), current_dir);
let out = g.read_output();
let path = super::truncate_home_path(current_dir.to_string_lossy().to_string());
assert_eq!(out, format!("{path}\n"));
}
#[test]
fn test_popd_empty_stack() {
let _g = TestGuard::new();
test_input("popd").unwrap_err();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_pushd_multiple_then_popd() {
let g = TestGuard::new();
let original = env::current_dir().unwrap();
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().to_path_buf();
let path2 = tmp2.path().to_path_buf();
test_input(format!("pushd {}", path1.display())).unwrap();
test_input(format!("pushd {}", path2.display())).unwrap();
g.read_output();
assert_eq!(env::current_dir().unwrap(), path2);
let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 2);
assert_eq!(stack[0], path1);
assert_eq!(stack[1], original);
test_input("popd").unwrap();
assert_eq!(env::current_dir().unwrap(), path1);
test_input("popd").unwrap();
assert_eq!(env::current_dir().unwrap(), original);
let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 0);
}
#[test]
fn test_pushd_rotate_plus() {
let g = TestGuard::new();
let original = env::current_dir().unwrap();
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().to_path_buf();
let path2 = tmp2.path().to_path_buf();
// Build stack: cwd=original, then pushd path1, pushd path2
// Stack after: cwd=path2, [path1, original]
test_input(format!("pushd {}", path1.display())).unwrap();
test_input(format!("pushd {}", path2.display())).unwrap();
g.read_output();
// pushd +1 rotates: [path2, path1, original] -> rotate_left(1) -> [path1, original, path2]
// pop front -> cwd=path1, stack=[original, path2]
test_input("pushd +1").unwrap();
assert_eq!(env::current_dir().unwrap(), path1);
let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 2);
assert_eq!(stack[0], original);
assert_eq!(stack[1], path2);
}
#[test]
fn test_pushd_no_cd_flag() {
let _g = TestGuard::new();
let original = env::current_dir().unwrap();
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
test_input(format!("pushd -n {}", path.display())).unwrap();
// -n means don't cd, but the dir should still be on the stack
assert_eq!(env::current_dir().unwrap(), original);
}
#[test]
fn test_dirs_clear() {
let _g = TestGuard::new();
let tmp = TempDir::new().unwrap();
test_input(format!("pushd {}", tmp.path().display())).unwrap();
assert_eq!(read_meta(|m| m.dirs().len()), 1);
test_input("dirs -c").unwrap();
assert_eq!(read_meta(|m| m.dirs().len()), 0);
}
#[test]
fn test_dirs_one_per_line() {
let g = TestGuard::new();
let original = env::current_dir().unwrap();
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
test_input(format!("pushd {}", path.display())).unwrap();
g.read_output();
test_input("dirs -p").unwrap();
let out = g.read_output();
let lines: Vec<&str> = out.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(
lines[0],
super::truncate_home_path(path.to_string_lossy().to_string())
);
assert_eq!(
lines[1],
super::truncate_home_path(original.to_string_lossy().to_string())
);
}
#[test]
fn test_popd_indexed_from_top() {
let _g = TestGuard::new();
let original = env::current_dir().unwrap();
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().to_path_buf();
let path2 = tmp2.path().to_path_buf();
// Stack: cwd=path2, [path1, original]
test_input(format!("pushd {}", path1.display())).unwrap();
test_input(format!("pushd {}", path2.display())).unwrap();
// popd +1 removes index (1-1)=0 from stored dirs, i.e. path1
test_input("popd +1").unwrap();
assert_eq!(env::current_dir().unwrap(), path2); // no cd
let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 1);
assert_eq!(stack[0], original);
}
#[test]
fn test_pushd_nonexistent_dir() {
let _g = TestGuard::new();
let result = test_input("pushd /nonexistent_dir_12345");
assert!(result.is_err());
}
}

View File

@@ -5,7 +5,7 @@ use crate::{
parse::{NdRule, Node, execute::prepare_argv},
prelude::*,
procio::borrow_fd,
state,
state::{self, read_shopts},
};
pub const ECHO_OPTS: [OptSpec; 4] = [
@@ -31,7 +31,7 @@ bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EchoFlags: u32 {
const NO_NEWLINE = 0b000001;
const USE_STDERR = 0b000010;
const NO_ESCAPE = 0b000010;
const USE_ESCAPE = 0b000100;
const USE_PROMPT = 0b001000;
}
@@ -54,18 +54,18 @@ pub fn echo(node: Node) -> ShResult<()> {
argv.remove(0);
}
let output_channel = if flags.contains(EchoFlags::USE_STDERR) {
borrow_fd(STDERR_FILENO)
} else {
borrow_fd(STDOUT_FILENO)
};
let output_channel = borrow_fd(STDOUT_FILENO);
let xpg_echo = read_shopts(|o| o.core.xpg_echo); // If true, echo expands escape sequences by default, and -E opts out
let use_escape =
(xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE);
let mut echo_output = prepare_echo_args(
argv
.into_iter()
.map(|a| a.0) // Extract the String from the tuple of (String,Span)
.collect::<Vec<_>>(),
flags.contains(EchoFlags::USE_ESCAPE),
use_escape,
flags.contains(EchoFlags::USE_PROMPT),
)?
.join(" ");
@@ -206,9 +206,9 @@ pub fn get_echo_flags(opts: Vec<Opt>) -> ShResult<EchoFlags> {
for opt in opts {
match opt {
Opt::Short('n') => flags |= EchoFlags::NO_NEWLINE,
Opt::Short('r') => flags |= EchoFlags::USE_STDERR,
Opt::Short('e') => flags |= EchoFlags::USE_ESCAPE,
Opt::Short('p') => flags |= EchoFlags::USE_PROMPT,
Opt::Short('E') => flags |= EchoFlags::NO_ESCAPE,
_ => {
return Err(ShErr::simple(
ShErrKind::ExecFail,
@@ -220,3 +220,250 @@ pub fn get_echo_flags(opts: Vec<Opt>) -> ShResult<EchoFlags> {
Ok(flags)
}
#[cfg(test)]
mod tests {
use super::prepare_echo_args;
use crate::state::{self, write_shopts};
use crate::testutil::{TestGuard, test_input};
// ===================== Pure: prepare_echo_args =====================
#[test]
fn prepare_no_escape() {
let result = prepare_echo_args(vec!["hello\\nworld".into()], false, false).unwrap();
assert_eq!(result, vec!["hello\\nworld"]);
}
#[test]
fn prepare_escape_newline() {
let result = prepare_echo_args(vec!["hello\\nworld".into()], true, false).unwrap();
assert_eq!(result, vec!["hello\nworld"]);
}
#[test]
fn prepare_escape_tab() {
let result = prepare_echo_args(vec!["a\\tb".into()], true, false).unwrap();
assert_eq!(result, vec!["a\tb"]);
}
#[test]
fn prepare_escape_carriage_return() {
let result = prepare_echo_args(vec!["a\\rb".into()], true, false).unwrap();
assert_eq!(result, vec!["a\rb"]);
}
#[test]
fn prepare_escape_bell() {
let result = prepare_echo_args(vec!["a\\ab".into()], true, false).unwrap();
assert_eq!(result, vec!["a\x07b"]);
}
#[test]
fn prepare_escape_backspace() {
let result = prepare_echo_args(vec!["a\\bb".into()], true, false).unwrap();
assert_eq!(result, vec!["a\x08b"]);
}
#[test]
fn prepare_escape_escape_char() {
let result = prepare_echo_args(vec!["a\\eb".into()], true, false).unwrap();
assert_eq!(result, vec!["a\x1bb"]);
}
#[test]
fn prepare_escape_upper_e() {
let result = prepare_echo_args(vec!["a\\Eb".into()], true, false).unwrap();
assert_eq!(result, vec!["a\x1bb"]);
}
#[test]
fn prepare_escape_backslash() {
let result = prepare_echo_args(vec!["a\\\\b".into()], true, false).unwrap();
assert_eq!(result, vec!["a\\b"]);
}
#[test]
fn prepare_escape_hex() {
let result = prepare_echo_args(vec!["\\x41".into()], true, false).unwrap();
assert_eq!(result, vec!["A"]);
}
#[test]
fn prepare_escape_hex_lowercase() {
let result = prepare_echo_args(vec!["\\x61".into()], true, false).unwrap();
assert_eq!(result, vec!["a"]);
}
#[test]
fn prepare_escape_octal() {
let result = prepare_echo_args(vec!["\\0101".into()], true, false).unwrap();
assert_eq!(result, vec!["A"]); // octal 101 = 65 = 'A'
}
#[test]
fn prepare_escape_multiple() {
let result = prepare_echo_args(vec!["a\\nb\\tc".into()], true, false).unwrap();
assert_eq!(result, vec!["a\nb\tc"]);
}
#[test]
fn prepare_multiple_args() {
let result = prepare_echo_args(vec!["hello".into(), "world".into()], false, false).unwrap();
assert_eq!(result, vec!["hello", "world"]);
}
#[test]
fn prepare_trailing_backslash() {
let result = prepare_echo_args(vec!["hello\\".into()], true, false).unwrap();
assert_eq!(result, vec!["hello\\"]);
}
#[test]
fn prepare_unknown_escape_literal() {
// Unknown escape like \z should keep the backslash
let result = prepare_echo_args(vec!["\\z".into()], true, false).unwrap();
assert_eq!(result, vec!["\\z"]);
}
// ===================== Integration: basic echo =====================
#[test]
fn echo_simple() {
let guard = TestGuard::new();
test_input("echo hello").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\n");
}
#[test]
fn echo_multiple_args() {
let guard = TestGuard::new();
test_input("echo hello world").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
#[test]
fn echo_no_args() {
let guard = TestGuard::new();
test_input("echo").unwrap();
let out = guard.read_output();
assert_eq!(out, "\n");
}
#[test]
fn echo_status_zero() {
let _g = TestGuard::new();
test_input("echo hello").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== Integration: -n flag =====================
#[test]
fn echo_no_newline() {
let guard = TestGuard::new();
test_input("echo -n hello").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello");
}
#[test]
fn echo_no_newline_no_args() {
let guard = TestGuard::new();
test_input("echo -n").unwrap();
let out = guard.read_output();
assert_eq!(out, "");
}
// ===================== Integration: -e flag =====================
#[test]
fn echo_escape_newline() {
let guard = TestGuard::new();
test_input("echo -e 'hello\\nworld'").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\nworld\n");
}
#[test]
fn echo_escape_tab() {
let guard = TestGuard::new();
test_input("echo -e 'a\\tb'").unwrap();
let out = guard.read_output();
assert_eq!(out, "a\tb\n");
}
#[test]
fn echo_no_escape_by_default() {
let guard = TestGuard::new();
test_input("echo 'hello\\nworld'").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\\nworld\n");
}
// ===================== Integration: -E flag + xpg_echo =====================
#[test]
fn echo_xpg_echo_expands_by_default() {
let guard = TestGuard::new();
write_shopts(|o| o.core.xpg_echo = true);
test_input("echo 'hello\\nworld'").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\nworld\n");
}
#[test]
fn echo_xpg_echo_suppressed_by_big_e() {
let guard = TestGuard::new();
write_shopts(|o| o.core.xpg_echo = true);
test_input("echo -E 'hello\\nworld'").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\\nworld\n");
}
#[test]
fn echo_small_e_overrides_without_xpg() {
let guard = TestGuard::new();
write_shopts(|o| o.core.xpg_echo = false);
test_input("echo -e 'a\\tb'").unwrap();
let out = guard.read_output();
assert_eq!(out, "a\tb\n");
}
#[test]
fn echo_big_e_noop_without_xpg() {
let guard = TestGuard::new();
write_shopts(|o| o.core.xpg_echo = false);
// -E without xpg_echo is a no-op — escapes already off
test_input("echo -E 'hello\\nworld'").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\\nworld\n");
}
// ===================== Integration: combined flags =====================
#[test]
fn echo_n_and_e() {
let guard = TestGuard::new();
test_input("echo -n -e 'a\\nb'").unwrap();
let out = guard.read_output();
assert_eq!(out, "a\nb");
}
#[test]
fn echo_xpg_n_suppresses_newline() {
let guard = TestGuard::new();
write_shopts(|o| o.core.xpg_echo = true);
test_input("echo -n 'hello\\nworld'").unwrap();
let out = guard.read_output();
// xpg_echo expands \n, -n suppresses trailing newline
assert_eq!(out, "hello\nworld");
}
}

View File

@@ -34,3 +34,91 @@ pub fn eval(node: Node) -> ShResult<()> {
exec_input(joined_argv, None, false, Some("eval".into()))
}
#[cfg(test)]
mod tests {
use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
use crate::testutil::{TestGuard, test_input};
// ===================== Basic =====================
#[test]
fn eval_simple_command() {
let guard = TestGuard::new();
test_input("eval echo hello").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\n");
}
#[test]
fn eval_no_args_succeeds() {
let _g = TestGuard::new();
test_input("eval").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn eval_status_zero() {
let _g = TestGuard::new();
test_input("eval true").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== Joins args =====================
#[test]
fn eval_joins_args() {
let guard = TestGuard::new();
// eval receives "echo" "hello" "world" as separate args, joins to "echo hello world"
test_input("eval echo hello world").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
// ===================== Re-evaluation =====================
#[test]
fn eval_expands_variable() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("CMD", VarKind::Str("echo evaluated".into()), VarFlags::NONE))
.unwrap();
test_input("eval $CMD").unwrap();
let out = guard.read_output();
assert_eq!(out, "evaluated\n");
}
#[test]
fn eval_sets_variable() {
let _g = TestGuard::new();
test_input("eval x=42").unwrap();
let val = read_vars(|v| v.get_var("x"));
assert_eq!(val, "42");
}
#[test]
fn eval_pipeline() {
let guard = TestGuard::new();
test_input("eval 'echo hello | cat'").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\n");
}
#[test]
fn eval_compound_command() {
let guard = TestGuard::new();
test_input("eval 'echo first; echo second'").unwrap();
let out = guard.read_output();
assert!(out.contains("first"));
assert!(out.contains("second"));
}
// ===================== Status propagation =====================
#[test]
fn eval_propagates_failure_status() {
let _g = TestGuard::new();
let _ = test_input("eval false");
assert_ne!(state::get_status(), 0);
}
}

View File

@@ -45,3 +45,26 @@ pub fn exec_builtin(node: Node) -> ShResult<()> {
_ => Err(ShErr::at(ShErrKind::Errno(e), span, format!("{e}"))),
}
}
#[cfg(test)]
mod tests {
use crate::state;
use crate::testutil::{TestGuard, test_input};
// Testing exec is a bit tricky since it replaces the current process, so we just test that it correctly handles the case of no arguments and the case of a nonexistent command. We can't really test that it successfully executes a command since that would replace the test process itself.
#[test]
fn exec_no_args_succeeds() {
let _g = TestGuard::new();
test_input("exec").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn exec_nonexistent_command_fails() {
let _g = TestGuard::new();
let result = test_input(
"exec _____________no_such_______command_xyz_____________hopefully______this_doesnt______exist_____somewhere_in___your______PATH__________________",
);
assert!(result.is_err());
}
}

View File

@@ -41,3 +41,105 @@ pub fn flowctl(node: Node, kind: ShErrKind) -> ShResult<()> {
Err(ShErr::simple(kind, message))
}
#[cfg(test)]
mod tests {
use crate::libsh::error::ShErrKind;
use crate::state;
use crate::testutil::{TestGuard, test_input};
// ===================== break =====================
#[test]
fn break_exits_loop() {
let guard = TestGuard::new();
test_input("for i in 1 2 3; do echo $i; break; done").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), "1");
}
#[test]
fn break_outside_loop_errors() {
let _g = TestGuard::new();
let result = test_input("break");
assert!(result.is_err());
}
#[test]
fn break_non_numeric_errors() {
let _g = TestGuard::new();
let result = test_input("for i in 1; do break abc; done");
assert!(result.is_err());
}
// ===================== continue =====================
#[test]
fn continue_skips_iteration() {
let guard = TestGuard::new();
test_input("for i in 1 2 3; do if [[ $i == 2 ]]; then continue; fi; echo $i; done").unwrap();
let out = guard.read_output();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines, vec!["1", "3"]);
}
#[test]
fn continue_outside_loop_errors() {
let _g = TestGuard::new();
let result = test_input("continue");
assert!(result.is_err());
}
// ===================== return =====================
#[test]
fn return_exits_function() {
let guard = TestGuard::new();
test_input("f() { echo before; return; echo after; }").unwrap();
test_input("f").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), "before");
}
#[test]
fn return_with_status() {
let _g = TestGuard::new();
test_input("f() { return 42; }").unwrap();
test_input("f").unwrap();
assert_eq!(state::get_status(), 42);
}
#[test]
fn return_outside_function_errors() {
let _g = TestGuard::new();
let result = test_input("return");
assert!(result.is_err());
}
// ===================== exit =====================
#[test]
fn exit_returns_clean_exit() {
let _g = TestGuard::new();
let result = test_input("exit 0");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err.kind(), ShErrKind::CleanExit(0)));
}
#[test]
fn exit_with_code() {
let _g = TestGuard::new();
let result = test_input("exit 5");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err.kind(), ShErrKind::CleanExit(5)));
}
#[test]
fn exit_non_numeric_errors() {
let _g = TestGuard::new();
let result = test_input("exit abc");
assert!(result.is_err());
}
}

View File

@@ -251,3 +251,217 @@ pub fn getopts(node: Node) -> ShResult<()> {
getopts_inner(&opts_spec, &opt_var.0, &pos_params, span)
}
}
#[cfg(test)]
mod tests {
use crate::state::{self, read_vars};
use crate::testutil::{TestGuard, test_input};
fn get_var(name: &str) -> String {
read_vars(|v| v.get_var(name))
}
// ===================== Spec parsing =====================
#[test]
fn parse_simple_spec() {
use super::GetOptsSpec;
use std::str::FromStr;
let spec = GetOptsSpec::from_str("abc").unwrap();
assert!(!spec.silent_err);
assert_eq!(spec.opt_specs.len(), 3);
}
#[test]
fn parse_spec_with_args() {
use super::GetOptsSpec;
use std::str::FromStr;
let spec = GetOptsSpec::from_str("a:bc:").unwrap();
assert!(!spec.silent_err);
assert!(spec.opt_specs[0].takes_arg); // a:
assert!(!spec.opt_specs[1].takes_arg); // b
assert!(spec.opt_specs[2].takes_arg); // c:
}
#[test]
fn parse_silent_spec() {
use super::GetOptsSpec;
use std::str::FromStr;
let spec = GetOptsSpec::from_str(":ab").unwrap();
assert!(spec.silent_err);
assert_eq!(spec.opt_specs.len(), 2);
}
#[test]
fn parse_invalid_char() {
use super::GetOptsSpec;
use std::str::FromStr;
let result = GetOptsSpec::from_str("a@b");
assert!(result.is_err());
}
// ===================== Basic option matching =====================
#[test]
fn getopts_simple_flag() {
let _g = TestGuard::new();
test_input("getopts ab opt -a").unwrap();
assert_eq!(get_var("opt"), "a");
assert_eq!(state::get_status(), 0);
}
#[test]
fn getopts_second_flag() {
let _g = TestGuard::new();
test_input("getopts ab opt -b").unwrap();
assert_eq!(get_var("opt"), "b");
}
// ===================== Option with argument =====================
#[test]
fn getopts_option_with_separate_arg() {
let _g = TestGuard::new();
test_input("getopts a: opt -a value").unwrap();
assert_eq!(get_var("opt"), "a");
assert_eq!(get_var("OPTARG"), "value");
}
#[test]
fn getopts_option_with_attached_arg() {
let _g = TestGuard::new();
test_input("getopts a: opt -avalue").unwrap();
assert_eq!(get_var("opt"), "a");
assert_eq!(get_var("OPTARG"), "value");
}
// ===================== Bundled options =====================
#[test]
fn getopts_bundled_flags() {
let _g = TestGuard::new();
// First call gets 'a' from -ab
test_input("getopts abc opt -ab").unwrap();
assert_eq!(get_var("opt"), "a");
// Second call gets 'b' from same -ab
test_input("getopts abc opt -ab").unwrap();
assert_eq!(get_var("opt"), "b");
}
// ===================== OPTIND advancement =====================
#[test]
fn getopts_advances_optind() {
let _g = TestGuard::new();
test_input("getopts ab opt -a").unwrap();
let optind: usize = get_var("OPTIND").parse().unwrap();
assert_eq!(optind, 2); // Advanced past -a
}
#[test]
fn getopts_arg_option_advances_by_two() {
let _g = TestGuard::new();
test_input("getopts a: opt -a val").unwrap();
let optind: usize = get_var("OPTIND").parse().unwrap();
assert_eq!(optind, 3); // Advanced past both -a and val
}
// ===================== Multiple calls (loop simulation) =====================
#[test]
fn getopts_multiple_separate_args() {
let _g = TestGuard::new();
test_input("getopts ab opt -a -b").unwrap();
assert_eq!(get_var("opt"), "a");
assert_eq!(state::get_status(), 0);
test_input("getopts ab opt -a -b").unwrap();
assert_eq!(get_var("opt"), "b");
assert_eq!(state::get_status(), 0);
// Third call: no more options
test_input("getopts ab opt -a -b").unwrap();
assert_eq!(state::get_status(), 1);
}
// ===================== End of options =====================
#[test]
fn getopts_no_options_returns_1() {
let _g = TestGuard::new();
test_input("getopts ab opt foo").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn getopts_double_dash_stops() {
let _g = TestGuard::new();
test_input("getopts ab opt -- -a").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn getopts_bare_dash_stops() {
let _g = TestGuard::new();
test_input("getopts ab opt -").unwrap();
assert_eq!(state::get_status(), 1);
}
// ===================== Unknown option =====================
#[test]
fn getopts_unknown_option() {
let _g = TestGuard::new();
test_input("getopts ab opt -z").unwrap();
assert_eq!(get_var("opt"), "?");
assert_eq!(state::get_status(), 0);
}
// ===================== Silent error mode =====================
#[test]
fn getopts_silent_unknown_sets_optarg() {
let _g = TestGuard::new();
test_input("getopts :ab opt -z").unwrap();
assert_eq!(get_var("opt"), "?");
assert_eq!(get_var("OPTARG"), "z");
}
#[test]
fn getopts_silent_missing_arg() {
let _g = TestGuard::new();
test_input("getopts :a: opt -a").unwrap();
assert_eq!(get_var("opt"), ":");
assert_eq!(get_var("OPTARG"), "a");
}
// ===================== Missing required argument (non-silent) =====================
#[test]
fn getopts_missing_arg_non_silent() {
let _g = TestGuard::new();
test_input("getopts a: opt -a").unwrap();
assert_eq!(get_var("opt"), "?");
}
// ===================== Error cases =====================
#[test]
fn getopts_missing_spec() {
let _g = TestGuard::new();
let result = test_input("getopts");
assert!(result.is_err());
}
#[test]
fn getopts_missing_varname() {
let _g = TestGuard::new();
let result = test_input("getopts ab");
assert!(result.is_err());
}
}

306
src/builtin/help.rs Normal file
View File

@@ -0,0 +1,306 @@
use std::{env, io::Write, path::Path};
use ariadne::Span as ASpan;
use crate::{
libsh::{
error::{ShErr, ShErrKind, ShResult},
guards::RawModeGuard,
},
parse::{
NdRule, Node,
execute::{exec_input, prepare_argv},
lex::{QuoteState, Span},
},
readline::{complete::ScoredCandidate, markers},
state,
};
const TAG_SEQ: &str = "\x1b[1;33m"; // bold yellow — searchable tags
const REF_SEQ: &str = "\x1b[4;36m"; // underline cyan — cross-references
const RESET_SEQ: &str = "\x1b[0m";
const HEADER_SEQ: &str = "\x1b[1;35m"; // bold magenta — section headers
const CODE_SEQ: &str = "\x1b[32m"; // green — inline code
const KEYWORD_2_SEQ: &str = "\x1b[1;32m"; // bold green — {keyword}
const KEYWORD_3_SEQ: &str = "\x1b[3;37m"; // italic white — [optional]
pub fn help(node: Node) -> ShResult<()> {
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let mut argv = prepare_argv(argv)?.into_iter().peekable();
let help = argv.next().unwrap(); // drop 'help'
// Join all of the word-split arguments into a single string
// Preserve the span too
let (topic, span) = if argv.peek().is_none() {
("help.txt".to_string(), help.1)
} else {
argv.fold((String::new(), Span::default()), |mut acc, arg| {
if acc.1 == Span::default() {
acc.1 = arg.1.clone();
} else {
let new_end = arg.1.end();
let start = acc.1.start();
acc.1.set_range(start..new_end);
}
if acc.0.is_empty() {
acc.0 = arg.0;
} else {
acc.0 = acc.0 + &format!(" {}", arg.0);
}
acc
})
};
let hpath = env::var("SHED_HPATH").unwrap_or_default();
// search for prefixes of help doc filenames
for path in hpath.split(':') {
let dir = Path::new(path);
let Ok(entries) = dir.read_dir() else { continue };
for entry in entries {
let Ok(entry) = entry else { continue };
let path = entry.path();
if !path.is_file() {
continue;
}
let stem = path.file_stem().unwrap().to_string_lossy();
if stem.starts_with(&topic) {
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let unescaped = unescape_help(&contents);
let expanded = expand_help(&unescaped);
open_help(&expanded, None, Some(stem.into_owned()))?;
state::set_status(0);
return Ok(());
}
}
}
// didn't find a filename match, its probably a tag search
for path in hpath.split(':') {
let path = Path::new(path);
if let Ok(entries) = path.read_dir() {
for entry in entries {
let Ok(entry) = entry else { continue };
let path = entry.path();
let filename = path.file_stem().unwrap().to_string_lossy().to_string();
if !path.is_file() {
continue;
}
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
let unescaped = unescape_help(&contents);
let expanded = expand_help(&unescaped);
let tags = read_tags(&expanded);
for (_tag, _line) in &tags {}
if let Some((_matched_tag, line)) = get_best_match(&topic, &tags) {
open_help(&expanded, Some(line), Some(filename))?;
state::set_status(0);
return Ok(());
}
}
}
}
state::set_status(1);
Err(ShErr::at(
ShErrKind::NotFound,
span,
"No relevant help page found for this topic",
))
}
pub fn open_help(content: &str, line: Option<usize>, file_name: Option<String>) -> ShResult<()> {
let pager = env::var("SHED_HPAGER").unwrap_or(env::var("PAGER").unwrap_or("less -R".into()));
let line_arg = line.map(|ln| format!("+{ln}")).unwrap_or_default();
let prompt_arg = file_name
.map(|name| format!("-Ps'{name}'"))
.unwrap_or_default();
let mut tmp = tempfile::NamedTempFile::new()?;
let tmp_path = tmp.path().to_string_lossy().to_string();
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
RawModeGuard::with_cooked_mode(|| {
exec_input(
format!("{pager} {line_arg} {prompt_arg} {tmp_path}"),
None,
true,
Some("help".into()),
)
})
}
pub fn get_best_match(topic: &str, tags: &[(String, usize)]) -> Option<(String, usize)> {
let mut candidates: Vec<_> = tags
.iter()
.map(|(tag, line)| (ScoredCandidate::new(tag.to_string()), *line))
.collect();
for (cand, _) in candidates.iter_mut() {
cand.fuzzy_score(topic);
}
candidates.retain(|(c, _)| c.score.unwrap_or(i32::MIN) > i32::MIN);
candidates.sort_by_key(|(c, _)| c.score.unwrap_or(i32::MIN));
candidates
.first()
.map(|(c, line)| (c.content.clone(), *line))
}
pub fn read_tags(raw: &str) -> Vec<(String, usize)> {
let mut tags = vec![];
for (line_num, line) in raw.lines().enumerate() {
let mut rest = line;
while let Some(pos) = rest.find(TAG_SEQ) {
let after_seq = &rest[pos + TAG_SEQ.len()..];
if let Some(end) = after_seq.find(RESET_SEQ) {
let tag = &after_seq[..end];
tags.push((tag.to_string(), line_num + 1));
rest = &after_seq[end + RESET_SEQ.len()..];
} else {
break;
}
}
}
tags
}
pub fn expand_help(raw: &str) -> String {
let mut result = String::new();
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
match ch {
markers::RESET => result.push_str(RESET_SEQ),
markers::TAG => result.push_str(TAG_SEQ),
markers::REFERENCE => result.push_str(REF_SEQ),
markers::HEADER => result.push_str(HEADER_SEQ),
markers::CODE => result.push_str(CODE_SEQ),
markers::KEYWORD_2 => result.push_str(KEYWORD_2_SEQ),
markers::KEYWORD_3 => result.push_str(KEYWORD_3_SEQ),
_ => result.push(ch),
}
}
result
}
pub fn unescape_help(raw: &str) -> String {
let mut result = String::new();
let mut chars = raw.chars().peekable();
let mut qt_state = QuoteState::default();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(next_ch) = chars.next() {
result.push(next_ch);
}
}
'\n' => {
result.push(ch);
qt_state = QuoteState::default();
}
'"' => {
result.push(ch);
qt_state.toggle_double();
}
'\'' => {
result.push(ch);
qt_state.toggle_single();
}
_ if qt_state.in_quote() || chars.peek().is_none_or(|ch| ch.is_whitespace()) => {
result.push(ch);
}
'*' => {
result.push(markers::TAG);
while let Some(next_ch) = chars.next() {
if next_ch == '*' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'|' => {
result.push(markers::REFERENCE);
while let Some(next_ch) = chars.next() {
if next_ch == '|' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'#' => {
result.push(markers::HEADER);
while let Some(next_ch) = chars.next() {
if next_ch == '#' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'`' => {
result.push(markers::CODE);
while let Some(next_ch) = chars.next() {
if next_ch == '`' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'{' => {
result.push(markers::KEYWORD_2);
while let Some(next_ch) = chars.next() {
if next_ch == '}' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
'[' => {
result.push(markers::KEYWORD_3);
while let Some(next_ch) = chars.next() {
if next_ch == ']' {
result.push(markers::RESET);
break;
} else {
result.push(next_ch);
}
}
}
_ => result.push(ch),
}
}
result
}

View File

@@ -1,4 +1,4 @@
use std::{env, os::unix::fs::PermissionsExt, path::Path};
use std::os::unix::fs::PermissionsExt;
use ariadne::{Fmt, Span};
@@ -6,6 +6,8 @@ use crate::{
builtin::BUILTINS,
libsh::error::{ShErr, ShErrKind, ShResult, next_color},
parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS},
prelude::*,
procio::borrow_fd,
state::{self, ShAlias, ShFunc, read_logic},
};
@@ -31,28 +33,33 @@ pub fn type_builtin(node: Node) -> ShResult<()> {
*/
'outer: for (arg, span) in argv {
let stdout = borrow_fd(STDOUT_FILENO);
if let Some(func) = read_logic(|v| v.get_func(&arg)) {
let ShFunc { body: _, source } = func;
let (line, col) = source.line_and_col();
let name = source.source().name();
println!(
"{arg} is a function defined at {name}:{}:{}",
let msg = format!(
"{arg} is a function defined at {name}:{}:{}\n",
line + 1,
col + 1
);
write(stdout, msg.as_bytes())?;
} else if let Some(alias) = read_logic(|v| v.get_alias(&arg)) {
let ShAlias { body, source } = alias;
let (line, col) = source.line_and_col();
let name = source.source().name();
println!(
"{arg} is an alias for '{body}' defined at {name}:{}:{}",
let msg = format!(
"{arg} is an alias for '{body}' defined at {name}:{}:{}\n",
line + 1,
col + 1
);
write(stdout, msg.as_bytes())?;
} else if BUILTINS.contains(&arg.as_str()) {
println!("{arg} is a shell builtin");
let msg = format!("{arg} is a shell builtin\n");
write(stdout, msg.as_bytes())?;
} else if KEYWORDS.contains(&arg.as_str()) {
println!("{arg} is a shell keyword");
let msg = format!("{arg} is a shell keyword\n");
write(stdout, msg.as_bytes())?;
} else {
let path = env::var("PATH").unwrap_or_default();
let paths = path.split(':').map(Path::new).collect::<Vec<_>>();
@@ -70,7 +77,8 @@ pub fn type_builtin(node: Node) -> ShResult<()> {
&& let Some(name) = entry.file_name().to_str()
&& name == arg
{
println!("{arg} is {}", entry.path().display());
let msg = format!("{arg} is {}\n", entry.path().display());
write(stdout, msg.as_bytes())?;
continue 'outer;
}
}
@@ -92,3 +100,136 @@ pub fn type_builtin(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state::{self};
use crate::testutil::{TestGuard, test_input};
// ===================== Builtins =====================
#[test]
fn type_builtin_echo() {
let guard = TestGuard::new();
test_input("type echo").unwrap();
let out = guard.read_output();
assert!(out.contains("echo"));
assert!(out.contains("shell builtin"));
}
#[test]
fn type_builtin_cd() {
let guard = TestGuard::new();
test_input("type cd").unwrap();
let out = guard.read_output();
assert!(out.contains("cd"));
assert!(out.contains("shell builtin"));
}
// ===================== Keywords =====================
#[test]
fn type_keyword_if() {
let guard = TestGuard::new();
test_input("type if").unwrap();
let out = guard.read_output();
assert!(out.contains("if"));
assert!(out.contains("shell keyword"));
}
#[test]
fn type_keyword_for() {
let guard = TestGuard::new();
test_input("type for").unwrap();
let out = guard.read_output();
assert!(out.contains("for"));
assert!(out.contains("shell keyword"));
}
// ===================== Functions =====================
#[test]
fn type_function() {
let guard = TestGuard::new();
test_input("myfn() { echo hi; }").unwrap();
guard.read_output();
test_input("type myfn").unwrap();
let out = guard.read_output();
assert!(out.contains("myfn"));
assert!(out.contains("function"));
}
// ===================== Aliases =====================
#[test]
fn type_alias() {
let guard = TestGuard::new();
test_input("alias ll='ls -la'").unwrap();
guard.read_output();
test_input("type ll").unwrap();
let out = guard.read_output();
assert!(out.contains("ll"));
assert!(out.contains("alias"));
assert!(out.contains("ls -la"));
}
// ===================== External commands =====================
#[test]
fn type_external_command() {
let guard = TestGuard::new();
// /bin/cat or /usr/bin/cat should exist on any Unix system
test_input("type cat").unwrap();
let out = guard.read_output();
assert!(out.contains("cat"));
assert!(out.contains("is"));
assert!(out.contains("/")); // Should show a path
}
// ===================== Not found =====================
#[test]
fn type_not_found() {
let _g = TestGuard::new();
let result = test_input("type __hopefully____not_______a____command__");
assert!(result.is_err());
assert_eq!(state::get_status(), 1);
}
// ===================== Priority order =====================
#[test]
fn type_function_shadows_builtin() {
let guard = TestGuard::new();
// Define a function named 'echo' — should shadow the builtin
test_input("echo() { true; }").unwrap();
guard.read_output();
test_input("type echo").unwrap();
let out = guard.read_output();
assert!(out.contains("function"));
}
#[test]
fn type_alias_shadows_external() {
let guard = TestGuard::new();
test_input("alias cat='echo meow'").unwrap();
guard.read_output();
test_input("type cat").unwrap();
let out = guard.read_output();
// alias check comes before external PATH scan
assert!(out.contains("alias"));
}
// ===================== Status =====================
#[test]
fn type_status_zero_on_found() {
let _g = TestGuard::new();
test_input("type echo").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -59,7 +59,7 @@ impl KeyMapOpts {
}
Ok(Self { remove, flags })
}
pub fn keymap_opts() -> [OptSpec; 6] {
pub fn keymap_opts() -> [OptSpec; 7] {
[
OptSpec {
opt: Opt::Short('n'), // normal mode
@@ -81,6 +81,10 @@ impl KeyMapOpts {
opt: Opt::Short('o'), // operator-pending mode
takes_arg: false,
},
OptSpec {
opt: Opt::Long("remove".into()),
takes_arg: true,
},
OptSpec {
opt: Opt::Short('r'), // replace mode
takes_arg: false,
@@ -172,3 +176,158 @@ pub fn keymap(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::expand::expand_keymap;
use crate::getopt::Opt;
use crate::state::{self, read_logic};
use crate::testutil::{TestGuard, test_input};
// ===================== KeyMapOpts parsing =====================
#[test]
fn opts_normal_mode() {
let opts = KeyMapOpts::from_opts(&[Opt::Short('n')]).unwrap();
assert!(opts.flags.contains(KeyMapFlags::NORMAL));
}
#[test]
fn opts_insert_mode() {
let opts = KeyMapOpts::from_opts(&[Opt::Short('i')]).unwrap();
assert!(opts.flags.contains(KeyMapFlags::INSERT));
}
#[test]
fn opts_multiple_modes() {
let opts = KeyMapOpts::from_opts(&[Opt::Short('n'), Opt::Short('i')]).unwrap();
assert!(opts.flags.contains(KeyMapFlags::NORMAL));
assert!(opts.flags.contains(KeyMapFlags::INSERT));
}
#[test]
fn opts_no_mode_errors() {
let result = KeyMapOpts::from_opts(&[]);
assert!(result.is_err());
}
#[test]
fn opts_remove() {
let opts = KeyMapOpts::from_opts(&[
Opt::Short('n'),
Opt::LongWithArg("remove".into(), "jk".into()),
])
.unwrap();
assert_eq!(opts.remove, Some("jk".into()));
}
#[test]
fn opts_duplicate_remove_errors() {
let result = KeyMapOpts::from_opts(&[
Opt::Short('n'),
Opt::LongWithArg("remove".into(), "jk".into()),
Opt::LongWithArg("remove".into(), "kj".into()),
]);
assert!(result.is_err());
}
// ===================== KeyMap::compare =====================
#[test]
fn compare_exact_match() {
let km = KeyMap {
flags: KeyMapFlags::NORMAL,
keys: "jk".into(),
action: "<ESC>".into(),
};
let keys = expand_keymap("jk");
assert_eq!(km.compare(&keys), KeyMapMatch::IsExact);
}
#[test]
fn compare_prefix_match() {
let km = KeyMap {
flags: KeyMapFlags::NORMAL,
keys: "jk".into(),
action: "<ESC>".into(),
};
let keys = expand_keymap("j");
assert_eq!(km.compare(&keys), KeyMapMatch::IsPrefix);
}
#[test]
fn compare_no_match() {
let km = KeyMap {
flags: KeyMapFlags::NORMAL,
keys: "jk".into(),
action: "<ESC>".into(),
};
let keys = expand_keymap("zz");
assert_eq!(km.compare(&keys), KeyMapMatch::NoMatch);
}
// ===================== Registration via test_input =====================
#[test]
fn keymap_register() {
let _g = TestGuard::new();
test_input("keymap -n jk '<ESC>'").unwrap();
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
assert!(!maps.is_empty());
}
#[test]
fn keymap_register_insert() {
let _g = TestGuard::new();
test_input("keymap -i jk '<ESC>'").unwrap();
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::INSERT, &expand_keymap("jk")));
assert!(!maps.is_empty());
}
#[test]
fn keymap_overwrite() {
let _g = TestGuard::new();
test_input("keymap -n jk '<ESC>'").unwrap();
test_input("keymap -n jk 'dd'").unwrap();
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
assert_eq!(maps.len(), 1);
assert_eq!(maps[0].action, "dd");
}
#[test]
fn keymap_remove() {
let _g = TestGuard::new();
test_input("keymap -n jk '<ESC>'").unwrap();
test_input("keymap -n --remove jk").unwrap();
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
assert!(maps.is_empty());
}
#[test]
fn keymap_status_zero() {
let _g = TestGuard::new();
test_input("keymap -n jk '<ESC>'").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== Error cases =====================
#[test]
fn keymap_missing_keys() {
let _g = TestGuard::new();
let result = test_input("keymap -n");
assert!(result.is_err());
}
#[test]
fn keymap_missing_action() {
let _g = TestGuard::new();
let result = test_input("keymap -n jk");
assert!(result.is_err());
}
}

View File

@@ -386,3 +386,234 @@ pub fn get_map_opts(opts: Vec<Opt>) -> MapOpts {
}
map_opts
}
#[cfg(test)]
mod tests {
use super::{MapFlags, MapNode, get_map_opts};
use crate::getopt::Opt;
use crate::state::{self, read_vars};
use crate::testutil::{TestGuard, test_input};
// ===================== Pure: MapNode get/set/remove =====================
#[test]
fn mapnode_set_and_get() {
let mut root = MapNode::default();
root.set(&["key".into()], MapNode::StaticLeaf("val".into()));
let node = root.get(&["key".into()]).unwrap();
assert!(matches!(node, MapNode::StaticLeaf(s) if s == "val"));
}
#[test]
fn mapnode_nested_set_and_get() {
let mut root = MapNode::default();
root.set(
&["a".into(), "b".into(), "c".into()],
MapNode::StaticLeaf("deep".into()),
);
let node = root.get(&["a".into(), "b".into(), "c".into()]).unwrap();
assert!(matches!(node, MapNode::StaticLeaf(s) if s == "deep"));
}
#[test]
fn mapnode_get_missing() {
let root = MapNode::default();
assert!(root.get(&["nope".into()]).is_none());
}
#[test]
fn mapnode_remove() {
let mut root = MapNode::default();
root.set(&["key".into()], MapNode::StaticLeaf("val".into()));
let removed = root.remove(&["key".into()]);
assert!(removed.is_some());
assert!(root.get(&["key".into()]).is_none());
}
#[test]
fn mapnode_remove_nested() {
let mut root = MapNode::default();
root.set(&["a".into(), "b".into()], MapNode::StaticLeaf("val".into()));
root.remove(&["a".into(), "b".into()]);
assert!(root.get(&["a".into(), "b".into()]).is_none());
// Parent branch should still exist
assert!(root.get(&["a".into()]).is_some());
}
#[test]
fn mapnode_keys() {
let mut root = MapNode::default();
root.set(&["x".into()], MapNode::StaticLeaf("1".into()));
root.set(&["y".into()], MapNode::StaticLeaf("2".into()));
let mut keys = root.keys();
keys.sort();
assert_eq!(keys, vec!["x", "y"]);
}
#[test]
fn mapnode_display_leaf() {
let leaf = MapNode::StaticLeaf("hello".into());
assert_eq!(leaf.display(false, false).unwrap(), "hello");
}
#[test]
fn mapnode_display_json() {
let mut root = MapNode::default();
root.set(&["k".into()], MapNode::StaticLeaf("v".into()));
let json = root.display(true, false).unwrap();
assert!(json.contains("\"k\""));
assert!(json.contains("\"v\""));
}
#[test]
fn mapnode_overwrite() {
let mut root = MapNode::default();
root.set(&["key".into()], MapNode::StaticLeaf("old".into()));
root.set(&["key".into()], MapNode::StaticLeaf("new".into()));
let node = root.get(&["key".into()]).unwrap();
assert!(matches!(node, MapNode::StaticLeaf(s) if s == "new"));
}
#[test]
fn mapnode_promote_leaf_to_branch() {
let mut root = MapNode::default();
root.set(&["key".into()], MapNode::StaticLeaf("leaf".into()));
// Setting a sub-path should promote the leaf to a branch
root.set(
&["key".into(), "sub".into()],
MapNode::StaticLeaf("nested".into()),
);
let node = root.get(&["key".into(), "sub".into()]).unwrap();
assert!(matches!(node, MapNode::StaticLeaf(s) if s == "nested"));
}
// ===================== Pure: MapNode JSON round-trip =====================
#[test]
fn mapnode_json_roundtrip() {
let mut root = MapNode::default();
root.set(&["name".into()], MapNode::StaticLeaf("test".into()));
root.set(&["count".into()], MapNode::StaticLeaf("42".into()));
let val: serde_json::Value = root.clone().into();
let back: MapNode = val.into();
assert!(back.get(&["name".into()]).is_some());
assert!(back.get(&["count".into()]).is_some());
}
// ===================== Pure: option parsing =====================
#[test]
fn parse_remove_flag() {
let opts = get_map_opts(vec![Opt::Short('r')]);
assert!(opts.flags.contains(MapFlags::REMOVE));
}
#[test]
fn parse_json_flag() {
let opts = get_map_opts(vec![Opt::Short('j')]);
assert!(opts.flags.contains(MapFlags::JSON));
}
#[test]
fn parse_keys_flag() {
let opts = get_map_opts(vec![Opt::Short('k')]);
assert!(opts.flags.contains(MapFlags::KEYS));
}
#[test]
fn parse_pretty_flag() {
let opts = get_map_opts(vec![Opt::Long("pretty".into())]);
assert!(opts.flags.contains(MapFlags::PRETTY));
}
#[test]
fn parse_func_flag() {
let opts = get_map_opts(vec![Opt::Short('F')]);
assert!(opts.flags.contains(MapFlags::FUNC));
}
#[test]
fn parse_combined_flags() {
let opts = get_map_opts(vec![Opt::Short('j'), Opt::Short('k')]);
assert!(opts.flags.contains(MapFlags::JSON));
assert!(opts.flags.contains(MapFlags::KEYS));
}
// ===================== Integration =====================
#[test]
fn map_set_and_read() {
let guard = TestGuard::new();
test_input("map mymap.key=hello").unwrap();
test_input("map mymap.key").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), "hello");
}
#[test]
fn map_nested_path() {
let guard = TestGuard::new();
test_input("map mymap.a.b.c=deep").unwrap();
test_input("map mymap.a.b.c").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), "deep");
}
#[test]
fn map_remove() {
let _g = TestGuard::new();
test_input("map mymap.key=val").unwrap();
test_input("map -r mymap.key").unwrap();
let has = read_vars(|v| {
v.get_map("mymap")
.and_then(|m| m.get(&["key".into()]).cloned())
.is_some()
});
assert!(!has);
}
#[test]
fn map_remove_entire() {
let _g = TestGuard::new();
test_input("map mymap.key=val").unwrap();
test_input("map -r mymap").unwrap();
let has = read_vars(|v| v.get_map("mymap").is_some());
assert!(!has);
}
#[test]
fn map_keys() {
let guard = TestGuard::new();
test_input("map mymap.x=1").unwrap();
test_input("map mymap.y=2").unwrap();
test_input("map -k mymap").unwrap();
let out = guard.read_output();
assert!(out.contains("x"));
assert!(out.contains("y"));
}
#[test]
fn map_json_output() {
let guard = TestGuard::new();
test_input("map mymap.key=val").unwrap();
test_input("map -j mymap").unwrap();
let out = guard.read_output();
assert!(out.contains("\"key\""));
assert!(out.contains("\"val\""));
}
#[test]
fn map_nonexistent_errors() {
let _g = TestGuard::new();
let result = test_input("map __no_such_map__");
assert!(result.is_err());
}
#[test]
fn map_status_zero() {
let _g = TestGuard::new();
test_input("map mymap.key=val").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -11,26 +11,28 @@ pub mod eval;
pub mod exec;
pub mod flowctl;
pub mod getopts;
pub mod help;
pub mod intro;
pub mod jobctl;
pub mod keymap;
pub mod map;
pub mod pwd;
pub mod read;
pub mod resource;
pub mod seek;
pub mod shift;
pub mod shopt;
pub mod source;
pub mod test; // [[ ]] thing
pub mod trap;
pub mod varcmds;
pub mod zoltraak;
pub const BUILTINS: [&str; 47] = [
"echo", "cd", "read", "export", "local", "pwd", "source", "shift", "jobs", "fg", "bg", "disown",
"alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin",
pub const BUILTINS: [&str; 51] = [
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
"disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
"getopts", "keymap", "read_key", "autocmd",
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek", "help",
];
pub fn true_builtin() -> ShResult<()> {
@@ -47,3 +49,37 @@ pub fn noop_builtin() -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
pub mod tests {
use crate::{
state,
testutil::{TestGuard, test_input},
};
// You can never be too sure!!!!!!
#[test]
fn test_true() {
let _g = TestGuard::new();
test_input("true").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_false() {
let _g = TestGuard::new();
test_input("false").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn test_noop() {
let _g = TestGuard::new();
test_input(":").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -24,3 +24,41 @@ pub fn pwd(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state;
use crate::testutil::{TestGuard, test_input};
use std::env;
use tempfile::TempDir;
#[test]
fn pwd_prints_cwd() {
let guard = TestGuard::new();
let cwd = env::current_dir().unwrap();
test_input("pwd").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), cwd.display().to_string());
}
#[test]
fn pwd_after_cd() {
let guard = TestGuard::new();
let tmp = TempDir::new().unwrap();
test_input(format!("cd {}", tmp.path().display())).unwrap();
guard.read_output();
test_input("pwd").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), tmp.path().display().to_string());
}
#[test]
fn pwd_status_zero() {
let _g = TestGuard::new();
test_input("pwd").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -212,7 +212,8 @@ pub fn read_builtin(node: Node) -> ShResult<()> {
for (i, arg) in argv.iter().enumerate() {
if i == argv.len() - 1 {
// Last arg, stuff the rest of the input into it
write_vars(|v| v.set_var(&arg.0, VarKind::Str(remaining.clone()), VarFlags::NONE))?;
let trimmed = remaining.trim_start_matches(|c: char| field_sep.contains(c));
write_vars(|v| v.set_var(&arg.0, VarKind::Str(trimmed.to_string()), VarFlags::NONE))?;
break;
}
@@ -363,3 +364,131 @@ pub fn get_read_key_opts(opts: Vec<Opt>) -> ShResult<ReadKeyOpts> {
Ok(read_key_opts)
}
#[cfg(test)]
mod tests {
use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
use crate::testutil::{TestGuard, test_input};
// ===================== Basic read into REPLY =====================
#[test]
fn read_pipe_into_reply() {
let _g = TestGuard::new();
test_input("read < <(echo hello)").unwrap();
let val = read_vars(|v| v.get_var("REPLY"));
assert_eq!(val, "hello");
}
#[test]
fn read_pipe_into_named_var() {
let _g = TestGuard::new();
test_input("read myvar < <(echo world)").unwrap();
let val = read_vars(|v| v.get_var("myvar"));
assert_eq!(val, "world");
}
// ===================== Field splitting =====================
#[test]
fn read_two_vars() {
let _g = TestGuard::new();
test_input("read a b < <(echo 'hello world')").unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "hello");
assert_eq!(read_vars(|v| v.get_var("b")), "world");
}
#[test]
fn read_last_var_gets_remainder() {
let _g = TestGuard::new();
test_input("read a b < <(echo 'one two three four')").unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "one");
assert_eq!(read_vars(|v| v.get_var("b")), "two three four");
}
#[test]
fn read_more_vars_than_fields() {
let _g = TestGuard::new();
test_input("read a b c < <(echo 'only')").unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "only");
// b and c get empty strings since there are no more fields
assert_eq!(read_vars(|v| v.get_var("b")), "");
assert_eq!(read_vars(|v| v.get_var("c")), "");
}
// ===================== Custom IFS =====================
#[test]
fn read_custom_ifs() {
let _g = TestGuard::new();
write_vars(|v| v.set_var("IFS", VarKind::Str(":".into()), VarFlags::NONE)).unwrap();
test_input("read x y z < <(echo 'a:b:c')").unwrap();
assert_eq!(read_vars(|v| v.get_var("x")), "a");
assert_eq!(read_vars(|v| v.get_var("y")), "b");
assert_eq!(read_vars(|v| v.get_var("z")), "c");
}
#[test]
fn read_custom_ifs_remainder() {
let _g = TestGuard::new();
write_vars(|v| v.set_var("IFS", VarKind::Str(":".into()), VarFlags::NONE)).unwrap();
test_input("read x y < <(echo 'a:b:c:d')").unwrap();
assert_eq!(read_vars(|v| v.get_var("x")), "a");
assert_eq!(read_vars(|v| v.get_var("y")), "b:c:d");
}
// ===================== Custom delimiter =====================
#[test]
fn read_custom_delim() {
let _g = TestGuard::new();
// -d sets the delimiter; printf sends "hello,world" — read stops at ','
test_input("read -d , myvar < <(echo -n 'hello,world')").unwrap();
assert_eq!(read_vars(|v| v.get_var("myvar")), "hello");
}
// ===================== Status =====================
#[test]
fn read_status_zero() {
let _g = TestGuard::new();
test_input("read < <(echo hello)").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn read_eof_status_one() {
let _g = TestGuard::new();
// Empty input / EOF should set status 1
test_input("read < <(echo -n '')").unwrap();
assert_eq!(state::get_status(), 1);
}
// ===================== Flag parsing (pure) =====================
#[test]
fn flags_raw_mode() {
use super::get_read_flags;
use crate::getopt::Opt;
let flags = get_read_flags(vec![Opt::Short('r')]).unwrap();
assert!(flags.flags.contains(super::ReadFlags::NO_ESCAPES));
}
#[test]
fn flags_prompt() {
use super::get_read_flags;
use crate::getopt::Opt;
let flags = get_read_flags(vec![Opt::ShortWithArg('p', "Enter: ".into())]).unwrap();
assert_eq!(flags.prompt, Some("Enter: ".into()));
}
#[test]
fn flags_delimiter() {
use super::get_read_flags;
use crate::getopt::Opt;
let flags = get_read_flags(vec![Opt::ShortWithArg('d', ",".into())]).unwrap();
assert_eq!(flags.delim, b',');
}
}

665
src/builtin/resource.rs Normal file
View File

@@ -0,0 +1,665 @@
use ariadne::Fmt;
use nix::{
libc::STDOUT_FILENO,
sys::{
resource::{Resource, getrlimit, setrlimit},
stat::{Mode, umask},
},
unistd::write,
};
use crate::{
getopt::{Opt, OptSpec, get_opts_from_tokens_strict},
libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color},
parse::{NdRule, Node},
procio::borrow_fd,
state::{self},
};
fn ulimit_opt_spec() -> [OptSpec; 5] {
[
OptSpec {
opt: Opt::Short('n'), // file descriptors
takes_arg: true,
},
OptSpec {
opt: Opt::Short('u'), // max user processes
takes_arg: true,
},
OptSpec {
opt: Opt::Short('s'), // stack size
takes_arg: true,
},
OptSpec {
opt: Opt::Short('c'), // core dump file size
takes_arg: true,
},
OptSpec {
opt: Opt::Short('v'), // virtual memory
takes_arg: true,
},
]
}
struct UlimitOpts {
fds: Option<u64>,
procs: Option<u64>,
stack: Option<u64>,
core: Option<u64>,
vmem: Option<u64>,
}
fn get_ulimit_opts(opt: &[Opt]) -> ShResult<UlimitOpts> {
let mut opts = UlimitOpts {
fds: None,
procs: None,
stack: None,
core: None,
vmem: None,
};
for o in opt {
match o {
Opt::ShortWithArg('n', arg) => {
opts.fds = Some(arg.parse().map_err(|_| {
ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -n: {}", arg.fg(next_color())),
)
})?);
}
Opt::ShortWithArg('u', arg) => {
opts.procs = Some(arg.parse().map_err(|_| {
ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -u: {}", arg.fg(next_color())),
)
})?);
}
Opt::ShortWithArg('s', arg) => {
opts.stack = Some(arg.parse().map_err(|_| {
ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -s: {}", arg.fg(next_color())),
)
})?);
}
Opt::ShortWithArg('c', arg) => {
opts.core = Some(arg.parse().map_err(|_| {
ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -c: {}", arg.fg(next_color())),
)
})?);
}
Opt::ShortWithArg('v', arg) => {
opts.vmem = Some(arg.parse().map_err(|_| {
ShErr::simple(
ShErrKind::ParseErr,
format!("invalid argument for -v: {}", arg.fg(next_color())),
)
})?);
}
o => {
return Err(ShErr::simple(
ShErrKind::ParseErr,
format!("invalid option: {}", o.fg(next_color())),
));
}
}
}
Ok(opts)
}
pub fn ulimit(node: Node) -> ShResult<()> {
let span = node.get_span();
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let (_, opts) =
get_opts_from_tokens_strict(argv, &ulimit_opt_spec()).promote_err(span.clone())?;
let ulimit_opts = get_ulimit_opts(&opts).promote_err(span.clone())?;
if let Some(fds) = ulimit_opts.fds {
let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get file descriptor limit: {}", e),
)
})?;
setrlimit(Resource::RLIMIT_NOFILE, fds, hard).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set file descriptor limit: {}", e),
)
})?;
}
if let Some(procs) = ulimit_opts.procs {
let (_, hard) = getrlimit(Resource::RLIMIT_NPROC).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get process limit: {}", e),
)
})?;
setrlimit(Resource::RLIMIT_NPROC, procs, hard).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set process limit: {}", e),
)
})?;
}
if let Some(stack) = ulimit_opts.stack {
let (_, hard) = getrlimit(Resource::RLIMIT_STACK).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get stack size limit: {}", e),
)
})?;
setrlimit(Resource::RLIMIT_STACK, stack, hard).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set stack size limit: {}", e),
)
})?;
}
if let Some(core) = ulimit_opts.core {
let (_, hard) = getrlimit(Resource::RLIMIT_CORE).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get core dump size limit: {}", e),
)
})?;
setrlimit(Resource::RLIMIT_CORE, core, hard).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set core dump size limit: {}", e),
)
})?;
}
if let Some(vmem) = ulimit_opts.vmem {
let (_, hard) = getrlimit(Resource::RLIMIT_AS).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get virtual memory limit: {}", e),
)
})?;
setrlimit(Resource::RLIMIT_AS, vmem, hard).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set virtual memory limit: {}", e),
)
})?;
}
state::set_status(0);
Ok(())
}
pub fn umask_builtin(node: Node) -> ShResult<()> {
let span = node.get_span();
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let (argv, opts) = get_opts_from_tokens_strict(
argv,
&[OptSpec {
opt: Opt::Short('S'),
takes_arg: false,
}],
)?;
let argv = &argv[1..]; // skip command name
let old = umask(Mode::empty());
umask(old);
let mut old_bits = old.bits();
if !argv.is_empty() {
if argv.len() > 1 {
return Err(ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("umask takes at most one argument, got {}", argv.len()),
));
}
let arg = argv[0].clone();
let raw = arg.as_str();
if raw.chars().any(|c| c.is_ascii_digit()) {
let mode_raw = u32::from_str_radix(raw, 8).map_err(|_| {
ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("invalid numeric umask: {}", raw.fg(next_color())),
)
})?;
let mode = Mode::from_bits(mode_raw).ok_or_else(|| {
ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("invalid umask value: {}", raw.fg(next_color())),
)
})?;
umask(mode);
} else {
let parts = raw.split(',');
for part in parts {
if let Some((who, bits)) = part.split_once('=') {
let mut new_bits = 0;
if bits.contains('r') {
new_bits |= 4;
}
if bits.contains('w') {
new_bits |= 2;
}
if bits.contains('x') {
new_bits |= 1;
}
for ch in who.chars() {
match ch {
'o' => {
old_bits &= !0o7;
old_bits |= !new_bits & 0o7;
}
'g' => {
old_bits &= !(0o7 << 3);
old_bits |= (!new_bits & 0o7) << 3;
}
'u' => {
old_bits &= !(0o7 << 6);
old_bits |= (!new_bits & 0o7) << 6;
}
'a' => {
let denied = !new_bits & 0o7;
old_bits = denied | (denied << 3) | (denied << 6);
}
_ => {
return Err(ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("invalid umask 'who' character: {}", ch.fg(next_color())),
));
}
}
}
umask(Mode::from_bits_truncate(old_bits));
} else if let Some((who, bits)) = part.split_once('+') {
let mut new_bits = 0;
if bits.contains('r') {
new_bits |= 4;
}
if bits.contains('w') {
new_bits |= 2;
}
if bits.contains('x') {
new_bits |= 1;
}
for ch in who.chars() {
match ch {
'o' => {
old_bits &= !(new_bits & 0o7);
}
'g' => {
old_bits &= !((new_bits & 0o7) << 3);
}
'u' => {
old_bits &= !((new_bits & 0o7) << 6);
}
'a' => {
let mask = new_bits & 0o7;
old_bits &= !(mask | (mask << 3) | (mask << 6));
}
_ => {
return Err(ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("invalid umask 'who' character: {}", ch.fg(next_color())),
));
}
}
}
umask(Mode::from_bits_truncate(old_bits));
} else if let Some((who, bits)) = part.split_once('-') {
let mut new_bits = 0;
if bits.contains('r') {
new_bits |= 4;
}
if bits.contains('w') {
new_bits |= 2;
}
if bits.contains('x') {
new_bits |= 1;
}
for ch in who.chars() {
match ch {
'o' => {
old_bits |= new_bits & 0o7;
}
'g' => {
old_bits |= (new_bits << 3) & (0o7 << 3);
}
'u' => {
old_bits |= (new_bits << 6) & (0o7 << 6);
}
'a' => {
old_bits |= (new_bits | (new_bits << 3) | (new_bits << 6)) & 0o777;
}
_ => {
return Err(ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("invalid umask 'who' character: {}", ch.fg(next_color())),
));
}
}
}
umask(Mode::from_bits_truncate(old_bits));
} else {
return Err(ShErr::at(
ShErrKind::ParseErr,
span.clone(),
format!("invalid symbolic umask part: {}", part.fg(next_color())),
));
}
}
}
} else if !opts.is_empty() {
let u = (old_bits >> 6) & 0o7;
let g = (old_bits >> 3) & 0o7;
let o = old_bits & 0o7;
let mut u_str = String::from("u=");
let mut g_str = String::from("g=");
let mut o_str = String::from("o=");
let stuff = [(u, &mut u_str), (g, &mut g_str), (o, &mut o_str)];
for (bits, out) in stuff.into_iter() {
if bits & 4 == 0 {
out.push('r');
}
if bits & 2 == 0 {
out.push('w');
}
if bits & 1 == 0 {
out.push('x');
}
}
let msg = [u_str, g_str, o_str].join(",");
let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, msg.as_bytes())?;
write(stdout, b"\n")?;
} else {
let raw = format!("{:04o}\n", old_bits);
let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, raw.as_bytes())?;
}
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::get_ulimit_opts;
use crate::getopt::Opt;
use crate::state;
use crate::testutil::{TestGuard, test_input};
use nix::sys::resource::{Resource, getrlimit};
use nix::sys::stat::{Mode, umask};
// ===================== Pure: option parsing =====================
#[test]
fn parse_fds() {
let opts = get_ulimit_opts(&[Opt::ShortWithArg('n', "1024".into())]).unwrap();
assert_eq!(opts.fds, Some(1024));
}
#[test]
fn parse_procs() {
let opts = get_ulimit_opts(&[Opt::ShortWithArg('u', "512".into())]).unwrap();
assert_eq!(opts.procs, Some(512));
}
#[test]
fn parse_stack() {
let opts = get_ulimit_opts(&[Opt::ShortWithArg('s', "8192".into())]).unwrap();
assert_eq!(opts.stack, Some(8192));
}
#[test]
fn parse_core() {
let opts = get_ulimit_opts(&[Opt::ShortWithArg('c', "0".into())]).unwrap();
assert_eq!(opts.core, Some(0));
}
#[test]
fn parse_vmem() {
let opts = get_ulimit_opts(&[Opt::ShortWithArg('v', "100000".into())]).unwrap();
assert_eq!(opts.vmem, Some(100000));
}
#[test]
fn parse_multiple() {
let opts = get_ulimit_opts(&[
Opt::ShortWithArg('n', "256".into()),
Opt::ShortWithArg('c', "0".into()),
])
.unwrap();
assert_eq!(opts.fds, Some(256));
assert_eq!(opts.core, Some(0));
assert!(opts.procs.is_none());
}
#[test]
fn parse_non_numeric_fails() {
let result = get_ulimit_opts(&[Opt::ShortWithArg('n', "abc".into())]);
assert!(result.is_err());
}
#[test]
fn parse_invalid_option() {
let result = get_ulimit_opts(&[Opt::Short('z')]);
assert!(result.is_err());
}
// ===================== Integration =====================
#[test]
fn ulimit_set_core_zero() {
let _g = TestGuard::new();
// Setting core dump size to 0 is always safe
test_input("ulimit -c 0").unwrap();
let (soft, _) = getrlimit(Resource::RLIMIT_CORE).unwrap();
assert_eq!(soft, 0);
}
#[test]
fn ulimit_invalid_flag() {
let _g = TestGuard::new();
let result = test_input("ulimit -z 100");
assert!(result.is_err());
}
#[test]
fn ulimit_non_numeric_value() {
let _g = TestGuard::new();
let result = test_input("ulimit -n abc");
assert!(result.is_err());
}
#[test]
fn ulimit_status_zero() {
let _g = TestGuard::new();
test_input("ulimit -c 0").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== umask =====================
fn with_umask(mask: u32, f: impl FnOnce()) {
let saved = umask(Mode::from_bits_truncate(mask));
f();
umask(saved);
}
#[test]
fn umask_display_octal() {
let g = TestGuard::new();
with_umask(0o022, || {
test_input("umask").unwrap();
});
assert_eq!(g.read_output(), "0022\n");
}
#[test]
fn umask_display_symbolic() {
let g = TestGuard::new();
with_umask(0o022, || {
test_input("umask -S").unwrap();
});
assert_eq!(g.read_output(), "u=rwx,g=rx,o=rx\n");
}
#[test]
fn umask_display_symbolic_all_denied() {
let g = TestGuard::new();
with_umask(0o777, || {
test_input("umask -S").unwrap();
});
assert_eq!(g.read_output(), "u=,g=,o=\n");
}
#[test]
fn umask_display_symbolic_none_denied() {
let g = TestGuard::new();
with_umask(0o000, || {
test_input("umask -S").unwrap();
});
assert_eq!(g.read_output(), "u=rwx,g=rwx,o=rwx\n");
}
#[test]
fn umask_set_octal() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o022));
test_input("umask 077").unwrap();
let cur = umask(saved);
assert_eq!(cur.bits(), 0o077);
}
#[test]
fn umask_set_symbolic_equals() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o000));
test_input("umask u=rwx,g=rx,o=rx").unwrap();
let cur = umask(saved);
assert_eq!(cur.bits(), 0o022);
}
#[test]
fn umask_set_symbolic_plus() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o077));
test_input("umask g+r").unwrap();
let cur = umask(saved);
// 0o077 with g+r (clear read bit in group) → 0o037
assert_eq!(cur.bits(), 0o037);
}
#[test]
fn umask_set_symbolic_minus() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o022));
test_input("umask o-r").unwrap();
let cur = umask(saved);
// 0o022 with o-r (set read bit in other) → 0o026
assert_eq!(cur.bits(), 0o026);
}
#[test]
fn umask_set_symbolic_all() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o000));
test_input("umask a=rx").unwrap();
let cur = umask(saved);
// a=rx → deny w for all → 0o222
assert_eq!(cur.bits(), 0o222);
}
#[test]
fn umask_set_symbolic_plus_all() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o777));
test_input("umask a+rwx").unwrap();
let cur = umask(saved);
assert_eq!(cur.bits(), 0o000);
}
#[test]
fn umask_set_symbolic_minus_all() {
let _g = TestGuard::new();
let saved = umask(Mode::from_bits_truncate(0o000));
test_input("umask a-rwx").unwrap();
let cur = umask(saved);
assert_eq!(cur.bits(), 0o777);
}
#[test]
fn umask_invalid_octal() {
let _g = TestGuard::new();
let result = test_input("umask 999");
assert!(result.is_err());
}
#[test]
fn umask_too_many_args() {
let _g = TestGuard::new();
let result = test_input("umask 022 077");
assert!(result.is_err());
}
#[test]
fn umask_invalid_who() {
let _g = TestGuard::new();
let result = test_input("umask z=rwx");
assert!(result.is_err());
}
#[test]
fn umask_status_zero() {
let _g = TestGuard::new();
with_umask(0o022, || {
test_input("umask").unwrap();
});
assert_eq!(state::get_status(), 0);
}
}

263
src/builtin/seek.rs Normal file
View File

@@ -0,0 +1,263 @@
use nix::{
libc::STDOUT_FILENO,
unistd::{Whence, lseek, write},
};
use crate::{
getopt::{Opt, OptSpec, get_opts_from_tokens},
libsh::error::{ShErr, ShErrKind, ShResult},
parse::{NdRule, Node, execute::prepare_argv},
procio::borrow_fd,
state,
};
pub const LSEEK_OPTS: [OptSpec; 2] = [
OptSpec {
opt: Opt::Short('c'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('e'),
takes_arg: false,
},
];
pub struct LseekOpts {
cursor_rel: bool,
end_rel: bool,
}
pub fn seek(node: Node) -> ShResult<()> {
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let (argv, opts) = get_opts_from_tokens(argv, &LSEEK_OPTS)?;
let lseek_opts = get_lseek_opts(opts)?;
let mut argv = prepare_argv(argv)?.into_iter();
argv.next(); // drop 'seek'
let Some(fd) = argv.next() else {
return Err(ShErr::simple(
ShErrKind::ExecFail,
"lseek: Missing required argument 'fd'",
));
};
let Ok(fd) = fd.0.parse::<u32>() else {
return Err(
ShErr::at(ShErrKind::ExecFail, fd.1, "Invalid file descriptor")
.with_note("file descriptors are integers"),
);
};
let Some(offset) = argv.next() else {
return Err(ShErr::simple(
ShErrKind::ExecFail,
"lseek: Missing required argument 'offset'",
));
};
let Ok(offset) = offset.0.parse::<i64>() else {
return Err(
ShErr::at(ShErrKind::ExecFail, offset.1, "Invalid offset")
.with_note("offset can be a positive or negative integer"),
);
};
let whence = if lseek_opts.cursor_rel {
Whence::SeekCur
} else if lseek_opts.end_rel {
Whence::SeekEnd
} else {
Whence::SeekSet
};
match lseek(fd as i32, offset, whence) {
Ok(new_offset) => {
let stdout = borrow_fd(STDOUT_FILENO);
let buf = new_offset.to_string() + "\n";
write(stdout, buf.as_bytes())?;
}
Err(e) => {
state::set_status(1);
return Err(e.into());
}
}
state::set_status(0);
Ok(())
}
pub fn get_lseek_opts(opts: Vec<Opt>) -> ShResult<LseekOpts> {
let mut lseek_opts = LseekOpts {
cursor_rel: false,
end_rel: false,
};
for opt in opts {
match opt {
Opt::Short('c') => lseek_opts.cursor_rel = true,
Opt::Short('e') => lseek_opts.end_rel = true,
_ => {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!("lseek: Unexpected flag '{opt}'"),
));
}
}
}
Ok(lseek_opts)
}
#[cfg(test)]
mod tests {
use crate::testutil::{TestGuard, test_input};
use pretty_assertions::assert_eq;
#[test]
fn seek_set_beginning() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 0").unwrap();
let out = g.read_output();
assert_eq!(out, "0\n");
}
#[test]
fn seek_set_offset() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 6").unwrap();
let out = g.read_output();
assert_eq!(out, "6\n");
}
#[test]
fn seek_then_read() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 6").unwrap();
// Clear the seek output
g.read_output();
test_input("read line <&9").unwrap();
let val = crate::state::read_vars(|v| v.get_var("line"));
assert_eq!(val, "world");
}
#[test]
fn seek_cur_relative() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "abcdefghij\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 3").unwrap();
test_input("seek -c 9 4").unwrap();
let out = g.read_output();
assert_eq!(out, "3\n7\n");
}
#[test]
fn seek_end() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello\n").unwrap(); // 6 bytes
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek -e 9 0").unwrap();
let out = g.read_output();
assert_eq!(out, "6\n");
}
#[test]
fn seek_end_negative() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello\n").unwrap(); // 6 bytes
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek -e 9 -2").unwrap();
let out = g.read_output();
assert_eq!(out, "4\n");
}
#[test]
fn seek_write_overwrite() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "hello world\n").unwrap();
let _g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
test_input("seek 9 6").unwrap();
test_input("echo -n 'WORLD' >&9").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "hello WORLD\n");
}
#[test]
fn seek_rewind_full_read() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("seek.txt");
std::fs::write(&path, "abc\n").unwrap();
let g = TestGuard::new();
test_input(format!("exec 9<> {}", path.display())).unwrap();
// Read moves cursor to EOF
test_input("read line <&9").unwrap();
// Rewind
test_input("seek 9 0").unwrap();
// Clear output from seek
g.read_output();
// Read again from beginning
test_input("read line <&9").unwrap();
let val = crate::state::read_vars(|v| v.get_var("line"));
assert_eq!(val, "abc");
}
#[test]
fn seek_bad_fd() {
let _g = TestGuard::new();
let result = test_input("seek 99 0");
assert!(result.is_err());
}
#[test]
fn seek_missing_args() {
let _g = TestGuard::new();
let result = test_input("seek");
assert!(result.is_err());
let result = test_input("seek 9");
assert!(result.is_err());
}
}

View File

@@ -35,3 +35,53 @@ pub fn shift(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state;
use crate::testutil::{TestGuard, test_input};
#[test]
fn shift_in_function() {
let guard = TestGuard::new();
test_input("f() { echo $1; shift 1; echo $1; }").unwrap();
test_input("f a b").unwrap();
let out = guard.read_output();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "a");
assert_eq!(lines[1], "b");
}
#[test]
fn shift_multiple() {
let guard = TestGuard::new();
test_input("f() { shift 2; echo $1; }").unwrap();
test_input("f a b c").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), "c");
}
#[test]
fn shift_all_params() {
let guard = TestGuard::new();
test_input("f() { shift 3; echo \"[$1]\"; }").unwrap();
test_input("f a b c").unwrap();
let out = guard.read_output();
assert_eq!(out.trim(), "[]");
}
#[test]
fn shift_non_numeric_fails() {
let _g = TestGuard::new();
let result = test_input("shift abc");
assert!(result.is_err());
}
#[test]
fn shift_status_zero() {
let _g = TestGuard::new();
test_input("f() { shift 1; }").unwrap();
test_input("f a b").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -32,7 +32,7 @@ pub fn shopt(node: Node) -> ShResult<()> {
}
for (arg, span) in argv {
let Some(mut output) = write_shopts(|s| s.query(&arg)).blame(span)? else {
let Some(mut output) = write_shopts(|s| s.query(&arg)).promote_err(span)? else {
continue;
};
@@ -45,3 +45,103 @@ pub fn shopt(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state::{self, read_shopts};
use crate::testutil::{TestGuard, test_input};
// ===================== Display =====================
#[test]
fn shopt_no_args_displays_all() {
let guard = TestGuard::new();
test_input("shopt").unwrap();
let out = guard.read_output();
assert!(out.contains("dotglob"));
assert!(out.contains("autocd"));
assert!(out.contains("max_hist"));
assert!(out.contains("comp_limit"));
}
#[test]
fn shopt_query_category() {
let guard = TestGuard::new();
test_input("shopt core").unwrap();
let out = guard.read_output();
assert!(out.contains("dotglob"));
assert!(out.contains("autocd"));
// Should not contain prompt opts
assert!(!out.contains("comp_limit"));
}
#[test]
fn shopt_query_single_opt() {
let guard = TestGuard::new();
test_input("shopt core.dotglob").unwrap();
let out = guard.read_output();
assert!(out.contains("false"));
}
// ===================== Set =====================
#[test]
fn shopt_set_bool() {
let _g = TestGuard::new();
test_input("shopt core.dotglob=true").unwrap();
assert!(read_shopts(|o| o.core.dotglob));
}
#[test]
fn shopt_set_int() {
let _g = TestGuard::new();
test_input("shopt core.max_hist=500").unwrap();
assert_eq!(read_shopts(|o| o.core.max_hist), 500);
}
#[test]
fn shopt_set_string() {
let _g = TestGuard::new();
test_input("shopt prompt.leader=space").unwrap();
assert_eq!(read_shopts(|o| o.prompt.leader.clone()), "space");
}
#[test]
fn shopt_set_completion_ignore_case() {
let _g = TestGuard::new();
test_input("shopt prompt.completion_ignore_case=true").unwrap();
assert!(read_shopts(|o| o.prompt.completion_ignore_case));
}
// ===================== Error cases =====================
#[test]
fn shopt_invalid_category() {
let _g = TestGuard::new();
let result = test_input("shopt bogus.dotglob");
assert!(result.is_err());
}
#[test]
fn shopt_invalid_option() {
let _g = TestGuard::new();
let result = test_input("shopt core.nonexistent");
assert!(result.is_err());
}
#[test]
fn shopt_invalid_value() {
let _g = TestGuard::new();
let result = test_input("shopt core.dotglob=notabool");
assert!(result.is_err());
}
// ===================== Status =====================
#[test]
fn shopt_status_zero() {
let _g = TestGuard::new();
test_input("shopt core.autocd=true").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -41,3 +41,131 @@ pub fn source(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
pub mod tests {
use std::io::Write;
use crate::state::{self, read_logic, read_vars};
use crate::testutil::{TestGuard, test_input};
use tempfile::{NamedTempFile, TempDir};
#[test]
fn source_simple() {
let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"some_var=some_val").unwrap();
test_input(format!("source {path}")).unwrap();
let var = read_vars(|v| v.get_var("some_var"));
assert_eq!(var, "some_val".to_string());
}
#[test]
fn source_multiple_commands() {
let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"x=1\ny=2\nz=3").unwrap();
test_input(format!("source {path}")).unwrap();
assert_eq!(read_vars(|v| v.get_var("x")), "1");
assert_eq!(read_vars(|v| v.get_var("y")), "2");
assert_eq!(read_vars(|v| v.get_var("z")), "3");
}
#[test]
fn source_defines_function() {
let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"greet() { echo hi; }").unwrap();
test_input(format!("source {path}")).unwrap();
let func = read_logic(|l| l.get_func("greet"));
assert!(func.is_some());
}
#[test]
fn source_defines_alias() {
let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"alias ll='ls -la'").unwrap();
test_input(format!("source {path}")).unwrap();
let alias = read_logic(|l| l.get_alias("ll"));
assert!(alias.is_some());
}
#[test]
fn source_output_captured() {
let guard = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"echo sourced").unwrap();
test_input(format!("source {path}")).unwrap();
let out = guard.read_output();
assert!(out.contains("sourced"));
}
#[test]
fn source_multiple_files() {
let _g = TestGuard::new();
let mut file1 = NamedTempFile::new().unwrap();
let mut file2 = NamedTempFile::new().unwrap();
let path1 = file1.path().display().to_string();
let path2 = file2.path().display().to_string();
file1.write_all(b"a=from_file1").unwrap();
file2.write_all(b"b=from_file2").unwrap();
test_input(format!("source {path1} {path2}")).unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "from_file1");
assert_eq!(read_vars(|v| v.get_var("b")), "from_file2");
}
// ===================== Dot syntax =====================
#[test]
fn source_dot_syntax() {
let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"dot_var=dot_val").unwrap();
test_input(format!(". {path}")).unwrap();
assert_eq!(read_vars(|v| v.get_var("dot_var")), "dot_val");
}
// ===================== Error cases =====================
#[test]
fn source_nonexistent_file() {
let _g = TestGuard::new();
let result = test_input("source /tmp/__no_such_file_xyz__");
assert!(result.is_err());
}
#[test]
fn source_directory_fails() {
let _g = TestGuard::new();
let dir = TempDir::new().unwrap();
let result = test_input(format!("source {}", dir.path().display()));
assert!(result.is_err());
}
// ===================== Status =====================
#[test]
fn source_status_zero() {
let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string();
file.write_all(b"true").unwrap();
test_input(format!("source {path}")).unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -94,7 +94,10 @@ impl FromStr for TestOp {
"-ge" => Ok(Self::IntGe),
"-le" => Ok(Self::IntLe),
_ if TEST_UNARY_OPS.contains(&s) => Ok(Self::Unary(s.parse::<UnaryOp>()?)),
_ => Err(ShErr::simple(ShErrKind::SyntaxErr, "Invalid test operator")),
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("Invalid test operator '{}'", s),
)),
}
}
}
@@ -121,6 +124,7 @@ pub fn double_bracket_test(node: Node) -> ShResult<bool> {
};
let mut last_result = false;
let mut conjunct_op: Option<ConjunctOp>;
log::trace!("test cases: {:#?}", cases);
for case in cases {
let result = match case {
@@ -290,21 +294,333 @@ pub fn double_bracket_test(node: Node) -> ShResult<bool> {
}
};
last_result = result;
if let Some(op) = conjunct_op {
match op {
ConjunctOp::And if !last_result => {
last_result = result;
break;
}
ConjunctOp::Or if last_result => {
last_result = result;
break;
}
ConjunctOp::And if !last_result => break,
ConjunctOp::Or if last_result => break,
_ => {}
}
} else {
last_result = result;
}
}
Ok(last_result)
}
#[cfg(test)]
mod tests {
use crate::state;
use crate::testutil::{TestGuard, test_input};
use std::fs;
use tempfile::{NamedTempFile, TempDir};
// ===================== Unary: file tests =====================
#[test]
fn test_exists_true() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
test_input(format!("[[ -e {} ]]", file.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_exists_false() {
let _g = TestGuard::new();
test_input("[[ -e /tmp/__no_such_file_test_rs__ ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_is_directory() {
let _g = TestGuard::new();
let dir = TempDir::new().unwrap();
test_input(format!("[[ -d {} ]]", dir.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_is_directory_false() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
test_input(format!("[[ -d {} ]]", file.path().display())).unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_is_file() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
test_input(format!("[[ -f {} ]]", file.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_is_file_false() {
let _g = TestGuard::new();
let dir = TempDir::new().unwrap();
test_input(format!("[[ -f {} ]]", dir.path().display())).unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_readable() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
test_input(format!("[[ -r {} ]]", file.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_writable() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
test_input(format!("[[ -w {} ]]", file.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_non_empty_file() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
fs::write(file.path(), "content").unwrap();
test_input(format!("[[ -s {} ]]", file.path().display())).unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_empty_file() {
let _g = TestGuard::new();
let file = NamedTempFile::new().unwrap();
test_input(format!("[[ -s {} ]]", file.path().display())).unwrap();
assert_ne!(state::get_status(), 0);
}
// ===================== Unary: string tests =====================
#[test]
fn test_non_null_true() {
let _g = TestGuard::new();
test_input("[[ -n hello ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_non_null_empty() {
let _g = TestGuard::new();
test_input("[[ -n '' ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_null_true() {
let _g = TestGuard::new();
test_input("[[ -z '' ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_null_false() {
let _g = TestGuard::new();
test_input("[[ -z hello ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
// ===================== Binary: string comparison =====================
#[test]
fn test_string_eq() {
let _g = TestGuard::new();
test_input("[[ hello == hello ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_string_eq_false() {
let _g = TestGuard::new();
test_input("[[ hello == world ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_string_neq() {
let _g = TestGuard::new();
test_input("[[ hello != world ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_string_neq_false() {
let _g = TestGuard::new();
test_input("[[ hello != hello ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_string_glob_match() {
let _g = TestGuard::new();
test_input("[[ hello == hel* ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_string_glob_no_match() {
let _g = TestGuard::new();
test_input("[[ hello == wor* ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
// ===================== Binary: integer comparison =====================
#[test]
fn test_int_eq() {
let _g = TestGuard::new();
test_input("[[ 42 -eq 42 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_eq_false() {
let _g = TestGuard::new();
test_input("[[ 42 -eq 43 ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_int_ne() {
let _g = TestGuard::new();
test_input("[[ 1 -ne 2 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_gt() {
let _g = TestGuard::new();
test_input("[[ 10 -gt 5 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_gt_false() {
let _g = TestGuard::new();
test_input("[[ 5 -gt 10 ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_int_lt() {
let _g = TestGuard::new();
test_input("[[ 5 -lt 10 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_ge() {
let _g = TestGuard::new();
test_input("[[ 10 -ge 10 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_le() {
let _g = TestGuard::new();
test_input("[[ 5 -le 5 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_negative() {
let _g = TestGuard::new();
test_input("[[ -5 -lt 0 ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_int_non_integer_errors() {
let _g = TestGuard::new();
let result = test_input("[[ abc -eq 1 ]]");
assert!(result.is_err());
}
// ===================== Binary: regex match =====================
#[test]
fn test_regex_match() {
let _g = TestGuard::new();
test_input("[[ hello123 =~ ^hello[0-9]+$ ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_regex_no_match() {
let _g = TestGuard::new();
test_input("[[ goodbye =~ ^hello ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
// ===================== Conjuncts =====================
#[test]
fn test_and_both_true() {
let _g = TestGuard::new();
test_input("[[ -n hello && -n world ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_and_first_false() {
let _g = TestGuard::new();
test_input("[[ -z hello && -n world ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
#[test]
fn test_or_first_true() {
let _g = TestGuard::new();
test_input("[[ -n hello || -z hello ]]").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn test_or_both_false() {
let _g = TestGuard::new();
test_input("[[ -z hello || -z world ]]").unwrap();
assert_ne!(state::get_status(), 0);
}
// ===================== Pure: operator parsing =====================
#[test]
fn parse_unary_ops() {
use super::UnaryOp;
use std::str::FromStr;
for op in [
"-e", "-d", "-f", "-h", "-L", "-r", "-w", "-x", "-s", "-p", "-S", "-b", "-c", "-k", "-O",
"-G", "-N", "-u", "-g", "-t", "-n", "-z",
] {
assert!(UnaryOp::from_str(op).is_ok(), "failed to parse {op}");
}
}
#[test]
fn parse_invalid_unary_op() {
use super::UnaryOp;
use std::str::FromStr;
assert!(UnaryOp::from_str("-Q").is_err());
}
#[test]
fn parse_binary_ops() {
use super::TestOp;
use std::str::FromStr;
for op in ["==", "!=", "=~", "-eq", "-ne", "-gt", "-lt", "-ge", "-le"] {
assert!(TestOp::from_str(op).is_ok(), "failed to parse {op}");
}
}
#[test]
fn parse_invalid_binary_op() {
use super::TestOp;
use std::str::FromStr;
assert!(TestOp::from_str("~=").is_err());
}
}

View File

@@ -167,3 +167,148 @@ pub fn trap(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::TrapTarget;
use crate::state::{self, read_logic};
use crate::testutil::{TestGuard, test_input};
use nix::sys::signal::Signal;
use std::str::FromStr;
// ===================== Pure: TrapTarget parsing =====================
#[test]
fn parse_exit() {
assert_eq!(TrapTarget::from_str("EXIT").unwrap(), TrapTarget::Exit);
}
#[test]
fn parse_err() {
assert_eq!(TrapTarget::from_str("ERR").unwrap(), TrapTarget::Error);
}
#[test]
fn parse_signal_int() {
assert_eq!(
TrapTarget::from_str("INT").unwrap(),
TrapTarget::Signal(Signal::SIGINT)
);
}
#[test]
fn parse_signal_term() {
assert_eq!(
TrapTarget::from_str("TERM").unwrap(),
TrapTarget::Signal(Signal::SIGTERM)
);
}
#[test]
fn parse_signal_usr1() {
assert_eq!(
TrapTarget::from_str("USR1").unwrap(),
TrapTarget::Signal(Signal::SIGUSR1)
);
}
#[test]
fn parse_invalid() {
assert!(TrapTarget::from_str("BOGUS").is_err());
}
// ===================== Pure: Display round-trip =====================
#[test]
fn display_exit() {
assert_eq!(TrapTarget::Exit.to_string(), "EXIT");
}
#[test]
fn display_err() {
assert_eq!(TrapTarget::Error.to_string(), "ERR");
}
#[test]
fn display_signal_roundtrip() {
for name in &[
"INT", "QUIT", "TERM", "USR1", "USR2", "ALRM", "CHLD", "WINCH",
] {
let target = TrapTarget::from_str(name).unwrap();
assert_eq!(target.to_string(), *name);
}
}
// ===================== Integration: registration =====================
#[test]
fn trap_registers_exit() {
let _g = TestGuard::new();
test_input("trap 'echo bye' EXIT").unwrap();
let cmd = read_logic(|l| l.get_trap(TrapTarget::Exit));
assert_eq!(cmd.unwrap(), "echo bye");
}
#[test]
fn trap_registers_signal() {
let _g = TestGuard::new();
test_input("trap 'echo caught' INT").unwrap();
let cmd = read_logic(|l| l.get_trap(TrapTarget::Signal(Signal::SIGINT)));
assert_eq!(cmd.unwrap(), "echo caught");
}
#[test]
fn trap_multiple_signals() {
let _g = TestGuard::new();
test_input("trap 'handle' INT TERM").unwrap();
let int = read_logic(|l| l.get_trap(TrapTarget::Signal(Signal::SIGINT)));
let term = read_logic(|l| l.get_trap(TrapTarget::Signal(Signal::SIGTERM)));
assert_eq!(int.unwrap(), "handle");
assert_eq!(term.unwrap(), "handle");
}
#[test]
fn trap_remove() {
let _g = TestGuard::new();
test_input("trap 'echo hi' EXIT").unwrap();
assert!(read_logic(|l| l.get_trap(TrapTarget::Exit)).is_some());
test_input("trap - EXIT").unwrap();
assert!(read_logic(|l| l.get_trap(TrapTarget::Exit)).is_none());
}
#[test]
fn trap_display() {
let guard = TestGuard::new();
test_input("trap 'echo bye' EXIT").unwrap();
test_input("trap").unwrap();
let out = guard.read_output();
assert!(out.contains("echo bye"));
assert!(out.contains("EXIT"));
}
// ===================== Error cases =====================
#[test]
fn trap_single_arg_usage() {
let _g = TestGuard::new();
// Single arg prints usage and sets status 1
test_input("trap 'echo hi'").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn trap_invalid_signal() {
let _g = TestGuard::new();
let result = test_input("trap 'echo hi' BOGUS");
assert!(result.is_err());
}
// ===================== Status =====================
#[test]
fn trap_status_zero() {
let _g = TestGuard::new();
test_input("trap 'echo bye' EXIT").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -196,3 +196,231 @@ pub fn local(node: Node) -> ShResult<()> {
state::set_status(0);
Ok(())
}
#[cfg(test)]
mod tests {
use crate::state::{self, VarFlags, read_vars};
use crate::testutil::{TestGuard, test_input};
// ===================== readonly =====================
#[test]
fn readonly_sets_flag() {
let _g = TestGuard::new();
test_input("readonly myvar").unwrap();
let flags = read_vars(|v| v.get_var_flags("myvar"));
assert!(flags.unwrap().contains(VarFlags::READONLY));
}
#[test]
fn readonly_with_value() {
let _g = TestGuard::new();
test_input("readonly myvar=hello").unwrap();
assert_eq!(read_vars(|v| v.get_var("myvar")), "hello");
let flags = read_vars(|v| v.get_var_flags("myvar"));
assert!(flags.unwrap().contains(VarFlags::READONLY));
}
#[test]
fn readonly_prevents_reassignment() {
let _g = TestGuard::new();
test_input("readonly myvar=hello").unwrap();
let result = test_input("myvar=world");
assert!(result.is_err());
assert_eq!(read_vars(|v| v.get_var("myvar")), "hello");
}
#[test]
fn readonly_display() {
let guard = TestGuard::new();
test_input("readonly rdo_test_var=abc").unwrap();
test_input("readonly").unwrap();
let out = guard.read_output();
assert!(out.contains("rdo_test_var=abc"));
}
#[test]
fn readonly_multiple() {
let _g = TestGuard::new();
test_input("readonly a=1 b=2").unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "1");
assert_eq!(read_vars(|v| v.get_var("b")), "2");
assert!(
read_vars(|v| v.get_var_flags("a"))
.unwrap()
.contains(VarFlags::READONLY)
);
assert!(
read_vars(|v| v.get_var_flags("b"))
.unwrap()
.contains(VarFlags::READONLY)
);
}
#[test]
fn readonly_status_zero() {
let _g = TestGuard::new();
test_input("readonly x=1").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== unset =====================
#[test]
fn unset_removes_variable() {
let _g = TestGuard::new();
test_input("myvar=hello").unwrap();
assert_eq!(read_vars(|v| v.get_var("myvar")), "hello");
test_input("unset myvar").unwrap();
assert_eq!(read_vars(|v| v.get_var("myvar")), "");
}
#[test]
fn unset_multiple() {
let _g = TestGuard::new();
test_input("a=1").unwrap();
test_input("b=2").unwrap();
test_input("unset a b").unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "");
assert_eq!(read_vars(|v| v.get_var("b")), "");
}
#[test]
fn unset_nonexistent_fails() {
let _g = TestGuard::new();
let result = test_input("unset __no_such_var__");
assert!(result.is_err());
}
#[test]
fn unset_no_args_fails() {
let _g = TestGuard::new();
let result = test_input("unset");
assert!(result.is_err());
}
#[test]
fn unset_readonly_fails() {
let _g = TestGuard::new();
test_input("readonly myvar=protected").unwrap();
let result = test_input("unset myvar");
assert!(result.is_err());
assert_eq!(read_vars(|v| v.get_var("myvar")), "protected");
}
#[test]
fn unset_status_zero() {
let _g = TestGuard::new();
test_input("x=1").unwrap();
test_input("unset x").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== export =====================
#[test]
fn export_with_value() {
let _g = TestGuard::new();
test_input("export SHED_TEST_VAR=hello_export").unwrap();
assert_eq!(read_vars(|v| v.get_var("SHED_TEST_VAR")), "hello_export");
assert_eq!(std::env::var("SHED_TEST_VAR").unwrap(), "hello_export");
unsafe { std::env::remove_var("SHED_TEST_VAR") };
}
#[test]
fn export_existing_variable() {
let _g = TestGuard::new();
test_input("SHED_TEST_VAR2=existing").unwrap();
test_input("export SHED_TEST_VAR2").unwrap();
assert_eq!(std::env::var("SHED_TEST_VAR2").unwrap(), "existing");
unsafe { std::env::remove_var("SHED_TEST_VAR2") };
}
#[test]
fn export_sets_flag() {
let _g = TestGuard::new();
test_input("export SHED_TEST_VAR3=flagged").unwrap();
let flags = read_vars(|v| v.get_var_flags("SHED_TEST_VAR3"));
assert!(flags.unwrap().contains(VarFlags::EXPORT));
unsafe { std::env::remove_var("SHED_TEST_VAR3") };
}
#[test]
fn export_display() {
let guard = TestGuard::new();
test_input("export").unwrap();
let out = guard.read_output();
assert!(out.contains("PATH=") || out.contains("HOME="));
}
#[test]
fn export_multiple() {
let _g = TestGuard::new();
test_input("export SHED_A=1 SHED_B=2").unwrap();
assert_eq!(std::env::var("SHED_A").unwrap(), "1");
assert_eq!(std::env::var("SHED_B").unwrap(), "2");
unsafe { std::env::remove_var("SHED_A") };
unsafe { std::env::remove_var("SHED_B") };
}
#[test]
fn export_status_zero() {
let _g = TestGuard::new();
test_input("export SHED_ST=1").unwrap();
assert_eq!(state::get_status(), 0);
unsafe { std::env::remove_var("SHED_ST") };
}
// ===================== local =====================
#[test]
fn local_sets_variable() {
let _g = TestGuard::new();
test_input("local mylocal=hello").unwrap();
assert_eq!(read_vars(|v| v.get_var("mylocal")), "hello");
}
#[test]
fn local_sets_flag() {
let _g = TestGuard::new();
test_input("local mylocal=val").unwrap();
let flags = read_vars(|v| v.get_var_flags("mylocal"));
assert!(flags.unwrap().contains(VarFlags::LOCAL));
}
#[test]
fn local_empty_value() {
let _g = TestGuard::new();
test_input("local mylocal").unwrap();
assert_eq!(read_vars(|v| v.get_var("mylocal")), "");
assert!(
read_vars(|v| v.get_var_flags("mylocal"))
.unwrap()
.contains(VarFlags::LOCAL)
);
}
#[test]
fn local_display() {
let guard = TestGuard::new();
test_input("lv_test=display_val").unwrap();
test_input("local").unwrap();
let out = guard.read_output();
assert!(out.contains("lv_test=display_val"));
}
#[test]
fn local_multiple() {
let _g = TestGuard::new();
test_input("local x=10 y=20").unwrap();
assert_eq!(read_vars(|v| v.get_var("x")), "10");
assert_eq!(read_vars(|v| v.get_var("y")), "20");
}
#[test]
fn local_status_zero() {
let _g = TestGuard::new();
test_input("local z=1").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -1,207 +0,0 @@
use std::os::unix::fs::OpenOptionsExt;
use crate::{
getopt::{Opt, OptSpec, get_opts_from_tokens},
libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt},
parse::{NdRule, Node, execute::prepare_argv},
prelude::*,
procio::borrow_fd,
};
bitflags! {
#[derive(Clone,Copy,Debug,PartialEq,Eq)]
struct ZoltFlags: u32 {
const DRY = 0b000001;
const CONFIRM = 0b000010;
const NO_PRESERVE_ROOT = 0b000100;
const RECURSIVE = 0b001000;
const FORCE = 0b010000;
const VERBOSE = 0b100000;
}
}
/// Annihilate a file
///
/// This command works similarly to 'rm', but behaves more destructively.
/// The file given as an argument is completely destroyed. The command works by
/// shredding all of the data contained in the file, before truncating the
/// length of the file to 0 to ensure that not even any metadata remains.
pub fn zoltraak(node: Node) -> ShResult<()> {
let NdRule::Command {
assignments: _,
argv,
} = node.class
else {
unreachable!()
};
let zolt_opts = [
OptSpec {
opt: Opt::Long("dry-run".into()),
takes_arg: false,
},
OptSpec {
opt: Opt::Long("confirm".into()),
takes_arg: false,
},
OptSpec {
opt: Opt::Long("no-preserve-root".into()),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('r'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('f'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('v'),
takes_arg: false,
},
];
let mut flags = ZoltFlags::empty();
let (argv, opts) = get_opts_from_tokens(argv, &zolt_opts)?;
for opt in opts {
match opt {
Opt::Long(flag) => match flag.as_str() {
"no-preserve-root" => flags |= ZoltFlags::NO_PRESERVE_ROOT,
"confirm" => flags |= ZoltFlags::CONFIRM,
"dry-run" => flags |= ZoltFlags::DRY,
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("zoltraak: unrecognized option '{flag}'"),
));
}
},
Opt::Short(flag) => match flag {
'r' => flags |= ZoltFlags::RECURSIVE,
'f' => flags |= ZoltFlags::FORCE,
'v' => flags |= ZoltFlags::VERBOSE,
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("zoltraak: unrecognized option '{flag}'"),
));
}
},
Opt::LongWithArg(flag, _) => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("zoltraak: unrecognized option '{flag}'"),
));
}
Opt::ShortWithArg(flag, _) => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("zoltraak: unrecognized option '{flag}'"),
));
}
}
}
let mut argv = prepare_argv(argv)?;
if !argv.is_empty() {
argv.remove(0);
}
for (arg, span) in argv {
if &arg == "/" && !flags.contains(ZoltFlags::NO_PRESERVE_ROOT) {
return Err(
ShErr::simple(
ShErrKind::ExecFail,
"zoltraak: Attempted to destroy root directory '/'",
)
.with_note("If you really want to do this, you can use the --no-preserve-root flag"),
);
}
annihilate(&arg, flags).blame(span)?
}
Ok(())
}
fn annihilate(path: &str, flags: ZoltFlags) -> ShResult<()> {
let path_buf = PathBuf::from(path);
let is_recursive = flags.contains(ZoltFlags::RECURSIVE);
let is_verbose = flags.contains(ZoltFlags::VERBOSE);
const BLOCK_SIZE: u64 = 4096;
if !path_buf.exists() {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!("zoltraak: File '{path}' not found"),
));
}
if path_buf.is_file() {
let mut file = OpenOptions::new()
.write(true)
.custom_flags(libc::O_DIRECT)
.open(path_buf)?;
let meta = file.metadata()?;
let file_size = meta.len();
let full_blocks = file_size / BLOCK_SIZE;
let byte_remainder = file_size % BLOCK_SIZE;
let full_buf = vec![0; BLOCK_SIZE as usize];
let remainder_buf = vec![0; byte_remainder as usize];
for _ in 0..full_blocks {
file.write_all(&full_buf)?;
}
if byte_remainder > 0 {
file.write_all(&remainder_buf)?;
}
file.set_len(0)?;
mem::drop(file);
fs::remove_file(path)?;
if is_verbose {
let stderr = borrow_fd(STDERR_FILENO);
write(stderr, format!("shredded file '{path}'\n").as_bytes())?;
}
} else if path_buf.is_dir() {
if is_recursive {
annihilate_recursive(path, flags)?; // scary
} else {
return Err(
ShErr::simple(
ShErrKind::ExecFail,
format!("zoltraak: '{path}' is a directory"),
)
.with_note("Use the '-r' flag to recursively shred directories"),
);
}
}
Ok(())
}
fn annihilate_recursive(dir: &str, flags: ZoltFlags) -> ShResult<()> {
let dir_path = PathBuf::from(dir);
let is_verbose = flags.contains(ZoltFlags::VERBOSE);
for dir_entry in fs::read_dir(&dir_path)? {
let entry = dir_entry?.path();
let file = entry.to_str().unwrap();
if entry.is_file() {
annihilate(file, flags)?;
} else if entry.is_dir() {
annihilate_recursive(file, flags)?;
}
}
fs::remove_dir(dir)?;
if is_verbose {
let stderr = borrow_fd(STDERR_FILENO);
write(stderr, format!("shredded directory '{dir}'\n").as_bytes())?;
}
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,13 @@
use std::sync::Arc;
use ariadne::Fmt;
use fmt::Display;
use crate::{libsh::error::ShResult, parse::lex::Tk, prelude::*};
use crate::{
libsh::error::{ShErr, ShErrKind, ShResult, next_color},
parse::lex::Tk,
prelude::*,
};
pub type OptSet = Arc<[Opt]>;
@@ -67,20 +72,37 @@ pub fn get_opts(words: Vec<String>) -> (Vec<String>, Vec<Opt>) {
(non_opts, opts)
}
pub fn get_opts_from_tokens_strict(
tokens: Vec<Tk>,
opt_specs: &[OptSpec],
) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
sort_tks(tokens, opt_specs, true)
}
pub fn get_opts_from_tokens(
tokens: Vec<Tk>,
opt_specs: &[OptSpec],
) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
sort_tks(tokens, opt_specs, false)
}
pub fn sort_tks(
tokens: Vec<Tk>,
opt_specs: &[OptSpec],
strict: bool,
) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
let mut tokens_iter = tokens
.into_iter()
.map(|t| t.expand())
.collect::<ShResult<Vec<_>>>()?
.into_iter();
.into_iter()
.peekable();
let mut opts = vec![];
let mut non_opts = vec![];
while let Some(token) = tokens_iter.next() {
if &token.to_string() == "--" {
non_opts.push(token);
non_opts.extend(tokens_iter);
break;
}
@@ -113,10 +135,275 @@ pub fn get_opts_from_tokens(
}
}
if !pushed {
non_opts.push(token.clone());
if strict {
return Err(ShErr::simple(
ShErrKind::ParseErr,
format!("Unknown option: {}", opt.to_string().fg(next_color())),
));
} else {
non_opts.push(token.clone());
}
}
}
}
}
Ok((non_opts, opts))
}
#[cfg(test)]
mod tests {
use crate::parse::lex::{LexFlags, LexStream};
use super::*;
#[test]
fn parse_short_single() {
let opts = Opt::parse("-a");
assert_eq!(opts, vec![Opt::Short('a')]);
}
#[test]
fn parse_short_combined() {
let opts = Opt::parse("-abc");
assert_eq!(
opts,
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
);
}
#[test]
fn parse_long() {
let opts = Opt::parse("--verbose");
assert_eq!(opts, vec![Opt::Long("verbose".into())]);
}
#[test]
fn parse_non_option() {
let opts = Opt::parse("hello");
assert!(opts.is_empty());
}
#[test]
fn get_opts_basic() {
let words = vec![
"file.txt".into(),
"-v".into(),
"--help".into(),
"arg".into(),
];
let (non_opts, opts) = get_opts(words);
assert_eq!(non_opts, vec!["file.txt", "arg"]);
assert_eq!(opts, vec![Opt::Short('v'), Opt::Long("help".into())]);
}
#[test]
fn get_opts_double_dash_stops_parsing() {
let words = vec!["-a".into(), "--".into(), "-b".into(), "--foo".into()];
let (non_opts, opts) = get_opts(words);
assert_eq!(opts, vec![Opt::Short('a')]);
assert_eq!(non_opts, vec!["-b", "--foo"]);
}
#[test]
fn get_opts_combined_short() {
let words = vec!["-abc".into(), "file".into()];
let (non_opts, opts) = get_opts(words);
assert_eq!(
opts,
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
);
assert_eq!(non_opts, vec!["file"]);
}
#[test]
fn get_opts_no_flags() {
let words = vec!["foo".into(), "bar".into()];
let (non_opts, opts) = get_opts(words);
assert!(opts.is_empty());
assert_eq!(non_opts, vec!["foo", "bar"]);
}
#[test]
fn get_opts_empty_input() {
let (non_opts, opts) = get_opts(vec![]);
assert!(opts.is_empty());
assert!(non_opts.is_empty());
}
#[test]
fn display_formatting() {
assert_eq!(Opt::Short('v').to_string(), "-v");
assert_eq!(Opt::Long("help".into()).to_string(), "--help");
assert_eq!(Opt::ShortWithArg('o', "file".into()).to_string(), "-o file");
assert_eq!(
Opt::LongWithArg("output".into(), "file".into()).to_string(),
"--output file"
);
}
fn lex(input: &str) -> Vec<Tk> {
LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
.collect::<ShResult<Vec<Tk>>>()
.unwrap()
}
#[test]
fn get_opts_from_tks() {
let tokens = lex("file.txt --help -v arg");
let opt_spec = vec![
OptSpec {
opt: Opt::Short('v'),
takes_arg: false,
},
OptSpec {
opt: Opt::Long("help".into()),
takes_arg: false,
},
];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
let mut opts = opts.into_iter();
assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into())));
assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into())));
let mut non_opts = non_opts.into_iter().map(|s| s.to_string());
assert!(non_opts.any(|s| s == "file.txt" || s == "arg"));
assert!(non_opts.any(|s| s == "file.txt" || s == "arg"));
}
#[test]
fn tks_short_with_arg() {
let tokens = lex("-o output.txt file.txt");
let opt_spec = vec![OptSpec {
opt: Opt::Short('o'),
takes_arg: true,
}];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::ShortWithArg('o', "output.txt".into())]);
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"file.txt".to_string()));
}
#[test]
fn tks_long_with_arg() {
let tokens = lex("--output result.txt input.txt");
let opt_spec = vec![OptSpec {
opt: Opt::Long("output".into()),
takes_arg: true,
}];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(
opts,
vec![Opt::LongWithArg("output".into(), "result.txt".into())]
);
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"input.txt".to_string()));
}
#[test]
fn tks_double_dash_stops() {
let tokens = lex("-v -- -a --foo");
let opt_spec = vec![
OptSpec {
opt: Opt::Short('v'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('a'),
takes_arg: false,
},
];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::Short('v')]);
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"-a".to_string()));
assert!(non_opts.contains(&"--foo".to_string()));
}
#[test]
fn tks_combined_short_with_spec() {
let tokens = lex("-abc");
let opt_spec = vec![
OptSpec {
opt: Opt::Short('a'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('b'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('c'),
takes_arg: false,
},
];
let (_non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(
opts,
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
);
}
#[test]
fn tks_unknown_opt_becomes_non_opt() {
let tokens = lex("-v -x file");
let opt_spec = vec![OptSpec {
opt: Opt::Short('v'),
takes_arg: false,
}];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::Short('v')]);
// -x is not in spec, so its token goes to non_opts
assert!(
non_opts
.into_iter()
.map(|s| s.to_string())
.any(|s| s == "-x" || s == "file")
);
}
#[test]
fn tks_mixed_short_and_long_with_args() {
let tokens = lex("-n 5 --output file.txt input");
let opt_spec = vec![
OptSpec {
opt: Opt::Short('n'),
takes_arg: true,
},
OptSpec {
opt: Opt::Long("output".into()),
takes_arg: true,
},
];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(
opts,
vec![
Opt::ShortWithArg('n', "5".into()),
Opt::LongWithArg("output".into(), "file.txt".into()),
]
);
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"input".to_string()));
}
}

View File

@@ -1,15 +1,19 @@
use std::collections::VecDeque;
use ariadne::Fmt;
use nix::unistd::getpid;
use scopeguard::defer;
use yansi::Color;
use crate::{
libsh::{
error::{ShErr, ShErrKind, ShResult},
sys::TTY_FILENO,
term::{Style, Styled},
},
prelude::*,
procio::{IoMode, borrow_fd},
signal::{disable_reaping, enable_reaping},
state::{self, ShellParam, set_status, write_jobs, write_vars},
state::{self, ShellParam, VarFlags, VarKind, set_status, write_jobs, write_vars},
};
pub const SIG_EXIT_OFFSET: i32 = 128;
@@ -149,7 +153,7 @@ pub struct RegisteredFd {
pub owner_pid: Pid,
}
#[derive(Default, Debug)]
#[derive(Clone, Default, Debug)]
pub struct JobTab {
fg: Option<Job>,
order: Vec<usize>,
@@ -595,6 +599,29 @@ impl Job {
.map(|chld| chld.stat())
.collect::<Vec<WtStat>>()
}
pub fn pipe_status(stats: &[WtStat]) -> Option<Vec<i32>> {
if stats.iter().any(|stat| {
matches!(
stat,
WtStat::StillAlive | WtStat::Continued(_) | WtStat::PtraceSyscall(_)
)
}) || stats.len() <= 1
{
return None;
}
Some(
stats
.iter()
.map(|stat| match stat {
WtStat::Exited(_, code) => *code,
WtStat::Signaled(_, signal, _) => SIG_EXIT_OFFSET + *signal as i32,
WtStat::Stopped(_, signal) => SIG_EXIT_OFFSET + *signal as i32,
WtStat::PtraceEvent(_, signal, _) => SIG_EXIT_OFFSET + *signal as i32,
WtStat::PtraceSyscall(_) | WtStat::Continued(_) | WtStat::StillAlive => unreachable!(),
})
.collect(),
)
}
pub fn get_pids(&self) -> Vec<Pid> {
self
.children
@@ -724,12 +751,12 @@ impl Job {
stat_line = format!("{}{} ", pid, stat_line);
stat_line = format!("{} {}", stat_line, cmd);
stat_line = match job_stat {
WtStat::Stopped(..) | WtStat::Signaled(..) => stat_line.styled(Style::Magenta),
WtStat::Stopped(..) | WtStat::Signaled(..) => stat_line.fg(Color::Magenta).to_string(),
WtStat::Exited(_, code) => match code {
0 => stat_line.styled(Style::Green),
_ => stat_line.styled(Style::Red),
0 => stat_line.fg(Color::Green).to_string(),
_ => stat_line.fg(Color::Red).to_string(),
},
_ => stat_line.styled(Style::Cyan),
_ => stat_line.fg(Color::Cyan).to_string(),
};
if i != 0 {
let padding = " ".repeat(id_box.len() - 1);
@@ -838,22 +865,35 @@ pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> {
enable_reaping();
}
let statuses = write_jobs(|j| j.new_fg(job))?;
for status in statuses {
code = code_from_status(&status).unwrap_or(0);
for status in &statuses {
code = code_from_status(status).unwrap_or(0);
match status {
WtStat::Stopped(_, _) => {
was_stopped = true;
write_jobs(|j| j.fg_to_bg(status))?;
write_jobs(|j| j.fg_to_bg(*status))?;
}
WtStat::Signaled(_, sig, _) => {
if sig == Signal::SIGTSTP {
if *sig == Signal::SIGINT {
// interrupt propagates to the shell
// necessary for interrupting stuff like
// while/for loops
kill(getpid(), Signal::SIGINT)?;
} else if *sig == Signal::SIGTSTP {
was_stopped = true;
write_jobs(|j| j.fg_to_bg(status))?;
write_jobs(|j| j.fg_to_bg(*status))?;
}
}
_ => { /* Do nothing */ }
}
}
if let Some(pipe_status) = Job::pipe_status(&statuses) {
let pipe_status = pipe_status
.into_iter()
.map(|s| s.to_string())
.collect::<VecDeque<String>>();
write_vars(|v| v.set_var("PIPESTATUS", VarKind::Arr(pipe_status), VarFlags::NONE))?;
}
// If job wasn't stopped (moved to bg), clear the fg slot
if !was_stopped {
write_jobs(|j| {

View File

@@ -1,13 +1,13 @@
use ariadne::Color;
use ariadne::{Color, Fmt};
use ariadne::{Report, ReportKind};
use rand::TryRng;
use std::cell::RefCell;
use std::collections::{HashMap, VecDeque};
use std::fmt::Display;
use yansi::Paint;
use crate::procio::RedirGuard;
use crate::{
libsh::term::{Style, Styled},
parse::lex::{Span, SpanSource},
prelude::*,
};
@@ -144,12 +144,13 @@ impl Note {
impl Display for Note {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let note = "note".styled(Style::Green);
let note = Fmt::fg("note", Color::Green);
let main = &self.main;
if self.depth == 0 {
writeln!(f, "{note}: {main}")?;
} else {
let bar_break = "-".styled(Style::Cyan | Style::Bold);
let bar_break = Fmt::fg("-", Color::Cyan);
let bar_break = bar_break.bold();
let indent = " ".repeat(self.depth);
writeln!(f, " {indent}{bar_break} {main}")?;
}
@@ -200,6 +201,7 @@ impl ShErr {
pub fn is_flow_control(&self) -> bool {
self.kind.is_flow_control()
}
/// Promotes a shell error from a simple error to an error that blames a span
pub fn promote(mut self, span: Span) -> Self {
if self.notes.is_empty() {
return self;
@@ -207,6 +209,8 @@ impl ShErr {
let first = self.notes[0].clone();
if self.notes.len() > 1 {
self.notes = self.notes[1..].to_vec();
} else {
self.notes = vec![];
}
self.labeled(span, first)
@@ -455,7 +459,7 @@ pub enum ShErrKind {
FuncReturn(i32),
LoopContinue(i32),
LoopBreak(i32),
ClearReadline,
Interrupt,
Null,
}
@@ -467,7 +471,7 @@ impl ShErrKind {
| Self::FuncReturn(_)
| Self::LoopContinue(_)
| Self::LoopBreak(_)
| Self::ClearReadline
| Self::Interrupt
)
}
}
@@ -492,7 +496,7 @@ impl Display for ShErrKind {
Self::LoopBreak(_) => "Syntax Error",
Self::ReadlineErr => "Readline Error",
Self::ExCommand => "Ex Command Error",
Self::ClearReadline => "",
Self::Interrupt => "",
Self::Null => "",
};
write!(f, "{output}")

View File

@@ -1,149 +0,0 @@
use std::fmt::Display;
use super::term::{Style, Styled};
#[derive(Clone, Copy, PartialEq, PartialOrd, Ord, Eq, Debug)]
#[repr(u8)]
pub enum ShedLogLevel {
NONE = 0,
ERROR = 1,
WARN = 2,
INFO = 3,
DEBUG = 4,
TRACE = 5,
}
impl Display for ShedLogLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use ShedLogLevel::*;
match self {
ERROR => write!(f, "{}", "ERROR".styled(Style::Red | Style::Bold)),
WARN => write!(f, "{}", "WARN".styled(Style::Yellow | Style::Bold)),
INFO => write!(f, "{}", "INFO".styled(Style::Green | Style::Bold)),
DEBUG => write!(f, "{}", "DEBUG".styled(Style::Magenta | Style::Bold)),
TRACE => write!(f, "{}", "TRACE".styled(Style::Blue | Style::Bold)),
NONE => write!(f, ""),
}
}
}
pub fn log_level() -> ShedLogLevel {
use ShedLogLevel::*;
let level = std::env::var("SHED_LOG_LEVEL").unwrap_or_default();
match level.to_lowercase().as_str() {
"error" => ERROR,
"warn" => WARN,
"info" => INFO,
"debug" => DEBUG,
"trace" => TRACE,
_ => NONE,
}
}
/// A structured logging macro designed for `shed`.
///
/// `flog!` was implemented because `rustyline` uses `env_logger`, which
/// clutters the debug output. This macro prints log messages in a structured
/// format, including the log level, filename, and line number.
///
/// # Usage
///
/// The macro supports three types of arguments:
///
/// ## 1. **Formatted Messages**
/// Similar to `println!` or `format!`, allows embedding values inside a
/// formatted string.
///
/// ```rust
/// flog!(ERROR, "foo is {}", foo);
/// ```
/// **Output:**
/// ```plaintext
/// [ERROR][file.rs:10] foo is <value of foo>
/// ```
///
/// ## 2. **Literals**
/// Directly prints each literal argument as a separate line.
///
/// ```rust
/// flog!(WARN, "foo", "bar");
/// ```
/// **Output:**
/// ```plaintext
/// [WARN][file.rs:10] foo
/// [WARN][file.rs:10] bar
/// ```
///
/// ## 3. **Expressions**
/// Logs the evaluated result of each given expression, displaying both the
/// expression and its value.
///
/// ```rust
/// flog!(INFO, 1.min(2));
/// ```
/// **Output:**
/// ```plaintext
/// [INFO][file.rs:10] 1
/// ```
///
/// # Considerations
/// - This macro uses `eprintln!()` internally, so its formatting rules must be
/// followed.
/// - **Literals and formatted messages** require arguments that implement
/// [`std::fmt::Display`].
/// - **Expressions** require arguments that implement [`std::fmt::Debug`].
#[macro_export]
macro_rules! flog {
($level:path, $fmt:literal, $($args:expr),+ $(,)?) => {{
use $crate::libsh::flog::log_level;
use $crate::libsh::term::Styled;
use $crate::libsh::term::Style;
if $level <= log_level() {
let file = file!().styled(Style::Cyan);
let line = line!().to_string().styled(Style::Cyan);
eprintln!(
"[{}][{}:{}] {}",
$level, file, line, format!($fmt, $($args),+)
);
}
}};
($level:path, $($val:expr),+ $(,)?) => {{
use $crate::libsh::flog::log_level;
use $crate::libsh::term::Styled;
use $crate::libsh::term::Style;
if $level <= log_level() {
let file = file!().styled(Style::Cyan);
let line = line!().to_string().styled(Style::Cyan);
$(
let val_name = stringify!($val);
eprintln!(
"[{}][{}:{}] {} = {:#?}",
$level, file, line, val_name, &$val
);
)+
}
}};
($level:path, $($lit:literal),+ $(,)?) => {{
use $crate::libsh::flog::log_level;
use $crate::libsh::term::Styled;
use $crate::libsh::term::Style;
if $level <= log_level() {
let file = file!().styled(Style::Cyan);
let line = line!().to_string().styled(Style::Cyan);
$(
eprintln!(
"[{}][{}:{}] {}",
$level, file, line, $lit
);
)+
}
}};
}

View File

@@ -3,7 +3,7 @@ use std::collections::HashSet;
use std::os::fd::{BorrowedFd, RawFd};
use nix::sys::termios::{self, LocalFlags, Termios, tcgetattr, tcsetattr};
use nix::unistd::isatty;
use nix::unistd::{isatty, write};
use scopeguard::guard;
thread_local! {
@@ -147,11 +147,10 @@ impl RawModeGuard {
let orig = ORIG_TERMIOS
.with(|cell| cell.borrow().clone())
.expect("with_cooked_mode called before raw_mode()");
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig)
.expect("Failed to restore cooked mode");
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig).ok();
let res = f();
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &current)
.expect("Failed to restore raw mode");
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &current).ok();
unsafe { write(BorrowedFd::borrow_raw(*TTY_FILENO), b"\x1b[?1l\x1b>").ok() };
res
}
}
@@ -159,11 +158,12 @@ impl RawModeGuard {
impl Drop for RawModeGuard {
fn drop(&mut self) {
unsafe {
let _ = termios::tcsetattr(
termios::tcsetattr(
BorrowedFd::borrow_raw(self.fd),
termios::SetArg::TCSANOW,
&self.orig,
);
)
.ok();
}
}
}

View File

@@ -1,5 +1,4 @@
pub mod error;
pub mod flog;
pub mod guards;
pub mod sys;
pub mod term;

View File

@@ -2,6 +2,15 @@ use std::sync::LazyLock;
use crate::prelude::*;
/// Minimum fd number for shell-internal file descriptors.
const MIN_INTERNAL_FD: RawFd = 10;
pub static TTY_FILENO: LazyLock<RawFd> = LazyLock::new(|| {
open("/dev/tty", OFlag::O_RDWR, Mode::empty()).expect("Failed to open /dev/tty")
let fd = open("/dev/tty", OFlag::O_RDWR, Mode::empty()).expect("Failed to open /dev/tty");
// Move the tty fd above the user-accessible range so that
// `exec 3>&-` and friends don't collide with shell internals.
let high =
fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD)).expect("Failed to dup /dev/tty high");
close(fd).ok();
high
});

View File

@@ -8,6 +8,33 @@ use crate::parse::{Node, Redir, RedirType};
use crate::prelude::*;
use crate::state::AutoCmd;
#[macro_export]
/// Defines a two-way mapping between an enum and its string representation, implementing both Display and FromStr.
macro_rules! two_way_display {
($name:ident, $($member:ident <=> $val:expr;)*) => {
impl Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
$(Self::$member => write!(f, $val),)*
}
}
}
impl FromStr for $name {
type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
$($val => Ok(Self::$member),)*
_ => Err(ShErr::simple(
ShErrKind::ParseErr,
format!("Invalid {} kind: {}",stringify!($name),s),
)),
}
}
}
};
}
pub trait VecDequeExt<T> {
fn to_vec(self) -> Vec<T>;
}

View File

@@ -17,9 +17,13 @@ pub mod shopt;
pub mod signal;
pub mod state;
#[cfg(test)]
pub mod testutil;
use std::os::fd::BorrowedFd;
use std::process::ExitCode;
use std::sync::atomic::Ordering;
use std::time::Duration;
use nix::errno::Errno;
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
@@ -30,20 +34,23 @@ use crate::builtin::trap::TrapTarget;
use crate::libsh::error::{self, ShErr, ShErrKind, ShResult};
use crate::libsh::sys::TTY_FILENO;
use crate::libsh::utils::AutoCmdVecUtils;
use crate::parse::execute::exec_input;
use crate::parse::execute::{exec_dash_c, exec_input};
use crate::prelude::*;
use crate::procio::borrow_fd;
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
use crate::readline::{Prompt, ReadlineEvent, ShedVi};
use crate::signal::{GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending};
use crate::state::{AutoCmdKind, read_logic, read_shopts, source_rc, write_jobs, write_meta};
use crate::signal::{
GOT_SIGUSR1, GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending,
};
use crate::state::{
AutoCmdKind, read_logic, read_shopts, source_env, source_login, source_rc, write_jobs,
write_meta, write_shopts,
};
use clap::Parser;
use state::{read_vars, write_vars};
use state::write_vars;
#[derive(Parser, Debug)]
struct ShedArgs {
script: Option<String>,
#[arg(short)]
command: Option<String>,
@@ -56,24 +63,13 @@ struct ShedArgs {
#[arg(short)]
interactive: bool,
#[arg(short)]
stdin: bool,
#[arg(long, short)]
login_shell: bool,
}
/// Force evaluation of lazily-initialized values early in shell startup.
///
/// In particular, this ensures that the variable table is initialized, which
/// populates environment variables from the system. If this initialization is
/// deferred too long, features like prompt expansion may fail due to missing
/// environment variables.
///
/// This function triggers initialization by calling `read_vars` with a no-op
/// closure, which forces access to the variable table and causes its `LazyLock`
/// constructor to run.
fn kickstart_lazy_evals() {
read_vars(|_| {});
}
/// We need to make sure that even if we panic, our child processes get sighup
fn setup_panic_handler() {
let default_panic_hook = std::panic::take_hook();
@@ -108,7 +104,6 @@ fn setup_panic_handler() {
fn main() -> ExitCode {
yansi::enable();
env_logger::init();
kickstart_lazy_evals();
setup_panic_handler();
let mut args = ShedArgs::parse();
@@ -127,14 +122,31 @@ fn main() -> ExitCode {
return ExitCode::SUCCESS;
}
if let Err(e) = if let Some(path) = args.script {
// Increment SHLVL, or set to 1 if not present or invalid.
// This var represents how many nested shell instances we're in
if let Ok(var) = env::var("SHLVL")
&& let Ok(lvl) = var.parse::<u32>()
{
unsafe { env::set_var("SHLVL", (lvl + 1).to_string()) };
} else {
unsafe { env::set_var("SHLVL", "1") };
}
if let Err(e) = source_env() {
e.print_error();
}
if let Err(e) = if let Some(cmd) = args.command {
exec_dash_c(cmd)
} else if args.stdin || !isatty(STDIN_FILENO).unwrap_or(false) {
read_commands(args.script_args)
} else if !args.script_args.is_empty() {
let path = args.script_args.remove(0);
run_script(path, args.script_args)
} else if let Some(cmd) = args.command {
exec_input(cmd, None, false, None)
} else {
let res = shed_interactive(args);
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
res
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
res
} {
e.print_error();
};
@@ -149,9 +161,41 @@ fn main() -> ExitCode {
on_exit_autocmds.exec();
write_jobs(|j| j.hang_up());
let code = QUIT_CODE.load(Ordering::SeqCst) as u8;
if code == 0 && isatty(STDIN_FILENO).unwrap_or_default() {
write(borrow_fd(STDERR_FILENO), b"\nexit\n").ok();
}
ExitCode::from(QUIT_CODE.load(Ordering::SeqCst) as u8)
}
fn read_commands(args: Vec<String>) -> ShResult<()> {
let mut input = vec![];
let mut read_buf = [0u8; 4096];
loop {
match read(STDIN_FILENO, &mut read_buf) {
Ok(0) => break,
Ok(n) => input.extend_from_slice(&read_buf[..n]),
Err(Errno::EINTR) => continue,
Err(e) => {
QUIT_CODE.store(1, Ordering::SeqCst);
return Err(ShErr::simple(
ShErrKind::CleanExit(1),
format!("error reading from stdin: {e}"),
));
}
}
}
let commands = String::from_utf8_lossy(&input).to_string();
for arg in args {
write_vars(|v| v.cur_scope_mut().bpush_arg(arg))
}
exec_input(commands, None, false, None)
}
fn run_script<P: AsRef<Path>>(path: P, args: Vec<String>) -> ShResult<()> {
let path = path.as_ref();
let path_raw = path.to_string_lossy().to_string();
@@ -187,6 +231,12 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
let _raw_mode = raw_mode(); // sets raw mode, restores termios on drop
sig_setup(args.login_shell);
if args.login_shell
&& let Err(e) = source_login()
{
e.print_error();
}
if let Err(e) = source_rc() {
e.print_error();
}
@@ -204,7 +254,9 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
}
};
readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode
readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode
let mut screensaver_deadline: Option<Instant> = None;
// Main poll loop
loop {
@@ -218,14 +270,14 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
while signals_pending() {
if let Err(e) = check_signals() {
match e.kind() {
ShErrKind::ClearReadline => {
ShErrKind::Interrupt => {
// We got Ctrl+C - clear current input and redraw
readline.reset_active_widget(false)?;
}
ShErrKind::CleanExit(code) => {
QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(());
}
QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(());
}
_ => e.print_error(),
}
}
@@ -244,22 +296,71 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
readline.prompt_mut().refresh();
}
if GOT_SIGUSR1.swap(false, Ordering::SeqCst) {
log::info!("SIGUSR1 received: refreshing readline state");
readline.mark_dirty();
readline.prompt_mut().refresh();
}
readline.print_line(false)?;
// Poll for stdin input
// Poll for
// stdin input
let mut fds = [PollFd::new(
unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) },
PollFlags::POLLIN,
)];
let mut exec_if_timeout = None;
let timeout = if readline.pending_keymap.is_empty() {
PollTimeout::MAX
let screensaver_cmd = read_shopts(|o| o.prompt.screensaver_cmd.clone())
.trim()
.to_string();
let screensaver_idle_time = read_shopts(|o| o.prompt.screensaver_idle_time);
if screensaver_idle_time > 0 && !screensaver_cmd.is_empty() {
exec_if_timeout = Some(screensaver_cmd);
if screensaver_deadline.is_none() {
screensaver_deadline = Some(Instant::now() + Duration::from_secs(screensaver_idle_time as u64));
}
// We unfortunately cant just set the PollTimeout to use 'idle_time * 1000' as the timeout
// because u16 overflows after 65 seconds (65535 ms).
// So we set a one second timeout and check against the Instant in 'screensaver_deadline'
PollTimeout::from(1000u16)
} else {
screensaver_deadline = None;
PollTimeout::MAX
}
} else {
PollTimeout::from(1000u16)
};
match poll(&mut fds, timeout) {
Ok(_) => {}
Ok(0) => {
// We timed out.
if let Some(cmd) = exec_if_timeout
&& screensaver_deadline.is_some_and(|d| Instant::now() >= d) {
screensaver_deadline = None;
let prepared = ReadlineEvent::Line(cmd.clone());
let saved_hist_opt = read_shopts(|o| o.core.auto_hist);
let _guard = scopeguard::guard(saved_hist_opt, |opt| {
write_shopts(|o| o.core.auto_hist = opt);
});
write_shopts(|o| o.core.auto_hist = false); // don't save screensaver command to history
let pre_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnScreensaverExec));
pre_cmds.exec_with(&cmd);
let res = handle_readline_event(&mut readline, Ok(prepared))?;
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnScreensaverReturn));
post_cmds.exec_with(&cmd);
match res {
true => return Ok(()),
false => continue,
}
}
}
Err(Errno::EINTR) => {
// Interrupted by signal, loop back to handle it
continue;
@@ -268,6 +369,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
eprintln!("poll error: {e}");
break;
}
Ok(_) => {}
}
// Timeout — resolve pending keymap ambiguity
@@ -330,6 +432,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
}
Ok(n) => {
readline.feed_bytes(&buffer[..n]);
screensaver_deadline = None;
}
Err(Errno::EINTR) => {
// Interrupted, continue to handle signals
@@ -361,12 +464,18 @@ fn handle_readline_event(readline: &mut ShedVi, event: ShResult<ReadlineEvent>)
pre_exec.exec_with(&input);
// Time this command and temporarily restore cooked terminal mode while it runs.
let start = Instant::now();
write_meta(|m| m.start_timer());
if let Err(e) = RawModeGuard::with_cooked_mode(|| {
exec_input(input.clone(), None, true, Some("<stdin>".into()))
}) {
// CleanExit signals an intentional shell exit; any other error is printed.
match e.kind() {
ShErrKind::Interrupt => {
// We got Ctrl+C during command execution
// Just fall through here
}
ShErrKind::CleanExit(code) => {
QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(true);

View File

@@ -18,18 +18,20 @@ use crate::{
eval, exec,
flowctl::flowctl,
getopts::getopts,
help::help,
intro,
jobctl::{self, JobBehavior, continue_job, disown, jobs},
keymap, map,
pwd::pwd,
read::{self, read_builtin},
resource::{ulimit, umask_builtin},
seek::seek,
shift::shift,
shopt::shopt,
source::source,
test::double_bracket_test,
trap::{TrapTarget, trap},
varcmds::{export, local, readonly, unset},
zoltraak::zoltraak,
},
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
@@ -40,6 +42,7 @@ use crate::{
},
prelude::*,
procio::{IoMode, IoStack, PipeGenerator},
signal::{check_signals, signals_pending},
state::{
self, ShFunc, VarFlags, VarKind, read_logic, read_shopts, write_jobs, write_logic, write_vars,
},
@@ -132,6 +135,73 @@ impl ExecArgs {
}
}
/// Execute a `-c` command string, optimizing single simple commands to exec
/// directly without forking. This avoids process group issues where grandchild
/// processes (e.g. nvim spawning opencode) lose their controlling terminal.
pub fn exec_dash_c(input: String) -> ShResult<()> {
let log_tab = read_logic(|l| l.clone());
let expanded = expand_aliases(input, HashSet::new(), &log_tab);
let source_name = "<shed -c>".to_string();
let mut parser = ParsedSrc::new(Arc::new(expanded))
.with_lex_flags(super::lex::LexFlags::empty())
.with_name(source_name.clone());
if let Err(errors) = parser.parse_src() {
for error in errors {
error.print_error();
}
return Ok(());
}
let mut nodes = parser.extract_nodes();
// Single simple command: exec directly without forking.
// The parser wraps single commands as Conjunction → Pipeline → Command.
// Unwrap all layers to check, then set NO_FORK on the inner Command.
if nodes.len() == 1 {
let is_single_cmd = match &nodes[0].class {
NdRule::Command { .. } => true,
NdRule::Pipeline { cmds } => {
cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. })
}
NdRule::Conjunction { elements } => {
elements.len() == 1
&& match &elements[0].cmd.class {
NdRule::Pipeline { cmds } => {
cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. })
}
NdRule::Command { .. } => true,
_ => false,
}
}
_ => false,
};
if is_single_cmd {
// Unwrap to the inner Command node
let mut node = nodes.remove(0);
loop {
match node.class {
NdRule::Conjunction { mut elements } => {
node = *elements.remove(0).cmd;
}
NdRule::Pipeline { mut cmds } => {
node = cmds.remove(0);
}
NdRule::Command { .. } => break,
_ => break,
}
}
node.flags |= NdFlags::NO_FORK;
nodes.push(node);
}
}
let mut dispatcher = Dispatcher::new(nodes, false, source_name);
// exec_cmd expects a job on the stack (normally set up by exec_pipeline).
// For the NO_FORK exec-in-place path, create one so it doesn't panic.
dispatcher.job_stack.new_job();
dispatcher.begin_dispatch()
}
pub fn exec_input(
input: String,
io_stack: Option<IoStack>,
@@ -204,6 +274,13 @@ impl Dispatcher {
Ok(())
}
pub fn dispatch_node(&mut self, node: Node) -> ShResult<()> {
while signals_pending() {
// If we have received SIGINT,
// this will stop the execution here
// and propagate back to the functions in main.rs
check_signals()?;
}
match node.class {
NdRule::Conjunction { .. } => self.exec_conjunction(node)?,
NdRule::Pipeline { .. } => self.exec_pipeline(node)?,
@@ -213,6 +290,7 @@ impl Dispatcher {
NdRule::CaseNode { .. } => self.exec_case(node)?,
NdRule::BraceGrp { .. } => self.exec_brc_grp(node)?,
NdRule::FuncDef { .. } => self.exec_func_def(node)?,
NdRule::Negate { .. } => self.exec_negated(node)?,
NdRule::Command { .. } => self.dispatch_cmd(node)?,
NdRule::Test { .. } => self.exec_test(node)?,
_ => unreachable!(),
@@ -220,6 +298,15 @@ impl Dispatcher {
Ok(())
}
pub fn dispatch_cmd(&mut self, node: Node) -> ShResult<()> {
let (line, _) = node.get_span().clone().line_and_col();
write_vars(|v| {
v.set_var(
"LINENO",
VarKind::Str((line + 1).to_string()),
VarFlags::NONE,
)
})?;
let Some(cmd) = node.get_command() else {
return self.exec_cmd(node); // Argv is empty, probably an assignment
};
@@ -247,30 +334,35 @@ impl Dispatcher {
self.exec_cmd(node)
}
}
pub fn exec_negated(&mut self, node: Node) -> ShResult<()> {
let NdRule::Negate { cmd } = node.class else {
unreachable!()
};
self.dispatch_node(*cmd)?;
let status = state::get_status();
state::set_status(if status == 0 { 1 } else { 0 });
Ok(())
}
pub fn exec_conjunction(&mut self, conjunction: Node) -> ShResult<()> {
let NdRule::Conjunction { elements } = conjunction.class else {
unreachable!()
};
let mut elem_iter = elements.into_iter();
let mut skip = false;
while let Some(element) = elem_iter.next() {
let ConjunctNode { cmd, operator } = element;
self.dispatch_node(*cmd)?;
if !skip {
self.dispatch_node(*cmd)?;
}
let status = state::get_status();
match operator {
ConjunctOp::And => {
if status != 0 {
break;
}
}
ConjunctOp::Or => {
if status == 0 {
break;
}
}
skip = match operator {
ConjunctOp::And => status != 0,
ConjunctOp::Or => status == 0,
ConjunctOp::Null => break,
}
};
}
Ok(())
}
@@ -290,7 +382,11 @@ impl Dispatcher {
};
let body_span = body.get_span();
let body = body_span.as_str().to_string();
let name = name.span.as_str().strip_suffix("()").unwrap();
let name = name
.span
.as_str()
.strip_suffix("()")
.unwrap_or(name.span.as_str());
if KEYWORDS.contains(&name) {
return Err(ShErr::at(
@@ -313,7 +409,7 @@ impl Dispatcher {
Ok(())
}
fn exec_subsh(&mut self, subsh: Node) -> ShResult<()> {
let _blame = subsh.get_span().clone();
let _blame = subsh.get_span().clone();
let NdRule::Command { assignments, argv } = subsh.class else {
unreachable!()
};
@@ -374,7 +470,8 @@ impl Dispatcher {
blame.rename(func_name.clone());
let argv = prepare_argv(argv).try_blame(blame.clone())?;
let mut argv = prepare_argv(argv).try_blame(blame.clone())?;
argv.insert(0, (func_name.clone(), blame.clone()));
let result = if let Some(ref mut func_body) = read_logic(|l| l.get_func(&func_name)) {
let _guard = scope_guard(Some(argv));
func_body.body_mut().propagate_context(func_ctx);
@@ -412,7 +509,6 @@ impl Dispatcher {
let fork_builtins = brc_grp.flags.contains(NdFlags::FORK_BUILTINS);
self.io_stack.append_to_frame(brc_grp.redirs);
if self.interactive {}
let guard = self.io_stack.pop_frame().redirect()?;
let brc_grp_logic = |s: &mut Self| -> ShResult<()> {
for node in body {
@@ -541,6 +637,7 @@ impl Dispatcher {
}
}
} else {
state::set_status(0);
break;
}
}
@@ -677,9 +774,13 @@ impl Dispatcher {
}
}
if !matched && !else_block.is_empty() {
for node in else_block {
s.dispatch_node(node)?;
if !matched {
if !else_block.is_empty() {
for node in else_block {
s.dispatch_node(node)?;
}
} else {
state::set_status(0);
}
}
@@ -712,7 +813,18 @@ impl Dispatcher {
if cmds.len() == 1 {
self.fg_job = !is_bg && self.interactive;
let cmd = cmds.into_iter().next().unwrap();
self.dispatch_node(cmd)?;
if is_bg && !matches!(cmd.class, NdRule::Command { .. }) {
self.run_fork(
&cmd.get_command().map(|t| t.to_string()).unwrap_or_default(),
|s| {
if let Err(e) = s.dispatch_node(cmd) {
e.print_error();
}
},
)?;
} else {
self.dispatch_node(cmd)?;
}
// Give the pipeline terminal control as soon as the first child
// establishes the PGID, so later children (e.g. nvim) don't get
@@ -785,7 +897,10 @@ impl Dispatcher {
if fork_builtins {
log::trace!("Forking builtin: {}", cmd_raw);
let _guard = self.io_stack.pop_frame().redirect()?;
let guard = self.io_stack.pop_frame().redirect()?;
if cmd_raw.as_str() == "exec" {
guard.persist();
}
self.run_fork(&cmd_raw, |s| {
if let Err(e) = s.dispatch_builtin(cmd) {
e.print_error();
@@ -873,7 +988,7 @@ impl Dispatcher {
"export" => export(cmd),
"local" => local(cmd),
"pwd" => pwd(cmd),
"source" => source(cmd),
"source" | "." => source(cmd),
"shift" => shift(cmd),
"fg" => continue_job(cmd, JobBehavior::Foregound),
"bg" => continue_job(cmd, JobBehavior::Background),
@@ -885,7 +1000,6 @@ impl Dispatcher {
"break" => flowctl(cmd, ShErrKind::LoopBreak(0)),
"continue" => flowctl(cmd, ShErrKind::LoopContinue(0)),
"exit" => flowctl(cmd, ShErrKind::CleanExit(0)),
"zoltraak" => zoltraak(cmd),
"shopt" => shopt(cmd),
"read" => read_builtin(cmd),
"trap" => trap(cmd),
@@ -909,6 +1023,10 @@ impl Dispatcher {
"keymap" => keymap::keymap(cmd),
"read_key" => read::read_key(cmd),
"autocmd" => autocmd(cmd),
"ulimit" => ulimit(cmd),
"umask" => umask_builtin(cmd),
"seek" => seek(cmd),
"help" => help(cmd),
"true" | ":" => {
state::set_status(0);
Ok(())
@@ -946,7 +1064,6 @@ impl Dispatcher {
}
let no_fork = cmd.flags.contains(NdFlags::NO_FORK);
if argv.is_empty() {
return Ok(());
}
@@ -959,36 +1076,34 @@ impl Dispatcher {
let existing_pgid = job.pgid();
let fg_job = self.fg_job;
let interactive = self.interactive;
let child_logic = |pgid: Option<Pid>| -> ! {
// Put ourselves in the correct process group before exec.
// For the first child in a pipeline pgid is None, so we
// become our own group leader (setpgid(0,0)). For later
// children we join the leader's group.
let our_pgid = pgid.unwrap_or(Pid::from_raw(0));
let _ = setpgid(Pid::from_raw(0), our_pgid);
// For non-interactive exec-in-place (e.g. shed -c), skip process group
// and terminal setup — just transparently replace the current process.
if interactive || !no_fork {
// Put ourselves in the correct process group before exec.
// For the first child in a pipeline pgid is None, so we
// become our own group leader (setpgid(0,0)). For later
// children we join the leader's group.
let our_pgid = pgid.unwrap_or(Pid::from_raw(0));
let _ = setpgid(Pid::from_raw(0), our_pgid);
// For foreground jobs, take the terminal BEFORE resetting
// signals. SIGTTOU is still SIG_IGN (inherited from the shell),
// so tcsetpgrp won't stop us. This prevents a race
// where the child exec's and tries to read stdin before the
// parent has called tcsetpgrp — which would deliver SIGTTIN
// (now SIG_DFL after reset_signals) and stop the child.
if fg_job {
let tty_pgid = if our_pgid == Pid::from_raw(0) {
nix::unistd::getpid()
} else {
our_pgid
};
let _ = tcsetpgrp(
unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
tty_pgid,
);
if fg_job {
let tty_pgid = if our_pgid == Pid::from_raw(0) {
nix::unistd::getpid()
} else {
our_pgid
};
let _ = tcsetpgrp(
unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
tty_pgid,
);
}
}
// Reset signal dispositions before exec. SIG_IGN is preserved
// across execvpe, so the shell's ignored SIGTTIN/SIGTTOU would
// leak into child processes.
crate::signal::reset_signals();
if interactive || !no_fork {
crate::signal::reset_signals(fg_job);
}
let cmd = &exec_args.cmd.0;
let span = exec_args.cmd.1;
@@ -1049,6 +1164,7 @@ impl Dispatcher {
match unsafe { fork()? } {
ForkResult::Child => {
let _ = setpgid(Pid::from_raw(0), existing_pgid.unwrap_or(Pid::from_raw(0)));
self.interactive = false;
f(self);
exit(state::get_status())
}
@@ -1160,3 +1276,202 @@ pub fn is_func(tk: Option<Tk>) -> bool {
pub fn is_subsh(tk: Option<Tk>) -> bool {
tk.is_some_and(|tk| tk.flags.contains(TkFlags::IS_SUBSH))
}
#[cfg(test)]
mod tests {
use crate::state;
use crate::testutil::{TestGuard, test_input};
// ===================== while/until status =====================
#[test]
fn while_loop_status_zero_after_completion() {
let _g = TestGuard::new();
test_input("while false; do :; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn while_loop_status_zero_after_iterations() {
let _g = TestGuard::new();
test_input("X=0; while [[ $X -lt 3 ]]; do X=$((X+1)); done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn until_loop_status_zero_after_completion() {
let _g = TestGuard::new();
test_input("until true; do :; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn until_loop_status_zero_after_iterations() {
let _g = TestGuard::new();
test_input("X=3; until [[ $X -le 0 ]]; do X=$((X-1)); done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn while_break_preserves_status() {
let _g = TestGuard::new();
test_input("while true; do break; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn while_body_status_propagates() {
let _g = TestGuard::new();
test_input("X=0; while [[ $X -lt 1 ]]; do X=$((X+1)); false; done").unwrap();
// Loop body ended with `false` (status 1), but the loop itself
// completed normally when the condition failed, so status should be 0
assert_eq!(state::get_status(), 0);
}
// ===================== if/elif/else status =====================
#[test]
fn if_true_body_status() {
let _g = TestGuard::new();
test_input("if true; then echo ok; fi").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn if_false_no_else_status() {
let _g = TestGuard::new();
test_input("if false; then echo ok; fi").unwrap();
// No branch taken, POSIX says status is 0
assert_eq!(state::get_status(), 0);
}
#[test]
fn if_else_branch_status() {
let _g = TestGuard::new();
test_input("if false; then true; else false; fi").unwrap();
assert_eq!(state::get_status(), 1);
}
// ===================== for loop status =====================
#[test]
fn for_loop_empty_list_status() {
let _g = TestGuard::new();
test_input("for x in; do echo $x; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn for_loop_body_status() {
let _g = TestGuard::new();
test_input("for x in a b c; do true; done").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== case status =====================
#[test]
fn case_match_status() {
let _g = TestGuard::new();
test_input("case foo in foo) true;; esac").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn case_no_match_status() {
let _g = TestGuard::new();
test_input("case foo in bar) true;; esac").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== other stuff =====================
#[test]
fn for_loop_var_zip() {
let g = TestGuard::new();
test_input("for a b in 1 2 3 4 5 6; do echo $a $b; done").unwrap();
let out = g.read_output();
assert_eq!(out, "1 2\n3 4\n5 6\n");
}
#[test]
fn for_loop_unsets_zipped() {
let g = TestGuard::new();
test_input("for a b c d in 1 2 3 4 5 6; do echo $a $b $c $d; done").unwrap();
let out = g.read_output();
assert_eq!(out, "1 2 3 4\n5 6\n");
}
// ===================== negation (!) status =====================
#[test]
fn negate_true() {
let _g = TestGuard::new();
test_input("! true").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn negate_false() {
let _g = TestGuard::new();
test_input("! false").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn double_negate_true() {
let _g = TestGuard::new();
test_input("! ! true").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn double_negate_false() {
let _g = TestGuard::new();
test_input("! ! false").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn negate_pipeline_last_cmd() {
let _g = TestGuard::new();
// pipeline status = last cmd (false) = 1, negated → 0
test_input("! true | false").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn negate_pipeline_last_cmd_true() {
let _g = TestGuard::new();
// pipeline status = last cmd (true) = 0, negated → 1
test_input("! false | true").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn negate_in_conjunction() {
let _g = TestGuard::new();
// ! binds to pipeline, not conjunction: (! (true && false)) && true
test_input("! (true && false) && true").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn negate_in_if_condition() {
let g = TestGuard::new();
test_input("if ! false; then echo yes; fi").unwrap();
assert_eq!(state::get_status(), 0);
assert_eq!(g.read_output(), "yes\n");
}
#[test]
fn empty_var_in_test() {
let _g = TestGuard::new();
// POSIX specifies that a quoted unset variable expands to an empty string, so the shell actually sees `[ -n "" ]`, which returns false
test_input("[ -n \"$EMPTYVAR_PROBABLY_NOT_SET_TO_ANYTHING\" ]").unwrap();
assert_eq!(state::get_status(), 1);
// Without quotes, word splitting causes an empty var to be removed entirely, so the shell actually sees `[ -n ]`, testing the value of ']', which returns true
test_input("[ -n $EMPTYVAR_PROBABLY_NOT_SET_TO_ANYTHING ]").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -17,9 +17,9 @@ use crate::{
},
};
pub const KEYWORDS: [&str; 16] = [
pub const KEYWORDS: [&str; 17] = [
"if", "then", "elif", "else", "fi", "while", "until", "select", "for", "in", "do", "done",
"case", "esac", "[[", "]]",
"case", "esac", "[[", "]]", "!",
];
pub const OPENERS: [&str; 6] = ["if", "while", "until", "for", "select", "case"];
@@ -166,6 +166,7 @@ pub enum TkRule {
ErrPipe,
And,
Or,
Bang,
Bg,
Sep,
Redir,
@@ -216,6 +217,31 @@ impl Tk {
};
self.span.as_str().trim() == ";;"
}
pub fn is_opener(&self) -> bool {
OPENERS.contains(&self.as_str())
|| matches!(self.class, TkRule::BraceGrpStart)
|| matches!(self.class, TkRule::CasePattern)
}
pub fn is_closer(&self) -> bool {
matches!(self.as_str(), "fi" | "done" | "esac")
|| self.has_double_semi()
|| matches!(self.class, TkRule::BraceGrpEnd)
}
pub fn is_closer_for(&self, other: &Tk) -> bool {
if (matches!(other.class, TkRule::BraceGrpStart) && matches!(self.class, TkRule::BraceGrpEnd))
|| (matches!(other.class, TkRule::CasePattern) && self.has_double_semi())
{
return true;
}
match other.as_str() {
"for" | "while" | "until" => matches!(self.as_str(), "done"),
"if" => matches!(self.as_str(), "fi"),
"case" => matches!(self.as_str(), "esac"),
_ => false,
}
}
}
impl Display for Tk {
@@ -240,19 +266,12 @@ bitflags! {
const ASSIGN = 0b0000000001000000;
const BUILTIN = 0b0000000010000000;
const IS_PROCSUB = 0b0000000100000000;
const IS_HEREDOC = 0b0000001000000000;
const LIT_HEREDOC = 0b0000010000000000;
const TAB_HEREDOC = 0b0000100000000000;
}
}
pub struct LexStream {
source: Arc<String>,
pub cursor: usize,
pub name: String,
quote_state: QuoteState,
brc_grp_depth: usize,
brc_grp_start: Option<usize>,
flags: LexFlags,
}
bitflags! {
#[derive(Debug, Clone, Copy)]
pub struct LexFlags: u32 {
@@ -271,7 +290,6 @@ bitflags! {
/// The lexer has no more tokens to produce
const STALE = 0b0001000000;
const EXPECTING_IN = 0b0010000000;
const IN_CASE = 0b0100000000;
}
}
@@ -295,6 +313,18 @@ pub fn clean_input(input: &str) -> String {
output
}
pub struct LexStream {
source: Arc<String>,
pub cursor: usize,
pub name: String,
quote_state: QuoteState,
brc_grp_depth: usize,
brc_grp_start: Option<usize>,
case_depth: usize,
heredoc_skip: Option<usize>,
flags: LexFlags,
}
impl LexStream {
pub fn new(source: Arc<String>, flags: LexFlags) -> Self {
let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD;
@@ -306,6 +336,8 @@ impl LexStream {
quote_state: QuoteState::default(),
brc_grp_depth: 0,
brc_grp_start: None,
heredoc_skip: None,
case_depth: 0,
}
}
/// Returns a slice of the source input using the given range
@@ -365,7 +397,7 @@ impl LexStream {
}
pub fn read_redir(&mut self) -> Option<ShResult<Tk>> {
assert!(self.cursor <= self.source.len());
let slice = self.slice(self.cursor..)?;
let slice = self.slice(self.cursor..)?.to_string();
let mut pos = self.cursor;
let mut chars = slice.chars().peekable();
let mut tk = Tk::default();
@@ -377,33 +409,47 @@ impl LexStream {
return None; // It's a process sub
}
pos += 1;
if let Some('|') = chars.peek() {
// noclobber force '>|'
chars.next();
pos += 1;
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
if let Some('>') = chars.peek() {
chars.next();
pos += 1;
}
if let Some('&') = chars.peek() {
chars.next();
pos += 1;
let Some('&') = chars.peek() else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
};
let mut found_fd = false;
chars.next();
pos += 1;
let mut found_fd = false;
if chars.peek().is_some_and(|ch| *ch == '-') {
chars.next();
found_fd = true;
pos += 1;
} else {
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
@@ -415,14 +461,94 @@ impl LexStream {
}
pos += 1;
for _ in 0..2 {
if let Some('<') = chars.peek() {
match chars.peek() {
Some('<') => {
chars.next();
pos += 1;
} else {
match chars.peek() {
Some('<') => {
chars.next();
pos += 1;
}
Some(ch) => {
let mut ch = *ch;
while is_field_sep(ch) {
let Some(next_ch) = chars.next() else {
// Incomplete input — fall through to emit << as Redir
break;
};
pos += next_ch.len_utf8();
ch = next_ch;
}
if is_field_sep(ch) {
// Ran out of input while skipping whitespace — fall through
} else {
let saved_cursor = self.cursor;
match self.read_heredoc(pos) {
Ok(Some(heredoc_tk)) => {
// cursor is set to after the delimiter word;
// heredoc_skip is set to after the body
pos = self.cursor;
self.cursor = saved_cursor;
tk = heredoc_tk;
break;
}
Ok(None) => {
// Incomplete heredoc — restore cursor and fall through
self.cursor = saved_cursor;
}
Err(e) => return Some(Err(e)),
}
}
}
_ => {
// No delimiter yet — input is incomplete
// Fall through to emit the << as a Redir token
}
}
}
Some('>') => {
chars.next();
pos += 1;
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
Some('&') => {
chars.next();
pos += 1;
let mut found_fd = false;
if chars.peek().is_some_and(|ch| *ch == '-') {
chars.next();
found_fd = true;
pos += 1;
} else {
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
}
_ => {}
}
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
@@ -446,6 +572,133 @@ impl LexStream {
self.cursor = pos;
Some(Ok(tk))
}
pub fn read_heredoc(&mut self, mut pos: usize) -> ShResult<Option<Tk>> {
let slice = self.slice(pos..).unwrap_or_default().to_string();
let mut chars = slice.chars();
let mut delim = String::new();
let mut flags = TkFlags::empty();
let mut first_char = true;
// Parse the delimiter word, stripping quotes
while let Some(ch) = chars.next() {
match ch {
'-' if first_char => {
pos += 1;
flags |= TkFlags::TAB_HEREDOC;
}
'\"' => {
pos += 1;
self.quote_state.toggle_double();
flags |= TkFlags::LIT_HEREDOC;
}
'\'' => {
pos += 1;
self.quote_state.toggle_single();
flags |= TkFlags::LIT_HEREDOC;
}
_ if self.quote_state.in_quote() => {
pos += ch.len_utf8();
delim.push(ch);
}
ch if is_hard_sep(ch) => {
break;
}
ch => {
pos += ch.len_utf8();
delim.push(ch);
}
}
first_char = false;
}
// pos is now right after the delimiter word — this is where
// the cursor should return so the rest of the line gets lexed
let cursor_after_delim = pos;
// Re-slice from cursor_after_delim so iterator and pos are in sync
// (the old chars iterator consumed the hard_sep without advancing pos)
let rest = self
.slice(cursor_after_delim..)
.unwrap_or_default()
.to_string();
let mut chars = rest.chars();
// Scan forward to the newline (or use heredoc_skip from a previous heredoc)
let body_start = if let Some(skip) = self.heredoc_skip {
// A previous heredoc on this line already read its body;
// our body starts where that one ended
let skip_offset = skip - cursor_after_delim;
for _ in 0..skip_offset {
chars.next();
}
skip
} else {
// Skip the rest of the current line to find where the body begins
let mut scan = pos;
let mut found_newline = false;
while let Some(ch) = chars.next() {
scan += ch.len_utf8();
if ch == '\n' {
found_newline = true;
break;
}
}
if !found_newline {
if self.flags.contains(LexFlags::LEX_UNFINISHED) {
return Ok(None);
} else {
return Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(pos..pos, self.source.clone()),
"Heredoc delimiter not found",
));
}
}
scan
};
pos = body_start;
let start = pos;
// Read lines until we find one that matches the delimiter exactly
let mut line = String::new();
let mut line_start = pos;
while let Some(ch) = chars.next() {
pos += ch.len_utf8();
if ch == '\n' {
let trimmed = line.trim_end_matches('\r');
if trimmed == delim {
let mut tk = self.get_token(start..line_start, TkRule::Redir);
tk.flags |= TkFlags::IS_HEREDOC | flags;
self.heredoc_skip = Some(pos);
self.cursor = cursor_after_delim;
return Ok(Some(tk));
}
line.clear();
line_start = pos;
} else {
line.push(ch);
}
}
// Check the last line (no trailing newline)
let trimmed = line.trim_end_matches('\r');
if trimmed == delim {
let mut tk = self.get_token(start..line_start, TkRule::Redir);
tk.flags |= TkFlags::IS_HEREDOC | flags;
self.heredoc_skip = Some(pos);
self.cursor = cursor_after_delim;
return Ok(Some(tk));
}
if !self.flags.contains(LexFlags::LEX_UNFINISHED) {
Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(start..pos, self.source.clone()),
format!("Heredoc delimiter '{}' not found", delim),
))
} else {
Ok(None)
}
}
pub fn read_string(&mut self) -> ShResult<Tk> {
assert!(self.cursor <= self.source.len());
let slice = self.slice_from_cursor().unwrap().to_string();
@@ -453,7 +706,7 @@ impl LexStream {
let mut chars = slice.chars().peekable();
let can_be_subshell = chars.peek() == Some(&'(');
if self.flags.contains(LexFlags::IN_CASE)
if self.case_depth > 0
&& let Some(count) = case_pat_lookahead(chars.clone())
{
pos += count;
@@ -623,6 +876,16 @@ impl LexStream {
));
}
}
'(' if can_be_subshell && chars.peek() == Some(&')') => {
// standalone "()" — function definition marker
pos += 2;
chars.next();
let mut tk = self.get_token(self.cursor..pos, TkRule::Str);
tk.mark(TkFlags::KEYWORD);
self.cursor = pos;
self.set_next_is_cmd(true);
return Ok(tk);
}
'(' if self.next_is_cmd() && can_be_subshell => {
pos += 1;
let mut paren_count = 1;
@@ -731,7 +994,7 @@ impl LexStream {
"case" | "select" | "for" => {
new_tk.mark(TkFlags::KEYWORD);
self.flags |= LexFlags::EXPECTING_IN;
self.flags |= LexFlags::IN_CASE;
self.case_depth += 1;
self.set_next_is_cmd(false);
}
"in" if self.flags.contains(LexFlags::EXPECTING_IN) => {
@@ -739,8 +1002,8 @@ impl LexStream {
self.flags &= !LexFlags::EXPECTING_IN;
}
_ if is_keyword(text) => {
if text == "esac" && self.flags.contains(LexFlags::IN_CASE) {
self.flags &= !LexFlags::IN_CASE;
if text == "esac" && self.case_depth > 0 {
self.case_depth -= 1;
}
new_tk.mark(TkFlags::KEYWORD);
}
@@ -843,10 +1106,19 @@ impl Iterator for LexStream {
let token = match get_char(&self.source, self.cursor).unwrap() {
'\r' | '\n' | ';' => {
let ch = get_char(&self.source, self.cursor).unwrap();
let ch_idx = self.cursor;
self.cursor += 1;
self.set_next_is_cmd(true);
// If a heredoc was parsed on this line, skip past the body
// Only on newline — ';' is a command separator within the same line
if (ch == '\n' || ch == '\r')
&& let Some(skip) = self.heredoc_skip.take()
{
self.cursor = skip;
}
while let Some(ch) = get_char(&self.source, self.cursor) {
match ch {
'\\' if get_char(&self.source, self.cursor + 1) == Some('\n') => {
@@ -881,6 +1153,14 @@ impl Iterator for LexStream {
return self.next();
}
}
'!' if self.next_is_cmd() => {
self.cursor += 1;
let tk_type = TkRule::Bang;
let mut tk = self.get_token((self.cursor - 1)..self.cursor, tk_type);
tk.flags |= TkFlags::KEYWORD;
tk
}
'|' => {
let ch_idx = self.cursor;
self.cursor += 1;

File diff suppressed because one or more lines are too long

View File

@@ -19,7 +19,7 @@ pub use std::os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd,
pub use bitflags::bitflags;
pub use nix::{
errno::Errno,
fcntl::{OFlag, open},
fcntl::{FcntlArg, OFlag, fcntl, open},
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
sys::{
signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal},
@@ -33,7 +33,4 @@ pub use nix::{
},
};
pub use crate::flog;
pub use crate::libsh::flog::ShedLogLevel::*;
// Additional utilities, if needed, can be added here

View File

@@ -8,15 +8,27 @@ use crate::{
expand::Expander,
libsh::{
error::{ShErr, ShErrKind, ShResult},
sys::TTY_FILENO,
utils::RedirVecUtils,
},
parse::{Redir, RedirType, get_redir_file},
parse::{Redir, RedirType, get_redir_file, lex::TkFlags},
prelude::*,
state,
};
// Credit to fish-shell for many of the implementation ideas present in this
// module https://fishshell.com/
/// Minimum fd number for shell-internal file descriptors.
/// User-visible fds (0-9) are kept clear so `exec 3>&-` etc. work as expected.
const MIN_INTERNAL_FD: RawFd = 10;
/// Like `dup()`, but places the new fd at `MIN_INTERNAL_FD` or above so it
/// doesn't collide with user-managed fds.
fn dup_high(fd: RawFd) -> nix::Result<RawFd> {
fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD))
}
#[derive(Clone, Debug)]
pub enum IoMode {
Fd {
@@ -37,8 +49,9 @@ pub enum IoMode {
pipe: Arc<OwnedFd>,
},
Buffer {
tgt_fd: RawFd,
buf: String,
pipe: Arc<OwnedFd>,
flags: TkFlags, // so we can see if its a heredoc or not
},
Close {
tgt_fd: RawFd,
@@ -79,19 +92,37 @@ impl IoMode {
if let IoMode::File { tgt_fd, path, mode } = self {
let path_raw = path.as_os_str().to_str().unwrap_or_default().to_string();
let expanded_path = Expander::from_raw(&path_raw)?.expand()?.join(" "); // should just be one string, will have to find some way to handle a return of
// multiple
let expanded_path = Expander::from_raw(&path_raw, TkFlags::empty())?
.expand()?
.join(" "); // should just be one string, will have to find some way to handle a return of multiple paths
let expanded_pathbuf = PathBuf::from(expanded_path);
let file = get_redir_file(mode, expanded_pathbuf)?;
// Move the opened fd above the user-accessible range so it never
// collides with the target fd (e.g. `3>/tmp/foo` where open() returns 3,
// causing dup2(3,3) to be a no-op and then OwnedFd drop closes it).
let raw = file.as_raw_fd();
let high = fcntl(raw, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD)).map_err(ShErr::from)?;
drop(file); // closes the original low fd
self = IoMode::OpenedFile {
tgt_fd,
file: Arc::new(OwnedFd::from(file)),
file: Arc::new(unsafe { OwnedFd::from_raw_fd(high) }),
}
}
Ok(self)
}
pub fn buffer(tgt_fd: RawFd, buf: String, flags: TkFlags) -> ShResult<Self> {
Ok(Self::Buffer { tgt_fd, buf, flags })
}
pub fn loaded_pipe(tgt_fd: RawFd, buf: &[u8]) -> ShResult<Self> {
let (rpipe, wpipe) = nix::unistd::pipe()?;
write(wpipe, buf)?;
Ok(Self::Pipe {
tgt_fd,
pipe: rpipe.into(),
})
}
pub fn get_pipes() -> (Self, Self) {
let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap();
(
@@ -206,24 +237,107 @@ impl<'e> IoFrame {
)
}
pub fn save(&'e mut self) {
let saved_in = dup(STDIN_FILENO).unwrap();
let saved_out = dup(STDOUT_FILENO).unwrap();
let saved_err = dup(STDERR_FILENO).unwrap();
let saved_in = dup_high(STDIN_FILENO).unwrap();
let saved_out = dup_high(STDOUT_FILENO).unwrap();
let saved_err = dup_high(STDERR_FILENO).unwrap();
self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err));
}
pub fn redirect(mut self) -> ShResult<RedirGuard> {
self.save();
for redir in &mut self.redirs {
let io_mode = &mut redir.io_mode;
if let IoMode::File { .. } = io_mode {
*io_mode = io_mode.clone().open_file()?;
};
let tgt_fd = io_mode.tgt_fd();
let src_fd = io_mode.src_fd();
dup2(src_fd, tgt_fd)?;
if let Err(e) = self.apply_redirs() {
// Restore saved fds before propagating the error so they don't leak.
self.restore().ok();
return Err(e);
}
Ok(RedirGuard::new(self))
}
fn apply_redirs(&mut self) -> ShResult<()> {
for redir in &mut self.redirs {
let io_mode = &mut redir.io_mode;
match io_mode {
IoMode::Close { tgt_fd } => {
if *tgt_fd == *TTY_FILENO {
// Don't let user close the shell's tty fd.
continue;
}
close(*tgt_fd).ok();
continue;
}
IoMode::File { .. } => match io_mode.clone().open_file() {
Ok(file) => *io_mode = file,
Err(e) => {
if let Some(span) = redir.span.as_ref() {
return Err(e.promote(span.clone()));
}
return Err(e);
}
},
IoMode::Buffer { tgt_fd, buf, flags } => {
let (rpipe, wpipe) = nix::unistd::pipe()?;
let mut text = if flags.contains(TkFlags::LIT_HEREDOC) {
buf.clone()
} else {
let words = Expander::from_raw(buf, *flags)?.expand()?;
if flags.contains(TkFlags::IS_HEREDOC) {
words.into_iter().next().unwrap_or_default()
} else {
let ifs = state::get_separator();
words.join(&ifs).trim().to_string() + "\n"
}
};
if flags.contains(TkFlags::TAB_HEREDOC) {
let lines = text.lines();
let mut min_tabs = usize::MAX;
for line in lines {
if line.is_empty() {
continue;
}
let line_len = line.len();
let after_strip = line.trim_start_matches('\t').len();
let delta = line_len - after_strip;
min_tabs = min_tabs.min(delta);
}
if min_tabs == usize::MAX {
// let's avoid possibly allocating a string with 18 quintillion tabs
min_tabs = 0;
}
if min_tabs > 0 {
let stripped = text
.lines()
.fold(vec![], |mut acc, ln| {
if ln.is_empty() {
acc.push("");
return acc;
}
let stripped_ln = ln.strip_prefix(&"\t".repeat(min_tabs)).unwrap();
acc.push(stripped_ln);
acc
})
.join("\n");
text = stripped + "\n";
}
}
write(wpipe, text.as_bytes())?;
*io_mode = IoMode::Pipe {
tgt_fd: *tgt_fd,
pipe: rpipe.into(),
};
}
_ => {}
}
let tgt_fd = io_mode.tgt_fd();
let src_fd = io_mode.src_fd();
if let Err(e) = dup2(src_fd, tgt_fd) {
if let Some(span) = redir.span.as_ref() {
return Err(ShErr::from(e).promote(span.clone()));
} else {
return Err(e.into());
}
}
}
Ok(())
}
pub fn restore(&mut self) -> ShResult<()> {
if let Some(saved) = self.saved_io.take() {
dup2(saved.0, STDIN_FILENO)?;
@@ -333,6 +447,9 @@ pub fn borrow_fd<'f>(fd: i32) -> BorrowedFd<'f> {
unsafe { BorrowedFd::borrow_raw(fd) }
}
type PipeFrames = Map<PipeGenerator, fn((Option<Redir>, Option<Redir>)) -> IoFrame>;
/// An iterator that lazily creates a specific number of pipes.
pub struct PipeGenerator {
num_cmds: usize,
cursor: usize,
@@ -347,7 +464,7 @@ impl PipeGenerator {
last_rpipe: None,
}
}
pub fn as_io_frames(self) -> Map<Self, fn((Option<Redir>, Option<Redir>)) -> IoFrame> {
pub fn as_io_frames(self) -> PipeFrames {
self.map(|(r, w)| {
let mut frame = IoFrame::new();
if let Some(r) = r {
@@ -385,3 +502,169 @@ impl Iterator for PipeGenerator {
Some((rpipe, Some(wpipe)))
}
}
#[cfg(test)]
pub mod tests {
use crate::testutil::{TestGuard, has_cmd, has_cmds, test_input};
use pretty_assertions::assert_eq;
#[test]
fn pipeline_simple() {
if !has_cmd("sed") {
return;
};
let g = TestGuard::new();
test_input("echo foo | sed 's/foo/bar/'").unwrap();
let out = g.read_output();
assert_eq!(out, "bar\n");
}
#[test]
fn pipeline_multi() {
if !has_cmds(&["cut", "sed"]) {
return;
}
let g = TestGuard::new();
test_input("echo foo bar baz | cut -d ' ' -f 2 | sed 's/a/A/'").unwrap();
let out = g.read_output();
assert_eq!(out, "bAr\n");
}
#[test]
fn rube_goldberg_pipeline() {
if !has_cmds(&["sed", "cat"]) {
return;
}
let g = TestGuard::new();
test_input("{ echo foo; echo bar } | if cat; then :; else echo failed; fi | (read line && echo $line | sed 's/foo/baz/'; sed 's/bar/buzz/')").unwrap();
let out = g.read_output();
assert_eq!(out, "baz\nbuzz\n");
}
#[test]
fn simple_file_redir() {
let mut g = TestGuard::new();
test_input("echo this is in a file > /tmp/simple_file_redir.txt").unwrap();
g.add_cleanup(|| {
std::fs::remove_file("/tmp/simple_file_redir.txt").ok();
});
let contents = std::fs::read_to_string("/tmp/simple_file_redir.txt").unwrap();
assert_eq!(contents, "this is in a file\n");
}
#[test]
fn append_file_redir() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("append.txt");
let _g = TestGuard::new();
test_input(format!("echo first > {}", path.display())).unwrap();
test_input(format!("echo second >> {}", path.display())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "first\nsecond\n");
}
#[test]
fn input_redir() {
if !has_cmd("cat") {
return;
}
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("input.txt");
std::fs::write(&path, "hello from file\n").unwrap();
let g = TestGuard::new();
test_input(format!("cat < {}", path.display())).unwrap();
let out = g.read_output();
assert_eq!(out, "hello from file\n");
}
#[test]
fn stderr_redir_to_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("err.txt");
let g = TestGuard::new();
test_input(format!("echo error msg 2> {} >&2", path.display())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "error msg\n");
// stdout should be empty since we redirected to stderr
let out = g.read_output();
assert_eq!(out, "");
}
#[test]
fn pipe_and_stderr() {
if !has_cmd("cat") {
return;
}
let g = TestGuard::new();
test_input("echo on stderr >&2 |& cat").unwrap();
let out = g.read_output();
assert_eq!(out, "on stderr\n");
}
#[test]
fn output_redir_clobber() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("clobber.txt");
let _g = TestGuard::new();
test_input(format!("echo first > {}", path.display())).unwrap();
test_input(format!("echo second > {}", path.display())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "second\n");
}
#[test]
fn pipeline_preserves_exit_status() {
if !has_cmd("cat") {
return;
}
let _g = TestGuard::new();
test_input("false | cat").unwrap();
// Pipeline exit status is the last command
let status = crate::state::get_status();
assert_eq!(status, 0);
test_input("cat < /dev/null | false").unwrap();
let status = crate::state::get_status();
assert_ne!(status, 0);
}
#[test]
fn fd_duplication() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("dup.txt");
let _g = TestGuard::new();
// Redirect stdout to file, then dup stderr to stdout — both should go to file
test_input(format!(
"{{ echo out; echo err >&2 }} > {} 2>&1",
path.display()
))
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("out"));
assert!(contents.contains("err"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -88,7 +88,9 @@ impl Highlighter {
while prefix_chars.peek().is_some() {
match chars.next() {
Some(c) if c == markers::VISUAL_MODE_START || c == markers::VISUAL_MODE_END => continue,
Some(c) if Some(&c) == prefix_chars.peek() => { prefix_chars.next(); }
Some(c) if Some(&c) == prefix_chars.peek() => {
prefix_chars.next();
}
_ => return text.to_string(), // mismatch, return original
}
}
@@ -104,7 +106,9 @@ impl Highlighter {
let mut si = suffix_chars.len();
while si > 0 {
if ti == 0 { return text.to_string(); }
if ti == 0 {
return text.to_string();
}
ti -= 1;
if chars[ti] == markers::VISUAL_MODE_START || chars[ti] == markers::VISUAL_MODE_END {
continue; // skip visual markers
@@ -346,7 +350,9 @@ impl Highlighter {
recursive_highlighter.highlight();
// Read back visual state — selection may have started/ended inside
self.in_selection = recursive_highlighter.in_selection;
self.style_stack.append(&mut recursive_highlighter.style_stack);
self
.style_stack
.append(&mut recursive_highlighter.style_stack);
if selection_at_entry {
self.emit_style(Style::BgWhite | Style::Black);
self.output.push_str(prefix);

View File

@@ -203,6 +203,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
.collect()
}
#[derive(Default, Clone, Debug)]
pub struct History {
path: PathBuf,
pub pending: Option<LineBuf>, // command, cursor_pos
@@ -214,21 +215,41 @@ pub struct History {
//search_direction: Direction,
ignore_dups: bool,
max_size: Option<u32>,
stateless: bool,
}
impl History {
pub fn empty() -> Self {
Self {
path: PathBuf::new(),
pending: None,
entries: Vec::new(),
search_mask: Vec::new(),
fuzzy_finder: FuzzySelector::new("History").number_candidates(true),
no_matches: false,
cursor: 0,
//search_direction: Direction::Backward,
ignore_dups: false,
max_size: None,
stateless: true,
}
}
pub fn new() -> ShResult<Self> {
let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes);
let max_hist = crate::state::read_shopts(|s| s.core.max_hist);
let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({
let home = env::var("HOME").unwrap();
format!("{home}/.shed_history")
}));
let mut entries = read_hist_file(&path)?;
// Enforce max_hist limit on loaded entries (negative = unlimited)
if max_hist >= 0 && entries.len() > max_hist as usize {
entries = entries.split_off(entries.len() - max_hist as usize);
}
let search_mask = dedupe_entries(&entries);
let cursor = search_mask.len();
let max_size = if max_hist < 0 {
@@ -236,6 +257,7 @@ impl History {
} else {
Some(max_hist as u32)
};
Ok(Self {
path,
entries,
@@ -247,6 +269,7 @@ impl History {
//search_direction: Direction::Backward,
ignore_dups,
max_size,
stateless: false,
})
}
@@ -261,7 +284,7 @@ impl History {
.search_mask
.clone()
.into_iter()
.map(|ent| ent.command().to_string());
.map(|ent| super::complete::Candidate::from(ent.command()));
self.fuzzy_finder.activate(raw_entries.collect());
None
}
@@ -294,7 +317,7 @@ impl History {
pub fn update_pending_cmd(&mut self, buf: (&str, usize)) {
let cursor_pos = if let Some(pending) = &self.pending {
pending.cursor.get()
pending.cursor_to_flat()
} else {
buf.1
};
@@ -306,7 +329,7 @@ impl History {
if let Some(pending) = &mut self.pending {
pending.set_buffer(cmd);
pending.cursor.set(cursor_pos);
pending.set_cursor_from_flat(cursor_pos);
} else {
self.pending = Some(LineBuf::new().with_initial(&cmd, cursor_pos));
}
@@ -391,7 +414,12 @@ impl History {
}
pub fn get_hint(&self) -> Option<String> {
if self.at_pending() && self.pending.as_ref().is_some_and(|p| !p.buffer.is_empty()) {
if self.at_pending()
&& self
.pending
.as_ref()
.is_some_and(|p| !p.joined().is_empty())
{
let entry = self.hint_entry()?;
Some(entry.command().to_string())
} else {
@@ -431,6 +459,9 @@ impl History {
}
pub fn save(&mut self) -> ShResult<()> {
if self.stateless {
return Ok(());
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
@@ -466,3 +497,111 @@ impl History {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{state, testutil::TestGuard};
use scopeguard::guard;
use std::{env, fs, path::Path};
use tempfile::tempdir;
fn with_env_var(key: &str, val: &str) -> impl Drop {
let prev = env::var(key).ok();
unsafe {
env::set_var(key, val);
}
guard(prev, move |p| match p {
Some(v) => unsafe { env::set_var(key, v) },
None => unsafe { env::remove_var(key) },
})
}
/// Temporarily mutate shell options for a test and restore the
/// previous values when the returned guard is dropped.
fn with_shopts(modifier: impl FnOnce(&mut crate::shopt::ShOpts)) -> impl Drop {
let original = state::read_shopts(|s| s.clone());
state::write_shopts(|s| modifier(s));
guard(original, |orig| {
state::write_shopts(|s| *s = orig);
})
}
fn write_history_file(path: &Path) {
fs::write(
path,
[": 1;1;first\n", ": 2;1;second\n", ": 3;1;third\n"].concat(),
)
.unwrap();
}
#[test]
fn history_new_respects_max_hist_limit() {
let _lock = TestGuard::new();
let tmp = tempdir().unwrap();
let hist_path = tmp.path().join("history");
write_history_file(&hist_path);
let _env_guard = with_env_var("SHEDHIST", hist_path.to_str().unwrap());
let _opts_guard = with_shopts(|s| {
s.core.max_hist = 2;
s.core.hist_ignore_dupes = true;
});
let history = History::new().unwrap();
assert_eq!(history.entries.len(), 2);
assert_eq!(history.search_mask.len(), 2);
assert_eq!(history.cursor, 2);
assert_eq!(history.max_size, Some(2));
assert!(history.ignore_dups);
assert!(history.pending.is_none());
assert_eq!(history.entries[0].command(), "second");
assert_eq!(history.entries[1].command(), "third");
}
#[test]
fn history_new_keeps_all_when_unlimited() {
let _lock = TestGuard::new();
let tmp = tempdir().unwrap();
let hist_path = tmp.path().join("history");
write_history_file(&hist_path);
let _env_guard = with_env_var("SHEDHIST", hist_path.to_str().unwrap());
let _opts_guard = with_shopts(|s| {
s.core.max_hist = -1;
s.core.hist_ignore_dupes = false;
});
let history = History::new().unwrap();
assert_eq!(history.entries.len(), 3);
assert_eq!(history.search_mask.len(), 3);
assert_eq!(history.cursor, 3);
assert_eq!(history.max_size, None);
assert!(!history.ignore_dups);
}
#[test]
fn history_new_dedupes_search_mask_to_latest_occurrence() {
let _lock = TestGuard::new();
let tmp = tempdir().unwrap();
let hist_path = tmp.path().join("history");
fs::write(
&hist_path,
[": 1;1;repeat\n", ": 2;1;unique\n", ": 3;1;repeat\n"].concat(),
)
.unwrap();
let _env_guard = with_env_var("SHEDHIST", hist_path.to_str().unwrap());
let _opts_guard = with_shopts(|s| {
s.core.max_hist = 10;
});
let history = History::new().unwrap();
let masked: Vec<_> = history.search_mask.iter().map(|e| e.command()).collect();
assert_eq!(masked, vec!["unique", "repeat"]);
assert_eq!(history.cursor, history.search_mask.len());
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,27 @@
use std::{fmt::Display, sync::Mutex};
use crate::readline::linebuf::Line;
pub static REGISTERS: Mutex<Registers> = Mutex::new(Registers::new());
#[cfg(test)]
pub static SAVED_REGISTERS: Mutex<Option<Registers>> = Mutex::new(None);
#[cfg(test)]
pub fn save_registers() {
let mut saved = SAVED_REGISTERS.lock().unwrap();
*saved = Some(REGISTERS.lock().unwrap().clone());
}
#[cfg(test)]
pub fn restore_registers() {
let mut saved = SAVED_REGISTERS.lock().unwrap();
if let Some(ref registers) = *saved {
*REGISTERS.lock().unwrap() = registers.clone();
}
*saved = None;
}
pub fn read_register(ch: Option<char>) -> Option<RegisterContent> {
let lock = REGISTERS.lock().unwrap();
lock.get_reg(ch).map(|r| r.content().clone())
@@ -23,8 +43,9 @@ pub fn append_register(ch: Option<char>, buf: RegisterContent) {
#[derive(Default, Clone, Debug)]
pub enum RegisterContent {
Span(String),
Line(String),
Span(Vec<Line>),
Line(Vec<Line>),
Block(Vec<Line>),
#[default]
Empty,
}
@@ -32,8 +53,16 @@ pub enum RegisterContent {
impl Display for RegisterContent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Span(s) => write!(f, "{}", s),
Self::Line(s) => write!(f, "{}", s),
Self::Block(s) | Self::Line(s) | Self::Span(s) => {
write!(
f,
"{}",
s.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n")
)
}
Self::Empty => write!(f, ""),
}
}
@@ -41,16 +70,11 @@ impl Display for RegisterContent {
impl RegisterContent {
pub fn clear(&mut self) {
match self {
Self::Span(s) => s.clear(),
Self::Line(s) => s.clear(),
Self::Empty => {}
}
*self = Self::Empty
}
pub fn len(&self) -> usize {
match self {
Self::Span(s) => s.len(),
Self::Line(s) => s.len(),
Self::Span(s) | Self::Line(s) | Self::Block(s) => s.len(),
Self::Empty => 0,
}
}
@@ -58,28 +82,25 @@ impl RegisterContent {
match self {
Self::Span(s) => s.is_empty(),
Self::Line(s) => s.is_empty(),
Self::Block(s) => s.is_empty(),
Self::Empty => true,
}
}
pub fn is_block(&self) -> bool {
matches!(self, Self::Block(_))
}
pub fn is_line(&self) -> bool {
matches!(self, Self::Line(_))
}
pub fn is_span(&self) -> bool {
matches!(self, Self::Span(_))
}
pub fn as_str(&self) -> &str {
match self {
Self::Span(s) => s,
Self::Line(s) => s,
Self::Empty => "",
}
}
pub fn char_count(&self) -> usize {
self.as_str().chars().count()
self.to_string().chars().count()
}
}
#[derive(Default, Debug)]
#[derive(Default, Clone, Debug)]
pub struct Registers {
default: Register,
a: Register,
@@ -220,7 +241,7 @@ pub struct Register {
impl Register {
pub const fn new() -> Self {
Self {
content: RegisterContent::Span(String::new()),
content: RegisterContent::Empty,
}
}
pub fn content(&self) -> &RegisterContent {
@@ -229,13 +250,16 @@ impl Register {
pub fn write(&mut self, buf: RegisterContent) {
self.content = buf
}
pub fn append(&mut self, buf: RegisterContent) {
pub fn append(&mut self, mut buf: RegisterContent) {
match buf {
RegisterContent::Empty => {}
RegisterContent::Span(ref s) | RegisterContent::Line(ref s) => match &mut self.content {
RegisterContent::Span(ref mut s)
| RegisterContent::Block(ref mut s)
| RegisterContent::Line(ref mut s) => match &mut self.content {
RegisterContent::Empty => self.content = buf,
RegisterContent::Span(existing) => existing.push_str(s),
RegisterContent::Line(existing) => existing.push_str(s),
RegisterContent::Span(existing)
| RegisterContent::Line(existing)
| RegisterContent::Block(existing) => existing.append(s),
},
}
}

View File

@@ -4,7 +4,7 @@ use std::{
fmt::{Debug, Write},
io::{BufRead, BufReader, Read},
os::fd::{AsFd, BorrowedFd, RawFd},
sync::Arc, time::Instant,
time::Instant,
};
use nix::{
@@ -69,17 +69,19 @@ pub fn get_win_size(fd: RawFd) -> (Col, Row) {
}
}
fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String {
let total_lines = s.lines().count();
let max_num_len = total_lines.to_string().len();
s.lines()
fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool, offset: usize, _total_buf_lines: usize) -> String {
let lines: Vec<&str> = s.split('\n').collect();
let visible_count = lines.len();
let max_num_len = (offset + visible_count).to_string().len();
lines
.into_iter()
.enumerate()
.fold(String::new(), |mut acc, (i, ln)| {
if i == 0 {
acc.push_str(ln);
acc.push('\n');
} else {
let num = (i + 1).to_string();
let num = (i + offset + 1).to_string();
let num_pad = max_num_len - num.len();
// " 2 | " — num + padding + " | "
let prefix_len = max_num_len + 3; // "N | "
@@ -89,7 +91,7 @@ fn enumerate_lines(s: &str, left_pad: usize, show_numbers: bool) -> String {
} else {
" ".repeat(prefix_len + 1).to_string()
};
if i == total_lines - 1 {
if i == visible_count - 1 {
write!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
} else {
writeln!(acc, "{prefix}{}{ln}", " ".repeat(trail_pad)).unwrap();
@@ -218,7 +220,7 @@ pub trait KeyReader {
pub trait LineWriter {
fn clear_rows(&mut self, layout: &Layout) -> ShResult<()>;
fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout) -> ShResult<()>;
fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout, offset: usize, total_buf_lines: usize) -> ShResult<()>;
fn flush_write(&mut self, buf: &str) -> ShResult<()>;
fn send_bell(&mut self) -> ShResult<()>;
}
@@ -294,12 +296,14 @@ impl Read for TermBuffer {
struct KeyCollector {
events: VecDeque<KeyEvent>,
ss3_pending: bool,
}
impl KeyCollector {
fn new() -> Self {
Self {
events: VecDeque::new(),
ss3_pending: false,
}
}
@@ -337,7 +341,55 @@ impl Default for KeyCollector {
impl Perform for KeyCollector {
fn print(&mut self, c: char) {
log::trace!("print: {c:?}");
// vte routes 0x7f (DEL) to print instead of execute
if self.ss3_pending {
self.ss3_pending = false;
match c {
'A' => {
self.push(KeyEvent(KeyCode::Up, ModKeys::empty()));
return;
}
'B' => {
self.push(KeyEvent(KeyCode::Down, ModKeys::empty()));
return;
}
'C' => {
self.push(KeyEvent(KeyCode::Right, ModKeys::empty()));
return;
}
'D' => {
self.push(KeyEvent(KeyCode::Left, ModKeys::empty()));
return;
}
'H' => {
self.push(KeyEvent(KeyCode::Home, ModKeys::empty()));
return;
}
'F' => {
self.push(KeyEvent(KeyCode::End, ModKeys::empty()));
return;
}
'P' => {
self.push(KeyEvent(KeyCode::F(1), ModKeys::empty()));
return;
}
'Q' => {
self.push(KeyEvent(KeyCode::F(2), ModKeys::empty()));
return;
}
'R' => {
self.push(KeyEvent(KeyCode::F(3), ModKeys::empty()));
return;
}
'S' => {
self.push(KeyEvent(KeyCode::F(4), ModKeys::empty()));
return;
}
_ => {}
}
}
if c == '\x7f' {
self.push(KeyEvent(KeyCode::Backspace, ModKeys::empty()));
} else {
@@ -346,6 +398,7 @@ impl Perform for KeyCollector {
}
fn execute(&mut self, byte: u8) {
log::trace!("execute: {byte:#04x}");
let event = match byte {
0x00 => KeyEvent(KeyCode::Char(' '), ModKeys::CTRL), // Ctrl+Space / Ctrl+@
0x09 => KeyEvent(KeyCode::Tab, ModKeys::empty()), // Tab (Ctrl+I)
@@ -370,6 +423,9 @@ impl Perform for KeyCollector {
_ignore: bool,
action: char,
) {
log::trace!(
"CSI dispatch: params={params:?}, intermediates={intermediates:?}, action={action:?}"
);
let params: Vec<u16> = params
.iter()
.map(|p| p.first().copied().unwrap_or(0))
@@ -444,8 +500,8 @@ impl Perform for KeyCollector {
21 => KeyCode::F(10),
23 => KeyCode::F(11),
24 => KeyCode::F(12),
200 => KeyCode::BracketedPasteStart,
201 => KeyCode::BracketedPasteEnd,
200 => KeyCode::BracketedPasteStart,
201 => KeyCode::BracketedPasteEnd,
_ => return,
};
KeyEvent(key, mods)
@@ -481,16 +537,10 @@ impl Perform for KeyCollector {
}
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
// SS3 sequences (ESC O P/Q/R/S for F1-F4)
if intermediates == [b'O'] {
let key = match byte {
b'P' => KeyCode::F(1),
b'Q' => KeyCode::F(2),
b'R' => KeyCode::F(3),
b'S' => KeyCode::F(4),
_ => return,
};
self.push(KeyEvent(key, ModKeys::empty()));
log::trace!("ESC dispatch: intermediates={intermediates:?}, byte={byte:#04x}");
// SS3 sequences
if byte == b'O' {
self.ss3_pending = true;
}
}
}
@@ -498,8 +548,9 @@ impl Perform for KeyCollector {
pub struct PollReader {
parser: Parser,
collector: KeyCollector,
byte_buf: VecDeque<u8>,
pub verbatim: bool,
byte_buf: VecDeque<u8>,
pub verbatim_single: bool,
pub verbatim: bool,
}
impl PollReader {
@@ -507,32 +558,45 @@ impl PollReader {
Self {
parser: Parser::new(),
collector: KeyCollector::new(),
byte_buf: VecDeque::new(),
verbatim: false,
byte_buf: VecDeque::new(),
verbatim_single: false,
verbatim: false,
}
}
pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> {
let end_marker = b"\x1b[201~";
let mut raw = vec![];
while let Some(byte) = self.byte_buf.pop_front() {
raw.push(byte);
if raw.ends_with(end_marker) {
// Strip the end marker from the raw sequence
raw.truncate(raw.len() - end_marker.len());
let paste = String::from_utf8_lossy(&raw).to_string();
self.verbatim = false;
return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty()));
}
}
pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> {
let end_marker = b"\x1b[201~";
let mut raw = vec![];
while let Some(byte) = self.byte_buf.pop_front() {
raw.push(byte);
if raw.ends_with(end_marker) {
// Strip the end marker from the raw sequence
raw.truncate(raw.len() - end_marker.len());
let paste = String::from_utf8_lossy(&raw).to_string();
self.verbatim = false;
return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty()));
}
}
self.verbatim = true;
self.byte_buf.extend(raw);
None
}
self.verbatim = true;
self.byte_buf.extend(raw);
None
}
pub fn read_one_verbatim(&mut self) -> Option<KeyEvent> {
if self.byte_buf.is_empty() {
return None;
}
let bytes: Vec<u8> = self.byte_buf.drain(..).collect();
let verbatim_str = String::from_utf8_lossy(&bytes).to_string();
Some(KeyEvent(
KeyCode::Verbatim(verbatim_str.into()),
ModKeys::empty(),
))
}
pub fn feed_bytes(&mut self, bytes: &[u8]) {
self.byte_buf.extend(bytes);
self.byte_buf.extend(bytes);
}
}
@@ -544,33 +608,42 @@ impl Default for PollReader {
impl KeyReader for PollReader {
fn read_key(&mut self) -> Result<Option<KeyEvent>, ShErr> {
if self.verbatim {
if let Some(paste) = self.handle_bracket_paste() {
return Ok(Some(paste));
}
// If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys
return Ok(None);
} else if self.byte_buf.len() == 1
&& self.byte_buf.front() == Some(&b'\x1b') {
// User pressed escape
self.byte_buf.pop_front(); // Consume the escape byte
return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty())));
}
while let Some(byte) = self.byte_buf.pop_front() {
self.parser.advance(&mut self.collector, &[byte]);
if let Some(key) = self.collector.pop() {
match key {
KeyEvent(KeyCode::BracketedPasteStart, _) => {
if let Some(paste) = self.handle_bracket_paste() {
return Ok(Some(paste));
} else {
continue;
}
}
_ => return Ok(Some(key))
}
}
}
if self.verbatim_single {
if let Some(key) = self.read_one_verbatim() {
self.verbatim_single = false;
return Ok(Some(key));
}
return Ok(None);
}
if self.verbatim {
if let Some(paste) = self.handle_bracket_paste() {
return Ok(Some(paste));
}
// If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys
return Ok(None);
} else if self.byte_buf.front() == Some(&b'\x1b') {
// Escape: if it's the only byte, or the next byte isn't a valid
// escape sequence prefix ([ or O), emit a standalone Escape
if self.byte_buf.len() == 1 || !matches!(self.byte_buf.get(1), Some(b'[') | Some(b'O')) {
self.byte_buf.pop_front();
return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty())));
}
}
while let Some(byte) = self.byte_buf.pop_front() {
self.parser.advance(&mut self.collector, &[byte]);
if let Some(key) = self.collector.pop() {
match key {
KeyEvent(KeyCode::BracketedPasteStart, _) => {
if let Some(paste) = self.handle_bracket_paste() {
return Ok(Some(paste));
} else {
continue;
}
}
_ => return Ok(Some(key)),
}
}
}
Ok(None)
}
}
@@ -821,8 +894,9 @@ impl Default for Layout {
}
}
#[derive(Clone, Debug, Default)]
pub struct TermWriter {
last_bell: Option<Instant>,
last_bell: Option<Instant>,
out: RawFd,
pub t_cols: Col, // terminal width
buffer: String,
@@ -832,7 +906,7 @@ impl TermWriter {
pub fn new(out: RawFd) -> Self {
let (t_cols, _) = get_win_size(out);
Self {
last_bell: None,
last_bell: None,
out,
t_cols,
buffer: String::new(),
@@ -1020,7 +1094,7 @@ impl LineWriter for TermWriter {
Ok(())
}
fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout) -> ShResult<()> {
fn redraw(&mut self, prompt: &str, line: &str, new_layout: &Layout, offset: usize, total_buf_lines: usize) -> ShResult<()> {
let err = |_| {
ShErr::simple(
ShErrKind::InternalErr,
@@ -1046,7 +1120,7 @@ impl LineWriter for TermWriter {
if multiline {
let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0, false);
let show_numbers = read_shopts(|o| o.prompt.line_numbers);
let display_line = enumerate_lines(line, prompt_end.col as usize, show_numbers);
let display_line = enumerate_lines(line, prompt_end.col as usize, show_numbers, offset, total_buf_lines);
self.buffer.push_str(&display_line);
} else {
self.buffer.push_str(line);
@@ -1069,24 +1143,24 @@ impl LineWriter for TermWriter {
Ok(())
}
fn send_bell(&mut self) -> ShResult<()> {
if read_shopts(|o| o.core.bell_enabled) {
// we use a cooldown because I don't like having my ears assaulted by 1 million bells
// whenever i finish clearing the line using backspace.
let now = Instant::now();
fn send_bell(&mut self) -> ShResult<()> {
if read_shopts(|o| o.core.bell_enabled) {
// we use a cooldown because I don't like having my ears assaulted by 1 million bells
// whenever i finish clearing the line using backspace.
let now = Instant::now();
// surprisingly, a fixed cooldown like '100' is actually more annoying than 1 million bells.
// I've found this range of 50-150 to be the best balance
let cooldown = rand::random_range(50..150);
let should_send = match self.last_bell {
None => true,
Some(time) => now.duration_since(time).as_millis() > cooldown,
};
if should_send {
self.flush_write("\x07")?;
self.last_bell = Some(now);
}
}
Ok(())
}
// surprisingly, a fixed cooldown like '100' is actually more annoying than 1 million bells.
// I've found this range of 50-150 to be the best balance
let cooldown = rand::random_range(50..150);
let should_send = match self.last_bell {
None => true,
Some(time) => now.duration_since(time).as_millis() > cooldown,
};
if should_send {
self.flush_write("\x07")?;
self.last_bell = Some(now);
}
}
Ok(())
}
}

529
src/readline/tests.rs Normal file
View File

@@ -0,0 +1,529 @@
#![allow(non_snake_case)]
use std::os::fd::AsRawFd;
use crate::{
readline::{Prompt, ShedVi, annotate_input},
testutil::TestGuard,
};
fn assert_annotated(input: &str, expected: &str) {
let result = annotate_input(input);
assert_eq!(result, expected, "\nInput: {input:?}");
}
/// Tests for our vim logic emulation. Each test consists of an initial text, a sequence of keys to feed, and the expected final text and cursor position.
macro_rules! vi_test {
{ $($name:ident: $input:expr => $op:expr => $expected_text:expr,$expected_cursor:expr);* } => {
$(
#[test]
fn $name() {
let (mut vi, _g) = test_vi($input);
vi.feed_bytes(b"\x1b"); // Start in normal mode
vi.process_input().unwrap();
vi.feed_bytes($op.as_bytes());
vi.process_input().unwrap();
assert_eq!(vi.editor.joined(), $expected_text);
assert_eq!(vi.editor.cursor_to_flat(), $expected_cursor);
}
)*
};
}
// ===================== Annotation Tests =====================
#[test]
fn annotate_simple_command() {
assert_annotated("echo hello", "\u{e101}echo\u{e11a} \u{e102}hello\u{e11a}");
}
#[test]
fn annotate_pipeline() {
assert_annotated(
"ls | grep foo",
"\u{e100}ls\u{e11a} \u{e104}|\u{e11a} \u{e100}grep\u{e11a} \u{e102}foo\u{e11a}",
);
}
#[test]
fn annotate_conjunction() {
assert_annotated(
"echo foo && echo bar",
"\u{e101}echo\u{e11a} \u{e102}foo\u{e11a} \u{e104}&&\u{e11a} \u{e101}echo\u{e11a} \u{e102}bar\u{e11a}",
);
}
#[test]
fn annotate_redirect_output() {
assert_annotated(
"echo hello > file.txt",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a} \u{e105}>\u{e11a} \u{e102}file.txt\u{e11a}",
);
}
#[test]
fn annotate_redirect_append() {
assert_annotated(
"echo hello >> file.txt",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a} \u{e105}>>\u{e11a} \u{e102}file.txt\u{e11a}",
);
}
#[test]
fn annotate_redirect_input() {
assert_annotated(
"cat < file.txt",
"\u{e100}cat\u{e11a} \u{e105}<\u{e11a} \u{e102}file.txt\u{e11a}",
);
}
#[test]
fn annotate_fd_redirect() {
assert_annotated("cmd 2>&1", "\u{e100}cmd\u{e11a} \u{e105}2>&1\u{e11a}");
}
#[test]
fn annotate_variable_sub() {
assert_annotated(
"echo $HOME",
"\u{e101}echo\u{e11a} \u{e102}\u{e10c}$HOME\u{e10d}\u{e11a}",
);
}
#[test]
fn annotate_variable_brace_sub() {
assert_annotated(
"echo ${HOME}",
"\u{e101}echo\u{e11a} \u{e102}\u{e10c}${HOME}\u{e10d}\u{e11a}",
);
}
#[test]
fn annotate_command_sub() {
assert_annotated(
"echo $(ls)",
"\u{e101}echo\u{e11a} \u{e102}\u{e10e}$(ls)\u{e10f}\u{e11a}",
);
}
#[test]
fn annotate_single_quoted_string() {
assert_annotated(
"echo 'hello world'",
"\u{e101}echo\u{e11a} \u{e102}\u{e114}'hello world'\u{e115}\u{e11a}",
);
}
#[test]
fn annotate_double_quoted_string() {
assert_annotated(
"echo \"hello world\"",
"\u{e101}echo\u{e11a} \u{e102}\u{e112}\"hello world\"\u{e113}\u{e11a}",
);
}
#[test]
fn annotate_assignment() {
assert_annotated("FOO=bar", "\u{e107}FOO=bar\u{e11a}");
}
#[test]
fn annotate_assignment_with_command() {
assert_annotated(
"FOO=bar echo hello",
"\u{e107}FOO=bar\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}",
);
}
#[test]
fn annotate_if_statement() {
assert_annotated(
"if true; then echo yes; fi",
"\u{e103}if\u{e11a} \u{e101}true\u{e11a}\u{e108}; \u{e11a}\u{e103}then\u{e11a} \u{e101}echo\u{e11a} \u{e102}yes\u{e11a}\u{e108}; \u{e11a}\u{e103}fi\u{e11a}",
);
}
#[test]
fn annotate_for_loop() {
assert_annotated(
"for i in a b c; do echo $i; done",
"\u{e103}for\u{e11a} \u{e102}i\u{e11a} \u{e103}in\u{e11a} \u{e102}a\u{e11a} \u{e102}b\u{e11a} \u{e102}c\u{e11a}\u{e108}; \u{e11a}\u{e103}do\u{e11a} \u{e101}echo\u{e11a} \u{e102}\u{e10c}$i\u{e10d}\u{e11a}\u{e108}; \u{e11a}\u{e103}done\u{e11a}",
);
}
#[test]
fn annotate_while_loop() {
assert_annotated(
"while true; do echo hello; done",
"\u{e103}while\u{e11a} \u{e101}true\u{e11a}\u{e108}; \u{e11a}\u{e103}do\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}\u{e108}; \u{e11a}\u{e103}done\u{e11a}",
);
}
#[test]
fn annotate_case_statement() {
assert_annotated(
"case foo in bar) echo bar;; esac",
"\u{e103}case\u{e11a} \u{e102}foo\u{e11a} \u{e103}in\u{e11a} \u{e104}bar\u{e109})\u{e11a} \u{e101}echo\u{e11a} \u{e102}bar\u{e11a}\u{e108};; \u{e11a}\u{e103}esac\u{e11a}",
);
}
#[test]
fn annotate_brace_group() {
assert_annotated(
"{ echo hello; }",
"\u{e104}{\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}\u{e108}; \u{e11a}\u{e104}}\u{e11a}",
);
}
#[test]
fn annotate_comment() {
assert_annotated(
"echo hello # this is a comment",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a} \u{e106}# this is a comment\u{e11a}",
);
}
#[test]
fn annotate_semicolon_sep() {
assert_annotated(
"echo foo; echo bar",
"\u{e101}echo\u{e11a} \u{e102}foo\u{e11a}\u{e108}; \u{e11a}\u{e101}echo\u{e11a} \u{e102}bar\u{e11a}",
);
}
#[test]
fn annotate_escaped_char() {
assert_annotated(
"echo hello\\ world",
"\u{e101}echo\u{e11a} \u{e102}hello\\ world\u{e11a}",
);
}
#[test]
fn annotate_glob() {
assert_annotated(
"ls *.txt",
"\u{e100}ls\u{e11a} \u{e102}\u{e117}*\u{e11a}.txt\u{e11a}",
);
}
#[test]
fn annotate_heredoc_operator() {
assert_annotated(
"cat <<EOF",
"\u{e100}cat\u{e11a} \u{e105}<<\u{e11a}\u{e102}EOF\u{e11a}",
);
}
#[test]
fn annotate_herestring_operator() {
assert_annotated(
"cat <<< hello",
"\u{e100}cat\u{e11a} \u{e105}<<<\u{e11a} \u{e102}hello\u{e11a}",
);
}
#[test]
fn annotate_nested_command_sub() {
assert_annotated(
"echo $(echo $(ls))",
"\u{e101}echo\u{e11a} \u{e102}\u{e10e}$(echo $(ls))\u{e10f}\u{e11a}",
);
}
#[test]
fn annotate_var_in_double_quotes() {
assert_annotated(
"echo \"hello $USER\"",
"\u{e101}echo\u{e11a} \u{e102}\u{e112}\"hello \u{e10c}$USER\u{e10d}\"\u{e113}\u{e11a}",
);
}
#[test]
fn annotate_func_def() {
assert_annotated(
"foo() { echo hello; }",
"\u{e103}foo()\u{e11a} \u{e104}{\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}\u{e108}; \u{e11a}\u{e104}}\u{e11a}",
);
}
#[test]
fn annotate_negate() {
assert_annotated(
"! echo hello",
"\u{e104}!\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}",
);
}
#[test]
fn annotate_or_conjunction() {
assert_annotated(
"false || echo fallback",
"\u{e101}false\u{e11a} \u{e104}||\u{e11a} \u{e101}echo\u{e11a} \u{e102}fallback\u{e11a}",
);
}
#[test]
fn annotate_complex_pipeline() {
assert_annotated(
"cat file.txt | grep pattern | wc -l",
"\u{e100}cat\u{e11a} \u{e102}file.txt\u{e11a} \u{e104}|\u{e11a} \u{e100}grep\u{e11a} \u{e102}pattern\u{e11a} \u{e104}|\u{e11a} \u{e100}wc\u{e11a} \u{e102}-l\u{e11a}",
);
}
#[test]
fn annotate_multiple_redirects() {
assert_annotated(
"cmd > out.txt 2> err.txt",
"\u{e100}cmd\u{e11a} \u{e105}>\u{e11a} \u{e102}out.txt\u{e11a} \u{e105}2>\u{e11a} \u{e102}err.txt\u{e11a}",
);
}
// ===================== Vi Tests =====================
fn test_vi(initial: &str) -> (ShedVi, TestGuard) {
let g = TestGuard::new();
let prompt = Prompt::default();
let vi = ShedVi::new_no_hist(prompt, g.pty_slave().as_raw_fd())
.unwrap()
.with_initial(initial);
(vi, g)
}
// Why can't I marry a programming language
vi_test! {
vi_dw_basic : "hello world" => "dw" => "world", 0;
vi_dw_middle : "one two three" => "wdw" => "one three", 4;
vi_dd_whole_line : "hello world" => "dd" => "", 0;
vi_x_single : "hello" => "x" => "ello", 0;
vi_x_middle : "hello" => "llx" => "helo", 2;
vi_X_backdelete : "hello" => "llX" => "hllo", 1;
vi_h_motion : "hello" => "$h" => "hello", 3;
vi_l_motion : "hello" => "l" => "hello", 1;
vi_h_at_start : "hello" => "h" => "hello", 0;
vi_l_at_end : "hello" => "$l" => "hello", 4;
vi_w_forward : "one two three" => "w" => "one two three", 4;
vi_b_backward : "one two three" => "$b" => "one two three", 8;
vi_e_end : "one two three" => "e" => "one two three", 2;
vi_ge_back_end : "one two three" => "$ge" => "one two three", 6;
vi_w_punctuation : "foo.bar baz" => "w" => "foo.bar baz", 3;
vi_e_punctuation : "foo.bar baz" => "e" => "foo.bar baz", 2;
vi_b_punctuation : "foo.bar baz" => "$b" => "foo.bar baz", 8;
vi_w_at_eol : "hello" => "$w" => "hello", 4;
vi_b_at_bol : "hello" => "b" => "hello", 0;
vi_W_forward : "foo.bar baz" => "W" => "foo.bar baz", 8;
vi_B_backward : "foo.bar baz" => "$B" => "foo.bar baz", 8;
vi_E_end : "foo.bar baz" => "E" => "foo.bar baz", 6;
vi_gE_back_end : "one two three" => "$gE" => "one two three", 6;
vi_W_skip_punct : "one-two three" => "W" => "one-two three", 8;
vi_B_skip_punct : "one two-three" => "$B" => "one two-three", 4;
vi_E_skip_punct : "one-two three" => "E" => "one-two three", 6;
vi_dW_big : "foo.bar baz" => "dW" => "baz", 0;
vi_cW_big : "foo.bar baz" => "cWx\x1b" => "x baz", 0;
vi_zero_bol : " hello" => "$0" => " hello", 0;
vi_caret_first_char : " hello" => "$^" => " hello", 2;
vi_dollar_eol : "hello world" => "$" => "hello world", 10;
vi_g_last_nonws : "hello " => "g_" => "hello ", 4;
vi_g_no_trailing : "hello" => "g_" => "hello", 4;
vi_pipe_column : "hello world" => "6|" => "hello world", 5;
vi_pipe_col1 : "hello world" => "1|" => "hello world", 0;
vi_I_insert_front : " hello" => "Iworld \x1b" => " world hello", 7;
vi_A_append_end : "hello" => "A world\x1b" => "hello world", 10;
vi_f_find : "hello world" => "fo" => "hello world", 4;
vi_F_find_back : "hello world" => "$Fo" => "hello world", 7;
vi_t_till : "hello world" => "tw" => "hello world", 5;
vi_T_till_back : "hello world" => "$To" => "hello world", 8;
vi_f_no_match : "hello" => "fz" => "hello", 0;
vi_semicolon_repeat : "abcabc" => "fa;;" => "abcabc", 3;
vi_comma_reverse : "abcabc" => "fa;;," => "abcabc", 0;
vi_df_semicolon : "abcabc" => "fa;;dfa" => "abcabc", 3;
vi_t_at_target : "aab" => "lta" => "aab", 1;
vi_D_to_end : "hello world" => "wD" => "hello ", 5;
vi_d_dollar : "hello world" => "wd$" => "hello ", 5;
vi_d0_to_start : "hello world" => "$d0" => "d", 0;
vi_dw_multiple : "one two three" => "d2w" => "three", 0;
vi_dt_char : "hello world" => "dtw" => "world", 0;
vi_df_char : "hello world" => "dfw" => "orld", 0;
vi_dh_back : "hello" => "lldh" => "hllo", 1;
vi_dl_forward : "hello" => "dl" => "ello", 0;
vi_dge_back_end : "one two three" => "$dge" => "one tw", 5;
vi_dG_to_end : "hello world" => "dG" => "", 0;
vi_dgg_to_start : "hello world" => "$dgg" => "", 0;
vi_d_semicolon : "abcabc" => "fad;" => "abcabc", 3;
vi_cw_basic : "hello world" => "cwfoo\x1b" => "foo world", 2;
vi_C_to_end : "hello world" => "wCfoo\x1b" => "hello foo", 8;
vi_cc_whole : "hello world" => "ccfoo\x1b" => "foo", 2;
vi_ct_char : "hello world" => "ctwfoo\x1b" => "fooworld", 2;
vi_s_single : "hello" => "sfoo\x1b" => "fooello", 2;
vi_S_whole_line : "hello world" => "Sfoo\x1b" => "foo", 2;
vi_cl_forward : "hello" => "clX\x1b" => "Xello", 0;
vi_ch_backward : "hello" => "llchX\x1b" => "hXllo", 1;
vi_cb_word_back : "hello world" => "$cbfoo\x1b" => "hello food", 8;
vi_ce_word_end : "hello world" => "cefoo\x1b" => "foo world", 2;
vi_c0_to_start : "hello world" => "wc0foo\x1b" => "fooworld", 2;
vi_yw_p_basic : "hello world" => "ywwP" => "hello hello world", 11;
vi_dw_p_paste : "hello world" => "dwP" => "hello world", 5;
vi_dd_p_paste : "hello world" => "ddp" => "\nhello world", 1;
vi_y_dollar_p : "hello world" => "wy$P" => "hello worldworld", 10;
vi_ye_p : "hello world" => "yewP" => "hello helloworld", 10;
vi_yy_p : "hello world" => "yyp" => "hello world\nhello world", 12;
vi_Y_p : "hello world" => "Yp" => "hhello worldello world", 11;
vi_p_after_x : "hello" => "xp" => "ehllo", 1;
vi_P_before : "hello" => "llxP" => "hello", 2;
vi_paste_empty : "hello" => "p" => "hello", 0;
vi_r_replace : "hello" => "ra" => "aello", 0;
vi_r_middle : "hello" => "llra" => "healo", 2;
vi_r_at_end : "hello" => "$ra" => "hella", 4;
vi_r_space : "hello" => "r " => " ello", 0;
vi_r_with_count : "hello" => "3rx" => "xxxlo", 2;
vi_tilde_single : "hello" => "~" => "Hello", 1;
vi_tilde_count : "hello" => "3~" => "HELlo", 3;
vi_tilde_at_end : "HELLO" => "$~" => "HELLo", 4;
vi_tilde_mixed : "hElLo" => "5~" => "HeLlO", 4;
vi_gu_word : "HELLO world" => "guw" => "hello world", 0;
vi_gU_word : "hello WORLD" => "gUw" => "HELLO WORLD", 0;
vi_gu_dollar : "HELLO WORLD" => "gu$" => "hello world", 0;
vi_gU_dollar : "hello world" => "gU$" => "HELLO WORLD", 0;
vi_gu_0 : "HELLO WORLD" => "$gu0" => "hello worlD", 0;
vi_gU_0 : "hello world" => "$gU0" => "HELLO WORLd", 0;
vi_gtilde_word : "hello WORLD" => "g~w" => "HELLO WORLD", 0;
vi_gtilde_dollar : "hello WORLD" => "g~$" => "HELLO world", 0;
vi_diw_inner : "one two three" => "wdiw" => "one three", 4;
vi_ciw_replace : "hello world" => "ciwfoo\x1b" => "foo world", 2;
vi_daw_around : "one two three" => "wdaw" => "one three", 4;
vi_yiw_p : "hello world" => "yiwAp \x1bp" => "hello worldp hello", 17;
vi_diW_big_inner : "one-two three" => "diW" => " three", 0;
vi_daW_big_around : "one two-three end" => "wdaW" => "one end", 4;
vi_ciW_big : "one-two three" => "ciWx\x1b" => "x three", 0;
vi_di_dquote : "one \"two\" three" => "f\"di\"" => "one \"\" three", 5;
vi_da_dquote : "one \"two\" three" => "f\"da\"" => "one three", 4;
vi_ci_dquote : "one \"two\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5;
vi_di_squote : "one 'two' three" => "f'di'" => "one '' three", 5;
vi_da_squote : "one 'two' three" => "f'da'" => "one three", 4;
vi_di_backtick : "one `two` three" => "f`di`" => "one `` three", 5;
vi_da_backtick : "one `two` three" => "f`da`" => "one three", 4;
vi_ci_dquote_empty : "one \"\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5;
vi_di_paren : "one (two) three" => "f(di(" => "one () three", 5;
vi_da_paren : "one (two) three" => "f(da(" => "one three", 4;
vi_ci_paren : "one (two) three" => "f(ci(x\x1b" => "one (x) three", 5;
vi_di_brace : "one {two} three" => "f{di{" => "one {} three", 5;
vi_da_brace : "one {two} three" => "f{da{" => "one three", 4;
vi_di_bracket : "one [two] three" => "f[di[" => "one [] three", 5;
vi_da_bracket : "one [two] three" => "f[da[" => "one three", 4;
vi_di_angle : "one <two> three" => "f<di<" => "one <> three", 5;
vi_da_angle : "one <two> three" => "f<da<" => "one three", 4;
vi_di_paren_nested : "fn(a, (b, c))" => "f(di(" => "fn()", 3;
vi_di_paren_empty : "fn() end" => "f(di(" => "fn() end", 3;
vi_dib_alias : "one (two) three" => "f(dib" => "one () three", 5;
vi_diB_alias : "one {two} three" => "f{diB" => "one {} three", 5;
vi_percent_paren : "(hello) world" => "%" => "(hello) world", 6;
vi_percent_brace : "{hello} world" => "%" => "{hello} world", 6;
vi_percent_bracket : "[hello] world" => "%" => "[hello] world", 6;
vi_percent_from_close: "(hello) world" => "f)%" => "(hello) world", 0;
vi_d_percent_paren : "(hello) world" => "d%" => " world", 0;
vi_to_paren_fwd : "foo (bar) baz" => "])" => "foo (bar) baz", 8;
vi_to_paren_bkwd : "foo (bar) baz" => "f)[(" => "foo (bar) baz", 4;
vi_to_brace_fwd : "foo {bar} baz" => "]}" => "foo {bar} baz", 8;
vi_to_brace_bkwd : "foo {bar} baz" => "f}[{" => "foo {bar} baz", 4;
vi_to_paren_nested : "((a)(b)) end" => "])" => "((a)(b)) end", 7;
vi_to_brace_nested : "{{a}{b}} end" => "]}" => "{{a}{b}} end", 7;
vi_d_to_paren_fwd : "foo (bar) baz" => "wd])" => "foo baz", 4;
vi_d_to_brace_fwd : "foo {bar} baz" => "wd]}" => "foo baz", 4;
vi_to_paren_no_match : "foo bar baz" => "])" => "foo bar baz", 0;
vi_to_brace_no_match : "foo bar baz" => "]}" => "foo bar baz", 0;
vi_i_insert : "hello" => "iX\x1b" => "Xhello", 0;
vi_a_append : "hello" => "aX\x1b" => "hXello", 1;
vi_I_front : " hello" => "IX\x1b" => " Xhello", 2;
vi_A_end : "hello" => "AX\x1b" => "helloX", 5;
vi_o_open_below : "hello" => "oworld\x1b" => "hello\nworld", 10;
vi_O_open_above : "hello" => "Oworld\x1b" => "world\nhello", 4;
vi_empty_input : "" => "i hello\x1b" => " hello", 5;
vi_insert_escape : "hello" => "aX\x1b" => "hXello", 1;
vi_ctrl_w_del_word : "hello world" => "A\x17\x1b" => "hello ", 5;
vi_ctrl_h_backspace : "hello" => "A\x08\x1b" => "hell", 3;
vi_u_undo_delete : "hello world" => "dwu" => "hello world", 0;
vi_u_undo_change : "hello world" => "ciwfoo\x1bu" => "hello world", 0;
vi_u_undo_x : "hello" => "xu" => "hello", 0;
vi_ctrl_r_redo : "hello" => "xu\x12" => "ello", 0;
vi_u_multiple : "hello world" => "xdwu" => "ello world", 0;
vi_redo_after_undo : "hello world" => "dwu\x12" => "world", 0;
vi_dot_repeat_x : "hello" => "x." => "llo", 0;
vi_dot_repeat_dw : "one two three" => "dw." => "three", 0;
vi_dot_repeat_cw : "one two three" => "cwfoo\x1bw." => "foo foo three", 6;
vi_dot_repeat_r : "hello" => "ra.." => "aello", 0;
vi_dot_repeat_s : "hello" => "sX\x1bl." => "XXllo", 1;
vi_count_h : "hello world" => "$3h" => "hello world", 7;
vi_count_l : "hello world" => "3l" => "hello world", 3;
vi_count_w : "one two three four" => "2w" => "one two three four", 8;
vi_count_b : "one two three four" => "$2b" => "one two three four", 8;
vi_count_x : "hello" => "3x" => "lo", 0;
vi_count_dw : "one two three four" => "2dw" => "three four", 0;
vi_verb_count_motion : "one two three four" => "d2w" => "three four", 0;
vi_count_s : "hello" => "3sX\x1b" => "Xlo", 0;
vi_indent_line : "hello" => ">>" => "\thello", 1;
vi_dedent_line : "\thello" => "<<" => "hello", 0;
vi_indent_double : "hello" => ">>>>" => "\t\thello", 2;
vi_J_join_lines : "hello\nworld" => "J" => "hello world", 5;
vi_v_u_lower : "HELLO" => "vlllu" => "hellO", 0;
vi_v_U_upper : "hello" => "vlllU" => "HELLo", 0;
vi_v_d_delete : "hello world" => "vwwd" => "", 0;
vi_v_x_delete : "hello world" => "vwwx" => "", 0;
vi_v_c_change : "hello world" => "vwcfoo\x1b" => "fooorld", 2;
vi_v_y_p_yank : "hello world" => "vwyAp \x1bp" => "hello worldp hello w", 19;
vi_v_dollar_d : "hello world" => "wv$d" => "hello ", 5;
vi_v_0_d : "hello world" => "$v0d" => "", 0;
vi_ve_d : "hello world" => "ved" => " world", 0;
vi_v_o_swap : "hello world" => "vllod" => "lo world", 0;
vi_v_r_replace : "hello" => "vlllrx" => "xxxxo", 0;
vi_v_tilde_case : "hello" => "vlll~" => "HELLo", 0;
vi_V_d_delete : "hello world" => "Vd" => "", 0;
vi_V_y_p : "hello world" => "Vyp" => "hello world\nhello world", 12;
vi_V_S_change : "hello world" => "VSfoo\x1b" => "foo", 2;
vi_ctrl_a_inc : "num 5 end" => "w\x01" => "num 6 end", 4;
vi_ctrl_x_dec : "num 5 end" => "w\x18" => "num 4 end", 4;
vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 5;
vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 5;
vi_ctrl_a_count : "num 5 end" => "w3\x01" => "num 8 end", 4;
vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 8;
vi_delete_empty : "" => "x" => "", 0;
vi_undo_on_empty : "" => "u" => "", 0;
vi_w_single_char : "a b c" => "w" => "a b c", 2;
vi_dw_last_word : "hello" => "dw" => "", 0;
vi_dollar_single : "h" => "$" => "h", 0;
vi_caret_no_ws : "hello" => "$^" => "hello", 0;
vi_f_last_char : "hello" => "fo" => "hello", 4;
vi_r_on_space : "hello world" => "5|r-" => "hell- world", 4;
vi_vw_doesnt_crash : "" => "vw" => "", 0;
vi_indent_cursor_pos : "echo foo" => ">>" => "\techo foo", 1;
vi_join_indent_lines : "echo foo\n\t\techo bar" => "J" => "echo foo echo bar", 8;
vi_cw_stays_on_line : "echo foo\necho bar" => "wcw" => "echo \necho bar", 5
}
#[test]
fn vi_auto_indent() {
let (mut vi, _g) = test_vi("");
// Type each line and press Enter separately so auto-indent triggers
let lines = [
"func() {",
"case foo in",
"bar)",
"while true; do",
"echo foo \\\rbar \\\rbiz \\\rbazz\rbreak\rdone\r;;\resac\r}",
];
for (i, line) in lines.iter().enumerate() {
vi.feed_bytes(line.as_bytes());
if i != lines.len() - 1 {
vi.feed_bytes(b"\r");
}
vi.process_input().unwrap();
}
assert_eq!(
vi.editor.joined(),
"func() {\n\tcase foo in\n\t\tbar)\n\t\t\twhile true; do\n\t\t\t\techo foo \\\n\t\t\t\tbar \\\n\t\t\t\tbiz \\\n\t\t\t\tbazz\n\t\t\t\tbreak\n\t\t\tdone\n\t\t;;\n\tesac\n}"
);
}

View File

@@ -1,5 +1,12 @@
use std::path::PathBuf;
use bitflags::bitflags;
use crate::readline::{
linebuf::{Grapheme, Pos},
vimode::ex::SubFlags,
};
use super::register::{RegisterContent, append_register, read_register, write_register};
//TODO: write tests that take edit results and cursor positions from actual
@@ -64,6 +71,7 @@ bitflags! {
const VISUAL_LINE = 1<<1;
const VISUAL_BLOCK = 1<<2;
const EXIT_CUR_MODE = 1<<3;
const IS_EX_CMD = 1<<4;
}
}
@@ -153,26 +161,12 @@ impl ViCmd {
}) && self.motion.is_none()
}
pub fn is_line_motion(&self) -> bool {
self.motion.as_ref().is_some_and(|m| {
matches!(
m.1,
Motion::LineUp | Motion::LineDown | Motion::LineUpCharwise | Motion::LineDownCharwise
)
})
self
.motion
.as_ref()
.is_some_and(|m| matches!(m.1, Motion::LineUp | Motion::LineDown))
}
/// If a ViCmd has a linewise motion, but no verb, we change it to charwise
pub fn alter_line_motion_if_no_verb(&mut self) {
if self.is_line_motion()
&& self.verb.is_none()
&& let Some(motion) = self.motion.as_mut()
{
match motion.1 {
Motion::LineUp => motion.1 = Motion::LineUpCharwise,
Motion::LineDown => motion.1 = Motion::LineDownCharwise,
_ => unreachable!(),
}
}
}
pub fn is_mode_transition(&self) -> bool {
self.verb.as_ref().is_some_and(|v| {
matches!(
@@ -249,13 +243,16 @@ pub enum Verb {
Equalize,
AcceptLineOrNewline,
EndOfFile,
PrintPosition,
// Ex-mode verbs
ExMode,
ShellCmd(String),
Normal(String),
Read(ReadSrc),
Write(WriteDest),
Substitute(String, String, super::vimode::ex::SubFlags),
Edit(PathBuf),
Quit,
Substitute(String, String, SubFlags),
RepeatSubstitute,
RepeatGlobal,
}
@@ -301,6 +298,9 @@ impl Verb {
| Self::JoinLines
| Self::InsertChar(_)
| Self::Insert(_)
| Self::Dedent
| Self::Indent
| Self::Equalize
| Self::Rot13
| Self::EndOfFile
| Self::IncrementNumber(_)
@@ -310,47 +310,40 @@ impl Verb {
pub fn is_char_insert(&self) -> bool {
matches!(
self,
Self::Change | Self::InsertChar(_) | Self::ReplaceChar(_) | Self::ReplaceCharInplace(_, _)
Self::InsertChar(_) | Self::ReplaceChar(_)
)
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Motion {
WholeLineInclusive, // whole line including the linebreak
WholeLineExclusive, // whole line excluding the linebreak
WholeLine,
TextObj(TextObj),
EndOfLastWord,
BeginningOfFirstWord,
BeginningOfLine,
StartOfFirstWord,
StartOfLine,
EndOfLine,
WordMotion(To, Word, Direction),
CharSearch(Direction, Dest, char),
CharSearch(Direction, Dest, Grapheme),
BackwardChar,
ForwardChar,
BackwardCharForced, // These two variants can cross line boundaries
ForwardCharForced,
LineUp,
LineUpCharwise,
ScreenLineUp,
ScreenLineUpCharwise,
LineDown,
LineDownCharwise,
ScreenLineDown,
ScreenLineDownCharwise,
BeginningOfScreenLine,
FirstGraphicalOnScreenLine,
HalfOfScreen,
HalfOfScreenLineText,
WholeBuffer,
BeginningOfBuffer,
StartOfBuffer,
EndOfBuffer,
ToColumn,
ToDelimMatch,
HalfScreenDown,
HalfScreenUp,
ToBrace(Direction),
ToBracket(Direction),
ToParen(Direction),
Range(usize, usize),
CharRange(Pos, Pos),
LineRange(usize, usize),
BlockRange(Pos, Pos),
RepeatMotion,
RepeatMotionRev,
Null,
@@ -379,14 +372,8 @@ impl Motion {
pub fn is_exclusive(&self) -> bool {
matches!(
&self,
Self::BeginningOfLine
| Self::BeginningOfFirstWord
| Self::BeginningOfScreenLine
| Self::FirstGraphicalOnScreenLine
| Self::LineDownCharwise
| Self::LineUpCharwise
| Self::ScreenLineUpCharwise
| Self::ScreenLineDownCharwise
Self::StartOfLine
| Self::StartOfFirstWord
| Self::ToColumn
| Self::TextObj(TextObj::Sentence(_))
| Self::TextObj(TextObj::Paragraph(_))
@@ -395,21 +382,11 @@ impl Motion {
| Self::ToBrace(_)
| Self::ToBracket(_)
| Self::ToParen(_)
| Self::ScreenLineDown
| Self::ScreenLineUp
| Self::Range(_, _)
| Self::CharRange(_, _)
)
}
pub fn is_linewise(&self) -> bool {
matches!(
self,
Self::WholeLineInclusive
| Self::WholeLineExclusive
| Self::LineUp
| Self::LineDown
| Self::ScreenLineDown
| Self::ScreenLineUp
)
matches!(self, Self::WholeLine | Self::LineUp | Self::LineDown)
}
}

View File

@@ -5,7 +5,10 @@ use std::str::Chars;
use itertools::Itertools;
use crate::bitflags;
use crate::expand::Expander;
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
use crate::parse::lex::TkFlags;
use crate::readline::history::History;
use crate::readline::keys::KeyEvent;
use crate::readline::linebuf::LineBuf;
use crate::readline::vicmd::{
@@ -33,16 +36,61 @@ bitflags! {
struct ExEditor {
buf: LineBuf,
mode: ViInsert,
history: History,
}
impl ExEditor {
pub fn new(history: History) -> Self {
Self {
history,
..Default::default()
}
}
pub fn clear(&mut self) {
*self = Self::default()
}
pub fn should_grab_history(&mut self, cmd: &ViCmd) -> bool {
cmd.verb().is_none()
&& (cmd
.motion()
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineUp)))
&& self.buf.start_of_line() == 0)
|| (cmd
.motion()
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineDown)))
&& self.buf.on_last_line())
}
pub fn scroll_history(&mut self, cmd: ViCmd) {
let count = &cmd.motion().unwrap().0;
let motion = &cmd.motion().unwrap().1;
let count = match motion {
Motion::LineUp => -(*count as isize),
Motion::LineDown => *count as isize,
_ => unreachable!(),
};
let entry = self.history.scroll(count);
if let Some(entry) = entry {
let buf = std::mem::take(&mut self.buf);
self.buf.set_buffer(entry.command().to_string());
if self.history.pending.is_none() {
self.history.pending = Some(buf);
}
self.buf.set_hint(None);
self.buf.move_cursor_to_end();
} else if let Some(pending) = self.history.pending.take() {
self.buf = pending;
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<()> {
let Some(cmd) = self.mode.handle_key(key) else {
return Ok(());
};
log::debug!("ExEditor got cmd: {:?}", cmd);
if self.should_grab_history(&cmd) {
log::debug!("Grabbing history for cmd: {:?}", cmd);
self.scroll_history(cmd);
return Ok(());
}
self.buf.exec_cmd(cmd)
}
}
@@ -53,8 +101,10 @@ pub struct ViEx {
}
impl ViEx {
pub fn new() -> Self {
Self::default()
pub fn new(history: History) -> Self {
Self {
pending_cmd: ExEditor::new(history),
}
}
}
@@ -62,48 +112,34 @@ impl ViMode for ViEx {
// Ex mode can return errors, so we use this fallible method instead of the normal one
fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult<Option<ViCmd>> {
use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M};
log::debug!("[ViEx] handle_key_fallible: key={:?}", key);
match key {
E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => {
let input = self.pending_cmd.buf.as_str();
log::debug!("[ViEx] Enter pressed, pending_cmd={:?}", input);
match parse_ex_cmd(input) {
Ok(cmd) => {
log::debug!("[ViEx] parse_ex_cmd Ok: {:?}", cmd);
Ok(cmd)
}
let input = self.pending_cmd.buf.joined();
match parse_ex_cmd(&input) {
Ok(cmd) => Ok(cmd),
Err(e) => {
log::debug!("[ViEx] parse_ex_cmd Err: {:?}", e);
let msg = e.unwrap_or(format!("Not an editor command: {}", input));
write_meta(|m| m.post_system_message(msg.clone()));
let msg = e.unwrap_or(format!("Not an editor command: {}", &input));
write_meta(|m| m.post_status_message(msg.clone()));
Err(ShErr::simple(ShErrKind::ParseErr, msg))
}
}
}
E(C::Char('C'), M::CTRL) => {
log::debug!("[ViEx] Ctrl-C, clearing");
self.pending_cmd.clear();
Ok(None)
}
E(C::Esc, M::NONE) => {
log::debug!("[ViEx] Esc, returning to normal mode");
Ok(Some(ViCmd {
register: RegisterName::default(),
verb: Some(VerbCmd(1, Verb::NormalMode)),
motion: None,
flags: CmdFlags::empty(),
raw_seq: "".into(),
}))
}
_ => {
log::debug!("[ViEx] forwarding key to ExEditor");
self.pending_cmd.handle_key(key).map(|_| None)
}
E(C::Esc, M::NONE) => Ok(Some(ViCmd {
register: RegisterName::default(),
verb: Some(VerbCmd(1, Verb::NormalMode)),
motion: None,
flags: CmdFlags::empty(),
raw_seq: "".into(),
})),
_ => self.pending_cmd.handle_key(key).map(|_| None),
}
}
fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> {
let result = self.handle_key_fallible(key);
log::debug!("[ViEx] handle_key result: {:?}", result);
result.ok().flatten()
}
fn is_repeatable(&self) -> bool {
@@ -114,16 +150,24 @@ impl ViMode for ViEx {
None
}
fn editor(&mut self) -> Option<&mut LineBuf> {
Some(&mut self.pending_cmd.buf)
}
fn history(&mut self) -> Option<&mut History> {
Some(&mut self.pending_cmd.history)
}
fn cursor_style(&self) -> String {
"\x1b[3 q".to_string()
}
fn pending_seq(&self) -> Option<String> {
Some(self.pending_cmd.buf.as_str().to_string())
Some(self.pending_cmd.buf.joined())
}
fn pending_cursor(&self) -> Option<usize> {
Some(self.pending_cmd.buf.cursor.get())
Some(self.pending_cmd.buf.cursor_to_flat())
}
fn move_cursor_on_undo(&self) -> bool {
@@ -177,15 +221,12 @@ fn parse_ex_cmd(raw: &str) -> Result<Option<ViCmd>, Option<String>> {
verb,
motion,
raw_seq: raw.to_string(),
flags: CmdFlags::EXIT_CUR_MODE,
flags: CmdFlags::EXIT_CUR_MODE | CmdFlags::IS_EX_CMD,
}))
}
/// Unescape shell command arguments
fn unescape_shell_cmd(cmd: &str) -> String {
// The pest grammar uses double quotes for vicut commands
// So shell commands need to escape double quotes
// We will be removing a single layer of escaping from double quotes
let mut result = String::new();
let mut chars = cmd.chars().peekable();
while let Some(ch) = chars.next() {
@@ -207,7 +248,7 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
let mut cmd_name = String::new();
while let Some(ch) = chars.peek() {
if ch == &'!' {
if cmd_name.is_empty() && ch == &'!' {
cmd_name.push(*ch);
chars.next();
break;
@@ -224,12 +265,19 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
let cmd = unescape_shell_cmd(&cmd);
Ok(Some(Verb::ShellCmd(cmd)))
}
_ if "help".starts_with(&cmd_name) => {
let cmd = "help ".to_string() + chars.collect::<String>().trim();
log::debug!("Parsed help command: {}", cmd);
Ok(Some(Verb::ShellCmd(cmd)))
}
"normal!" => parse_normal(chars),
_ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)),
_ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)),
_ if "put".starts_with(&cmd_name) => Ok(Some(Verb::Put(Anchor::After))),
_ if "quit".starts_with(&cmd_name) => Ok(Some(Verb::Quit)),
_ if "read".starts_with(&cmd_name) => parse_read(chars),
_ if "write".starts_with(&cmd_name) => parse_write(chars),
_ if "edit".starts_with(&cmd_name) => parse_edit(chars),
_ if "substitute".starts_with(&cmd_name) => parse_substitute(chars),
_ => Err(None),
}
@@ -244,6 +292,19 @@ fn parse_normal(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<
Ok(Some(Verb::Normal(seq)))
}
fn parse_edit(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
chars
.peeking_take_while(|c| c.is_whitespace())
.for_each(drop);
let arg: String = chars.collect();
if arg.trim().is_empty() {
return Err(Some("Expected file path after ':edit'".into()));
}
let arg_path = get_path(arg.trim())?;
Ok(Some(Verb::Edit(arg_path)))
}
fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
chars
.peeking_take_while(|c| c.is_whitespace())
@@ -266,23 +327,20 @@ fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<St
if is_shell_read {
Ok(Some(Verb::Read(ReadSrc::Cmd(arg))))
} else {
let arg_path = get_path(arg.trim());
let arg_path = get_path(arg.trim())?;
Ok(Some(Verb::Read(ReadSrc::File(arg_path))))
}
}
fn get_path(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(stripped);
}
if path == "~"
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home);
}
PathBuf::from(path)
fn get_path(path: &str) -> Result<PathBuf, Option<String>> {
log::debug!("Expanding path: {}", path);
let expanded = Expander::from_raw(path, TkFlags::empty())
.map_err(|e| Some(format!("Error expanding path: {}", e)))?
.expand()
.map_err(|e| Some(format!("Error expanding path: {}", e)))?
.join(" ");
log::debug!("Expanded path: {}", expanded);
Ok(PathBuf::from(&expanded))
}
fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
@@ -305,7 +363,7 @@ fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<S
}
let arg: String = chars.collect();
let arg_path = get_path(arg.trim());
let arg_path = get_path(arg.trim())?;
let dest = if is_file_append {
WriteDest::FileAppend(arg_path)

View File

@@ -13,6 +13,10 @@ impl ViInsert {
pub fn new() -> Self {
Self::default()
}
pub fn record_cmd(mut self, cmd: ViCmd) -> Self {
self.cmds.push(cmd);
self
}
pub fn with_count(mut self, repeat_count: u16) -> Self {
self.repeat_count = repeat_count;
self
@@ -61,10 +65,12 @@ impl ViMode for ViInsert {
raw_seq: String::new(),
flags: Default::default(),
}),
E(K::Verbatim(seq), _) => {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Insert(seq.to_string())));
self.register_and_return()
}
E(K::Verbatim(seq), _) => {
self
.pending_cmd
.set_verb(VerbCmd(1, Verb::Insert(seq.to_string())));
self.register_and_return()
}
E(K::Char('W'), M::CTRL) => {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
self.pending_cmd.set_motion(MotionCmd(

View File

@@ -3,7 +3,9 @@ use std::fmt::Display;
use unicode_segmentation::UnicodeSegmentation;
use crate::libsh::error::ShResult;
use crate::readline::history::History;
use crate::readline::keys::{KeyCode as K, KeyEvent as E, ModKeys as M};
use crate::readline::linebuf::LineBuf;
use crate::readline::vicmd::{Motion, MotionCmd, To, Verb, VerbCmd, ViCmd};
pub mod ex;
@@ -82,6 +84,12 @@ pub trait ViMode {
fn pending_cursor(&self) -> Option<usize> {
None
}
fn editor(&mut self) -> Option<&mut LineBuf> {
None
}
fn history(&mut self) -> Option<&mut History> {
None
}
fn move_cursor_on_undo(&self) -> bool;
fn clamp_cursor(&self) -> bool;
fn hist_scroll_start_pos(&self) -> Option<To>;
@@ -102,21 +110,22 @@ pub trait ViMode {
pub fn common_cmds(key: E) -> Option<ViCmd> {
let mut pending_cmd = ViCmd::new();
match key {
E(K::Home, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::BeginningOfLine)),
E(K::Home, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::StartOfLine)),
E(K::End, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::EndOfLine)),
E(K::Left, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::BackwardChar)),
E(K::Right, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::ForwardChar)),
E(K::Up, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::LineUp)),
E(K::Down, M::NONE) => pending_cmd.set_motion(MotionCmd(1, Motion::LineDown)),
E(K::Enter, M::SHIFT) => pending_cmd.set_verb(VerbCmd(1, Verb::InsertChar('\n'))),
E(K::Enter, M::NONE) => pending_cmd.set_verb(VerbCmd(1, Verb::AcceptLineOrNewline)),
E(K::Char('D'), M::CTRL) => pending_cmd.set_verb(VerbCmd(1, Verb::EndOfFile)),
E(K::Delete, M::NONE) => {
pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
pending_cmd.set_motion(MotionCmd(1, Motion::ForwardChar));
pending_cmd.set_motion(MotionCmd(1, Motion::ForwardCharForced));
}
E(K::Backspace, M::NONE) | E(K::Char('H'), M::CTRL) => {
pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
pending_cmd.set_motion(MotionCmd(1, Motion::BackwardChar));
pending_cmd.set_motion(MotionCmd(1, Motion::BackwardCharForced));
}
_ => return None,
}

View File

@@ -3,6 +3,7 @@ use std::str::Chars;
use super::{CmdReplay, CmdState, ModeReport, ViMode, common_cmds};
use crate::readline::keys::{KeyCode as K, KeyEvent as E, ModKeys as M};
use crate::readline::linebuf::Grapheme;
use crate::readline::vicmd::{
Anchor, Bound, CmdFlags, Dest, Direction, Motion, MotionCmd, RegisterName, TextObj, To, Verb,
VerbCmd, ViCmd, Word,
@@ -197,7 +198,7 @@ impl ViNormal {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Change)),
motion: Some(MotionCmd(1, Motion::WholeLineExclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: self.flags(),
});
@@ -331,7 +332,7 @@ impl ViNormal {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::InsertMode)),
motion: Some(MotionCmd(1, Motion::BeginningOfFirstWord)),
motion: Some(MotionCmd(1, Motion::StartOfFirstWord)),
raw_seq: self.take_cmd(),
flags: self.flags(),
});
@@ -411,10 +412,10 @@ impl ViNormal {
| ('~', Some(VerbCmd(_, Verb::ToggleCaseRange)))
| ('>', Some(VerbCmd(_, Verb::Indent)))
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineInclusive));
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
}
('c', Some(VerbCmd(_, Verb::Change))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineExclusive));
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
}
('W', Some(VerbCmd(_, Verb::Change))) => {
// Same with 'W'
@@ -434,7 +435,7 @@ impl ViNormal {
'g' => {
chars_clone.next();
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer));
break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer));
}
'e' => {
chars = chars_clone;
@@ -450,26 +451,10 @@ impl ViNormal {
Motion::WordMotion(To::End, Word::Big, Direction::Backward),
));
}
'k' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineUp));
}
'j' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineDown));
}
'_' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::EndOfLastWord));
}
'0' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfScreenLine));
}
'^' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::FirstGraphicalOnScreenLine));
}
_ => return self.quit_parse(),
}
}
@@ -551,7 +536,7 @@ impl ViNormal {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Forward, Dest::On, *ch),
Motion::CharSearch(Direction::Forward, Dest::On, Grapheme::from(*ch)),
));
}
'F' => {
@@ -561,7 +546,7 @@ impl ViNormal {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Backward, Dest::On, *ch),
Motion::CharSearch(Direction::Backward, Dest::On, Grapheme::from(*ch)),
));
}
't' => {
@@ -571,7 +556,7 @@ impl ViNormal {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Forward, Dest::Before, *ch),
Motion::CharSearch(Direction::Forward, Dest::Before, Grapheme::from(*ch)),
));
}
'T' => {
@@ -581,7 +566,7 @@ impl ViNormal {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Backward, Dest::Before, *ch),
Motion::CharSearch(Direction::Backward, Dest::Before, Grapheme::from(*ch)),
));
}
';' => {
@@ -598,11 +583,11 @@ impl ViNormal {
}
'^' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfFirstWord));
break 'motion_parse Some(MotionCmd(count, Motion::StartOfFirstWord));
}
'0' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfLine));
break 'motion_parse Some(MotionCmd(count, Motion::StartOfLine));
}
'$' => {
chars = chars_clone;
@@ -791,6 +776,36 @@ impl ViMode for ViNormal {
flags: self.flags(),
})
}
E(K::Char('G'), M::CTRL) => {
self.pending_seq.clear();
Some(ViCmd {
register: Default::default(),
verb: Some(VerbCmd(1, Verb::PrintPosition)),
motion: None,
raw_seq: "".into(),
flags: self.flags(),
})
}
E(K::Char('D'), M::CTRL) => {
self.pending_seq.clear();
Some(ViCmd {
register: Default::default(),
verb: None,
motion: Some(MotionCmd(1, Motion::HalfScreenDown)),
raw_seq: "".into(),
flags: self.flags(),
})
}
E(K::Char('U'), M::CTRL) => {
self.pending_seq.clear();
Some(ViCmd {
register: Default::default(),
verb: None,
motion: Some(MotionCmd(1, Motion::HalfScreenUp)),
raw_seq: "".into(),
flags: self.flags(),
})
}
E(K::Char(ch), M::NONE) => self.try_parse(ch),
E(K::Backspace, M::NONE) => Some(ViCmd {

View File

@@ -4,19 +4,11 @@ use crate::readline::vicmd::{CmdFlags, RegisterName, To, Verb, VerbCmd, ViCmd};
#[derive(Default, Clone, Debug)]
pub struct ViVerbatim {
pending_seq: String,
sent_cmd: Vec<ViCmd>,
repeat_count: u16,
read_one: bool
}
impl ViVerbatim {
pub fn read_one() -> Self {
Self {
read_one: true,
..Self::default()
}
}
pub fn new() -> Self {
Self::default()
}
@@ -31,7 +23,7 @@ impl ViVerbatim {
impl ViMode for ViVerbatim {
fn handle_key(&mut self, key: E) -> Option<ViCmd> {
match key {
E(K::Verbatim(seq), _mods) if self.read_one => {
E(K::Verbatim(seq), _mods) => {
log::debug!("Received verbatim key sequence: {:?}", seq);
let cmd = ViCmd {
register: RegisterName::default(),
@@ -43,22 +35,6 @@ impl ViMode for ViVerbatim {
self.sent_cmd.push(cmd.clone());
Some(cmd)
}
E(K::Verbatim(seq), _mods) => {
self.pending_seq.push_str(&seq);
None
}
E(K::BracketedPasteEnd, _mods) => {
log::debug!("Received verbatim paste: {:?}", self.pending_seq);
let cmd = ViCmd {
register: RegisterName::default(),
verb: Some(VerbCmd(1, Verb::Insert(self.pending_seq.clone()))),
motion: None,
raw_seq: std::mem::take(&mut self.pending_seq),
flags: CmdFlags::EXIT_CUR_MODE,
};
self.sent_cmd.push(cmd.clone());
Some(cmd)
}
_ => common_cmds(key),
}
}

View File

@@ -3,6 +3,7 @@ use std::str::Chars;
use super::{CmdReplay, CmdState, ModeReport, ViMode, common_cmds};
use crate::readline::keys::{KeyCode as K, KeyEvent as E, ModKeys as M};
use crate::readline::linebuf::Grapheme;
use crate::readline::vicmd::{
Anchor, Bound, CmdFlags, Dest, Direction, Motion, MotionCmd, RegisterName, TextObj, To, Verb,
VerbCmd, ViCmd, Word,
@@ -146,7 +147,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Delete)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -155,7 +156,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Yank)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -164,7 +165,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Delete)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -173,7 +174,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Change)),
motion: Some(MotionCmd(1, Motion::WholeLineExclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -182,7 +183,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Indent)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -191,7 +192,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Dedent)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -200,7 +201,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Equalize)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -237,6 +238,24 @@ impl ViVisual {
flags: CmdFlags::empty(),
});
}
's' => {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Delete)),
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
}
'S' => {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Change)),
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
}
'U' => {
return Some(ViCmd {
register,
@@ -268,7 +287,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::InsertMode)),
motion: Some(MotionCmd(1, Motion::BeginningOfLine)),
motion: Some(MotionCmd(1, Motion::StartOfLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -283,8 +302,13 @@ impl ViVisual {
});
}
'y' => {
chars = chars_clone;
break 'verb_parse Some(VerbCmd(count, Verb::Yank));
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Yank)),
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
}
'd' => {
chars = chars_clone;
@@ -321,10 +345,10 @@ impl ViVisual {
| ('=', Some(VerbCmd(_, Verb::Equalize)))
| ('>', Some(VerbCmd(_, Verb::Indent)))
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineInclusive));
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
}
('c', Some(VerbCmd(_, Verb::Change))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineExclusive));
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
}
_ => {}
}
@@ -335,7 +359,7 @@ impl ViVisual {
'g' => {
chars_clone.next();
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer));
break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer));
}
'e' => {
chars_clone.next();
@@ -353,16 +377,6 @@ impl ViVisual {
Motion::WordMotion(To::End, Word::Big, Direction::Backward),
));
}
'k' => {
chars_clone.next();
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineUp));
}
'j' => {
chars_clone.next();
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineDown));
}
_ => return self.quit_parse(),
}
} else {
@@ -412,7 +426,7 @@ impl ViVisual {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Forward, Dest::On, *ch),
Motion::CharSearch(Direction::Forward, Dest::On, Grapheme::from(*ch)),
));
}
'F' => {
@@ -422,7 +436,7 @@ impl ViVisual {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Backward, Dest::On, *ch),
Motion::CharSearch(Direction::Backward, Dest::On, Grapheme::from(*ch)),
));
}
't' => {
@@ -432,7 +446,7 @@ impl ViVisual {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Forward, Dest::Before, *ch),
Motion::CharSearch(Direction::Forward, Dest::Before, Grapheme::from(*ch)),
));
}
'T' => {
@@ -442,7 +456,7 @@ impl ViVisual {
break 'motion_parse Some(MotionCmd(
count,
Motion::CharSearch(Direction::Backward, Dest::Before, *ch),
Motion::CharSearch(Direction::Backward, Dest::Before, Grapheme::from(*ch)),
));
}
';' => {
@@ -459,7 +473,7 @@ impl ViVisual {
}
'0' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfLine));
break 'motion_parse Some(MotionCmd(count, Motion::StartOfLine));
}
'$' => {
chars = chars_clone;
@@ -649,6 +663,36 @@ impl ViMode for ViVisual {
flags: CmdFlags::empty(),
})
}
E(K::Char('G'), M::CTRL) => {
self.pending_seq.clear();
Some(ViCmd {
register: Default::default(),
verb: Some(VerbCmd(1, Verb::PrintPosition)),
motion: None,
raw_seq: "".into(),
flags: CmdFlags::empty()
})
}
E(K::Char('D'), M::CTRL) => {
self.pending_seq.clear();
Some(ViCmd {
register: Default::default(),
verb: None,
motion: Some(MotionCmd(1, Motion::HalfScreenDown)),
raw_seq: "".into(),
flags: CmdFlags::empty(),
})
}
E(K::Char('U'), M::CTRL) => {
self.pending_seq.clear();
Some(ViCmd {
register: Default::default(),
verb: None,
motion: Some(MotionCmd(1, Motion::HalfScreenUp)),
raw_seq: "".into(),
flags: CmdFlags::empty(),
})
}
E(K::Char('R'), M::CTRL) => {
let mut chars = self.pending_seq.chars().peekable();
let count = self.parse_count(&mut chars).unwrap_or(1);

View File

@@ -2,6 +2,35 @@ use std::{fmt::Display, str::FromStr};
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
/// Escapes a string for embedding inside single quotes.
/// Only escapes unescaped `\` and `'` characters.
pub fn escape_for_single_quote(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.peek() {
Some(&'\\') | Some(&'\'') => {
// Already escaped — pass through both characters
result.push(ch);
result.push(chars.next().unwrap());
}
_ => {
// Lone backslash — escape it
result.push('\\');
result.push('\\');
}
}
} else if ch == '\'' {
result.push('\\');
result.push('\'');
} else {
result.push(ch);
}
}
result
}
#[derive(Clone, Copy, Debug)]
pub enum ShedBellStyle {
Audible,
@@ -24,49 +53,113 @@ impl FromStr for ShedBellStyle {
}
}
#[derive(Default, Clone, Copy, Debug)]
pub enum ShedEditMode {
#[default]
Vi,
Emacs,
}
impl FromStr for ShedEditMode {
type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"vi" => Ok(Self::Vi),
"emacs" => Ok(Self::Emacs),
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("Invalid edit mode '{s}'"),
)),
/// Generates a shopt group struct with `set`, `get`, `Display`, and `Default` impls.
///
/// Doc comments on each field become the description shown by `shopt get`.
/// Every field type must implement `FromStr + Display`.
///
/// Optional per-field validation: `#[validate(|val| expr)]` runs after parsing
/// and must return `Result<(), String>` where the error string is the message.
macro_rules! shopt_group {
(
$(#[$struct_meta:meta])*
pub struct $name:ident ($group_name:literal) {
$(
$(#[doc = $desc:literal])*
$(#[validate($validator:expr)])?
$field:ident : $ty:ty = $default:expr
),* $(,)?
}
}
}
impl Display for ShedEditMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShedEditMode::Vi => write!(f, "vi"),
ShedEditMode::Emacs => write!(f, "emacs"),
) => {
$(#[$struct_meta])*
pub struct $name {
$(pub $field: $ty,)*
}
}
impl Default for $name {
fn default() -> Self {
Self {
$($field: $default,)*
}
}
}
impl $name {
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
match opt {
$(
stringify!($field) => {
let parsed = val.parse::<$ty>().map_err(|_| {
ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: invalid value '{}' for {}.{}", val, $group_name, opt),
)
})?;
$(
let validate: fn(&$ty) -> Result<(), String> = $validator;
validate(&parsed).map_err(|msg| {
ShErr::simple(ShErrKind::SyntaxErr, format!("shopt: {msg}"))
})?;
)?
self.$field = parsed;
}
)*
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: unexpected '{}' option '{opt}'", $group_name),
));
}
}
Ok(())
}
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
if query.is_empty() {
return Ok(Some(format!("{self}")));
}
match query {
$(
stringify!($field) => {
let desc = concat!($($desc, "\n",)*);
let output = format!("{}{}", desc, self.$field);
Ok(Some(output))
}
)*
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: unexpected '{}' option '{query}'", $group_name),
)),
}
}
}
impl Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let output = [
$(format!("{}.{}='{}'", $group_name, stringify!($field),
$crate::shopt::escape_for_single_quote(&self.$field.to_string())),)*
];
writeln!(f, "{}", output.join("\n"))
}
}
};
}
#[derive(Clone, Debug)]
pub struct ShOpts {
pub core: ShOptCore,
pub line: ShOptLine,
pub prompt: ShOptPrompt,
}
impl Default for ShOpts {
fn default() -> Self {
let core = ShOptCore::default();
let line = ShOptLine::default();
let prompt = ShOptPrompt::default();
Self { core, prompt }
Self { core, line, prompt }
}
}
@@ -82,8 +175,9 @@ impl ShOpts {
pub fn display_opts(&mut self) -> ShResult<String> {
let output = [
format!("core:\n{}", self.query("core")?.unwrap_or_default()),
format!("prompt:\n{}", self.query("prompt")?.unwrap_or_default()),
self.query("core")?.unwrap_or_default().to_string(),
self.query("line")?.unwrap_or_default().to_string(),
self.query("prompt")?.unwrap_or_default().to_string(),
];
Ok(output.join("\n"))
@@ -102,6 +196,7 @@ impl ShOpts {
match key {
"core" => self.core.set(&remainder, val)?,
"line" => self.line.set(&remainder, val)?,
"prompt" => self.prompt.set(&remainder, val)?,
_ => {
return Err(ShErr::simple(
@@ -126,6 +221,7 @@ impl ShOpts {
match key {
"core" => self.core.get(&remainder),
"line" => self.line.get(&remainder),
"prompt" => self.prompt.get(&remainder),
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
@@ -135,385 +231,212 @@ impl ShOpts {
}
}
#[derive(Clone, Debug)]
pub struct ShOptCore {
pub dotglob: bool,
pub autocd: bool,
pub hist_ignore_dupes: bool,
pub max_hist: isize,
pub interactive_comments: bool,
pub auto_hist: bool,
pub bell_enabled: bool,
pub max_recurse_depth: usize,
shopt_group! {
#[derive(Clone, Debug)]
pub struct ShOptLine ("line") {
/// The maximum height of the line editor viewport window. Can be a positive number or a percentage of terminal height like "50%"
viewport_height: String = "50%".to_string(),
/// The line offset from the top or bottom of the viewport to trigger scrolling
scroll_offset: usize = 2,
}
}
impl ShOptCore {
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
match opt {
"dotglob" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for dotglob value",
));
};
self.dotglob = val;
}
"autocd" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for autocd value",
));
};
self.autocd = val;
}
"hist_ignore_dupes" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for hist_ignore_dupes value",
));
};
self.hist_ignore_dupes = val;
}
"max_hist" => {
let Ok(val) = val.parse::<isize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected an integer for max_hist value (-1 for unlimited)",
));
};
self.max_hist = val;
}
"interactive_comments" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for interactive_comments value",
));
};
self.interactive_comments = val;
}
"auto_hist" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for auto_hist value",
));
};
self.auto_hist = val;
}
"bell_enabled" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for bell_enabled value",
));
};
self.bell_enabled = val;
}
"max_recurse_depth" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for max_recurse_depth value",
));
};
self.max_recurse_depth = val;
}
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'core' option '{opt}'"),
));
}
}
Ok(())
}
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
if query.is_empty() {
return Ok(Some(format!("{self}")));
}
shopt_group! {
#[derive(Clone, Debug)]
pub struct ShOptCore ("core") {
/// Include hidden files in glob patterns
dotglob: bool = false,
match query {
"dotglob" => {
let mut output = String::from("Include hidden files in glob patterns\n");
output.push_str(&format!("{}", self.dotglob));
Ok(Some(output))
}
"autocd" => {
let mut output = String::from(
"Allow navigation to directories by passing the directory as a command directly\n",
);
output.push_str(&format!("{}", self.autocd));
Ok(Some(output))
}
"hist_ignore_dupes" => {
let mut output = String::from("Ignore consecutive duplicate command history entries\n");
output.push_str(&format!("{}", self.hist_ignore_dupes));
Ok(Some(output))
}
"max_hist" => {
let mut output = String::from(
"Maximum number of entries in the command history file (-1 for unlimited)\n",
);
output.push_str(&format!("{}", self.max_hist));
Ok(Some(output))
}
"interactive_comments" => {
let mut output = String::from("Whether or not to allow comments in interactive mode\n");
output.push_str(&format!("{}", self.interactive_comments));
Ok(Some(output))
}
"auto_hist" => {
let mut output = String::from(
"Whether or not to automatically save commands to the command history file\n",
);
output.push_str(&format!("{}", self.auto_hist));
Ok(Some(output))
}
"bell_enabled" => {
let mut output = String::from("Whether or not to allow shed to trigger the terminal bell");
output.push_str(&format!("{}", self.bell_enabled));
Ok(Some(output))
}
"max_recurse_depth" => {
let mut output = String::from("Maximum limit of recursive shell function calls\n");
output.push_str(&format!("{}", self.max_recurse_depth));
Ok(Some(output))
}
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'core' option '{query}'"),
)),
}
/// Allow navigation to directories by passing the directory as a command directly
autocd: bool = false,
/// Ignore consecutive duplicate command history entries
hist_ignore_dupes: bool = true,
/// Maximum number of entries in the command history file (-1 for unlimited)
#[validate(|v: &isize| if *v < -1 {
Err("expected a non-negative integer or -1 for max_hist value".into())
} else {
Ok(())
})]
max_hist: isize = 10_000,
/// Whether or not to allow comments in interactive mode
interactive_comments: bool = true,
/// Whether or not to automatically save commands to the command history file
auto_hist: bool = true,
/// Whether or not to allow shed to trigger the terminal bell
bell_enabled: bool = true,
/// Maximum limit of recursive shell function calls
max_recurse_depth: usize = 1000,
/// Whether echo expands escape sequences by default
xpg_echo: bool = false,
/// Prevent > from overwriting existing files (use >| to override)
noclobber: bool = false,
}
}
impl Display for ShOptCore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut output = vec![];
output.push(format!("dotglob = {}", self.dotglob));
output.push(format!("autocd = {}", self.autocd));
output.push(format!("hist_ignore_dupes = {}", self.hist_ignore_dupes));
output.push(format!("max_hist = {}", self.max_hist));
output.push(format!(
"interactive_comments = {}",
self.interactive_comments
));
output.push(format!("auto_hist = {}", self.auto_hist));
output.push(format!("bell_enabled = {}", self.bell_enabled));
output.push(format!("max_recurse_depth = {}", self.max_recurse_depth));
shopt_group! {
#[derive(Clone, Debug)]
pub struct ShOptPrompt ("prompt") {
/// Maximum number of path segments used in the '\W' prompt escape sequence
trunc_prompt_path: usize = 4,
let final_output = output.join("\n");
/// Maximum number of completion candidates displayed upon pressing tab
comp_limit: usize = 100,
writeln!(f, "{final_output}")
/// Whether to enable or disable syntax highlighting on the prompt
highlight: bool = true,
/// Whether to automatically indent new lines in multiline commands
auto_indent: bool = true,
/// Whether to automatically insert a newline when the input is incomplete
linebreak_on_incomplete: bool = true,
/// The leader key sequence used in keymap bindings
leader: String = " ".to_string(),
/// Whether to display line numbers in multiline input
line_numbers: bool = true,
/// Command to execute as a screensaver after idle timeout
screensaver_cmd: String = String::new(),
/// Idle time in seconds before running screensaver_cmd (0 = disabled)
screensaver_idle_time: usize = 0,
/// Whether tab completion matching is case-insensitive
completion_ignore_case: bool = false,
}
}
impl Default for ShOptCore {
fn default() -> Self {
ShOptCore {
dotglob: false,
autocd: false,
hist_ignore_dupes: true,
max_hist: 10_000,
interactive_comments: true,
auto_hist: true,
bell_enabled: true,
max_recurse_depth: 1000,
}
}
}
#[derive(Clone, Debug)]
pub struct ShOptPrompt {
pub trunc_prompt_path: usize,
pub edit_mode: ShedEditMode,
pub comp_limit: usize,
pub highlight: bool,
pub auto_indent: bool,
pub linebreak_on_incomplete: bool,
pub leader: String,
pub line_numbers: bool,
}
impl ShOptPrompt {
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
match opt {
"trunc_prompt_path" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for trunc_prompt_path value",
));
};
self.trunc_prompt_path = val;
}
"edit_mode" => {
let Ok(val) = val.parse::<ShedEditMode>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'vi' or 'emacs' for edit_mode value",
));
};
self.edit_mode = val;
}
"comp_limit" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for comp_limit value",
));
};
self.comp_limit = val;
}
"highlight" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for highlight value",
));
};
self.highlight = val;
}
"auto_indent" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for auto_indent value",
));
};
self.auto_indent = val;
}
"linebreak_on_incomplete" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for linebreak_on_incomplete value",
));
};
self.linebreak_on_incomplete = val;
}
"leader" => {
self.leader = val.to_string();
}
"line_numbers" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for line_numbers value",
));
};
self.line_numbers = val;
}
"custom" => {
todo!()
}
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'prompt' option '{opt}'"),
));
}
}
Ok(())
}
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
if query.is_empty() {
return Ok(Some(format!("{self}")));
}
match query {
"trunc_prompt_path" => {
let mut output = String::from(
"Maximum number of path segments used in the '\\W' prompt escape sequence\n",
);
output.push_str(&format!("{}", self.trunc_prompt_path));
Ok(Some(output))
}
"edit_mode" => {
let mut output =
String::from("The style of editor shortcuts used in the line-editing of the prompt\n");
output.push_str(&format!("{}", self.edit_mode));
Ok(Some(output))
}
"comp_limit" => {
let mut output =
String::from("Maximum number of completion candidates displayed upon pressing tab\n");
output.push_str(&format!("{}", self.comp_limit));
Ok(Some(output))
}
"highlight" => {
let mut output =
String::from("Whether to enable or disable syntax highlighting on the prompt\n");
output.push_str(&format!("{}", self.highlight));
Ok(Some(output))
}
"auto_indent" => {
let mut output =
String::from("Whether to automatically indent new lines in multiline commands\n");
output.push_str(&format!("{}", self.auto_indent));
Ok(Some(output))
}
"linebreak_on_incomplete" => {
let mut output =
String::from("Whether to automatically insert a newline when the input is incomplete\n");
output.push_str(&format!("{}", self.linebreak_on_incomplete));
Ok(Some(output))
}
"leader" => {
let mut output = String::from("The leader key sequence used in keymap bindings\n");
output.push_str(&self.leader);
Ok(Some(output))
}
"line_numbers" => {
let mut output = String::from("Whether to display line numbers in multiline input\n");
output.push_str(&format!("{}", self.line_numbers));
Ok(Some(output))
}
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'prompt' option '{query}'"),
)),
}
}
}
impl Display for ShOptPrompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut output = vec![];
output.push(format!("trunc_prompt_path = {}", self.trunc_prompt_path));
output.push(format!("edit_mode = {}", self.edit_mode));
output.push(format!("comp_limit = {}", self.comp_limit));
output.push(format!("highlight = {}", self.highlight));
output.push(format!("auto_indent = {}", self.auto_indent));
output.push(format!(
"linebreak_on_incomplete = {}",
self.linebreak_on_incomplete
));
output.push(format!("leader = {}", self.leader));
output.push(format!("line_numbers = {}", self.line_numbers));
let final_output = output.join("\n");
writeln!(f, "{final_output}")
}
}
impl Default for ShOptPrompt {
fn default() -> Self {
ShOptPrompt {
trunc_prompt_path: 4,
edit_mode: ShedEditMode::Vi,
comp_limit: 100,
highlight: true,
auto_indent: true,
linebreak_on_incomplete: true,
leader: "\\".to_string(),
line_numbers: true,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_core_fields_covered() {
let ShOptCore {
dotglob,
autocd,
hist_ignore_dupes,
max_hist,
interactive_comments,
auto_hist,
bell_enabled,
max_recurse_depth,
xpg_echo,
noclobber,
} = ShOptCore::default();
// If a field is added to the struct, this destructure fails to compile.
let _ = (
dotglob,
autocd,
hist_ignore_dupes,
max_hist,
interactive_comments,
auto_hist,
bell_enabled,
max_recurse_depth,
xpg_echo,
noclobber,
);
}
#[test]
fn set_and_get_core_bool() {
let mut opts = ShOpts::default();
assert!(!opts.core.dotglob);
opts.set("core.dotglob", "true").unwrap();
assert!(opts.core.dotglob);
opts.set("core.dotglob", "false").unwrap();
assert!(!opts.core.dotglob);
}
#[test]
fn set_and_get_core_int() {
let mut opts = ShOpts::default();
assert_eq!(opts.core.max_hist, 10_000);
opts.set("core.max_hist", "500").unwrap();
assert_eq!(opts.core.max_hist, 500);
opts.set("core.max_hist", "-1").unwrap();
assert_eq!(opts.core.max_hist, -1);
assert!(opts.set("core.max_hist", "-500").is_err());
}
#[test]
fn set_and_get_prompt_opts() {
let mut opts = ShOpts::default();
opts.set("prompt.comp_limit", "50").unwrap();
assert_eq!(opts.prompt.comp_limit, 50);
opts.set("prompt.leader", "space").unwrap();
assert_eq!(opts.prompt.leader, "space");
}
#[test]
fn query_set_returns_none() {
let mut opts = ShOpts::default();
let result = opts.query("core.autocd=true").unwrap();
assert!(result.is_none());
assert!(opts.core.autocd);
}
#[test]
fn query_get_returns_some() {
let opts = ShOpts::default();
let result = opts.get("core.dotglob").unwrap();
assert!(result.is_some());
let text = result.unwrap();
assert!(text.contains("false"));
}
#[test]
fn invalid_category_errors() {
let mut opts = ShOpts::default();
assert!(opts.set("bogus.dotglob", "true").is_err());
assert!(opts.get("bogus.dotglob").is_err());
}
#[test]
fn invalid_option_errors() {
let mut opts = ShOpts::default();
assert!(opts.set("core.nonexistent", "true").is_err());
assert!(opts.set("prompt.nonexistent", "true").is_err());
}
#[test]
fn invalid_value_errors() {
let mut opts = ShOpts::default();
assert!(opts.set("core.dotglob", "notabool").is_err());
assert!(opts.set("core.max_hist", "notanint").is_err());
assert!(opts.set("core.max_recurse_depth", "-5").is_err());
assert!(opts.set("prompt.comp_limit", "abc").is_err());
}
#[test]
fn get_category_lists_all() {
let opts = ShOpts::default();
let core_output = opts.get("core").unwrap().unwrap();
assert!(core_output.contains("dotglob"));
assert!(core_output.contains("autocd"));
assert!(core_output.contains("max_hist"));
assert!(core_output.contains("bell_enabled"));
let prompt_output = opts.get("prompt").unwrap().unwrap();
assert!(prompt_output.contains("comp_limit"));
assert!(prompt_output.contains("highlight"));
}
}

View File

@@ -1,31 +1,45 @@
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
use std::{
collections::VecDeque,
sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering},
};
use nix::sys::signal::{SaFlags, SigAction, sigaction};
use nix::{
sys::signal::{SaFlags, SigAction, sigaction},
unistd::getpid,
};
use crate::{
builtin::trap::TrapTarget,
jobs::{JobCmdFlags, JobID, take_term},
jobs::{Job, JobCmdFlags, JobID, take_term},
libsh::error::{ShErr, ShErrKind, ShResult},
parse::execute::exec_input,
prelude::*,
state::{AutoCmd, AutoCmdKind, read_jobs, read_logic, write_jobs, write_meta},
state::{
AutoCmd, AutoCmdKind, VarFlags, VarKind, read_jobs, read_logic, write_jobs, write_meta,
write_vars,
},
};
static SIGNALS: AtomicU64 = AtomicU64::new(0);
pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true);
pub static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
pub static JOB_DONE: AtomicBool = AtomicBool::new(false);
pub static QUIT_CODE: AtomicI32 = AtomicI32::new(0);
const MISC_SIGNALS: [Signal; 22] = [
/// Window size change signal
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
/// SIGUSR1 tells the prompt that it needs to fully refresh.
/// Useful for dynamic prompt content and asynchronous refreshing
pub static GOT_SIGUSR1: AtomicBool = AtomicBool::new(false);
const MISC_SIGNALS: [Signal; 21] = [
Signal::SIGILL,
Signal::SIGTRAP,
Signal::SIGABRT,
Signal::SIGBUS,
Signal::SIGFPE,
Signal::SIGUSR1,
Signal::SIGSEGV,
Signal::SIGUSR2,
Signal::SIGPIPE,
@@ -65,7 +79,7 @@ pub fn check_signals() -> ShResult<()> {
if got_signal(Signal::SIGINT) {
interrupt()?;
run_trap(Signal::SIGINT)?;
return Err(ShErr::simple(ShErrKind::ClearReadline, ""));
return Err(ShErr::simple(ShErrKind::Interrupt, ""));
}
if got_signal(Signal::SIGHUP) {
run_trap(Signal::SIGHUP)?;
@@ -87,6 +101,10 @@ pub fn check_signals() -> ShResult<()> {
GOT_SIGWINCH.store(true, Ordering::SeqCst);
run_trap(Signal::SIGWINCH)?;
}
if got_signal(Signal::SIGUSR1) {
GOT_SIGUSR1.store(true, Ordering::SeqCst);
run_trap(Signal::SIGUSR1)?;
}
for sig in MISC_SIGNALS {
if got_signal(sig) {
@@ -154,10 +172,10 @@ pub fn sig_setup(is_login: bool) {
}
}
/// Reset all signal dispositions to SIG_DFL.
/// Reset signal dispositions to SIG_DFL.
/// Called in child processes before exec so that the shell's custom
/// handlers and SIG_IGN dispositions don't leak into child programs.
pub fn reset_signals() {
pub fn reset_signals(is_fg: bool) {
let default = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
unsafe {
for sig in Signal::iterator() {
@@ -165,6 +183,10 @@ pub fn reset_signals() {
if sig == Signal::SIGKILL || sig == Signal::SIGSTOP {
continue;
}
if is_fg && (sig == Signal::SIGTTIN || sig == Signal::SIGTTOU) {
log::debug!("Not resetting SIGTTIN/SIGTTOU in foreground child");
continue;
}
let _ = sigaction(sig, &default);
}
}
@@ -312,6 +334,25 @@ pub fn child_exited(pid: Pid, status: WtStat) -> ShResult<()> {
let result = read_jobs(|j| j.query(JobID::Pgid(pgid)).cloned());
if let Some(job) = result {
let job_complete_msg = job.display(&job_order, JobCmdFlags::PIDS).to_string();
let statuses = job.get_stats();
for status in &statuses {
if let WtStat::Signaled(_, sig, _) = status
&& *sig == Signal::SIGINT
{
// Necessary to interrupt stuff like shell loops
kill(getpid(), Signal::SIGINT).ok();
}
}
if let Some(pipe_status) = Job::pipe_status(&statuses) {
let pipe_status = pipe_status
.into_iter()
.map(|s| s.to_string())
.collect::<VecDeque<String>>();
write_vars(|v| v.set_var("PIPESTATUS", VarKind::Arr(pipe_status), VarFlags::NONE))?;
}
let post_job_hooks = read_logic(|l| l.get_autocmds(AutoCmdKind::OnJobFinish));
for cmd in post_job_hooks {

View File

@@ -8,7 +8,7 @@ use std::{
time::Duration,
};
use nix::unistd::{User, gethostname, getppid};
use nix::unistd::{User, gethostname, getppid, getuid};
use regex::Regex;
use crate::{
@@ -31,19 +31,27 @@ use crate::{
},
prelude::*,
readline::{
complete::{BashCompSpec, CompSpec},
complete::{BashCompSpec, Candidate, CompSpec},
keys::KeyEvent,
markers,
},
shopt::ShOpts,
};
thread_local! {
pub static SHED: Shed = Shed::new();
}
#[derive(Clone, Debug)]
pub struct Shed {
pub jobs: RefCell<JobTab>,
pub var_scopes: RefCell<ScopeStack>,
pub meta: RefCell<MetaTab>,
pub logic: RefCell<LogTab>,
pub shopts: RefCell<ShOpts>,
#[cfg(test)]
saved: RefCell<Option<Box<Self>>>,
}
impl Shed {
@@ -54,6 +62,9 @@ impl Shed {
meta: RefCell::new(MetaTab::new()),
logic: RefCell::new(LogTab::new()),
shopts: RefCell::new(ShOpts::default()),
#[cfg(test)]
saved: RefCell::new(None),
}
}
}
@@ -64,6 +75,31 @@ impl Default for Shed {
}
}
#[cfg(test)]
impl Shed {
pub fn save(&self) {
let saved = Self {
jobs: RefCell::new(self.jobs.borrow().clone()),
var_scopes: RefCell::new(self.var_scopes.borrow().clone()),
meta: RefCell::new(self.meta.borrow().clone()),
logic: RefCell::new(self.logic.borrow().clone()),
shopts: RefCell::new(self.shopts.borrow().clone()),
saved: RefCell::new(None),
};
*self.saved.borrow_mut() = Some(Box::new(saved));
}
pub fn restore(&self) {
if let Some(saved) = self.saved.take() {
*self.jobs.borrow_mut() = saved.jobs.into_inner();
*self.var_scopes.borrow_mut() = saved.var_scopes.into_inner();
*self.meta.borrow_mut() = saved.meta.into_inner();
*self.logic.borrow_mut() = saved.logic.into_inner();
*self.shopts.borrow_mut() = saved.shopts.into_inner();
}
}
}
#[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)]
pub enum ShellParam {
// Global
@@ -165,7 +201,7 @@ impl ScopeStack {
new
}
pub fn descend(&mut self, argv: Option<Vec<String>>) {
let mut new_vars = VarTab::new();
let mut new_vars = VarTab::bare();
if let Some(argv) = argv {
for arg in argv {
new_vars.bpush_arg(arg);
@@ -285,6 +321,34 @@ impl ScopeStack {
};
scope.set_var(var_name, val, flags)
}
pub fn get_magic_var(&self, var_name: &str) -> Option<String> {
match var_name {
"SECONDS" => {
let shell_time = read_meta(|m| m.shell_time());
let secs = Instant::now().duration_since(shell_time).as_secs();
Some(secs.to_string())
}
"EPOCHREALTIME" => {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs_f64();
Some(epoch.to_string())
}
"EPOCHSECONDS" => {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs();
Some(epoch.to_string())
}
"RANDOM" => {
let random = rand::random_range(0..32768);
Some(random.to_string())
}
_ => None,
}
}
pub fn get_arr_elems(&self, var_name: &str) -> ShResult<Vec<String>> {
for scope in self.scopes.iter().rev() {
if scope.var_exists(var_name)
@@ -410,7 +474,9 @@ impl ScopeStack {
pub fn try_get_var(&self, var_name: &str) -> Option<String> {
// This version of get_var() is mainly used internally
// so that we have access to Option methods
if let Ok(param) = var_name.parse::<ShellParam>() {
if let Some(magic) = self.get_magic_var(var_name) {
return Some(magic);
} else if let Ok(param) = var_name.parse::<ShellParam>() {
let val = self.get_param(param);
if !val.is_empty() {
return Some(val);
@@ -433,6 +499,9 @@ impl ScopeStack {
var
}
pub fn get_var(&self, var_name: &str) -> String {
if let Some(magic) = self.get_magic_var(var_name) {
return magic;
}
if let Ok(param) = var_name.parse::<ShellParam>() {
return self.get_param(param);
}
@@ -464,6 +533,16 @@ impl ScopeStack {
{
return val.clone();
}
// Positional params are scope-local; only check the current scope
if matches!(
param,
ShellParam::Pos(_) | ShellParam::AllArgs | ShellParam::AllArgsStr | ShellParam::ArgCount
) {
if let Some(scope) = self.scopes.last() {
return scope.get_param(param);
}
return "".into();
}
for scope in self.scopes.iter().rev() {
let val = scope.get_param(param);
if !val.is_empty() {
@@ -491,10 +570,6 @@ impl ScopeStack {
}
}
thread_local! {
pub static SHED: Shed = Shed::new();
}
#[derive(Clone, Debug)]
pub struct ShAlias {
pub body: String,
@@ -556,59 +631,31 @@ pub enum AutoCmdKind {
OnCompletionStart,
OnCompletionCancel,
OnCompletionSelect,
OnScreensaverExec,
OnScreensaverReturn,
OnExit,
}
impl Display for AutoCmdKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PreCmd => write!(f, "pre-cmd"),
Self::PostCmd => write!(f, "post-cmd"),
Self::PreChangeDir => write!(f, "pre-change-dir"),
Self::PostChangeDir => write!(f, "post-change-dir"),
Self::OnJobFinish => write!(f, "on-job-finish"),
Self::PrePrompt => write!(f, "pre-prompt"),
Self::PostPrompt => write!(f, "post-prompt"),
Self::PreModeChange => write!(f, "pre-mode-change"),
Self::PostModeChange => write!(f, "post-mode-change"),
Self::OnHistoryOpen => write!(f, "on-history-open"),
Self::OnHistoryClose => write!(f, "on-history-close"),
Self::OnHistorySelect => write!(f, "on-history-select"),
Self::OnCompletionStart => write!(f, "on-completion-start"),
Self::OnCompletionCancel => write!(f, "on-completion-cancel"),
Self::OnCompletionSelect => write!(f, "on-completion-select"),
Self::OnExit => write!(f, "on-exit"),
}
}
}
impl FromStr for AutoCmdKind {
type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"pre-cmd" => Ok(Self::PreCmd),
"post-cmd" => Ok(Self::PostCmd),
"pre-change-dir" => Ok(Self::PreChangeDir),
"post-change-dir" => Ok(Self::PostChangeDir),
"on-job-finish" => Ok(Self::OnJobFinish),
"pre-prompt" => Ok(Self::PrePrompt),
"post-prompt" => Ok(Self::PostPrompt),
"pre-mode-change" => Ok(Self::PreModeChange),
"post-mode-change" => Ok(Self::PostModeChange),
"on-history-open" => Ok(Self::OnHistoryOpen),
"on-history-close" => Ok(Self::OnHistoryClose),
"on-history-select" => Ok(Self::OnHistorySelect),
"on-completion-start" => Ok(Self::OnCompletionStart),
"on-completion-cancel" => Ok(Self::OnCompletionCancel),
"on-completion-select" => Ok(Self::OnCompletionSelect),
"on-exit" => Ok(Self::OnExit),
_ => Err(ShErr::simple(
ShErrKind::ParseErr,
format!("Invalid autocmd kind: {}", s),
)),
}
}
}
crate::two_way_display!(AutoCmdKind,
PreCmd <=> "pre-cmd";
PostCmd <=> "post-cmd";
PreChangeDir <=> "pre-change-dir";
PostChangeDir <=> "post-change-dir";
OnJobFinish <=> "on-job-finish";
PrePrompt <=> "pre-prompt";
PostPrompt <=> "post-prompt";
PreModeChange <=> "pre-mode-change";
PostModeChange <=> "post-mode-change";
OnHistoryOpen <=> "on-history-open";
OnHistoryClose <=> "on-history-close";
OnHistorySelect <=> "on-history-select";
OnCompletionStart <=> "on-completion-start";
OnCompletionCancel <=> "on-completion-cancel";
OnCompletionSelect <=> "on-completion-select";
OnScreensaverExec <=> "on-screensaver-exec";
OnScreensaverReturn <=> "on-screensaver-return";
OnExit <=> "on-exit";
);
#[derive(Clone, Debug)]
pub struct AutoCmd {
@@ -920,18 +967,41 @@ impl Display for Var {
}
}
impl From<String> for Var {
fn from(value: String) -> Self {
Self::new(VarKind::Str(value), VarFlags::NONE)
impl From<Vec<String>> for Var {
fn from(value: Vec<String>) -> Self {
Self::new(VarKind::Arr(value.into()), VarFlags::NONE)
}
}
impl From<&str> for Var {
fn from(value: &str) -> Self {
Self::new(VarKind::Str(value.into()), VarFlags::NONE)
impl From<Vec<Candidate>> for Var {
fn from(value: Vec<Candidate>) -> Self {
let as_strs = value.into_iter().map(|c| c.0).collect::<Vec<_>>();
Self::new(VarKind::Arr(as_strs.into()), VarFlags::NONE)
}
}
impl From<&[String]> for Var {
fn from(value: &[String]) -> Self {
let mut new = VecDeque::new();
new.extend(value.iter().cloned());
Self::new(VarKind::Arr(new), VarFlags::NONE)
}
}
macro_rules! impl_var_from {
($($t:ty),*) => {
$(impl From<$t> for Var {
fn from(value: $t) -> Self {
Self::new(VarKind::Str(value.to_string()), VarFlags::NONE)
}
})*
};
}
impl_var_from!(
i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, String, &str, bool
);
#[derive(Default, Clone, Debug)]
pub struct VarTab {
vars: HashMap<String, Var>,
@@ -942,8 +1012,16 @@ pub struct VarTab {
}
impl VarTab {
pub fn bare() -> Self {
Self {
vars: HashMap::new(),
params: HashMap::new(),
sh_argv: VecDeque::new(),
maps: HashMap::new(),
}
}
pub fn new() -> Self {
let vars = HashMap::new();
let vars = Self::init_sh_vars();
let params = Self::init_params();
Self::init_env();
let mut var_tab = Self {
@@ -962,6 +1040,11 @@ impl VarTab {
params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any)
params
}
fn init_sh_vars() -> HashMap<String, Var> {
let mut vars = HashMap::new();
vars.insert("COMP_WORDBREAKS".into(), " \t\n\"'@><=;|&(".into());
vars
}
fn init_env() {
let pathbuf_to_string =
|pb: Result<PathBuf, std::io::Error>| pb.unwrap_or_default().to_string_lossy().to_string();
@@ -994,6 +1077,8 @@ impl VarTab {
.map(|hname| hname.to_string_lossy().to_string())
.unwrap_or_default();
let help_paths = format!("/usr/share/shed/doc:{home}/.local/share/shed/doc");
unsafe {
env::set_var("IFS", " \t\n");
env::set_var("HOST", hostname.clone());
@@ -1010,6 +1095,7 @@ impl VarTab {
env::set_var("SHELL", pathbuf_to_string(std::env::current_exe()));
env::set_var("SHED_HIST", format!("{}/.shedhist", home));
env::set_var("SHED_RC", format!("{}/.shedrc", home));
env::set_var("SHED_HPATH", help_paths);
}
}
pub fn init_sh_argv(&mut self) {
@@ -1226,6 +1312,15 @@ impl VarTab {
.get(&ShellParam::Status)
.map(|s| s.to_string())
.unwrap_or("0".into()),
ShellParam::AllArgsStr => {
let ifs = get_separator();
self
.params
.get(&ShellParam::AllArgs)
.map(|s| s.replace(markers::ARG_SEP, &ifs).to_string())
.unwrap_or_default()
}
_ => self
.params
.get(&param)
@@ -1236,14 +1331,22 @@ impl VarTab {
}
/// A table of metadata for the shell
#[derive(Default, Debug)]
#[derive(Clone, Debug)]
pub struct MetaTab {
// Time when the shell was started, used for calculating shell uptime
shell_time: Instant,
// command running duration
runtime_start: Option<Instant>,
runtime_stop: Option<Instant>,
// pending system messages
system_msg: Vec<String>,
// are drawn above the prompt and survive redraws
system_msg: VecDeque<String>,
// same as system messages,
// but they appear under the prompt and are erased on redraw
status_msg: VecDeque<String>,
// pushd/popd stack
dir_stack: VecDeque<PathBuf>,
@@ -1262,6 +1365,26 @@ pub struct MetaTab {
pending_widget_keys: Vec<KeyEvent>,
}
impl Default for MetaTab {
fn default() -> Self {
Self {
shell_time: Instant::now(),
runtime_start: None,
runtime_stop: None,
system_msg: VecDeque::new(),
status_msg: VecDeque::new(),
dir_stack: VecDeque::new(),
getopts_offset: 0,
old_path: None,
old_pwd: None,
path_cache: HashSet::new(),
cwd_cache: HashSet::new(),
comp_specs: HashMap::new(),
pending_widget_keys: vec![],
}
}
}
impl MetaTab {
pub fn new() -> Self {
Self {
@@ -1269,6 +1392,9 @@ impl MetaTab {
..Default::default()
}
}
pub fn shell_time(&self) -> Instant {
self.shell_time
}
pub fn set_pending_widget_keys(&mut self, keys: &str) {
let exp = expand_keymap(keys);
self.pending_widget_keys = exp;
@@ -1466,14 +1592,23 @@ impl MetaTab {
}
}
pub fn post_system_message(&mut self, message: String) {
self.system_msg.push(message);
self.system_msg.push_back(message);
}
pub fn pop_system_message(&mut self) -> Option<String> {
self.system_msg.pop()
self.system_msg.pop_front()
}
pub fn system_msg_pending(&self) -> bool {
!self.system_msg.is_empty()
}
pub fn post_status_message(&mut self, message: String) {
self.status_msg.push_back(message);
}
pub fn pop_status_message(&mut self) -> Option<String> {
self.status_msg.pop_front()
}
pub fn status_msg_pending(&self) -> bool {
!self.status_msg.is_empty()
}
pub fn dir_stack_top(&self) -> Option<&PathBuf> {
self.dir_stack.front()
}
@@ -1713,6 +1848,15 @@ pub fn change_dir<P: AsRef<Path>>(dir: P) -> ShResult<()> {
Ok(())
}
pub fn get_separator() -> String {
env::var("IFS")
.unwrap_or(String::from(" "))
.chars()
.next()
.unwrap()
.to_string()
}
pub fn get_status() -> i32 {
read_vars(|v| v.get_param(ShellParam::Status))
.parse::<i32>()
@@ -1722,19 +1866,44 @@ pub fn set_status(code: i32) {
write_vars(|v| v.set_param(ShellParam::Status, &code.to_string()))
}
pub fn source_rc() -> ShResult<()> {
let path = if let Ok(path) = env::var("SHED_RC") {
pub fn source_runtime_file(name: &str, env_var_name: Option<&str>) -> ShResult<()> {
let etc_path = PathBuf::from(format!("/etc/shed/{name}"));
if etc_path.is_file()
&& let Err(e) = source_file(etc_path)
{
e.print_error();
}
let path = if let Some(name) = env_var_name
&& let Ok(path) = env::var(name)
{
PathBuf::from(&path)
} else if let Some(home) = get_home() {
home.join(format!(".{name}"))
} else {
let home = env::var("HOME").unwrap();
PathBuf::from(format!("{home}/.shedrc"))
return Err(ShErr::simple(
ShErrKind::InternalErr,
"could not determine home path",
));
};
if !path.exists() {
return Err(ShErr::simple(ShErrKind::InternalErr, ".shedrc not found"));
if !path.is_file() {
return Ok(());
}
source_file(path)
}
pub fn source_rc() -> ShResult<()> {
source_runtime_file("shedrc", Some("SHED_RC"))
}
pub fn source_login() -> ShResult<()> {
source_runtime_file("shed_profile", Some("SHED_PROFILE"))
}
pub fn source_env() -> ShResult<()> {
source_runtime_file("shedenv", Some("SHED_ENV"))
}
pub fn source_file(path: PathBuf) -> ShResult<()> {
let source_name = path.to_string_lossy().to_string();
let mut file = OpenOptions::new().read(true).open(path)?;
@@ -1744,3 +1913,42 @@ pub fn source_file(path: PathBuf) -> ShResult<()> {
exec_input(buf, None, false, Some(source_name))?;
Ok(())
}
#[track_caller]
pub fn get_home_unchecked() -> PathBuf {
if let Some(home) = get_home() {
home
} else {
let caller = std::panic::Location::caller();
panic!(
"get_home_unchecked: could not determine home directory (called from {}:{})",
caller.file(),
caller.line()
)
}
}
#[track_caller]
pub fn get_home_str_unchecked() -> String {
if let Some(home) = get_home() {
home.to_string_lossy().to_string()
} else {
let caller = std::panic::Location::caller();
panic!(
"get_home_str_unchecked: could not determine home directory (called from {}:{})",
caller.file(),
caller.line()
)
}
}
pub fn get_home() -> Option<PathBuf> {
env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(|| User::from_uid(getuid()).ok().flatten().map(|u| u.dir))
}
pub fn get_home_str() -> Option<String> {
get_home().map(|h| h.to_string_lossy().to_string())
}

273
src/testutil.rs Normal file
View File

@@ -0,0 +1,273 @@
use std::{
collections::{HashMap, HashSet},
env,
os::fd::{AsRawFd, BorrowedFd, OwnedFd},
path::PathBuf,
sync::{self, Arc, MutexGuard},
};
use nix::{
fcntl::{FcntlArg, OFlag, fcntl},
pty::openpty,
sys::termios::{OutputFlags, SetArg, tcgetattr, tcsetattr},
unistd::read,
};
use crate::{
expand::expand_aliases,
libsh::error::ShResult,
parse::{ParsedSrc, Redir, RedirType, execute::exec_input, lex::LexFlags},
procio::{IoFrame, IoMode, RedirGuard},
readline::register::{restore_registers, save_registers},
state::{MetaTab, SHED, read_logic},
};
static TEST_MUTEX: sync::Mutex<()> = sync::Mutex::new(());
pub fn has_cmds(cmds: &[&str]) -> bool {
let path_cmds = MetaTab::get_cmds_in_path();
path_cmds.iter().all(|c| cmds.iter().any(|&cmd| c == cmd))
}
pub fn has_cmd(cmd: &str) -> bool {
MetaTab::get_cmds_in_path().into_iter().any(|c| c == cmd)
}
pub fn test_input(input: impl Into<String>) -> ShResult<()> {
exec_input(input.into(), None, false, None)
}
pub struct TestGuard {
_lock: MutexGuard<'static, ()>,
_redir_guard: RedirGuard,
old_cwd: PathBuf,
saved_env: HashMap<String, String>,
pty_master: OwnedFd,
pty_slave: OwnedFd,
cleanups: Vec<Box<dyn FnOnce()>>,
}
impl TestGuard {
pub fn new() -> Self {
let _lock = TEST_MUTEX.lock().unwrap();
let pty = openpty(None, None).unwrap();
let (pty_master, pty_slave) = (pty.master, pty.slave);
let mut attrs = tcgetattr(&pty_slave).unwrap();
attrs.output_flags &= !OutputFlags::ONLCR;
tcsetattr(&pty_slave, SetArg::TCSANOW, &attrs).unwrap();
let mut frame = IoFrame::new();
frame.push(Redir::new(
IoMode::Fd {
tgt_fd: 0,
src_fd: pty_slave.as_raw_fd(),
},
RedirType::Input,
));
frame.push(Redir::new(
IoMode::Fd {
tgt_fd: 1,
src_fd: pty_slave.as_raw_fd(),
},
RedirType::Output,
));
frame.push(Redir::new(
IoMode::Fd {
tgt_fd: 2,
src_fd: pty_slave.as_raw_fd(),
},
RedirType::Output,
));
let _redir_guard = frame.redirect().unwrap();
let old_cwd = env::current_dir().unwrap();
let saved_env = env::vars().collect();
SHED.with(|s| s.save());
save_registers();
Self {
_lock,
_redir_guard,
old_cwd,
saved_env,
pty_master,
pty_slave,
cleanups: vec![],
}
}
pub fn pty_slave(&self) -> BorrowedFd<'_> {
unsafe { BorrowedFd::borrow_raw(self.pty_slave.as_raw_fd()) }
}
pub fn add_cleanup(&mut self, f: impl FnOnce() + 'static) {
self.cleanups.push(Box::new(f));
}
pub fn read_output(&self) -> String {
let flags = fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_GETFL).unwrap();
let flags = OFlag::from_bits_truncate(flags);
fcntl(
self.pty_master.as_raw_fd(),
FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK),
)
.unwrap();
let mut out = vec![];
let mut buf = [0; 4096];
loop {
match read(self.pty_master.as_raw_fd(), &mut buf) {
Ok(0) => break,
Ok(n) => out.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_SETFL(flags)).unwrap();
String::from_utf8_lossy(&out).to_string()
}
}
impl Default for TestGuard {
fn default() -> Self {
Self::new()
}
}
impl Drop for TestGuard {
fn drop(&mut self) {
env::set_current_dir(&self.old_cwd).ok();
for (k, _) in env::vars() {
unsafe {
env::remove_var(&k);
}
}
for (k, v) in &self.saved_env {
unsafe {
env::set_var(k, v);
}
}
for cleanup in self.cleanups.drain(..).rev() {
cleanup();
}
SHED.with(|s| s.restore());
restore_registers();
}
}
pub fn get_ast(input: &str) -> ShResult<Vec<crate::parse::Node>> {
let log_tab = read_logic(|l| l.clone());
let input = expand_aliases(input.into(), HashSet::new(), &log_tab);
let source_name = "test_input".to_string();
let mut parser = ParsedSrc::new(Arc::new(input))
.with_lex_flags(LexFlags::empty())
.with_name(source_name.clone());
parser
.parse_src()
.map_err(|e| e.into_iter().next().unwrap())?;
Ok(parser.extract_nodes())
}
impl crate::parse::Node {
pub fn assert_structure(
&mut self,
expected: &mut impl Iterator<Item = NdKind>,
) -> Result<(), String> {
let mut full_structure = vec![];
let mut before = vec![];
let mut after = vec![];
let mut offender = None;
self.walk_tree(&mut |s| {
let expected_rule = expected.next();
full_structure.push(s.class.as_nd_kind());
if offender.is_none()
&& expected_rule
.as_ref()
.is_none_or(|e| *e != s.class.as_nd_kind())
{
offender = Some((s.class.as_nd_kind(), expected_rule));
} else if offender.is_none() {
before.push(s.class.as_nd_kind());
} else {
after.push(s.class.as_nd_kind());
}
});
assert!(
expected.next().is_none(),
"Expected structure has more nodes than actual structure"
);
if let Some((nd_kind, expected_rule)) = offender {
let expected_rule = expected_rule.map_or("(none — expected array too short)".into(), |e| {
format!("{e:?}")
});
let full_structure_hint = full_structure
.into_iter()
.map(|s| format!("\tNdKind::{s:?},"))
.collect::<Vec<String>>()
.join("\n");
let full_structure_hint =
format!("let expected = &mut [\n{full_structure_hint}\n].into_iter();");
let output = [
"Structure assertion failed!\n".into(),
format!(
"Expected node type '{:?}', found '{:?}'",
expected_rule, nd_kind
),
format!("Before offender: {:?}", before),
format!("After offender: {:?}\n", after),
format!("hint: here is the full structure as an array\n {full_structure_hint}"),
]
.join("\n");
Err(output)
} else {
Ok(())
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum NdKind {
IfNode,
LoopNode,
ForNode,
CaseNode,
Command,
Pipeline,
Conjunction,
Assignment,
BraceGrp,
Negate,
Test,
FuncDef,
}
impl crate::parse::NdRule {
pub fn as_nd_kind(&self) -> NdKind {
match self {
Self::Negate { .. } => NdKind::Negate,
Self::IfNode { .. } => NdKind::IfNode,
Self::LoopNode { .. } => NdKind::LoopNode,
Self::ForNode { .. } => NdKind::ForNode,
Self::CaseNode { .. } => NdKind::CaseNode,
Self::Command { .. } => NdKind::Command,
Self::Pipeline { .. } => NdKind::Pipeline,
Self::Conjunction { .. } => NdKind::Conjunction,
Self::Assignment { .. } => NdKind::Assignment,
Self::BraceGrp { .. } => NdKind::BraceGrp,
Self::Test { .. } => NdKind::Test,
Self::FuncDef { .. } => NdKind::FuncDef,
}
}
}

319
tests/gen_vi_tests.lua Normal file
View File

@@ -0,0 +1,319 @@
-- Generate Rust vi_test! macro invocations using neovim as oracle
-- Usage: nvim --headless --clean -l tests/gen_vi_tests.lua
--
-- Define test cases as { name, input_text, key_sequence }
-- Key sequences use vim notation: <Esc>, <CR>, <C-w>, etc.
-- The script executes each in a fresh buffer and captures the result.
local tests = {
-- ===================== basic char motions =====================
{ "dw_basic", "hello world", "dw" },
{ "dw_middle", "one two three", "wdw" },
{ "dd_whole_line", "hello world", "dd" },
{ "x_single", "hello", "x" },
{ "x_middle", "hello", "llx" },
{ "X_backdelete", "hello", "llX" },
{ "h_motion", "hello", "$h" },
{ "l_motion", "hello", "l" },
{ "h_at_start", "hello", "h" },
{ "l_at_end", "hello", "$l" },
-- ===================== word motions (small) =====================
{ "w_forward", "one two three", "w" },
{ "b_backward", "one two three", "$b" },
{ "e_end", "one two three", "e" },
{ "ge_back_end", "one two three", "$ge" },
{ "w_punctuation", "foo.bar baz", "w" },
{ "e_punctuation", "foo.bar baz", "e" },
{ "b_punctuation", "foo.bar baz", "$b" },
{ "w_at_eol", "hello", "$w" },
{ "b_at_bol", "hello", "b" },
-- ===================== word motions (big) =====================
{ "W_forward", "foo.bar baz", "W" },
{ "B_backward", "foo.bar baz", "$B" },
{ "E_end", "foo.bar baz", "E" },
{ "gE_back_end", "one two three", "$gE" },
{ "W_skip_punct", "one-two three", "W" },
{ "B_skip_punct", "one two-three", "$B" },
{ "E_skip_punct", "one-two three", "E" },
{ "dW_big", "foo.bar baz", "dW" },
{ "cW_big", "foo.bar baz", "cWx<Esc>" },
-- ===================== line motions =====================
{ "zero_bol", " hello", "$0" },
{ "caret_first_char", " hello", "$^" },
{ "dollar_eol", "hello world", "$" },
{ "g_last_nonws", "hello ", "g_" },
{ "g_no_trailing", "hello", "g_" },
{ "pipe_column", "hello world", "6|" },
{ "pipe_col1", "hello world", "1|" },
{ "I_insert_front", " hello", "Iworld <Esc>" },
{ "A_append_end", "hello", "A world<Esc>" },
-- ===================== find motions =====================
{ "f_find", "hello world", "fo" },
{ "F_find_back", "hello world", "$Fo" },
{ "t_till", "hello world", "tw" },
{ "T_till_back", "hello world", "$To" },
{ "f_no_match", "hello", "fz" },
{ "semicolon_repeat", "abcabc", "fa;;" },
{ "comma_reverse", "abcabc", "fa;;," },
{ "df_semicolon", "abcabc", "fa;;dfa" },
{ "t_at_target", "aab", "lta" },
-- ===================== delete operations =====================
{ "D_to_end", "hello world", "wD" },
{ "d_dollar", "hello world", "wd$" },
{ "d0_to_start", "hello world", "$d0" },
{ "dw_multiple", "one two three", "d2w" },
{ "dt_char", "hello world", "dtw" },
{ "df_char", "hello world", "dfw" },
{ "dh_back", "hello", "lldh" },
{ "dl_forward", "hello", "dl" },
{ "dge_back_end", "one two three", "$dge" },
{ "dG_to_end", "hello world", "dG" },
{ "dgg_to_start", "hello world", "$dgg" },
{ "d_semicolon", "abcabc", "fad;" },
-- ===================== change operations =====================
{ "cw_basic", "hello world", "cwfoo<Esc>" },
{ "C_to_end", "hello world", "wCfoo<Esc>" },
{ "cc_whole", "hello world", "ccfoo<Esc>" },
{ "ct_char", "hello world", "ctwfoo<Esc>" },
{ "s_single", "hello", "sfoo<Esc>" },
{ "S_whole_line", "hello world", "Sfoo<Esc>" },
{ "cl_forward", "hello", "clX<Esc>" },
{ "ch_backward", "hello", "llchX<Esc>" },
{ "cb_word_back", "hello world", "$cbfoo<Esc>" },
{ "ce_word_end", "hello world", "cefoo<Esc>" },
{ "c0_to_start", "hello world", "wc0foo<Esc>" },
-- ===================== yank and paste =====================
{ "yw_p_basic", "hello world", "ywwP" },
{ "dw_p_paste", "hello world", "dwP" },
{ "dd_p_paste", "hello world", "ddp" },
{ "y_dollar_p", "hello world", "wy$P" },
{ "ye_p", "hello world", "yewP" },
{ "yy_p", "hello world", "yyp" },
{ "Y_p", "hello world", "Yp" },
{ "p_after_x", "hello", "xp" },
{ "P_before", "hello", "llxP" },
{ "paste_empty", "hello", "p" },
-- ===================== replace =====================
{ "r_replace", "hello", "ra" },
{ "r_middle", "hello", "llra" },
{ "r_at_end", "hello", "$ra" },
{ "r_space", "hello", "r " },
{ "r_with_count", "hello", "3rx" },
-- ===================== case operations =====================
{ "tilde_single", "hello", "~" },
{ "tilde_count", "hello", "3~" },
{ "tilde_at_end", "HELLO", "$~" },
{ "tilde_mixed", "hElLo", "5~" },
{ "gu_word", "HELLO world", "guw" },
{ "gU_word", "hello WORLD", "gUw" },
{ "gu_dollar", "HELLO WORLD", "gu$" },
{ "gU_dollar", "hello world", "gU$" },
{ "gu_0", "HELLO WORLD", "$gu0" },
{ "gU_0", "hello world", "$gU0" },
{ "gtilde_word", "hello WORLD", "g~w" },
{ "gtilde_dollar", "hello WORLD", "g~$" },
-- ===================== text objects: word =====================
{ "diw_inner", "one two three", "wdiw" },
{ "ciw_replace", "hello world", "ciwfoo<Esc>" },
{ "daw_around", "one two three", "wdaw" },
{ "yiw_p", "hello world", "yiwAp <Esc>p" },
{ "diW_big_inner", "one-two three", "diW" },
{ "daW_big_around", "one two-three end", "wdaW" },
{ "ciW_big", "one-two three", "ciWx<Esc>" },
-- ===================== text objects: quotes =====================
{ "di_dquote", 'one "two" three', 'f"di"' },
{ "da_dquote", 'one "two" three', 'f"da"' },
{ "ci_dquote", 'one "two" three', 'f"ci"x<Esc>' },
{ "di_squote", "one 'two' three", "f'di'" },
{ "da_squote", "one 'two' three", "f'da'" },
{ "di_backtick", "one `two` three", "f`di`" },
{ "da_backtick", "one `two` three", "f`da`" },
{ "ci_dquote_empty", 'one "" three', 'f"ci"x<Esc>' },
-- ===================== text objects: delimiters =====================
{ "di_paren", "one (two) three", "f(di(" },
{ "da_paren", "one (two) three", "f(da(" },
{ "ci_paren", "one (two) three", "f(ci(x<Esc>" },
{ "di_brace", "one {two} three", "f{di{" },
{ "da_brace", "one {two} three", "f{da{" },
{ "di_bracket", "one [two] three", "f[di[" },
{ "da_bracket", "one [two] three", "f[da[" },
{ "di_angle", "one <two> three", "f<di<" },
{ "da_angle", "one <two> three", "f<da<" },
{ "di_paren_nested", "fn(a, (b, c))", "f(di(" },
{ "di_paren_empty", "fn() end", "f(di(" },
{ "dib_alias", "one (two) three", "f(dib" },
{ "diB_alias", "one {two} three", "f{diB" },
-- ===================== delimiter matching =====================
{ "percent_paren", "(hello) world", "%" },
{ "percent_brace", "{hello} world", "%" },
{ "percent_bracket", "[hello] world", "%" },
{ "percent_from_close", "(hello) world", "f)%" },
{ "d_percent_paren", "(hello) world", "d%" },
-- ===================== insert mode entry =====================
{ "i_insert", "hello", "iX<Esc>" },
{ "a_append", "hello", "aX<Esc>" },
{ "I_front", " hello", "IX<Esc>" },
{ "A_end", "hello", "AX<Esc>" },
{ "o_open_below", "hello", "oworld<Esc>" },
{ "O_open_above", "hello", "Oworld<Esc>" },
-- ===================== insert mode operations =====================
{ "empty_input", "", "i hello<Esc>" },
{ "insert_escape", "hello", "aX<Esc>" },
{ "ctrl_w_del_word", "hello world", "A<C-w><Esc>" },
{ "ctrl_h_backspace", "hello", "A<C-h><Esc>" },
-- ===================== undo / redo =====================
{ "u_undo_delete", "hello world", "dwu" },
{ "u_undo_change", "hello world", "ciwfoo<Esc>u" },
{ "u_undo_x", "hello", "xu" },
{ "ctrl_r_redo", "hello", "xu<C-r>" },
{ "u_multiple", "hello world", "xdwu" },
{ "redo_after_undo", "hello world", "dwu<C-r>" },
-- ===================== dot repeat =====================
{ "dot_repeat_x", "hello", "x." },
{ "dot_repeat_dw", "one two three", "dw." },
{ "dot_repeat_cw", "one two three", "cwfoo<Esc>w." },
{ "dot_repeat_r", "hello", "ra.." },
{ "dot_repeat_s", "hello", "sX<Esc>l." },
-- ===================== counts =====================
{ "count_h", "hello world", "$3h" },
{ "count_l", "hello world", "3l" },
{ "count_w", "one two three four", "2w" },
{ "count_b", "one two three four", "$2b" },
{ "count_x", "hello", "3x" },
{ "count_dw", "one two three four", "2dw" },
{ "verb_count_motion", "one two three four", "d2w" },
{ "count_s", "hello", "3sX<Esc>" },
-- ===================== indent / dedent =====================
{ "indent_line", "hello", ">>" },
{ "dedent_line", "\thello", "<<" },
{ "indent_double", "hello", ">>>>" },
-- ===================== join =====================
{ "J_join_lines", "hello\nworld", "J" },
-- ===================== case in visual =====================
{ "v_u_lower", "HELLO", "vlllu" },
{ "v_U_upper", "hello", "vlllU" },
-- ===================== visual mode =====================
{ "v_d_delete", "hello world", "vwwd" },
{ "v_x_delete", "hello world", "vwwx" },
{ "v_c_change", "hello world", "vwcfoo<Esc>" },
{ "v_y_p_yank", "hello world", "vwyAp <Esc>p" },
{ "v_dollar_d", "hello world", "wv$d" },
{ "v_0_d", "hello world", "$v0d" },
{ "ve_d", "hello world", "ved" },
{ "v_o_swap", "hello world", "vllod" },
{ "v_r_replace", "hello", "vlllrx" },
{ "v_tilde_case", "hello", "vlll~" },
-- ===================== visual line mode =====================
{ "V_d_delete", "hello world", "Vd" },
{ "V_y_p", "hello world", "Vyp" },
{ "V_S_change", "hello world", "VSfoo<Esc>" },
-- ===================== increment / decrement =====================
{ "ctrl_a_inc", "num 5 end", "w<C-a>" },
{ "ctrl_x_dec", "num 5 end", "w<C-x>" },
{ "ctrl_a_negative", "num -3 end", "w<C-a>" },
{ "ctrl_x_to_neg", "num 0 end", "w<C-x>" },
{ "ctrl_a_count", "num 5 end", "w3<C-a>" },
-- ===================== misc / edge cases =====================
{ "delete_empty", "", "x" },
{ "undo_on_empty", "", "u" },
{ "w_single_char", "a b c", "w" },
{ "dw_last_word", "hello", "dw" },
{ "dollar_single", "h", "$" },
{ "caret_no_ws", "hello", "$^" },
{ "f_last_char", "hello", "fo" },
{ "r_on_space", "hello world", "5|r-" },
}
-- Map vim special key names to Rust string escape sequences
local key_to_bytes = {
["<Esc>"] = "\\x1b",
["<CR>"] = "\\r",
["<BS>"] = "\\x7f",
["<Tab>"] = "\\t",
["<Del>"] = "\\x1b[3~",
["<Up>"] = "\\x1b[A",
["<Down>"] = "\\x1b[B",
["<Right>"] = "\\x1b[C",
["<Left>"] = "\\x1b[D",
["<Home>"] = "\\x1b[H",
["<End>"] = "\\x1b[F",
}
-- Convert vim key notation to Rust string escape sequences
local function keys_to_rust(keys)
local result = keys
result = result:gsub("<C%-(.)>", function(ch)
local byte = string.byte(ch:lower()) - string.byte('a') + 1
return string.format("\\x%02x", byte)
end)
for name, bytes in pairs(key_to_bytes) do
result = result:gsub(vim.pesc(name), bytes)
end
return result
end
-- Escape a string for use in a Rust string literal
local function rust_escape(s)
return s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\t", "\\t")
end
io.write("vi_test! {\n")
for i, test in ipairs(tests) do
local name, input, keys = test[1], test[2], test[3]
-- Fresh buffer and register state
local input_lines = vim.split(input, "\n", { plain = true })
vim.api.nvim_buf_set_lines(0, 0, -1, false, input_lines)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
vim.fn.setreg('"', '')
-- Execute the key sequence synchronously
local translated = vim.api.nvim_replace_termcodes(keys, true, false, true)
vim.api.nvim_feedkeys(translated, "ntx", false)
vim.api.nvim_exec_autocmds("CursorMoved", {})
-- Capture result
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local result = table.concat(lines, "\n")
local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
local rust_keys = keys_to_rust(keys)
local rust_input = rust_escape(input)
local rust_result = rust_escape(result)
local sep = ";"
if i == #tests then sep = "" end
io.write(string.format('\tvi_%s: "%s" => "%s" => "%s", %d%s\n',
name, rust_input, rust_keys, rust_result, cursor_col, sep))
end
io.write("}\n")
vim.cmd("qa!")