Compare commits
48 Commits
b46877edde
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 782a3820da | |||
| b0325b6bbb | |||
| bce6cd10f7 | |||
| ac8940f936 | |||
| 3705986169 | |||
| db3f1b5108 | |||
| 958dad9942 | |||
| ec9795c781 | |||
| bcc4a87e10 | |||
| 067b4f6184 | |||
| 7e2763bb80 | |||
| 99b9440ee1 | |||
| f6a3935bcb | |||
| 1f9d59b546 | |||
| 101d8434f8 | |||
| 9bd9c66b92 | |||
| 5173e1908d | |||
| 1f9c96f24e | |||
| 09024728f6 | |||
| 307386ffc6 | |||
| 13227943c6 | |||
| a46ebe6868 | |||
| 5500b081fe | |||
| f279159873 | |||
| bb3db444db | |||
| 85e5fc2875 | |||
| ac429cbdf4 | |||
| a464540fbe | |||
| 07d7015dd4 | |||
| 490ce4571d | |||
| a43f8a6dde | |||
| ae73969969 | |||
| fe9fd5c797 | |||
| b137c38e92 | |||
| 42b4120055 | |||
| 8a7211d42e | |||
| dc0ff23903 | |||
| 5d827c7654 | |||
| c8531fb384 | |||
| e31e27f935 | |||
| cac7140c8b | |||
| 8c91748a7e | |||
| 633bc16960 | |||
| cdc9e7e266 | |||
| a34c939953 | |||
| 902f1e6889 | |||
| 8cd3405751 | |||
| 624677b961 |
6
.cargo/config.toml
Normal file
6
.cargo/config.toml
Normal 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"
|
||||||
120
Cargo.lock
generated
120
Cargo.lock
generated
@@ -47,7 +47,7 @@ version = "1.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -158,18 +158,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
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]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -191,12 +179,6 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "encode_unicode"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -233,7 +215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -307,18 +289,6 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -533,7 +503,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -599,7 +569,6 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"glob",
|
"glob",
|
||||||
"insta",
|
|
||||||
"itertools",
|
"itertools",
|
||||||
"log",
|
"log",
|
||||||
"nix",
|
"nix",
|
||||||
@@ -615,12 +584,6 @@ dependencies = [
|
|||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "similar"
|
|
||||||
version = "2.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -648,7 +611,7 @@ dependencies = [
|
|||||||
"getrandom",
|
"getrandom",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -749,15 +712,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -767,70 +721,6 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
17
Cargo.toml
17
Cargo.toml
@@ -17,18 +17,31 @@ env_logger = "0.11.9"
|
|||||||
glob = "0.3.2"
|
glob = "0.3.2"
|
||||||
itertools = "0.14.0"
|
itertools = "0.14.0"
|
||||||
log = "0.4.29"
|
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"
|
rand = "0.10.0"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
scopeguard = "1.2.0"
|
scopeguard = "1.2.0"
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
tempfile = "3.24.0"
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
unicode-width = "0.2.0"
|
unicode-width = "0.2.0"
|
||||||
vte = "0.15"
|
vte = "0.15"
|
||||||
yansi = "1.0.1"
|
yansi = "1.0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = "1.42.2"
|
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
tempfile = "3.24.0"
|
tempfile = "3.24.0"
|
||||||
|
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -1,14 +1,16 @@
|
|||||||
# shed
|
# 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.
|
||||||
|
|
||||||
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/5333dd47-ae1b-45cd-8729-b623f586b10e" />
|
<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" />
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Line Editor
|
### 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
|
- **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
|
- **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
|
- **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
|
- **Tab completion** - context-aware completion for commands, file paths, and variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Prompt
|
### Prompt
|
||||||
|
|
||||||
The prompt string supports escape sequences for dynamic content:
|
The prompt string supports escape sequences for dynamic content:
|
||||||
@@ -38,14 +42,20 @@ gitbranch() { git branch --show-current 2>/dev/null; }
|
|||||||
export PS1='\u@\h \W \@gitbranch \$ '
|
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.
|
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`!
|
### 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.
|
`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.
|
||||||
|
|
||||||
<img width="773" height="505" alt="shed_comp" src="https://github.com/user-attachments/assets/0078ef5a-ba01-479a-831e-96ae5a25b4e3" />
|
<img width="380" height="270" alt="shed_comp" src="https://github.com/user-attachments/assets/d317387e-4c33-406a-817f-1c183afab749" />
|
||||||
<img width="773" height="536" alt="shed_search" src="https://github.com/user-attachments/assets/7169dacc-a92b-48f7-bf45-0f14a6d38a10" />
|
<img width="380" height="270" alt="shed_search" src="https://github.com/user-attachments/assets/5109eb14-5c33-46bb-ab39-33c60ca039a8" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Keymaps
|
### Keymaps
|
||||||
|
|
||||||
@@ -68,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.
|
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
|
### Autocmds
|
||||||
|
|
||||||
The `autocmd` builtin registers shell commands to run on specific events:
|
The `autocmd` builtin registers shell commands to run on specific events:
|
||||||
@@ -93,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.
|
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
|
### Shell Language
|
||||||
|
|
||||||
shed's scripting language contains all of the essentials.
|
shed's scripting language contains all of the essentials.
|
||||||
@@ -112,6 +126,8 @@ shed's scripting language contains all of the essentials.
|
|||||||
- **Subshells** - `(...)` for isolated execution
|
- **Subshells** - `(...)` for isolated execution
|
||||||
- **Variable attributes** - `export`, `local`, `readonly`
|
- **Variable attributes** - `export`, `local`, `readonly`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Job Control
|
### Job Control
|
||||||
|
|
||||||
- Background execution with `&`
|
- Background execution with `&`
|
||||||
@@ -119,6 +135,8 @@ shed's scripting language contains all of the essentials.
|
|||||||
- `fg`, `bg`, `jobs`, `disown` with flags (`-l`, `-p`, `-r`, `-s`, `-h`, `-a`)
|
- `fg`, `bg`, `jobs`, `disown` with flags (`-l`, `-p`, `-r`, `-s`, `-h`, `-a`)
|
||||||
- Process group management and proper signal forwarding
|
- Process group management and proper signal forwarding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Shell options are managed through `shopt`:
|
Shell options are managed through `shopt`:
|
||||||
@@ -133,6 +151,8 @@ shopt core.max_hist=5000 # history size
|
|||||||
|
|
||||||
The rc file is loaded from `~/.shedrc` on startup.
|
The rc file is loaded from `~/.shedrc` on startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
### Cargo
|
### Cargo
|
||||||
|
|||||||
76
doc/arith.txt
Normal file
76
doc/arith.txt
Normal 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|
|
||||||
155
doc/glob.txt
Normal file
155
doc/glob.txt
Normal 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|
|
||||||
197
doc/param.txt
Normal file
197
doc/param.txt
Normal 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|
|
||||||
181
doc/redirect.txt
Normal file
181
doc/redirect.txt
Normal 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|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
## Prompt example
|
|
||||||
|
|
||||||
This is the `shed` code for the prompt that I currently use. Note that the scripting language for `shed` is essentially identical to bash. This prompt code uses the `\!` escape sequence which lets you use the output of a function as your prompt.
|
|
||||||
|
|
||||||
Also note that in `shed`, the `echo` builtin has a new `-p` flag which expands prompt escape sequences. This allows you to access these escape sequences in any context.
|
|
||||||
|
|
||||||
The end result is the prompt that appears in the README:
|
|
||||||
|
|
||||||
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/5333dd47-ae1b-45cd-8729-b623f586b10e" />
|
|
||||||
|
|
||||||
```bash
|
|
||||||
prompt_topline() {
|
|
||||||
local user_and_host="\e[0m\e[1m$USER\e[1;36m@\e[1;31m$HOST\e[0m"
|
|
||||||
echo -n "\e[1;34m┏━ $user_and_host\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt_stat_line() {
|
|
||||||
local last_exit_code="$?"
|
|
||||||
local last_cmd_status
|
|
||||||
local last_cmd_runtime
|
|
||||||
if [ "$last_exit_code" -eq "0" ]; then
|
|
||||||
last_cmd_status="\e[1;32m\e[0m"
|
|
||||||
else
|
|
||||||
last_cmd_status="\e[1;31m\e[0m"
|
|
||||||
fi
|
|
||||||
local last_runtime_raw="$(echo -p "\t")"
|
|
||||||
if [ -z "$last_runtime_raw" ]; then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
last_cmd_runtime="\e[1;38;2;249;226;175m $(echo -p "\T")\e[0m"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -n "\e[1;34m┃ $last_cmd_runtime ($last_cmd_status)\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt_git_line() {
|
|
||||||
git rev-parse --is-inside-work-tree > /dev/null 2>&1 || return
|
|
||||||
|
|
||||||
local gitsigns
|
|
||||||
local status="$(git status --porcelain 2>/dev/null)"
|
|
||||||
local branch="$(git branch --show-current 2>/dev/null)"
|
|
||||||
|
|
||||||
[ -n "$status" ] && echo "$status" | command grep -q '^ [MADR]' && gitsigns="$gitsigns!"
|
|
||||||
[ -n "$status" ] && echo "$status" | command grep -q '^??' && gitsigns="$gitsigns?"
|
|
||||||
[ -n "$status" ] && echo "$status" | command grep -q '^[MADR]' && gitsigns="$gitsigns+"
|
|
||||||
|
|
||||||
local ahead="$(git rev-list --count @{upstream}..HEAD 2>/dev/null)"
|
|
||||||
local behind="$(git rev-list --count HEAD..@{upstream} 2>/dev/null)"
|
|
||||||
[ $ahead -gt 0 ] && gitsigns="$gitsigns↑"
|
|
||||||
[ $behind -gt 0 ] && gitsigns="$gitsigns↓"
|
|
||||||
|
|
||||||
if [ -n "$gitsigns" ] || [ -n "$branch" ]; then
|
|
||||||
if [ -n "$gitsigns" ]; then
|
|
||||||
gitsigns="\e[1;31m[$gitsigns]"
|
|
||||||
fi
|
|
||||||
echo -n "\e[1;34m┃ \e[1;35m ${branch}$gitsigns\e[0m\n"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt_jobs_line() {
|
|
||||||
local job_count="$(echo -p '\j')"
|
|
||||||
if [ "$job_count" -gt 0 ]; then
|
|
||||||
echo -n "\e[1;34m┃ \e[1;33m $job_count job(s) running\e[0m\n"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt_ssh_line() {
|
|
||||||
local ssh_server="$(echo $SSH_CONNECTION | cut -f3 -d' ')"
|
|
||||||
[ -n "$ssh_server" ] && echo -n "\e[1;34m┃ \e[1;39m🌐 $ssh_server\e[0m\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt_pwd_line() {
|
|
||||||
echo -p "\e[1;34m┣━━ \e[1;36m\W\e[1;32m/"
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt_dollar_line() {
|
|
||||||
local dollar="$(echo -p "\$ ")"
|
|
||||||
local dollar="$(echo -e "\e[1;32m$dollar\e[0m")"
|
|
||||||
echo -n "\e[1;34m┗━ $dollar "
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt() {
|
|
||||||
local statline="$(prompt_stat_line)"
|
|
||||||
local topline="$(prompt_topline)"
|
|
||||||
local gitline="$(prompt_git_line)"
|
|
||||||
local jobsline="$(prompt_jobs_line)"
|
|
||||||
local sshline="$(prompt_ssh_line)"
|
|
||||||
local pwdline="$(prompt_pwd_line)"
|
|
||||||
local dollarline="$(prompt_dollar_line)"
|
|
||||||
local prompt="$topline$statline$gitline$jobsline$sshline$pwdline\n$dollarline"
|
|
||||||
|
|
||||||
echo -en "$prompt"
|
|
||||||
}
|
|
||||||
|
|
||||||
export PS1="\!prompt "
|
|
||||||
```
|
|
||||||
200
examples/cool_prompt.sh
Normal file
200
examples/cool_prompt.sh
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# This is the code for the prompt I currently use
|
||||||
|
# It makes use of the '\@funcname' function expansion escape sequence
|
||||||
|
# and the '-p' flag for echo which expands prompt escape sequences
|
||||||
|
#
|
||||||
|
# The final product looks like this:
|
||||||
|
# ┏━ user@hostname INSERT
|
||||||
|
# ┃ 1ms
|
||||||
|
# ┃ main[!?] ~1 +1 -1
|
||||||
|
# ┃ 1 job(s) running
|
||||||
|
# ┣━━ ~/path/to/pwd/
|
||||||
|
# ┗━ $ $shed 0.5.0 (x86_64 linux)
|
||||||
|
# (The vi mode indicator is styled to match the color of the separators)
|
||||||
|
|
||||||
|
prompt() {
|
||||||
|
local statline="$(prompt_stat_line)"
|
||||||
|
local topline="$(prompt_topline)"
|
||||||
|
local jobsline="$(prompt_jobs_line)"
|
||||||
|
local sshline="$(prompt_ssh_line)"
|
||||||
|
local pwdline="$(prompt_pwd_line)"
|
||||||
|
local dollarline="$(prompt_dollar_line)"
|
||||||
|
local prompt="$topline$statline$PROMPT_GIT_LINE$jobsline$sshline$pwdline\n$dollarline"
|
||||||
|
|
||||||
|
echo -en "$prompt"
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_dollar_line() {
|
||||||
|
local dollar="$(echo -p "\$ ")"
|
||||||
|
local dollar="$(echo -e "\e[1;32m$dollar\e[0m")"
|
||||||
|
echo -n "\e[1;34m┗━ $dollar "
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_git_line() {
|
||||||
|
# git is really expensive so we've gotta make these calls count
|
||||||
|
|
||||||
|
# get the status
|
||||||
|
local status="$(git status --porcelain -b 2>/dev/null)" || return
|
||||||
|
|
||||||
|
local branch="" gitsigns="" ahead=0 behind=0
|
||||||
|
# split at the first linebreak
|
||||||
|
local header="${status%%$'\n'*}"
|
||||||
|
|
||||||
|
# cut the '## ' prefix
|
||||||
|
branch="${header#\#\# }"
|
||||||
|
# cut the '..' suffix
|
||||||
|
branch="${branch%%...*}"
|
||||||
|
|
||||||
|
# parse ahead/behind counts
|
||||||
|
case "$header" in
|
||||||
|
*ahead*) ahead="${header#*ahead }"; ahead="${ahead%%[],]*}"; gitsigns="${gitsigns}↑" ;;
|
||||||
|
esac
|
||||||
|
case "$header" in
|
||||||
|
*behind*) behind="${header#*behind }"; behind="${behind%%[],]*}"; gitsigns="${gitsigns}↓" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# grab gitsigns
|
||||||
|
case "$status" in
|
||||||
|
# unstaged changes
|
||||||
|
*$'\n'" "[MAR]*) gitsigns="${gitsigns}!" ;;
|
||||||
|
esac
|
||||||
|
case "$status" in
|
||||||
|
# untracked files
|
||||||
|
*$'\n'"??"*) gitsigns="${gitsigns}?" ;;
|
||||||
|
esac
|
||||||
|
case "$status" in
|
||||||
|
# deleted files
|
||||||
|
*$'\n'" "[D]*) gitsigns="${gitsigns}" ;;
|
||||||
|
esac
|
||||||
|
case "$status" in
|
||||||
|
# staged changes
|
||||||
|
*$'\n'[MADR]*) gitsigns="${gitsigns}+" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# unfortunately we need one more git fork
|
||||||
|
local diff="$(git diff --shortstat 2>/dev/null)"
|
||||||
|
|
||||||
|
local changed="" add="" del=""
|
||||||
|
if [ -n "$diff" ]; then
|
||||||
|
changed="${diff%% file*}"; changed="${changed##* }"
|
||||||
|
case "$diff" in
|
||||||
|
*insertion*) add="${diff#*, }"; add="${add%% *}" ;;
|
||||||
|
esac
|
||||||
|
case "$diff" in
|
||||||
|
*deletion*) del="${diff% deletion*}"; del="${del##* }" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$gitsigns" ] || [ -n "$branch" ]; then
|
||||||
|
# style gitsigns if not empty
|
||||||
|
[ -n "$gitsigns" ] && gitsigns="\e[1;31m[$gitsigns]"
|
||||||
|
# style changed/deleted/added text
|
||||||
|
[ -n "$changed" ] && [ "$changed" -gt 0 ] && changed="\e[1;34m~$changed \e[0m"
|
||||||
|
[ -n "$add" ] && [ "$add" -gt 0 ] && add="\e[1;32m+$add \e[0m"
|
||||||
|
[ -n "$del" ] && [ "$del" -gt 0 ] && del="\e[1;31m-$del\e[0m"
|
||||||
|
|
||||||
|
# echo the final product
|
||||||
|
echo -n "\e[1;34m┃ \e[1;35m $branch$gitsigns\e[0m $changed$add$del\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_jobs_line() {
|
||||||
|
local job_count="$(echo -p '\j')"
|
||||||
|
if [ "$job_count" -gt 0 ]; then
|
||||||
|
echo -n "\e[1;34m┃ \e[1;33m $job_count job(s) running\e[0m\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_mode() {
|
||||||
|
local mode=""
|
||||||
|
local normal_fg='\e[0m\e[30m\e[1;43m'
|
||||||
|
local normal_bg='\e[0m\e[33m'
|
||||||
|
local insert_fg='\e[0m\e[30m\e[1;46m'
|
||||||
|
local insert_bg='\e[0m\e[36m'
|
||||||
|
local command_fg='\e[0m\e[30m\e[1;42m'
|
||||||
|
local command_bg='\e[0m\e[32m'
|
||||||
|
local visual_fg='\e[0m\e[30m\e[1;45m'
|
||||||
|
local visual_bg='\e[0m\e[35m'
|
||||||
|
local replace_fg='\e[0m\e[30m\e[1;41m'
|
||||||
|
local replace_bg='\e[0m\e[31m'
|
||||||
|
local search_fg='\e[0m\e[30m\e[1;47m'
|
||||||
|
local search_bg='\e[0m\e[39m'
|
||||||
|
local complete_fg='\e[0m\e[30m\e[1;47m'
|
||||||
|
local complete_bg='\e[0m\e[39m'
|
||||||
|
|
||||||
|
# shed exposes it's current vi mode as a variable
|
||||||
|
case "$SHED_VI_MODE" in
|
||||||
|
"NORMAL")
|
||||||
|
mode="$normal_bg${normal_fg}NORMAL$normal_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"INSERT")
|
||||||
|
mode="$insert_bg${insert_fg}INSERT$insert_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"COMMAND")
|
||||||
|
mode="$command_bg${command_fg}COMMAND$command_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"VISUAL")
|
||||||
|
mode="$visual_bg${visual_fg}VISUAL$visual_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"REPLACE")
|
||||||
|
mode="$replace_bg${replace_fg}REPLACE$replace_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"VERBATIM")
|
||||||
|
mode="$replace_bg${replace_fg}VERBATIM$replace_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"COMPLETE")
|
||||||
|
mode="$complete_bg${complete_fg}COMPLETE$complete_bg\e[0m"
|
||||||
|
;;
|
||||||
|
"SEARCH")
|
||||||
|
mode="$search_bg${search_fg}SEARCH$search_bg\e[0m"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
mode=""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo -en "$mode\n"
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_pwd_line() {
|
||||||
|
# the -p flag exposes prompt escape sequences like '\W'
|
||||||
|
echo -p "\e[1;34m┣━━ \e[1;36m\W\e[1;32m/"
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_ssh_line() {
|
||||||
|
local ssh_server="$(echo $SSH_CONNECTION | cut -f3 -d' ')"
|
||||||
|
[ -n "$ssh_server" ] && echo -n "\e[1;34m┃ \e[1;39m🌐 $ssh_server\e[0m\n"
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_stat_line() {
|
||||||
|
local last_exit_code="$?"
|
||||||
|
local last_cmd_status
|
||||||
|
local last_cmd_runtime
|
||||||
|
if [ "$last_exit_code" -eq "0" ]; then
|
||||||
|
last_cmd_status="\e[1;32m"
|
||||||
|
else
|
||||||
|
last_cmd_status="\e[1;31m"
|
||||||
|
fi
|
||||||
|
local last_runtime_raw="$(echo -p "\t")"
|
||||||
|
if [ -z "$last_runtime_raw" ]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
last_cmd_runtime="\e[1;38;2;249;226;175m ${last_cmd_status}$(echo -p "\T")\e[0m"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -n "\e[1;34m┃ $last_cmd_runtime\e[0m\n"
|
||||||
|
|
||||||
|
}
|
||||||
|
prompt_topline() {
|
||||||
|
local user_and_host="\e[0m\e[1m$USER\e[1;36m@\e[1;31m$HOST\e[0m"
|
||||||
|
local mode_text="$(prompt_mode)"
|
||||||
|
echo -n "\e[1;34m┏━ $user_and_host $mode_text\n"
|
||||||
|
|
||||||
|
}
|
||||||
|
shed_ver() {
|
||||||
|
shed --version
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export PS1="\@prompt "
|
||||||
|
# PSR is the text that expands on the right side of the prompt
|
||||||
|
export PSR='\e[36;1m$\@shed_ver\e[0m'
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# An implementation of the 'nvim-surround' plugin by kylechui
|
||||||
|
# that uses shed's keymap system to alter line editor state
|
||||||
|
|
||||||
|
|
||||||
_get_surround_target() {
|
_get_surround_target() {
|
||||||
# get the delimiters to surround our selection with
|
# get the delimiters to surround our selection with
|
||||||
read_key -v _s_ch
|
read_key -v _s_ch
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
lockFile = ./Cargo.lock;
|
lockFile = ./Cargo.lock;
|
||||||
};
|
};
|
||||||
|
|
||||||
doCheck = false;
|
|
||||||
passthru.shellPath = "/bin/shed";
|
passthru.shellPath = "/bin/shed";
|
||||||
|
|
||||||
|
checkPhase = ''
|
||||||
|
cargo test -- --test-threads=1
|
||||||
|
'';
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
meta = with pkgs.lib; {
|
||||||
description = "A Linux shell written in Rust";
|
description = "A Linux shell written in Rust";
|
||||||
homepage = "https://github.com/km-clay/shed";
|
homepage = "https://github.com/km-clay/shed";
|
||||||
|
|||||||
@@ -2,325 +2,14 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
cfg = config.programs.shed;
|
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
|
in
|
||||||
{
|
{
|
||||||
options.programs.shed = {
|
options.programs.shed = import ./shed_opts.nix { inherit 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";
|
|
||||||
};
|
|
||||||
|
|
||||||
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";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config =
|
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 {
|
lib.mkIf cfg.enable {
|
||||||
home.packages = [ cfg.package ];
|
home.packages = [ cfg.package ];
|
||||||
|
|
||||||
home.file.".shedrc".text = lib.concatLines [
|
home.file.".shedrc".text = import ./render_rc.nix lib cfg;
|
||||||
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
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,11 @@ let
|
|||||||
cfg = config.programs.shed;
|
cfg = config.programs.shed;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.programs.shed = {
|
options.programs.shed = import ./shed_opts.nix { inherit pkgs lib; };
|
||||||
enable = lib.mkEnableOption "shed shell";
|
|
||||||
|
|
||||||
package = lib.mkOption {
|
|
||||||
type = lib.types.package;
|
|
||||||
default = pkgs.shed;
|
|
||||||
description = "The shed package to use";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
environment.systemPackages = [ cfg.package ];
|
environment.systemPackages = [ cfg.package ];
|
||||||
environment.shells = [ cfg.package ];
|
environment.shells = [ cfg.package ];
|
||||||
|
environment.etc."shed/shedrc".text = import ./render_rc.nix lib cfg;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
83
nix/render_rc.nix
Normal file
83
nix/render_rc.nix
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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.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 core.xpg_echo=${boolToString cfg.settings.echoExpandsEscapes}"
|
||||||
|
"shopt core.noclobber=${boolToString cfg.settings.noClobber}"
|
||||||
|
|
||||||
|
"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}"
|
||||||
|
"shopt prompt.line_numbers=${boolToString cfg.settings.lineNumbers}"
|
||||||
|
"shopt prompt.screensaver_idle_time=${toString cfg.settings.screensaverIdleTime}"
|
||||||
|
"shopt prompt.screensaver_cmd='${cfg.settings.screensaverCmd}'"
|
||||||
|
"shopt prompt.completion_ignore_case=${boolToString cfg.settings.completionIgnoreCase}"
|
||||||
|
"shopt prompt.auto_indent=${boolToString cfg.settings.autoIndent}"
|
||||||
|
functionLines
|
||||||
|
completeLines
|
||||||
|
keymapLines
|
||||||
|
autocmdLines
|
||||||
|
])
|
||||||
|
cfg.settings.extraPostConfig
|
||||||
|
]
|
||||||
279
nix/shed_opts.nix
Normal file
279
nix/shed_opts.nix
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
{ 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";
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
echoExpandsEscapes = 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)";
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
lineNumbers = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to show line numbers in the prompt";
|
||||||
|
};
|
||||||
|
screensaverCmd = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "";
|
||||||
|
description = "A shell command to execute after a period of inactivity (i.e. a custom screensaver)";
|
||||||
|
};
|
||||||
|
screensaverIdleTime = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 0;
|
||||||
|
description = "The amount of inactivity time in seconds before the screensaver command is executed";
|
||||||
|
};
|
||||||
|
completionIgnoreCase = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Whether to ignore case when completing commands and file names";
|
||||||
|
};
|
||||||
|
autoIndent = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Whether to automatically indent new lines based on the previous line";
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,21 +38,32 @@ pub fn alias(node: Node) -> ShResult<()> {
|
|||||||
write(stdout, alias_output.as_bytes())?; // Write it
|
write(stdout, alias_output.as_bytes())?; // Write it
|
||||||
} else {
|
} else {
|
||||||
for (arg, span) in argv {
|
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(
|
return Err(ShErr::at(
|
||||||
ShErrKind::ExecFail,
|
ShErrKind::ExecFail,
|
||||||
span,
|
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()));
|
write_logic(|l| l.insert_alias(name, body, span.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +71,7 @@ pub fn alias(node: Node) -> ShResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove one or more aliases by name
|
||||||
pub fn unalias(node: Node) -> ShResult<()> {
|
pub fn unalias(node: Node) -> ShResult<()> {
|
||||||
let NdRule::Command {
|
let NdRule::Command {
|
||||||
assignments: _,
|
assignments: _,
|
||||||
@@ -103,3 +115,164 @@ pub fn unalias(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,3 +226,266 @@ pub fn get_arr_op_opts(opts: Vec<Opt>) -> ShResult<ArrOpOpts> {
|
|||||||
}
|
}
|
||||||
Ok(arr_op_opts)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,3 +111,218 @@ pub fn autocmd(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,3 +75,168 @@ pub fn cd(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,20 +173,24 @@ pub fn complete_builtin(node: Node) -> ShResult<()> {
|
|||||||
|
|
||||||
if comp_opts.flags.contains(CompFlags::PRINT) {
|
if comp_opts.flags.contains(CompFlags::PRINT) {
|
||||||
if argv.is_empty() {
|
if argv.is_empty() {
|
||||||
read_meta(|m| {
|
read_meta(|m| -> ShResult<()> {
|
||||||
let specs = m.comp_specs().values();
|
let specs = m.comp_specs().values();
|
||||||
for spec in specs {
|
for spec in specs {
|
||||||
println!("{}", spec.source());
|
let stdout = borrow_fd(STDOUT_FILENO);
|
||||||
|
write(stdout, spec.source().as_bytes())?;
|
||||||
}
|
}
|
||||||
})
|
Ok(())
|
||||||
|
})?;
|
||||||
} else {
|
} else {
|
||||||
read_meta(|m| {
|
read_meta(|m| -> ShResult<()> {
|
||||||
for (cmd, _) in &argv {
|
for (cmd, _) in &argv {
|
||||||
if let Some(spec) = m.comp_specs().get(cmd) {
|
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);
|
state::set_status(0);
|
||||||
@@ -309,3 +313,318 @@ pub fn get_comp_opts(opts: Vec<Opt>) -> ShResult<CompOpts> {
|
|||||||
|
|
||||||
Ok(comp_opts)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ use crate::{
|
|||||||
state::{self, read_meta, write_meta},
|
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 {
|
enum StackIdx {
|
||||||
FromTop(usize),
|
FromTop(usize),
|
||||||
FromBottom(usize),
|
FromBottom(usize),
|
||||||
@@ -23,18 +33,7 @@ fn print_dirs() -> ShResult<()> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(dirs_iter)
|
.chain(dirs_iter)
|
||||||
.map(|d| d.to_string_lossy().to_string())
|
.map(|d| d.to_string_lossy().to_string())
|
||||||
.map(|d| {
|
.map(truncate_home_path)
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
@@ -378,19 +377,7 @@ pub fn dirs(node: Node) -> ShResult<()> {
|
|||||||
.map(|d| d.to_string_lossy().to_string());
|
.map(|d| d.to_string_lossy().to_string());
|
||||||
|
|
||||||
if abbreviate_home {
|
if abbreviate_home {
|
||||||
let Ok(home) = env::var("HOME") else {
|
stack.map(truncate_home_path).collect()
|
||||||
return stack.collect();
|
|
||||||
};
|
|
||||||
stack
|
|
||||||
.map(|d| {
|
|
||||||
if d.starts_with(&home) {
|
|
||||||
let new = d.strip_prefix(&home).unwrap();
|
|
||||||
format!("~{new}")
|
|
||||||
} else {
|
|
||||||
d
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
} else {
|
||||||
stack.collect()
|
stack.collect()
|
||||||
}
|
}
|
||||||
@@ -438,3 +425,201 @@ pub fn dirs(node: Node) -> ShResult<()> {
|
|||||||
|
|
||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::{
|
|||||||
parse::{NdRule, Node, execute::prepare_argv},
|
parse::{NdRule, Node, execute::prepare_argv},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
procio::borrow_fd,
|
procio::borrow_fd,
|
||||||
state,
|
state::{self, read_shopts},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ECHO_OPTS: [OptSpec; 4] = [
|
pub const ECHO_OPTS: [OptSpec; 4] = [
|
||||||
@@ -31,7 +31,7 @@ bitflags! {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct EchoFlags: u32 {
|
pub struct EchoFlags: u32 {
|
||||||
const NO_NEWLINE = 0b000001;
|
const NO_NEWLINE = 0b000001;
|
||||||
const USE_STDERR = 0b000010;
|
const NO_ESCAPE = 0b000010;
|
||||||
const USE_ESCAPE = 0b000100;
|
const USE_ESCAPE = 0b000100;
|
||||||
const USE_PROMPT = 0b001000;
|
const USE_PROMPT = 0b001000;
|
||||||
}
|
}
|
||||||
@@ -54,18 +54,18 @@ pub fn echo(node: Node) -> ShResult<()> {
|
|||||||
argv.remove(0);
|
argv.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let output_channel = if flags.contains(EchoFlags::USE_STDERR) {
|
let output_channel = borrow_fd(STDOUT_FILENO);
|
||||||
borrow_fd(STDERR_FILENO)
|
let xpg_echo = read_shopts(|o| o.core.xpg_echo); // If true, echo expands escape sequences by default, and -E opts out
|
||||||
} else {
|
|
||||||
borrow_fd(STDOUT_FILENO)
|
let use_escape =
|
||||||
};
|
(xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE);
|
||||||
|
|
||||||
let mut echo_output = prepare_echo_args(
|
let mut echo_output = prepare_echo_args(
|
||||||
argv
|
argv
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| a.0) // Extract the String from the tuple of (String,Span)
|
.map(|a| a.0) // Extract the String from the tuple of (String,Span)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
flags.contains(EchoFlags::USE_ESCAPE),
|
use_escape,
|
||||||
flags.contains(EchoFlags::USE_PROMPT),
|
flags.contains(EchoFlags::USE_PROMPT),
|
||||||
)?
|
)?
|
||||||
.join(" ");
|
.join(" ");
|
||||||
@@ -206,9 +206,9 @@ pub fn get_echo_flags(opts: Vec<Opt>) -> ShResult<EchoFlags> {
|
|||||||
for opt in opts {
|
for opt in opts {
|
||||||
match opt {
|
match opt {
|
||||||
Opt::Short('n') => flags |= EchoFlags::NO_NEWLINE,
|
Opt::Short('n') => flags |= EchoFlags::NO_NEWLINE,
|
||||||
Opt::Short('r') => flags |= EchoFlags::USE_STDERR,
|
|
||||||
Opt::Short('e') => flags |= EchoFlags::USE_ESCAPE,
|
Opt::Short('e') => flags |= EchoFlags::USE_ESCAPE,
|
||||||
Opt::Short('p') => flags |= EchoFlags::USE_PROMPT,
|
Opt::Short('p') => flags |= EchoFlags::USE_PROMPT,
|
||||||
|
Opt::Short('E') => flags |= EchoFlags::NO_ESCAPE,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(ShErr::simple(
|
return Err(ShErr::simple(
|
||||||
ShErrKind::ExecFail,
|
ShErrKind::ExecFail,
|
||||||
@@ -220,3 +220,250 @@ pub fn get_echo_flags(opts: Vec<Opt>) -> ShResult<EchoFlags> {
|
|||||||
|
|
||||||
Ok(flags)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,3 +34,91 @@ pub fn eval(node: Node) -> ShResult<()> {
|
|||||||
|
|
||||||
exec_input(joined_argv, None, false, Some("eval".into()))
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,3 +45,26 @@ pub fn exec_builtin(node: Node) -> ShResult<()> {
|
|||||||
_ => Err(ShErr::at(ShErrKind::Errno(e), span, format!("{e}"))),
|
_ => 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,3 +41,105 @@ pub fn flowctl(node: Node, kind: ShErrKind) -> ShResult<()> {
|
|||||||
|
|
||||||
Err(ShErr::simple(kind, message))
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -251,3 +251,217 @@ pub fn getopts(node: Node) -> ShResult<()> {
|
|||||||
getopts_inner(&opts_spec, &opt_var.0, &pos_params, span)
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
300
src/builtin/help.rs
Normal file
300
src/builtin/help.rs
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
use std::{env, io::Write, path::Path};
|
||||||
|
|
||||||
|
use ariadne::Span as ASpan;
|
||||||
|
use nix::libc::STDIN_FILENO;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
libsh::{
|
||||||
|
error::{ShErr, ShErrKind, ShResult},
|
||||||
|
guards::RawModeGuard,
|
||||||
|
},
|
||||||
|
parse::{
|
||||||
|
NdRule, Node, Redir, RedirType,
|
||||||
|
execute::{exec_input, prepare_argv},
|
||||||
|
lex::{QuoteState, Span},
|
||||||
|
},
|
||||||
|
procio::{IoFrame, IoMode},
|
||||||
|
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();
|
||||||
|
|
||||||
|
for path in hpath.split(':') {
|
||||||
|
let path = Path::new(&path).join(&topic);
|
||||||
|
if path.is_file() {
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let filename = path.file_stem().unwrap().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
let unescaped = unescape_help(&contents);
|
||||||
|
let expanded = expand_help(&unescaped);
|
||||||
|
open_help(&expanded, None, Some(filename))?;
|
||||||
|
state::set_status(0);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// didn't find an exact 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(());
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{env, os::unix::fs::PermissionsExt, path::Path};
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
use ariadne::{Fmt, Span};
|
use ariadne::{Fmt, Span};
|
||||||
|
|
||||||
@@ -6,6 +6,8 @@ use crate::{
|
|||||||
builtin::BUILTINS,
|
builtin::BUILTINS,
|
||||||
libsh::error::{ShErr, ShErrKind, ShResult, next_color},
|
libsh::error::{ShErr, ShErrKind, ShResult, next_color},
|
||||||
parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS},
|
parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS},
|
||||||
|
prelude::*,
|
||||||
|
procio::borrow_fd,
|
||||||
state::{self, ShAlias, ShFunc, read_logic},
|
state::{self, ShAlias, ShFunc, read_logic},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,28 +33,33 @@ pub fn type_builtin(node: Node) -> ShResult<()> {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'outer: for (arg, span) in argv {
|
'outer: for (arg, span) in argv {
|
||||||
|
let stdout = borrow_fd(STDOUT_FILENO);
|
||||||
if let Some(func) = read_logic(|v| v.get_func(&arg)) {
|
if let Some(func) = read_logic(|v| v.get_func(&arg)) {
|
||||||
let ShFunc { body: _, source } = func;
|
let ShFunc { body: _, source } = func;
|
||||||
let (line, col) = source.line_and_col();
|
let (line, col) = source.line_and_col();
|
||||||
let name = source.source().name();
|
let name = source.source().name();
|
||||||
println!(
|
let msg = format!(
|
||||||
"{arg} is a function defined at {name}:{}:{}",
|
"{arg} is a function defined at {name}:{}:{}\n",
|
||||||
line + 1,
|
line + 1,
|
||||||
col + 1
|
col + 1
|
||||||
);
|
);
|
||||||
|
write(stdout, msg.as_bytes())?;
|
||||||
} else if let Some(alias) = read_logic(|v| v.get_alias(&arg)) {
|
} else if let Some(alias) = read_logic(|v| v.get_alias(&arg)) {
|
||||||
let ShAlias { body, source } = alias;
|
let ShAlias { body, source } = alias;
|
||||||
let (line, col) = source.line_and_col();
|
let (line, col) = source.line_and_col();
|
||||||
let name = source.source().name();
|
let name = source.source().name();
|
||||||
println!(
|
let msg = format!(
|
||||||
"{arg} is an alias for '{body}' defined at {name}:{}:{}",
|
"{arg} is an alias for '{body}' defined at {name}:{}:{}\n",
|
||||||
line + 1,
|
line + 1,
|
||||||
col + 1
|
col + 1
|
||||||
);
|
);
|
||||||
|
write(stdout, msg.as_bytes())?;
|
||||||
} else if BUILTINS.contains(&arg.as_str()) {
|
} 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()) {
|
} 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 {
|
} else {
|
||||||
let path = env::var("PATH").unwrap_or_default();
|
let path = env::var("PATH").unwrap_or_default();
|
||||||
let paths = path.split(':').map(Path::new).collect::<Vec<_>>();
|
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()
|
&& let Some(name) = entry.file_name().to_str()
|
||||||
&& name == arg
|
&& name == arg
|
||||||
{
|
{
|
||||||
println!("{arg} is {}", entry.path().display());
|
let msg = format!("{arg} is {}\n", entry.path().display());
|
||||||
|
write(stdout, msg.as_bytes())?;
|
||||||
continue 'outer;
|
continue 'outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,3 +100,136 @@ pub fn type_builtin(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ impl KeyMapOpts {
|
|||||||
}
|
}
|
||||||
Ok(Self { remove, flags })
|
Ok(Self { remove, flags })
|
||||||
}
|
}
|
||||||
pub fn keymap_opts() -> [OptSpec; 6] {
|
pub fn keymap_opts() -> [OptSpec; 7] {
|
||||||
[
|
[
|
||||||
OptSpec {
|
OptSpec {
|
||||||
opt: Opt::Short('n'), // normal mode
|
opt: Opt::Short('n'), // normal mode
|
||||||
@@ -81,6 +81,10 @@ impl KeyMapOpts {
|
|||||||
opt: Opt::Short('o'), // operator-pending mode
|
opt: Opt::Short('o'), // operator-pending mode
|
||||||
takes_arg: false,
|
takes_arg: false,
|
||||||
},
|
},
|
||||||
|
OptSpec {
|
||||||
|
opt: Opt::Long("remove".into()),
|
||||||
|
takes_arg: true,
|
||||||
|
},
|
||||||
OptSpec {
|
OptSpec {
|
||||||
opt: Opt::Short('r'), // replace mode
|
opt: Opt::Short('r'), // replace mode
|
||||||
takes_arg: false,
|
takes_arg: false,
|
||||||
@@ -172,3 +176,158 @@ pub fn keymap(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -386,3 +386,234 @@ pub fn get_map_opts(opts: Vec<Opt>) -> MapOpts {
|
|||||||
}
|
}
|
||||||
map_opts
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,26 +11,28 @@ pub mod eval;
|
|||||||
pub mod exec;
|
pub mod exec;
|
||||||
pub mod flowctl;
|
pub mod flowctl;
|
||||||
pub mod getopts;
|
pub mod getopts;
|
||||||
|
pub mod help;
|
||||||
pub mod intro;
|
pub mod intro;
|
||||||
pub mod jobctl;
|
pub mod jobctl;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
pub mod pwd;
|
pub mod pwd;
|
||||||
pub mod read;
|
pub mod read;
|
||||||
|
pub mod resource;
|
||||||
|
pub mod seek;
|
||||||
pub mod shift;
|
pub mod shift;
|
||||||
pub mod shopt;
|
pub mod shopt;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod test; // [[ ]] thing
|
pub mod test; // [[ ]] thing
|
||||||
pub mod trap;
|
pub mod trap;
|
||||||
pub mod varcmds;
|
pub mod varcmds;
|
||||||
pub mod zoltraak;
|
|
||||||
|
|
||||||
pub const BUILTINS: [&str; 47] = [
|
pub const BUILTINS: [&str; 51] = [
|
||||||
"echo", "cd", "read", "export", "local", "pwd", "source", "shift", "jobs", "fg", "bg", "disown",
|
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
|
||||||
"alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin",
|
"disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
|
||||||
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
|
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
|
||||||
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
|
"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<()> {
|
pub fn true_builtin() -> ShResult<()> {
|
||||||
@@ -47,3 +49,37 @@ pub fn noop_builtin() -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,3 +24,41 @@ pub fn pwd(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,7 +212,8 @@ pub fn read_builtin(node: Node) -> ShResult<()> {
|
|||||||
for (i, arg) in argv.iter().enumerate() {
|
for (i, arg) in argv.iter().enumerate() {
|
||||||
if i == argv.len() - 1 {
|
if i == argv.len() - 1 {
|
||||||
// Last arg, stuff the rest of the input into it
|
// 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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +298,7 @@ pub fn read_key(node: Node) -> ShResult<()> {
|
|||||||
}
|
}
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
let mut reader = PollReader::new();
|
let mut reader = PollReader::new();
|
||||||
reader.feed_bytes(&buf[..n], false);
|
reader.feed_bytes(&buf[..n]);
|
||||||
let Some(key) = reader.read_key()? else {
|
let Some(key) = reader.read_key()? else {
|
||||||
state::set_status(1);
|
state::set_status(1);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -363,3 +364,131 @@ pub fn get_read_key_opts(opts: Vec<Opt>) -> ShResult<ReadKeyOpts> {
|
|||||||
|
|
||||||
Ok(read_key_opts)
|
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
665
src/builtin/resource.rs
Normal 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
263
src/builtin/seek.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,3 +35,53 @@ pub fn shift(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub fn shopt(node: Node) -> ShResult<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (arg, span) in argv {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,3 +45,103 @@ pub fn shopt(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,3 +41,131 @@ pub fn source(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ impl FromStr for TestOp {
|
|||||||
"-ge" => Ok(Self::IntGe),
|
"-ge" => Ok(Self::IntGe),
|
||||||
"-le" => Ok(Self::IntLe),
|
"-le" => Ok(Self::IntLe),
|
||||||
_ if TEST_UNARY_OPS.contains(&s) => Ok(Self::Unary(s.parse::<UnaryOp>()?)),
|
_ 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 last_result = false;
|
||||||
let mut conjunct_op: Option<ConjunctOp>;
|
let mut conjunct_op: Option<ConjunctOp>;
|
||||||
|
log::trace!("test cases: {:#?}", cases);
|
||||||
|
|
||||||
for case in cases {
|
for case in cases {
|
||||||
let result = match case {
|
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 {
|
if let Some(op) = conjunct_op {
|
||||||
match op {
|
match op {
|
||||||
ConjunctOp::And if !last_result => {
|
ConjunctOp::And if !last_result => break,
|
||||||
last_result = result;
|
ConjunctOp::Or if last_result => break,
|
||||||
break;
|
|
||||||
}
|
|
||||||
ConjunctOp::Or if last_result => {
|
|
||||||
last_result = result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
last_result = result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(last_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,3 +167,148 @@ pub fn trap(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -196,3 +196,231 @@ pub fn local(node: Node) -> ShResult<()> {
|
|||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
|
||||||
}
|
|
||||||
1715
src/expand.rs
1715
src/expand.rs
File diff suppressed because it is too large
Load Diff
293
src/getopt.rs
293
src/getopt.rs
@@ -1,8 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use ariadne::Fmt;
|
||||||
use fmt::Display;
|
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]>;
|
pub type OptSet = Arc<[Opt]>;
|
||||||
|
|
||||||
@@ -67,20 +72,37 @@ pub fn get_opts(words: Vec<String>) -> (Vec<String>, Vec<Opt>) {
|
|||||||
(non_opts, opts)
|
(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(
|
pub fn get_opts_from_tokens(
|
||||||
tokens: Vec<Tk>,
|
tokens: Vec<Tk>,
|
||||||
opt_specs: &[OptSpec],
|
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>)> {
|
) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
|
||||||
let mut tokens_iter = tokens
|
let mut tokens_iter = tokens
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|t| t.expand())
|
.map(|t| t.expand())
|
||||||
.collect::<ShResult<Vec<_>>>()?
|
.collect::<ShResult<Vec<_>>>()?
|
||||||
.into_iter();
|
.into_iter()
|
||||||
|
.peekable();
|
||||||
let mut opts = vec![];
|
let mut opts = vec![];
|
||||||
let mut non_opts = vec![];
|
let mut non_opts = vec![];
|
||||||
|
|
||||||
while let Some(token) = tokens_iter.next() {
|
while let Some(token) = tokens_iter.next() {
|
||||||
if &token.to_string() == "--" {
|
if &token.to_string() == "--" {
|
||||||
|
non_opts.push(token);
|
||||||
non_opts.extend(tokens_iter);
|
non_opts.extend(tokens_iter);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -113,10 +135,275 @@ pub fn get_opts_from_tokens(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !pushed {
|
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))
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
64
src/jobs.rs
64
src/jobs.rs
@@ -1,15 +1,19 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use ariadne::Fmt;
|
||||||
|
use nix::unistd::getpid;
|
||||||
use scopeguard::defer;
|
use scopeguard::defer;
|
||||||
|
use yansi::Color;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
libsh::{
|
libsh::{
|
||||||
error::{ShErr, ShErrKind, ShResult},
|
error::{ShErr, ShErrKind, ShResult},
|
||||||
sys::TTY_FILENO,
|
sys::TTY_FILENO,
|
||||||
term::{Style, Styled},
|
|
||||||
},
|
},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
procio::{IoMode, borrow_fd},
|
procio::{IoMode, borrow_fd},
|
||||||
signal::{disable_reaping, enable_reaping},
|
signal::{disable_reaping, enable_reaping},
|
||||||
state::{self, ShellParam, set_status, write_jobs, write_vars},
|
state::{self, ShellParam, Var, VarFlags, VarKind, set_status, write_jobs, write_vars},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const SIG_EXIT_OFFSET: i32 = 128;
|
pub const SIG_EXIT_OFFSET: i32 = 128;
|
||||||
@@ -149,7 +153,7 @@ pub struct RegisteredFd {
|
|||||||
pub owner_pid: Pid,
|
pub owner_pid: Pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Clone, Default, Debug)]
|
||||||
pub struct JobTab {
|
pub struct JobTab {
|
||||||
fg: Option<Job>,
|
fg: Option<Job>,
|
||||||
order: Vec<usize>,
|
order: Vec<usize>,
|
||||||
@@ -595,6 +599,29 @@ impl Job {
|
|||||||
.map(|chld| chld.stat())
|
.map(|chld| chld.stat())
|
||||||
.collect::<Vec<WtStat>>()
|
.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> {
|
pub fn get_pids(&self) -> Vec<Pid> {
|
||||||
self
|
self
|
||||||
.children
|
.children
|
||||||
@@ -724,12 +751,12 @@ impl Job {
|
|||||||
stat_line = format!("{}{} ", pid, stat_line);
|
stat_line = format!("{}{} ", pid, stat_line);
|
||||||
stat_line = format!("{} {}", stat_line, cmd);
|
stat_line = format!("{} {}", stat_line, cmd);
|
||||||
stat_line = match job_stat {
|
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 {
|
WtStat::Exited(_, code) => match code {
|
||||||
0 => stat_line.styled(Style::Green),
|
0 => stat_line.fg(Color::Green).to_string(),
|
||||||
_ => stat_line.styled(Style::Red),
|
_ => stat_line.fg(Color::Red).to_string(),
|
||||||
},
|
},
|
||||||
_ => stat_line.styled(Style::Cyan),
|
_ => stat_line.fg(Color::Cyan).to_string(),
|
||||||
};
|
};
|
||||||
if i != 0 {
|
if i != 0 {
|
||||||
let padding = " ".repeat(id_box.len() - 1);
|
let padding = " ".repeat(id_box.len() - 1);
|
||||||
@@ -838,22 +865,35 @@ pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> {
|
|||||||
enable_reaping();
|
enable_reaping();
|
||||||
}
|
}
|
||||||
let statuses = write_jobs(|j| j.new_fg(job))?;
|
let statuses = write_jobs(|j| j.new_fg(job))?;
|
||||||
for status in statuses {
|
for status in &statuses {
|
||||||
code = code_from_status(&status).unwrap_or(0);
|
code = code_from_status(status).unwrap_or(0);
|
||||||
match status {
|
match status {
|
||||||
WtStat::Stopped(_, _) => {
|
WtStat::Stopped(_, _) => {
|
||||||
was_stopped = true;
|
was_stopped = true;
|
||||||
write_jobs(|j| j.fg_to_bg(status))?;
|
write_jobs(|j| j.fg_to_bg(*status))?;
|
||||||
}
|
}
|
||||||
WtStat::Signaled(_, sig, _) => {
|
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;
|
was_stopped = true;
|
||||||
write_jobs(|j| j.fg_to_bg(status))?;
|
write_jobs(|j| j.fg_to_bg(*status))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => { /* Do nothing */ }
|
_ => { /* 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 job wasn't stopped (moved to bg), clear the fg slot
|
||||||
if !was_stopped {
|
if !was_stopped {
|
||||||
write_jobs(|j| {
|
write_jobs(|j| {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
use ariadne::Color;
|
use ariadne::{Color, Fmt};
|
||||||
use ariadne::{Report, ReportKind};
|
use ariadne::{Report, ReportKind};
|
||||||
use rand::TryRng;
|
use rand::TryRng;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
use yansi::Paint;
|
||||||
|
|
||||||
use crate::procio::RedirGuard;
|
use crate::procio::RedirGuard;
|
||||||
use crate::{
|
use crate::{
|
||||||
libsh::term::{Style, Styled},
|
|
||||||
parse::lex::{Span, SpanSource},
|
parse::lex::{Span, SpanSource},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
};
|
};
|
||||||
@@ -144,12 +144,13 @@ impl Note {
|
|||||||
|
|
||||||
impl Display for Note {
|
impl Display for Note {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
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;
|
let main = &self.main;
|
||||||
if self.depth == 0 {
|
if self.depth == 0 {
|
||||||
writeln!(f, "{note}: {main}")?;
|
writeln!(f, "{note}: {main}")?;
|
||||||
} else {
|
} 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);
|
let indent = " ".repeat(self.depth);
|
||||||
writeln!(f, " {indent}{bar_break} {main}")?;
|
writeln!(f, " {indent}{bar_break} {main}")?;
|
||||||
}
|
}
|
||||||
@@ -200,6 +201,7 @@ impl ShErr {
|
|||||||
pub fn is_flow_control(&self) -> bool {
|
pub fn is_flow_control(&self) -> bool {
|
||||||
self.kind.is_flow_control()
|
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 {
|
pub fn promote(mut self, span: Span) -> Self {
|
||||||
if self.notes.is_empty() {
|
if self.notes.is_empty() {
|
||||||
return self;
|
return self;
|
||||||
@@ -207,6 +209,8 @@ impl ShErr {
|
|||||||
let first = self.notes[0].clone();
|
let first = self.notes[0].clone();
|
||||||
if self.notes.len() > 1 {
|
if self.notes.len() > 1 {
|
||||||
self.notes = self.notes[1..].to_vec();
|
self.notes = self.notes[1..].to_vec();
|
||||||
|
} else {
|
||||||
|
self.notes = vec![];
|
||||||
}
|
}
|
||||||
|
|
||||||
self.labeled(span, first)
|
self.labeled(span, first)
|
||||||
@@ -455,7 +459,7 @@ pub enum ShErrKind {
|
|||||||
FuncReturn(i32),
|
FuncReturn(i32),
|
||||||
LoopContinue(i32),
|
LoopContinue(i32),
|
||||||
LoopBreak(i32),
|
LoopBreak(i32),
|
||||||
ClearReadline,
|
Interrupt,
|
||||||
Null,
|
Null,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +471,7 @@ impl ShErrKind {
|
|||||||
| Self::FuncReturn(_)
|
| Self::FuncReturn(_)
|
||||||
| Self::LoopContinue(_)
|
| Self::LoopContinue(_)
|
||||||
| Self::LoopBreak(_)
|
| Self::LoopBreak(_)
|
||||||
| Self::ClearReadline
|
| Self::Interrupt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,7 +496,7 @@ impl Display for ShErrKind {
|
|||||||
Self::LoopBreak(_) => "Syntax Error",
|
Self::LoopBreak(_) => "Syntax Error",
|
||||||
Self::ReadlineErr => "Readline Error",
|
Self::ReadlineErr => "Readline Error",
|
||||||
Self::ExCommand => "Ex Command Error",
|
Self::ExCommand => "Ex Command Error",
|
||||||
Self::ClearReadline => "",
|
Self::Interrupt => "",
|
||||||
Self::Null => "",
|
Self::Null => "",
|
||||||
};
|
};
|
||||||
write!(f, "{output}")
|
write!(f, "{output}")
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
)+
|
|
||||||
}
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ use std::collections::HashSet;
|
|||||||
use std::os::fd::{BorrowedFd, RawFd};
|
use std::os::fd::{BorrowedFd, RawFd};
|
||||||
|
|
||||||
use nix::sys::termios::{self, LocalFlags, Termios, tcgetattr, tcsetattr};
|
use nix::sys::termios::{self, LocalFlags, Termios, tcgetattr, tcsetattr};
|
||||||
use nix::unistd::isatty;
|
use nix::unistd::{isatty, write};
|
||||||
use scopeguard::guard;
|
use scopeguard::guard;
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
@@ -147,11 +147,10 @@ impl RawModeGuard {
|
|||||||
let orig = ORIG_TERMIOS
|
let orig = ORIG_TERMIOS
|
||||||
.with(|cell| cell.borrow().clone())
|
.with(|cell| cell.borrow().clone())
|
||||||
.expect("with_cooked_mode called before raw_mode()");
|
.expect("with_cooked_mode called before raw_mode()");
|
||||||
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig)
|
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig).ok();
|
||||||
.expect("Failed to restore cooked mode");
|
|
||||||
let res = f();
|
let res = f();
|
||||||
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, ¤t)
|
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, ¤t).ok();
|
||||||
.expect("Failed to restore raw mode");
|
unsafe { write(BorrowedFd::borrow_raw(*TTY_FILENO), b"\x1b[?1l\x1b>").ok() };
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,11 +158,12 @@ impl RawModeGuard {
|
|||||||
impl Drop for RawModeGuard {
|
impl Drop for RawModeGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = termios::tcsetattr(
|
termios::tcsetattr(
|
||||||
BorrowedFd::borrow_raw(self.fd),
|
BorrowedFd::borrow_raw(self.fd),
|
||||||
termios::SetArg::TCSANOW,
|
termios::SetArg::TCSANOW,
|
||||||
&self.orig,
|
&self.orig,
|
||||||
);
|
)
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod flog;
|
|
||||||
pub mod guards;
|
pub mod guards;
|
||||||
pub mod sys;
|
pub mod sys;
|
||||||
pub mod term;
|
pub mod term;
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ use std::sync::LazyLock;
|
|||||||
|
|
||||||
use crate::prelude::*;
|
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(|| {
|
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
|
||||||
});
|
});
|
||||||
|
|||||||
148
src/main.rs
148
src/main.rs
@@ -16,8 +16,9 @@ pub mod readline;
|
|||||||
pub mod shopt;
|
pub mod shopt;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod tests;
|
pub mod testutil;
|
||||||
|
|
||||||
use std::os::fd::BorrowedFd;
|
use std::os::fd::BorrowedFd;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
@@ -32,19 +33,23 @@ use crate::builtin::trap::TrapTarget;
|
|||||||
use crate::libsh::error::{self, ShErr, ShErrKind, ShResult};
|
use crate::libsh::error::{self, ShErr, ShErrKind, ShResult};
|
||||||
use crate::libsh::sys::TTY_FILENO;
|
use crate::libsh::sys::TTY_FILENO;
|
||||||
use crate::libsh::utils::AutoCmdVecUtils;
|
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::prelude::*;
|
||||||
|
use crate::procio::borrow_fd;
|
||||||
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
|
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
|
||||||
use crate::readline::{Prompt, ReadlineEvent, ShedVi};
|
use crate::readline::{Prompt, ReadlineEvent, ShedVi};
|
||||||
use crate::signal::{GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending};
|
use crate::signal::{
|
||||||
use crate::state::{AutoCmdKind, read_logic, read_shopts, source_rc, write_jobs, write_meta};
|
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 clap::Parser;
|
||||||
use state::{read_vars, write_vars};
|
use state::write_vars;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
struct ShedArgs {
|
struct ShedArgs {
|
||||||
script: Option<String>,
|
|
||||||
|
|
||||||
#[arg(short)]
|
#[arg(short)]
|
||||||
command: Option<String>,
|
command: Option<String>,
|
||||||
|
|
||||||
@@ -57,24 +62,13 @@ struct ShedArgs {
|
|||||||
#[arg(short)]
|
#[arg(short)]
|
||||||
interactive: bool,
|
interactive: bool,
|
||||||
|
|
||||||
|
#[arg(short)]
|
||||||
|
stdin: bool,
|
||||||
|
|
||||||
#[arg(long, short)]
|
#[arg(long, short)]
|
||||||
login_shell: bool,
|
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
|
/// We need to make sure that even if we panic, our child processes get sighup
|
||||||
fn setup_panic_handler() {
|
fn setup_panic_handler() {
|
||||||
let default_panic_hook = std::panic::take_hook();
|
let default_panic_hook = std::panic::take_hook();
|
||||||
@@ -109,7 +103,6 @@ fn setup_panic_handler() {
|
|||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
yansi::enable();
|
yansi::enable();
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
kickstart_lazy_evals();
|
|
||||||
setup_panic_handler();
|
setup_panic_handler();
|
||||||
|
|
||||||
let mut args = ShedArgs::parse();
|
let mut args = ShedArgs::parse();
|
||||||
@@ -128,12 +121,31 @@ fn main() -> ExitCode {
|
|||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = if let Some(path) = args.script {
|
// Increment SHLVL, or set to 1 if not present or invalid.
|
||||||
run_script(path, args.script_args)
|
// This var represents how many nested shell instances we're in
|
||||||
} else if let Some(cmd) = args.command {
|
if let Ok(var) = env::var("SHLVL")
|
||||||
exec_input(cmd, None, false, None)
|
&& let Ok(lvl) = var.parse::<u32>()
|
||||||
|
{
|
||||||
|
unsafe { env::set_var("SHLVL", (lvl + 1).to_string()) };
|
||||||
} else {
|
} else {
|
||||||
shed_interactive(args)
|
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 {
|
||||||
|
let res = shed_interactive(args);
|
||||||
|
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
|
||||||
|
res
|
||||||
} {
|
} {
|
||||||
e.print_error();
|
e.print_error();
|
||||||
};
|
};
|
||||||
@@ -151,6 +163,32 @@ fn main() -> ExitCode {
|
|||||||
ExitCode::from(QUIT_CODE.load(Ordering::SeqCst) as u8)
|
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<()> {
|
fn run_script<P: AsRef<Path>>(path: P, args: Vec<String>) -> ShResult<()> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let path_raw = path.to_string_lossy().to_string();
|
let path_raw = path.to_string_lossy().to_string();
|
||||||
@@ -186,6 +224,12 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
|||||||
let _raw_mode = raw_mode(); // sets raw mode, restores termios on drop
|
let _raw_mode = raw_mode(); // sets raw mode, restores termios on drop
|
||||||
sig_setup(args.login_shell);
|
sig_setup(args.login_shell);
|
||||||
|
|
||||||
|
if args.login_shell
|
||||||
|
&& let Err(e) = source_login()
|
||||||
|
{
|
||||||
|
e.print_error();
|
||||||
|
}
|
||||||
|
|
||||||
if let Err(e) = source_rc() {
|
if let Err(e) = source_rc() {
|
||||||
e.print_error();
|
e.print_error();
|
||||||
}
|
}
|
||||||
@@ -203,6 +247,8 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode
|
||||||
|
|
||||||
// Main poll loop
|
// Main poll loop
|
||||||
loop {
|
loop {
|
||||||
write_meta(|m| {
|
write_meta(|m| {
|
||||||
@@ -215,8 +261,8 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
|||||||
while signals_pending() {
|
while signals_pending() {
|
||||||
if let Err(e) = check_signals() {
|
if let Err(e) = check_signals() {
|
||||||
match e.kind() {
|
match e.kind() {
|
||||||
ShErrKind::ClearReadline => {
|
ShErrKind::Interrupt => {
|
||||||
// Ctrl+C - clear current input and redraw
|
// We got Ctrl+C - clear current input and redraw
|
||||||
readline.reset_active_widget(false)?;
|
readline.reset_active_widget(false)?;
|
||||||
}
|
}
|
||||||
ShErrKind::CleanExit(code) => {
|
ShErrKind::CleanExit(code) => {
|
||||||
@@ -241,22 +287,53 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
|||||||
readline.prompt_mut().refresh();
|
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)?;
|
readline.print_line(false)?;
|
||||||
|
|
||||||
// Poll for stdin input
|
// Poll for
|
||||||
|
// stdin input
|
||||||
let mut fds = [PollFd::new(
|
let mut fds = [PollFd::new(
|
||||||
unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) },
|
unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) },
|
||||||
PollFlags::POLLIN,
|
PollFlags::POLLIN,
|
||||||
)];
|
)];
|
||||||
|
|
||||||
|
let mut exec_if_timeout = None;
|
||||||
|
|
||||||
let timeout = if readline.pending_keymap.is_empty() {
|
let timeout = if readline.pending_keymap.is_empty() {
|
||||||
PollTimeout::MAX
|
let screensaver_cmd = read_shopts(|o| o.prompt.screensaver_cmd.clone());
|
||||||
|
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);
|
||||||
|
PollTimeout::from((screensaver_idle_time * 1000) as u16)
|
||||||
|
} else {
|
||||||
|
PollTimeout::MAX
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
PollTimeout::from(1000u16)
|
PollTimeout::from(1000u16)
|
||||||
};
|
};
|
||||||
|
|
||||||
match poll(&mut fds, timeout) {
|
match poll(&mut fds, timeout) {
|
||||||
Ok(_) => {}
|
Ok(0) => {
|
||||||
|
// We timed out.
|
||||||
|
if let Some(cmd) = exec_if_timeout {
|
||||||
|
let prepared = ReadlineEvent::Line(cmd);
|
||||||
|
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
|
||||||
|
|
||||||
|
match handle_readline_event(&mut readline, Ok(prepared))? {
|
||||||
|
true => return Ok(()),
|
||||||
|
false => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(Errno::EINTR) => {
|
Err(Errno::EINTR) => {
|
||||||
// Interrupted by signal, loop back to handle it
|
// Interrupted by signal, loop back to handle it
|
||||||
continue;
|
continue;
|
||||||
@@ -265,6 +342,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
|||||||
eprintln!("poll error: {e}");
|
eprintln!("poll error: {e}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
Ok(_) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout — resolve pending keymap ambiguity
|
// Timeout — resolve pending keymap ambiguity
|
||||||
@@ -358,12 +436,18 @@ fn handle_readline_event(readline: &mut ShedVi, event: ShResult<ReadlineEvent>)
|
|||||||
|
|
||||||
pre_exec.exec_with(&input);
|
pre_exec.exec_with(&input);
|
||||||
|
|
||||||
|
// Time this command and temporarily restore cooked terminal mode while it runs.
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
write_meta(|m| m.start_timer());
|
write_meta(|m| m.start_timer());
|
||||||
if let Err(e) = RawModeGuard::with_cooked_mode(|| {
|
if let Err(e) = RawModeGuard::with_cooked_mode(|| {
|
||||||
exec_input(input.clone(), None, true, Some("<stdin>".into()))
|
exec_input(input.clone(), None, true, Some("<stdin>".into()))
|
||||||
}) {
|
}) {
|
||||||
|
// CleanExit signals an intentional shell exit; any other error is printed.
|
||||||
match e.kind() {
|
match e.kind() {
|
||||||
|
ShErrKind::Interrupt => {
|
||||||
|
// We got Ctrl+C during command execution
|
||||||
|
// Just fall through here
|
||||||
|
}
|
||||||
ShErrKind::CleanExit(code) => {
|
ShErrKind::CleanExit(code) => {
|
||||||
QUIT_CODE.store(*code, Ordering::SeqCst);
|
QUIT_CODE.store(*code, Ordering::SeqCst);
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
|
|||||||
@@ -18,18 +18,20 @@ use crate::{
|
|||||||
eval, exec,
|
eval, exec,
|
||||||
flowctl::flowctl,
|
flowctl::flowctl,
|
||||||
getopts::getopts,
|
getopts::getopts,
|
||||||
|
help::help,
|
||||||
intro,
|
intro,
|
||||||
jobctl::{self, JobBehavior, continue_job, disown, jobs},
|
jobctl::{self, JobBehavior, continue_job, disown, jobs},
|
||||||
keymap, map,
|
keymap, map,
|
||||||
pwd::pwd,
|
pwd::pwd,
|
||||||
read::{self, read_builtin},
|
read::{self, read_builtin},
|
||||||
|
resource::{ulimit, umask_builtin},
|
||||||
|
seek::seek,
|
||||||
shift::shift,
|
shift::shift,
|
||||||
shopt::shopt,
|
shopt::shopt,
|
||||||
source::source,
|
source::source,
|
||||||
test::double_bracket_test,
|
test::double_bracket_test,
|
||||||
trap::{TrapTarget, trap},
|
trap::{TrapTarget, trap},
|
||||||
varcmds::{export, local, readonly, unset},
|
varcmds::{export, local, readonly, unset},
|
||||||
zoltraak::zoltraak,
|
|
||||||
},
|
},
|
||||||
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
|
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
|
||||||
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
|
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
|
||||||
@@ -39,7 +41,8 @@ use crate::{
|
|||||||
utils::RedirVecUtils,
|
utils::RedirVecUtils,
|
||||||
},
|
},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
procio::{IoMode, IoStack},
|
procio::{IoMode, IoStack, PipeGenerator},
|
||||||
|
signal::{check_signals, signals_pending},
|
||||||
state::{
|
state::{
|
||||||
self, ShFunc, VarFlags, VarKind, read_logic, read_shopts, write_jobs, write_logic, write_vars,
|
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(
|
pub fn exec_input(
|
||||||
input: String,
|
input: String,
|
||||||
io_stack: Option<IoStack>,
|
io_stack: Option<IoStack>,
|
||||||
@@ -204,6 +274,13 @@ impl Dispatcher {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn dispatch_node(&mut self, node: Node) -> ShResult<()> {
|
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 {
|
match node.class {
|
||||||
NdRule::Conjunction { .. } => self.exec_conjunction(node)?,
|
NdRule::Conjunction { .. } => self.exec_conjunction(node)?,
|
||||||
NdRule::Pipeline { .. } => self.exec_pipeline(node)?,
|
NdRule::Pipeline { .. } => self.exec_pipeline(node)?,
|
||||||
@@ -213,6 +290,7 @@ impl Dispatcher {
|
|||||||
NdRule::CaseNode { .. } => self.exec_case(node)?,
|
NdRule::CaseNode { .. } => self.exec_case(node)?,
|
||||||
NdRule::BraceGrp { .. } => self.exec_brc_grp(node)?,
|
NdRule::BraceGrp { .. } => self.exec_brc_grp(node)?,
|
||||||
NdRule::FuncDef { .. } => self.exec_func_def(node)?,
|
NdRule::FuncDef { .. } => self.exec_func_def(node)?,
|
||||||
|
NdRule::Negate { .. } => self.exec_negated(node)?,
|
||||||
NdRule::Command { .. } => self.dispatch_cmd(node)?,
|
NdRule::Command { .. } => self.dispatch_cmd(node)?,
|
||||||
NdRule::Test { .. } => self.exec_test(node)?,
|
NdRule::Test { .. } => self.exec_test(node)?,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
@@ -220,6 +298,15 @@ impl Dispatcher {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub fn dispatch_cmd(&mut self, node: Node) -> ShResult<()> {
|
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 {
|
let Some(cmd) = node.get_command() else {
|
||||||
return self.exec_cmd(node); // Argv is empty, probably an assignment
|
return self.exec_cmd(node); // Argv is empty, probably an assignment
|
||||||
};
|
};
|
||||||
@@ -247,30 +334,35 @@ impl Dispatcher {
|
|||||||
self.exec_cmd(node)
|
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<()> {
|
pub fn exec_conjunction(&mut self, conjunction: Node) -> ShResult<()> {
|
||||||
let NdRule::Conjunction { elements } = conjunction.class else {
|
let NdRule::Conjunction { elements } = conjunction.class else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut elem_iter = elements.into_iter();
|
let mut elem_iter = elements.into_iter();
|
||||||
|
let mut skip = false;
|
||||||
while let Some(element) = elem_iter.next() {
|
while let Some(element) = elem_iter.next() {
|
||||||
let ConjunctNode { cmd, operator } = element;
|
let ConjunctNode { cmd, operator } = element;
|
||||||
self.dispatch_node(*cmd)?;
|
if !skip {
|
||||||
|
self.dispatch_node(*cmd)?;
|
||||||
|
}
|
||||||
|
|
||||||
let status = state::get_status();
|
let status = state::get_status();
|
||||||
match operator {
|
skip = match operator {
|
||||||
ConjunctOp::And => {
|
ConjunctOp::And => status != 0,
|
||||||
if status != 0 {
|
ConjunctOp::Or => status == 0,
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConjunctOp::Or => {
|
|
||||||
if status == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ConjunctOp::Null => break,
|
ConjunctOp::Null => break,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -290,7 +382,11 @@ impl Dispatcher {
|
|||||||
};
|
};
|
||||||
let body_span = body.get_span();
|
let body_span = body.get_span();
|
||||||
let body = body_span.as_str().to_string();
|
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) {
|
if KEYWORDS.contains(&name) {
|
||||||
return Err(ShErr::at(
|
return Err(ShErr::at(
|
||||||
@@ -313,28 +409,23 @@ impl Dispatcher {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn exec_subsh(&mut self, subsh: Node) -> ShResult<()> {
|
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 {
|
let NdRule::Command { assignments, argv } = subsh.class else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
};
|
};
|
||||||
let name = self.source_name.clone();
|
let name = self.source_name.clone();
|
||||||
|
|
||||||
|
self.io_stack.append_to_frame(subsh.redirs);
|
||||||
|
let _guard = self.io_stack.pop_frame().redirect()?;
|
||||||
|
|
||||||
self.run_fork("anonymous_subshell", |s| {
|
self.run_fork("anonymous_subshell", |s| {
|
||||||
if let Err(e) = s.set_assignments(assignments, AssignBehavior::Export) {
|
if let Err(e) = s.set_assignments(assignments, AssignBehavior::Export) {
|
||||||
e.print_error();
|
e.print_error();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
s.io_stack.append_to_frame(subsh.redirs);
|
|
||||||
let mut argv = match prepare_argv(argv) {
|
|
||||||
Ok(argv) => argv,
|
|
||||||
Err(e) => {
|
|
||||||
e.try_blame(blame).print_error();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let subsh = argv.remove(0);
|
let subsh_raw = argv[0].span.as_str();
|
||||||
let subsh_body = subsh.0.to_string();
|
let subsh_body = subsh_raw[1..subsh_raw.len() - 1].to_string(); // Remove surrounding parentheses
|
||||||
|
|
||||||
if let Err(e) = exec_input(subsh_body, None, s.interactive, Some(name)) {
|
if let Err(e) = exec_input(subsh_body, None, s.interactive, Some(name)) {
|
||||||
e.print_error();
|
e.print_error();
|
||||||
@@ -379,13 +470,14 @@ impl Dispatcher {
|
|||||||
|
|
||||||
blame.rename(func_name.clone());
|
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 result = if let Some(ref mut func_body) = read_logic(|l| l.get_func(&func_name)) {
|
||||||
let _guard = scope_guard(Some(argv));
|
let _guard = scope_guard(Some(argv));
|
||||||
func_body.body_mut().propagate_context(func_ctx);
|
func_body.body_mut().propagate_context(func_ctx);
|
||||||
func_body.body_mut().flags = func.flags;
|
func_body.body_mut().flags = func.flags;
|
||||||
|
|
||||||
if let Err(e) = self.exec_brc_grp(func_body.body().clone()) {
|
if let Err(e) = self.exec_pipeline(func_body.body().clone()) {
|
||||||
match e.kind() {
|
match e.kind() {
|
||||||
ShErrKind::FuncReturn(code) => {
|
ShErrKind::FuncReturn(code) => {
|
||||||
state::set_status(*code);
|
state::set_status(*code);
|
||||||
@@ -409,8 +501,11 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
fn exec_brc_grp(&mut self, brc_grp: Node) -> ShResult<()> {
|
fn exec_brc_grp(&mut self, brc_grp: Node) -> ShResult<()> {
|
||||||
let NdRule::BraceGrp { body } = brc_grp.class else {
|
let NdRule::BraceGrp { body } = brc_grp.class else {
|
||||||
unreachable!()
|
unreachable!("expected BraceGrp node, got {:?}", brc_grp.class)
|
||||||
};
|
};
|
||||||
|
if self.interactive {
|
||||||
|
log::debug!("Executing brace group, body: {:?}", body);
|
||||||
|
}
|
||||||
let fork_builtins = brc_grp.flags.contains(NdFlags::FORK_BUILTINS);
|
let fork_builtins = brc_grp.flags.contains(NdFlags::FORK_BUILTINS);
|
||||||
|
|
||||||
self.io_stack.append_to_frame(brc_grp.redirs);
|
self.io_stack.append_to_frame(brc_grp.redirs);
|
||||||
@@ -542,6 +637,7 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
state::set_status(0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -678,9 +774,13 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matched && !else_block.is_empty() {
|
if !matched {
|
||||||
for node in else_block {
|
if !else_block.is_empty() {
|
||||||
s.dispatch_node(node)?;
|
for node in else_block {
|
||||||
|
s.dispatch_node(node)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state::set_status(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,54 +805,83 @@ impl Dispatcher {
|
|||||||
let NdRule::Pipeline { cmds } = pipeline.class else {
|
let NdRule::Pipeline { cmds } = pipeline.class else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
};
|
};
|
||||||
self.job_stack.new_job();
|
if self.interactive {
|
||||||
let fork_builtin = cmds.len() > 1; // If there's more than one command, we need to fork builtins
|
log::debug!("Executing pipeline, cmds: {:#?}", cmds);
|
||||||
let (mut in_redirs, mut out_redirs) = self.io_stack.pop_frame().redirs.split_by_channel();
|
}
|
||||||
|
|
||||||
// Zip the commands and their respective pipes into an iterator
|
|
||||||
let pipes_and_cmds = get_pipe_stack(cmds.len()).into_iter().zip(cmds);
|
|
||||||
|
|
||||||
let is_bg = pipeline.flags.contains(NdFlags::BACKGROUND);
|
let is_bg = pipeline.flags.contains(NdFlags::BACKGROUND);
|
||||||
self.fg_job = !is_bg && self.interactive;
|
self.job_stack.new_job();
|
||||||
let mut tty_attached = false;
|
if cmds.len() == 1 {
|
||||||
|
self.fg_job = !is_bg && self.interactive;
|
||||||
for ((rpipe, wpipe), mut cmd) in pipes_and_cmds {
|
let cmd = cmds.into_iter().next().unwrap();
|
||||||
if let Some(pipe) = rpipe {
|
if is_bg && !matches!(cmd.class, NdRule::Command { .. }) {
|
||||||
self.io_stack.push_to_frame(pipe);
|
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 {
|
} else {
|
||||||
for redir in std::mem::take(&mut in_redirs) {
|
self.dispatch_node(cmd)?;
|
||||||
self.io_stack.push_to_frame(redir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(pipe) = wpipe {
|
|
||||||
self.io_stack.push_to_frame(pipe);
|
|
||||||
if cmd.flags.contains(NdFlags::PIPE_ERR) {
|
|
||||||
let err_redir = Redir::new(IoMode::Fd { tgt_fd: STDERR_FILENO, src_fd: STDOUT_FILENO }, RedirType::Output);
|
|
||||||
self.io_stack.push_to_frame(err_redir);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for redir in std::mem::take(&mut out_redirs) {
|
|
||||||
self.io_stack.push_to_frame(redir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fork_builtin {
|
|
||||||
cmd.flags |= NdFlags::FORK_BUILTINS;
|
|
||||||
}
|
|
||||||
self.dispatch_node(cmd)?;
|
|
||||||
|
|
||||||
// Give the pipeline terminal control as soon as the first child
|
// Give the pipeline terminal control as soon as the first child
|
||||||
// establishes the PGID, so later children (e.g. nvim) don't get
|
// establishes the PGID, so later children (e.g. nvim) don't get
|
||||||
// SIGTTOU when they try to modify terminal attributes.
|
// SIGTTOU when they try to modify terminal attributes.
|
||||||
// Only for interactive (top-level) pipelines — command substitution
|
// Only for interactive (top-level) pipelines — command substitution
|
||||||
// and other non-interactive contexts must not steal the terminal.
|
// and other non-interactive contexts must not steal the terminal.
|
||||||
if !tty_attached
|
if !is_bg
|
||||||
&& !is_bg
|
|
||||||
&& self.interactive
|
&& self.interactive
|
||||||
&& let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid()
|
&& let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid()
|
||||||
{
|
{
|
||||||
attach_tty(pgid).ok();
|
attach_tty(pgid).ok();
|
||||||
tty_attached = true;
|
}
|
||||||
|
} else {
|
||||||
|
let (mut in_redirs, mut out_redirs) = self.io_stack.pop_frame().redirs.split_by_channel();
|
||||||
|
|
||||||
|
let mut pipes = PipeGenerator::new(cmds.len()).as_io_frames();
|
||||||
|
|
||||||
|
self.fg_job = !is_bg && self.interactive;
|
||||||
|
let mut tty_attached = false;
|
||||||
|
|
||||||
|
let last_cmd = cmds.len() - 1;
|
||||||
|
for (i, mut cmd) in cmds.into_iter().enumerate() {
|
||||||
|
let mut frame = pipes.next().ok_or_else(|| {
|
||||||
|
ShErr::at(
|
||||||
|
ShErrKind::InternalErr,
|
||||||
|
cmd.get_span(),
|
||||||
|
"failed to set up pipeline redirections".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if i == 0 {
|
||||||
|
for redir in std::mem::take(&mut in_redirs) {
|
||||||
|
frame.push(redir);
|
||||||
|
}
|
||||||
|
} else if i == last_cmd {
|
||||||
|
for redir in std::mem::take(&mut out_redirs) {
|
||||||
|
frame.push(redir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _guard = frame.redirect()?;
|
||||||
|
|
||||||
|
cmd.flags |= NdFlags::FORK_BUILTINS; // multiple cmds means builtins must fork
|
||||||
|
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
|
||||||
|
// SIGTTOU when they try to modify terminal attributes.
|
||||||
|
// Only for interactive (top-level) pipelines — command substitution
|
||||||
|
// and other non-interactive contexts must not steal the terminal.
|
||||||
|
if !tty_attached
|
||||||
|
&& !is_bg
|
||||||
|
&& self.interactive
|
||||||
|
&& let Some(pgid) = self.job_stack.curr_job_mut().unwrap().pgid()
|
||||||
|
{
|
||||||
|
attach_tty(pgid).ok();
|
||||||
|
tty_attached = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let job = self.job_stack.finalize_job().unwrap();
|
let job = self.job_stack.finalize_job().unwrap();
|
||||||
@@ -768,7 +897,10 @@ impl Dispatcher {
|
|||||||
|
|
||||||
if fork_builtins {
|
if fork_builtins {
|
||||||
log::trace!("Forking builtin: {}", cmd_raw);
|
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| {
|
self.run_fork(&cmd_raw, |s| {
|
||||||
if let Err(e) = s.dispatch_builtin(cmd) {
|
if let Err(e) = s.dispatch_builtin(cmd) {
|
||||||
e.print_error();
|
e.print_error();
|
||||||
@@ -818,7 +950,15 @@ impl Dispatcher {
|
|||||||
|
|
||||||
// Set up redirections here so we can attach the guard to propagated errors.
|
// Set up redirections here so we can attach the guard to propagated errors.
|
||||||
self.io_stack.append_to_frame(mem::take(&mut cmd.redirs));
|
self.io_stack.append_to_frame(mem::take(&mut cmd.redirs));
|
||||||
let redir_guard = self.io_stack.pop_frame().redirect()?;
|
let frame = self.io_stack.pop_frame();
|
||||||
|
if self.interactive {
|
||||||
|
log::debug!(
|
||||||
|
"popped frame for builtin '{}', frame: {:#?}",
|
||||||
|
cmd_raw,
|
||||||
|
frame
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let redir_guard = frame.redirect()?;
|
||||||
|
|
||||||
// Register ChildProc in current job
|
// Register ChildProc in current job
|
||||||
let job = self.job_stack.curr_job_mut().unwrap();
|
let job = self.job_stack.curr_job_mut().unwrap();
|
||||||
@@ -848,7 +988,7 @@ impl Dispatcher {
|
|||||||
"export" => export(cmd),
|
"export" => export(cmd),
|
||||||
"local" => local(cmd),
|
"local" => local(cmd),
|
||||||
"pwd" => pwd(cmd),
|
"pwd" => pwd(cmd),
|
||||||
"source" => source(cmd),
|
"source" | "." => source(cmd),
|
||||||
"shift" => shift(cmd),
|
"shift" => shift(cmd),
|
||||||
"fg" => continue_job(cmd, JobBehavior::Foregound),
|
"fg" => continue_job(cmd, JobBehavior::Foregound),
|
||||||
"bg" => continue_job(cmd, JobBehavior::Background),
|
"bg" => continue_job(cmd, JobBehavior::Background),
|
||||||
@@ -860,7 +1000,6 @@ impl Dispatcher {
|
|||||||
"break" => flowctl(cmd, ShErrKind::LoopBreak(0)),
|
"break" => flowctl(cmd, ShErrKind::LoopBreak(0)),
|
||||||
"continue" => flowctl(cmd, ShErrKind::LoopContinue(0)),
|
"continue" => flowctl(cmd, ShErrKind::LoopContinue(0)),
|
||||||
"exit" => flowctl(cmd, ShErrKind::CleanExit(0)),
|
"exit" => flowctl(cmd, ShErrKind::CleanExit(0)),
|
||||||
"zoltraak" => zoltraak(cmd),
|
|
||||||
"shopt" => shopt(cmd),
|
"shopt" => shopt(cmd),
|
||||||
"read" => read_builtin(cmd),
|
"read" => read_builtin(cmd),
|
||||||
"trap" => trap(cmd),
|
"trap" => trap(cmd),
|
||||||
@@ -884,6 +1023,10 @@ impl Dispatcher {
|
|||||||
"keymap" => keymap::keymap(cmd),
|
"keymap" => keymap::keymap(cmd),
|
||||||
"read_key" => read::read_key(cmd),
|
"read_key" => read::read_key(cmd),
|
||||||
"autocmd" => autocmd(cmd),
|
"autocmd" => autocmd(cmd),
|
||||||
|
"ulimit" => ulimit(cmd),
|
||||||
|
"umask" => umask_builtin(cmd),
|
||||||
|
"seek" => seek(cmd),
|
||||||
|
"help" => help(cmd),
|
||||||
"true" | ":" => {
|
"true" | ":" => {
|
||||||
state::set_status(0);
|
state::set_status(0);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -921,7 +1064,6 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let no_fork = cmd.flags.contains(NdFlags::NO_FORK);
|
let no_fork = cmd.flags.contains(NdFlags::NO_FORK);
|
||||||
|
|
||||||
if argv.is_empty() {
|
if argv.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -934,36 +1076,34 @@ impl Dispatcher {
|
|||||||
let existing_pgid = job.pgid();
|
let existing_pgid = job.pgid();
|
||||||
|
|
||||||
let fg_job = self.fg_job;
|
let fg_job = self.fg_job;
|
||||||
|
let interactive = self.interactive;
|
||||||
let child_logic = |pgid: Option<Pid>| -> ! {
|
let child_logic = |pgid: Option<Pid>| -> ! {
|
||||||
// Put ourselves in the correct process group before exec.
|
// For non-interactive exec-in-place (e.g. shed -c), skip process group
|
||||||
// For the first child in a pipeline pgid is None, so we
|
// and terminal setup — just transparently replace the current process.
|
||||||
// become our own group leader (setpgid(0,0)). For later
|
if interactive || !no_fork {
|
||||||
// children we join the leader's group.
|
// Put ourselves in the correct process group before exec.
|
||||||
let our_pgid = pgid.unwrap_or(Pid::from_raw(0));
|
// For the first child in a pipeline pgid is None, so we
|
||||||
let _ = setpgid(Pid::from_raw(0), our_pgid);
|
// 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
|
if fg_job {
|
||||||
// signals. SIGTTOU is still SIG_IGN (inherited from the shell),
|
let tty_pgid = if our_pgid == Pid::from_raw(0) {
|
||||||
// so tcsetpgrp won't stop us. This prevents a race
|
nix::unistd::getpid()
|
||||||
// where the child exec's and tries to read stdin before the
|
} else {
|
||||||
// parent has called tcsetpgrp — which would deliver SIGTTIN
|
our_pgid
|
||||||
// (now SIG_DFL after reset_signals) and stop the child.
|
};
|
||||||
if fg_job {
|
let _ = tcsetpgrp(
|
||||||
let tty_pgid = if our_pgid == Pid::from_raw(0) {
|
unsafe { BorrowedFd::borrow_raw(*crate::libsh::sys::TTY_FILENO) },
|
||||||
nix::unistd::getpid()
|
tty_pgid,
|
||||||
} 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
|
if interactive || !no_fork {
|
||||||
// across execvpe, so the shell's ignored SIGTTIN/SIGTTOU would
|
crate::signal::reset_signals(fg_job);
|
||||||
// leak into child processes.
|
}
|
||||||
crate::signal::reset_signals();
|
|
||||||
|
|
||||||
let cmd = &exec_args.cmd.0;
|
let cmd = &exec_args.cmd.0;
|
||||||
let span = exec_args.cmd.1;
|
let span = exec_args.cmd.1;
|
||||||
@@ -1024,6 +1164,7 @@ impl Dispatcher {
|
|||||||
match unsafe { fork()? } {
|
match unsafe { fork()? } {
|
||||||
ForkResult::Child => {
|
ForkResult::Child => {
|
||||||
let _ = setpgid(Pid::from_raw(0), existing_pgid.unwrap_or(Pid::from_raw(0)));
|
let _ = setpgid(Pid::from_raw(0), existing_pgid.unwrap_or(Pid::from_raw(0)));
|
||||||
|
self.interactive = false;
|
||||||
f(self);
|
f(self);
|
||||||
exit(state::get_status())
|
exit(state::get_status())
|
||||||
}
|
}
|
||||||
@@ -1135,3 +1276,202 @@ pub fn is_func(tk: Option<Tk>) -> bool {
|
|||||||
pub fn is_subsh(tk: Option<Tk>) -> bool {
|
pub fn is_subsh(tk: Option<Tk>) -> bool {
|
||||||
tk.is_some_and(|tk| tk.flags.contains(TkFlags::IS_SUBSH))
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
354
src/parse/lex.rs
354
src/parse/lex.rs
@@ -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",
|
"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"];
|
pub const OPENERS: [&str; 6] = ["if", "while", "until", "for", "select", "case"];
|
||||||
@@ -166,6 +166,7 @@ pub enum TkRule {
|
|||||||
ErrPipe,
|
ErrPipe,
|
||||||
And,
|
And,
|
||||||
Or,
|
Or,
|
||||||
|
Bang,
|
||||||
Bg,
|
Bg,
|
||||||
Sep,
|
Sep,
|
||||||
Redir,
|
Redir,
|
||||||
@@ -216,6 +217,31 @@ impl Tk {
|
|||||||
};
|
};
|
||||||
self.span.as_str().trim() == ";;"
|
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 {
|
impl Display for Tk {
|
||||||
@@ -240,19 +266,12 @@ bitflags! {
|
|||||||
const ASSIGN = 0b0000000001000000;
|
const ASSIGN = 0b0000000001000000;
|
||||||
const BUILTIN = 0b0000000010000000;
|
const BUILTIN = 0b0000000010000000;
|
||||||
const IS_PROCSUB = 0b0000000100000000;
|
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! {
|
bitflags! {
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct LexFlags: u32 {
|
pub struct LexFlags: u32 {
|
||||||
@@ -271,7 +290,6 @@ bitflags! {
|
|||||||
/// The lexer has no more tokens to produce
|
/// The lexer has no more tokens to produce
|
||||||
const STALE = 0b0001000000;
|
const STALE = 0b0001000000;
|
||||||
const EXPECTING_IN = 0b0010000000;
|
const EXPECTING_IN = 0b0010000000;
|
||||||
const IN_CASE = 0b0100000000;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +313,18 @@ pub fn clean_input(input: &str) -> String {
|
|||||||
output
|
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 {
|
impl LexStream {
|
||||||
pub fn new(source: Arc<String>, flags: LexFlags) -> Self {
|
pub fn new(source: Arc<String>, flags: LexFlags) -> Self {
|
||||||
let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD;
|
let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD;
|
||||||
@@ -306,6 +336,8 @@ impl LexStream {
|
|||||||
quote_state: QuoteState::default(),
|
quote_state: QuoteState::default(),
|
||||||
brc_grp_depth: 0,
|
brc_grp_depth: 0,
|
||||||
brc_grp_start: None,
|
brc_grp_start: None,
|
||||||
|
heredoc_skip: None,
|
||||||
|
case_depth: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Returns a slice of the source input using the given range
|
/// 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>> {
|
pub fn read_redir(&mut self) -> Option<ShResult<Tk>> {
|
||||||
assert!(self.cursor <= self.source.len());
|
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 pos = self.cursor;
|
||||||
let mut chars = slice.chars().peekable();
|
let mut chars = slice.chars().peekable();
|
||||||
let mut tk = Tk::default();
|
let mut tk = Tk::default();
|
||||||
@@ -377,33 +409,47 @@ impl LexStream {
|
|||||||
return None; // It's a process sub
|
return None; // It's a process sub
|
||||||
}
|
}
|
||||||
pos += 1;
|
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() {
|
if let Some('>') = chars.peek() {
|
||||||
chars.next();
|
chars.next();
|
||||||
pos += 1;
|
pos += 1;
|
||||||
}
|
}
|
||||||
if let Some('&') = chars.peek() {
|
let Some('&') = chars.peek() else {
|
||||||
chars.next();
|
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||||
pos += 1;
|
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()) {
|
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||||
chars.next();
|
chars.next();
|
||||||
found_fd = true;
|
found_fd = true;
|
||||||
pos += 1;
|
pos += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||||
let span_start = self.cursor;
|
let span_start = self.cursor;
|
||||||
self.cursor = pos;
|
self.cursor = pos;
|
||||||
return Some(Err(ShErr::at(
|
return Some(Err(ShErr::at(
|
||||||
ShErrKind::ParseErr,
|
ShErrKind::ParseErr,
|
||||||
Span::new(span_start..pos, self.source.clone()),
|
Span::new(span_start..pos, self.source.clone()),
|
||||||
"Invalid redirection",
|
"Invalid redirection",
|
||||||
)));
|
)));
|
||||||
} else {
|
|
||||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||||
break;
|
break;
|
||||||
@@ -415,14 +461,94 @@ impl LexStream {
|
|||||||
}
|
}
|
||||||
pos += 1;
|
pos += 1;
|
||||||
|
|
||||||
for _ in 0..2 {
|
match chars.peek() {
|
||||||
if let Some('<') = chars.peek() {
|
Some('<') => {
|
||||||
chars.next();
|
chars.next();
|
||||||
pos += 1;
|
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;
|
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);
|
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -446,6 +572,133 @@ impl LexStream {
|
|||||||
self.cursor = pos;
|
self.cursor = pos;
|
||||||
Some(Ok(tk))
|
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> {
|
pub fn read_string(&mut self) -> ShResult<Tk> {
|
||||||
assert!(self.cursor <= self.source.len());
|
assert!(self.cursor <= self.source.len());
|
||||||
let slice = self.slice_from_cursor().unwrap().to_string();
|
let slice = self.slice_from_cursor().unwrap().to_string();
|
||||||
@@ -453,7 +706,7 @@ impl LexStream {
|
|||||||
let mut chars = slice.chars().peekable();
|
let mut chars = slice.chars().peekable();
|
||||||
let can_be_subshell = chars.peek() == Some(&'(');
|
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())
|
&& let Some(count) = case_pat_lookahead(chars.clone())
|
||||||
{
|
{
|
||||||
pos += count;
|
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 => {
|
'(' if self.next_is_cmd() && can_be_subshell => {
|
||||||
pos += 1;
|
pos += 1;
|
||||||
let mut paren_count = 1;
|
let mut paren_count = 1;
|
||||||
@@ -731,7 +994,7 @@ impl LexStream {
|
|||||||
"case" | "select" | "for" => {
|
"case" | "select" | "for" => {
|
||||||
new_tk.mark(TkFlags::KEYWORD);
|
new_tk.mark(TkFlags::KEYWORD);
|
||||||
self.flags |= LexFlags::EXPECTING_IN;
|
self.flags |= LexFlags::EXPECTING_IN;
|
||||||
self.flags |= LexFlags::IN_CASE;
|
self.case_depth += 1;
|
||||||
self.set_next_is_cmd(false);
|
self.set_next_is_cmd(false);
|
||||||
}
|
}
|
||||||
"in" if self.flags.contains(LexFlags::EXPECTING_IN) => {
|
"in" if self.flags.contains(LexFlags::EXPECTING_IN) => {
|
||||||
@@ -739,8 +1002,8 @@ impl LexStream {
|
|||||||
self.flags &= !LexFlags::EXPECTING_IN;
|
self.flags &= !LexFlags::EXPECTING_IN;
|
||||||
}
|
}
|
||||||
_ if is_keyword(text) => {
|
_ if is_keyword(text) => {
|
||||||
if text == "esac" && self.flags.contains(LexFlags::IN_CASE) {
|
if text == "esac" && self.case_depth > 0 {
|
||||||
self.flags &= !LexFlags::IN_CASE;
|
self.case_depth -= 1;
|
||||||
}
|
}
|
||||||
new_tk.mark(TkFlags::KEYWORD);
|
new_tk.mark(TkFlags::KEYWORD);
|
||||||
}
|
}
|
||||||
@@ -843,10 +1106,19 @@ impl Iterator for LexStream {
|
|||||||
|
|
||||||
let token = match get_char(&self.source, self.cursor).unwrap() {
|
let token = match get_char(&self.source, self.cursor).unwrap() {
|
||||||
'\r' | '\n' | ';' => {
|
'\r' | '\n' | ';' => {
|
||||||
|
let ch = get_char(&self.source, self.cursor).unwrap();
|
||||||
let ch_idx = self.cursor;
|
let ch_idx = self.cursor;
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
self.set_next_is_cmd(true);
|
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) {
|
while let Some(ch) = get_char(&self.source, self.cursor) {
|
||||||
match ch {
|
match ch {
|
||||||
'\\' if get_char(&self.source, self.cursor + 1) == Some('\n') => {
|
'\\' if get_char(&self.source, self.cursor + 1) == Some('\n') => {
|
||||||
@@ -881,6 +1153,14 @@ impl Iterator for LexStream {
|
|||||||
return self.next();
|
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;
|
let ch_idx = self.cursor;
|
||||||
self.cursor += 1;
|
self.cursor += 1;
|
||||||
|
|||||||
1293
src/parse/mod.rs
1293
src/parse/mod.rs
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ pub use std::os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd,
|
|||||||
pub use bitflags::bitflags;
|
pub use bitflags::bitflags;
|
||||||
pub use nix::{
|
pub use nix::{
|
||||||
errno::Errno,
|
errno::Errno,
|
||||||
fcntl::{OFlag, open},
|
fcntl::{FcntlArg, OFlag, fcntl, open},
|
||||||
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
|
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
|
||||||
sys::{
|
sys::{
|
||||||
signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal},
|
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
|
// Additional utilities, if needed, can be added here
|
||||||
|
|||||||
375
src/procio.rs
375
src/procio.rs
@@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fmt::Debug,
|
fmt::Debug,
|
||||||
|
iter::Map,
|
||||||
ops::{Deref, DerefMut},
|
ops::{Deref, DerefMut},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -7,15 +8,27 @@ use crate::{
|
|||||||
expand::Expander,
|
expand::Expander,
|
||||||
libsh::{
|
libsh::{
|
||||||
error::{ShErr, ShErrKind, ShResult},
|
error::{ShErr, ShErrKind, ShResult},
|
||||||
|
sys::TTY_FILENO,
|
||||||
utils::RedirVecUtils,
|
utils::RedirVecUtils,
|
||||||
},
|
},
|
||||||
parse::{Redir, RedirType, get_redir_file},
|
parse::{Redir, RedirType, get_redir_file, lex::TkFlags},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
|
state,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Credit to fish-shell for many of the implementation ideas present in this
|
// Credit to fish-shell for many of the implementation ideas present in this
|
||||||
// module https://fishshell.com/
|
// 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)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum IoMode {
|
pub enum IoMode {
|
||||||
Fd {
|
Fd {
|
||||||
@@ -36,8 +49,9 @@ pub enum IoMode {
|
|||||||
pipe: Arc<OwnedFd>,
|
pipe: Arc<OwnedFd>,
|
||||||
},
|
},
|
||||||
Buffer {
|
Buffer {
|
||||||
|
tgt_fd: RawFd,
|
||||||
buf: String,
|
buf: String,
|
||||||
pipe: Arc<OwnedFd>,
|
flags: TkFlags, // so we can see if its a heredoc or not
|
||||||
},
|
},
|
||||||
Close {
|
Close {
|
||||||
tgt_fd: RawFd,
|
tgt_fd: RawFd,
|
||||||
@@ -78,19 +92,37 @@ impl IoMode {
|
|||||||
if let IoMode::File { tgt_fd, path, mode } = self {
|
if let IoMode::File { tgt_fd, path, mode } = self {
|
||||||
let path_raw = path.as_os_str().to_str().unwrap_or_default().to_string();
|
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
|
let expanded_path = Expander::from_raw(&path_raw, TkFlags::empty())?
|
||||||
// multiple
|
.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 expanded_pathbuf = PathBuf::from(expanded_path);
|
||||||
|
|
||||||
let file = get_redir_file(mode, expanded_pathbuf)?;
|
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 {
|
self = IoMode::OpenedFile {
|
||||||
tgt_fd,
|
tgt_fd,
|
||||||
file: Arc::new(OwnedFd::from(file)),
|
file: Arc::new(unsafe { OwnedFd::from_raw_fd(high) }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(self)
|
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) {
|
pub fn get_pipes() -> (Self, Self) {
|
||||||
let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap();
|
let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap();
|
||||||
(
|
(
|
||||||
@@ -205,30 +237,107 @@ impl<'e> IoFrame {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
pub fn save(&'e mut self) {
|
pub fn save(&'e mut self) {
|
||||||
let saved_in = dup(STDIN_FILENO).unwrap();
|
let saved_in = dup_high(STDIN_FILENO).unwrap();
|
||||||
let saved_out = dup(STDOUT_FILENO).unwrap();
|
let saved_out = dup_high(STDOUT_FILENO).unwrap();
|
||||||
let saved_err = dup(STDERR_FILENO).unwrap();
|
let saved_err = dup_high(STDERR_FILENO).unwrap();
|
||||||
self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err));
|
self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err));
|
||||||
}
|
}
|
||||||
pub fn redirect(mut self) -> ShResult<RedirGuard> {
|
pub fn redirect(mut self) -> ShResult<RedirGuard> {
|
||||||
self.save();
|
self.save();
|
||||||
for redir in &mut self.redirs {
|
if let Err(e) = self.apply_redirs() {
|
||||||
let io_mode = &mut redir.io_mode;
|
// Restore saved fds before propagating the error so they don't leak.
|
||||||
if let IoMode::File { .. } = io_mode {
|
self.restore().ok();
|
||||||
*io_mode = io_mode.clone().open_file()?;
|
return Err(e);
|
||||||
};
|
|
||||||
let tgt_fd = io_mode.tgt_fd();
|
|
||||||
let src_fd = io_mode.src_fd();
|
|
||||||
dup2(src_fd, tgt_fd)?;
|
|
||||||
// Close the original pipe fd after dup2 — it's been duplicated to
|
|
||||||
// tgt_fd and keeping it open prevents SIGPIPE delivery in pipelines.
|
|
||||||
// We replace the IoMode to drop the Arc<OwnedFd>, which closes the fd.
|
|
||||||
if matches!(io_mode, IoMode::Pipe { .. }) {
|
|
||||||
*io_mode = IoMode::Close { tgt_fd };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(RedirGuard::new(self))
|
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<()> {
|
pub fn restore(&mut self) -> ShResult<()> {
|
||||||
if let Some(saved) = self.saved_io.take() {
|
if let Some(saved) = self.saved_io.take() {
|
||||||
dup2(saved.0, STDIN_FILENO)?;
|
dup2(saved.0, STDIN_FILENO)?;
|
||||||
@@ -337,3 +446,225 @@ impl From<Vec<IoFrame>> for IoStack {
|
|||||||
pub fn borrow_fd<'f>(fd: i32) -> BorrowedFd<'f> {
|
pub fn borrow_fd<'f>(fd: i32) -> BorrowedFd<'f> {
|
||||||
unsafe { BorrowedFd::borrow_raw(fd) }
|
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,
|
||||||
|
last_rpipe: Option<Redir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PipeGenerator {
|
||||||
|
pub fn new(num_cmds: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
num_cmds,
|
||||||
|
cursor: 0,
|
||||||
|
last_rpipe: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn as_io_frames(self) -> PipeFrames {
|
||||||
|
self.map(|(r, w)| {
|
||||||
|
let mut frame = IoFrame::new();
|
||||||
|
if let Some(r) = r {
|
||||||
|
frame.push(r);
|
||||||
|
}
|
||||||
|
if let Some(w) = w {
|
||||||
|
frame.push(w);
|
||||||
|
}
|
||||||
|
frame
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for PipeGenerator {
|
||||||
|
type Item = (Option<Redir>, Option<Redir>);
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
if self.cursor == self.num_cmds {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.cursor + 1 == self.num_cmds {
|
||||||
|
if self.num_cmds == 1 {
|
||||||
|
return None;
|
||||||
|
} else {
|
||||||
|
self.cursor += 1;
|
||||||
|
return Some((self.last_rpipe.take(), None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (r, w) = IoMode::get_pipes();
|
||||||
|
let mut rpipe = Some(Redir::new(r, RedirType::Input));
|
||||||
|
std::mem::swap(&mut self.last_rpipe, &mut rpipe);
|
||||||
|
|
||||||
|
let wpipe = Redir::new(w, RedirType::Output);
|
||||||
|
|
||||||
|
self.cursor += 1;
|
||||||
|
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
@@ -66,6 +66,61 @@ impl Highlighter {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn strip_markers_keep_visual(str: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for ch in str.chars() {
|
||||||
|
if ch == markers::VISUAL_MODE_START || ch == markers::VISUAL_MODE_END {
|
||||||
|
out.push(ch); // preserve visual markers
|
||||||
|
} else if !is_marker(ch) {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a prefix from a string, skipping over visual markers during matching.
|
||||||
|
/// Visual markers that appear after the prefix are preserved in the result.
|
||||||
|
fn strip_prefix_skip_visual(text: &str, prefix: &str) -> String {
|
||||||
|
let mut chars = text.chars();
|
||||||
|
let mut prefix_chars = prefix.chars().peekable();
|
||||||
|
|
||||||
|
// Walk through text, matching prefix chars while skipping visual markers
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
_ => return text.to_string(), // mismatch, return original
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remaining chars (including any visual markers) form the result
|
||||||
|
chars.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip a suffix from a string, skipping over visual markers during matching.
|
||||||
|
fn strip_suffix_skip_visual(text: &str, suffix: &str) -> String {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let suffix_chars: Vec<char> = suffix.chars().collect();
|
||||||
|
let mut ti = chars.len();
|
||||||
|
let mut si = suffix_chars.len();
|
||||||
|
|
||||||
|
while si > 0 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
si -= 1;
|
||||||
|
if chars[ti] != suffix_chars[si] {
|
||||||
|
return text.to_string(); // mismatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chars[..ti].iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn expand_control_chars(&mut self) {
|
pub fn expand_control_chars(&mut self) {
|
||||||
let mut expanded = String::new();
|
let mut expanded = String::new();
|
||||||
let mut chars = self.input.chars().peekable();
|
let mut chars = self.input.chars().peekable();
|
||||||
@@ -157,6 +212,16 @@ impl Highlighter {
|
|||||||
}
|
}
|
||||||
match *ch {
|
match *ch {
|
||||||
markers::RESET => break,
|
markers::RESET => break,
|
||||||
|
markers::VISUAL_MODE_START => {
|
||||||
|
self.emit_style(Style::BgWhite | Style::Black);
|
||||||
|
self.in_selection = true;
|
||||||
|
input_chars.next();
|
||||||
|
}
|
||||||
|
markers::VISUAL_MODE_END => {
|
||||||
|
self.reapply_style();
|
||||||
|
self.in_selection = false;
|
||||||
|
input_chars.next();
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
var_name.push(*ch);
|
var_name.push(*ch);
|
||||||
input_chars.next();
|
input_chars.next();
|
||||||
@@ -229,48 +294,73 @@ impl Highlighter {
|
|||||||
markers::PROC_SUB => markers::PROC_SUB_END,
|
markers::PROC_SUB => markers::PROC_SUB_END,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
// Save selection state at entry — the collection loop will update
|
||||||
|
// self.in_selection as it encounters visual markers, but the recursive
|
||||||
|
// highlighter needs the state as of the start of the body.
|
||||||
|
let selection_at_entry = self.in_selection;
|
||||||
while let Some(ch) = input_chars.peek() {
|
while let Some(ch) = input_chars.peek() {
|
||||||
if *ch == end_marker {
|
if *ch == end_marker {
|
||||||
incomplete = false;
|
incomplete = false;
|
||||||
input_chars.next(); // consume the end marker
|
input_chars.next();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if *ch == markers::VISUAL_MODE_START {
|
||||||
|
self.in_selection = true;
|
||||||
|
} else if *ch == markers::VISUAL_MODE_END {
|
||||||
|
self.in_selection = false;
|
||||||
|
}
|
||||||
inner.push(*ch);
|
inner.push(*ch);
|
||||||
input_chars.next();
|
input_chars.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
let inner_clean = Self::strip_markers(&inner);
|
// strip_markers_keep_visual preserves VISUAL_MODE_START/END
|
||||||
|
let inner_clean = Self::strip_markers_keep_visual(&inner);
|
||||||
|
// Use stripped version (no visual markers) for prefix/suffix detection
|
||||||
|
let inner_plain = Self::strip_markers(&inner);
|
||||||
|
|
||||||
// Determine prefix from content (handles both <( and >( for proc subs)
|
|
||||||
let prefix = match ch {
|
let prefix = match ch {
|
||||||
markers::CMD_SUB => "$(",
|
markers::CMD_SUB => "$(",
|
||||||
markers::SUBSH => "(",
|
markers::SUBSH => "(",
|
||||||
markers::PROC_SUB => {
|
markers::PROC_SUB => {
|
||||||
if inner_clean.starts_with("<(") {
|
if inner_plain.starts_with("<(") {
|
||||||
"<("
|
"<("
|
||||||
} else if inner_clean.starts_with(">(") {
|
} else if inner_plain.starts_with(">(") {
|
||||||
">("
|
">("
|
||||||
} else {
|
} else {
|
||||||
"<("
|
"<("
|
||||||
} // fallback
|
}
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Strip prefix/suffix from the visual-marker-aware version
|
||||||
let inner_content = if incomplete {
|
let inner_content = if incomplete {
|
||||||
inner_clean.strip_prefix(prefix).unwrap_or(&inner_clean)
|
Self::strip_prefix_skip_visual(&inner_clean, prefix)
|
||||||
} else {
|
} else {
|
||||||
inner_clean
|
let stripped = Self::strip_prefix_skip_visual(&inner_clean, prefix);
|
||||||
.strip_prefix(prefix)
|
Self::strip_suffix_skip_visual(&stripped, ")")
|
||||||
.and_then(|s| s.strip_suffix(")"))
|
|
||||||
.unwrap_or(&inner_clean)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut recursive_highlighter = Self::new();
|
let mut recursive_highlighter = Self::new();
|
||||||
recursive_highlighter.load_input(inner_content, self.linebuf_cursor_pos);
|
recursive_highlighter.in_selection = selection_at_entry;
|
||||||
|
if recursive_highlighter.in_selection {
|
||||||
|
recursive_highlighter.emit_style(Style::BgWhite | Style::Black);
|
||||||
|
}
|
||||||
|
recursive_highlighter.load_input(&inner_content, self.linebuf_cursor_pos);
|
||||||
recursive_highlighter.highlight();
|
recursive_highlighter.highlight();
|
||||||
self.push_style(Style::Blue);
|
// Read back visual state — selection may have started/ended inside
|
||||||
self.output.push_str(prefix);
|
self.in_selection = recursive_highlighter.in_selection;
|
||||||
self.pop_style();
|
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);
|
||||||
|
} else {
|
||||||
|
self.push_style(Style::Blue);
|
||||||
|
self.output.push_str(prefix);
|
||||||
|
self.pop_style();
|
||||||
|
}
|
||||||
self.output.push_str(&recursive_highlighter.take());
|
self.output.push_str(&recursive_highlighter.take());
|
||||||
if !incomplete {
|
if !incomplete {
|
||||||
self.push_style(Style::Blue);
|
self.push_style(Style::Blue);
|
||||||
@@ -285,6 +375,16 @@ impl Highlighter {
|
|||||||
if *ch == markers::HIST_EXP_END {
|
if *ch == markers::HIST_EXP_END {
|
||||||
input_chars.next();
|
input_chars.next();
|
||||||
break;
|
break;
|
||||||
|
} else if *ch == markers::VISUAL_MODE_START {
|
||||||
|
self.emit_style(Style::BgWhite | Style::Black);
|
||||||
|
self.in_selection = true;
|
||||||
|
input_chars.next();
|
||||||
|
continue;
|
||||||
|
} else if *ch == markers::VISUAL_MODE_END {
|
||||||
|
self.reapply_style();
|
||||||
|
self.in_selection = false;
|
||||||
|
input_chars.next();
|
||||||
|
continue;
|
||||||
} else if markers::is_marker(*ch) {
|
} else if markers::is_marker(*ch) {
|
||||||
input_chars.next();
|
input_chars.next();
|
||||||
continue;
|
continue;
|
||||||
@@ -302,6 +402,16 @@ impl Highlighter {
|
|||||||
if *ch == markers::VAR_SUB_END {
|
if *ch == markers::VAR_SUB_END {
|
||||||
input_chars.next(); // consume the end marker
|
input_chars.next(); // consume the end marker
|
||||||
break;
|
break;
|
||||||
|
} else if *ch == markers::VISUAL_MODE_START {
|
||||||
|
self.emit_style(Style::BgWhite | Style::Black);
|
||||||
|
self.in_selection = true;
|
||||||
|
input_chars.next();
|
||||||
|
continue;
|
||||||
|
} else if *ch == markers::VISUAL_MODE_END {
|
||||||
|
self.reapply_style();
|
||||||
|
self.in_selection = false;
|
||||||
|
input_chars.next();
|
||||||
|
continue;
|
||||||
} else if markers::is_marker(*ch) {
|
} else if markers::is_marker(*ch) {
|
||||||
input_chars.next(); // skip the marker
|
input_chars.next(); // skip the marker
|
||||||
continue;
|
continue;
|
||||||
@@ -447,10 +557,12 @@ impl Highlighter {
|
|||||||
/// variable.
|
/// variable.
|
||||||
pub fn pop_style(&mut self) {
|
pub fn pop_style(&mut self) {
|
||||||
self.style_stack.pop();
|
self.style_stack.pop();
|
||||||
if let Some(style) = self.style_stack.last().cloned() {
|
if !self.in_selection {
|
||||||
self.emit_style(style);
|
if let Some(style) = self.style_stack.last().cloned() {
|
||||||
} else {
|
self.emit_style(style);
|
||||||
self.emit_reset();
|
} else {
|
||||||
|
self.emit_reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug)]
|
||||||
pub struct History {
|
pub struct History {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
pub pending: Option<LineBuf>, // command, cursor_pos
|
pub pending: Option<LineBuf>, // command, cursor_pos
|
||||||
@@ -214,21 +215,41 @@ pub struct History {
|
|||||||
//search_direction: Direction,
|
//search_direction: Direction,
|
||||||
ignore_dups: bool,
|
ignore_dups: bool,
|
||||||
max_size: Option<u32>,
|
max_size: Option<u32>,
|
||||||
|
stateless: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl History {
|
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> {
|
pub fn new() -> ShResult<Self> {
|
||||||
let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes);
|
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 max_hist = crate::state::read_shopts(|s| s.core.max_hist);
|
||||||
|
|
||||||
let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({
|
let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({
|
||||||
let home = env::var("HOME").unwrap();
|
let home = env::var("HOME").unwrap();
|
||||||
format!("{home}/.shed_history")
|
format!("{home}/.shed_history")
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let mut entries = read_hist_file(&path)?;
|
let mut entries = read_hist_file(&path)?;
|
||||||
|
|
||||||
// Enforce max_hist limit on loaded entries (negative = unlimited)
|
// Enforce max_hist limit on loaded entries (negative = unlimited)
|
||||||
if max_hist >= 0 && entries.len() > max_hist as usize {
|
if max_hist >= 0 && entries.len() > max_hist as usize {
|
||||||
entries = entries.split_off(entries.len() - max_hist as usize);
|
entries = entries.split_off(entries.len() - max_hist as usize);
|
||||||
}
|
}
|
||||||
|
|
||||||
let search_mask = dedupe_entries(&entries);
|
let search_mask = dedupe_entries(&entries);
|
||||||
let cursor = search_mask.len();
|
let cursor = search_mask.len();
|
||||||
let max_size = if max_hist < 0 {
|
let max_size = if max_hist < 0 {
|
||||||
@@ -236,6 +257,7 @@ impl History {
|
|||||||
} else {
|
} else {
|
||||||
Some(max_hist as u32)
|
Some(max_hist as u32)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
path,
|
path,
|
||||||
entries,
|
entries,
|
||||||
@@ -247,6 +269,7 @@ impl History {
|
|||||||
//search_direction: Direction::Backward,
|
//search_direction: Direction::Backward,
|
||||||
ignore_dups,
|
ignore_dups,
|
||||||
max_size,
|
max_size,
|
||||||
|
stateless: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +284,7 @@ impl History {
|
|||||||
.search_mask
|
.search_mask
|
||||||
.clone()
|
.clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|ent| ent.command().to_string());
|
.map(|ent| super::complete::Candidate::from(ent.command()));
|
||||||
self.fuzzy_finder.activate(raw_entries.collect());
|
self.fuzzy_finder.activate(raw_entries.collect());
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -431,6 +454,9 @@ impl History {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&mut self) -> ShResult<()> {
|
pub fn save(&mut self) -> ShResult<()> {
|
||||||
|
if self.stateless {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut file = OpenOptions::new()
|
let mut file = OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
@@ -466,3 +492,111 @@ impl History {
|
|||||||
Ok(())
|
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
@@ -9,14 +9,13 @@ use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVis
|
|||||||
|
|
||||||
use crate::builtin::keymap::{KeyMapFlags, KeyMapMatch};
|
use crate::builtin::keymap::{KeyMapFlags, KeyMapMatch};
|
||||||
use crate::expand::expand_prompt;
|
use crate::expand::expand_prompt;
|
||||||
use crate::libsh::sys::TTY_FILENO;
|
|
||||||
use crate::libsh::utils::AutoCmdVecUtils;
|
use crate::libsh::utils::AutoCmdVecUtils;
|
||||||
use crate::parse::lex::{LexStream, QuoteState};
|
use crate::parse::lex::{LexStream, QuoteState};
|
||||||
use crate::readline::complete::{FuzzyCompleter, SelectorResponse};
|
use crate::readline::complete::{FuzzyCompleter, SelectorResponse};
|
||||||
use crate::readline::term::{Pos, TermReader, calc_str_width};
|
use crate::readline::term::{Pos, TermReader, calc_str_width};
|
||||||
use crate::readline::vimode::{ViEx, ViVerbatim};
|
use crate::readline::vimode::{ViEx, ViVerbatim};
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
AutoCmdKind, ShellParam, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta,
|
AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta,
|
||||||
write_vars,
|
write_vars,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -40,6 +39,9 @@ pub mod term;
|
|||||||
pub mod vicmd;
|
pub mod vicmd;
|
||||||
pub mod vimode;
|
pub mod vimode;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests;
|
||||||
|
|
||||||
pub mod markers {
|
pub mod markers {
|
||||||
use super::Marker;
|
use super::Marker;
|
||||||
|
|
||||||
@@ -130,6 +132,18 @@ pub mod markers {
|
|||||||
pub fn is_marker(c: Marker) -> bool {
|
pub fn is_marker(c: Marker) -> bool {
|
||||||
('\u{e000}'..'\u{efff}').contains(&c)
|
('\u{e000}'..'\u{efff}').contains(&c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Help command formatting markers
|
||||||
|
pub const TAG: Marker = '\u{e180}';
|
||||||
|
pub const REFERENCE: Marker = '\u{e181}';
|
||||||
|
pub const HEADER: Marker = '\u{e182}';
|
||||||
|
pub const CODE: Marker = '\u{e183}';
|
||||||
|
/// angle brackets
|
||||||
|
pub const KEYWORD_1: Marker = '\u{e184}';
|
||||||
|
/// curly brackets
|
||||||
|
pub const KEYWORD_2: Marker = '\u{e185}';
|
||||||
|
/// square brackets
|
||||||
|
pub const KEYWORD_3: Marker = '\u{e186}';
|
||||||
}
|
}
|
||||||
type Marker = char;
|
type Marker = char;
|
||||||
|
|
||||||
@@ -239,6 +253,7 @@ impl Default for Prompt {
|
|||||||
pub struct ShedVi {
|
pub struct ShedVi {
|
||||||
pub reader: PollReader,
|
pub reader: PollReader,
|
||||||
pub writer: TermWriter,
|
pub writer: TermWriter,
|
||||||
|
pub tty: RawFd,
|
||||||
|
|
||||||
pub prompt: Prompt,
|
pub prompt: Prompt,
|
||||||
pub highlighter: Highlighter,
|
pub highlighter: Highlighter,
|
||||||
@@ -250,10 +265,10 @@ pub struct ShedVi {
|
|||||||
pub repeat_action: Option<CmdReplay>,
|
pub repeat_action: Option<CmdReplay>,
|
||||||
pub repeat_motion: Option<MotionCmd>,
|
pub repeat_motion: Option<MotionCmd>,
|
||||||
pub editor: LineBuf,
|
pub editor: LineBuf,
|
||||||
pub next_is_escaped: bool,
|
|
||||||
|
|
||||||
pub old_layout: Option<Layout>,
|
pub old_layout: Option<Layout>,
|
||||||
pub history: History,
|
pub history: History,
|
||||||
|
pub ex_history: History,
|
||||||
|
|
||||||
pub needs_redraw: bool,
|
pub needs_redraw: bool,
|
||||||
}
|
}
|
||||||
@@ -264,10 +279,10 @@ impl ShedVi {
|
|||||||
reader: PollReader::new(),
|
reader: PollReader::new(),
|
||||||
writer: TermWriter::new(tty),
|
writer: TermWriter::new(tty),
|
||||||
prompt,
|
prompt,
|
||||||
|
tty,
|
||||||
completer: Box::new(FuzzyCompleter::default()),
|
completer: Box::new(FuzzyCompleter::default()),
|
||||||
highlighter: Highlighter::new(),
|
highlighter: Highlighter::new(),
|
||||||
mode: Box::new(ViInsert::new()),
|
mode: Box::new(ViInsert::new()),
|
||||||
next_is_escaped: false,
|
|
||||||
saved_mode: None,
|
saved_mode: None,
|
||||||
pending_keymap: Vec::new(),
|
pending_keymap: Vec::new(),
|
||||||
old_layout: None,
|
old_layout: None,
|
||||||
@@ -275,6 +290,39 @@ impl ShedVi {
|
|||||||
repeat_motion: None,
|
repeat_motion: None,
|
||||||
editor: LineBuf::new(),
|
editor: LineBuf::new(),
|
||||||
history: History::new()?,
|
history: History::new()?,
|
||||||
|
ex_history: History::empty(),
|
||||||
|
needs_redraw: true,
|
||||||
|
};
|
||||||
|
write_vars(|v| {
|
||||||
|
v.set_var(
|
||||||
|
"SHED_VI_MODE",
|
||||||
|
VarKind::Str(new.mode.report_mode().to_string()),
|
||||||
|
VarFlags::NONE,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
new.prompt.refresh();
|
||||||
|
new.writer.flush_write("\n")?; // ensure we start on a new line, in case the previous command didn't end with a newline
|
||||||
|
new.print_line(false)?;
|
||||||
|
Ok(new)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_no_hist(prompt: Prompt, tty: RawFd) -> ShResult<Self> {
|
||||||
|
let mut new = Self {
|
||||||
|
reader: PollReader::new(),
|
||||||
|
writer: TermWriter::new(tty),
|
||||||
|
tty,
|
||||||
|
prompt,
|
||||||
|
completer: Box::new(FuzzyCompleter::default()),
|
||||||
|
highlighter: Highlighter::new(),
|
||||||
|
mode: Box::new(ViInsert::new()),
|
||||||
|
saved_mode: None,
|
||||||
|
pending_keymap: Vec::new(),
|
||||||
|
old_layout: None,
|
||||||
|
repeat_action: None,
|
||||||
|
repeat_motion: None,
|
||||||
|
editor: LineBuf::new(),
|
||||||
|
history: History::empty(),
|
||||||
|
ex_history: History::empty(),
|
||||||
needs_redraw: true,
|
needs_redraw: true,
|
||||||
};
|
};
|
||||||
write_vars(|v| {
|
write_vars(|v| {
|
||||||
@@ -298,10 +346,21 @@ impl ShedVi {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A mutable reference to the currently focused editor
|
||||||
|
/// This includes the main LineBuf, and sub-editors for modes like Ex mode.
|
||||||
|
pub fn focused_editor(&mut self) -> &mut LineBuf {
|
||||||
|
self.mode.editor().unwrap_or(&mut self.editor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mutable reference to the currently focused history, if any.
|
||||||
|
/// This includes the main history struct, and history for sub-editors like Ex mode.
|
||||||
|
pub fn focused_history(&mut self) -> &mut History {
|
||||||
|
self.mode.history().unwrap_or(&mut self.history)
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed raw bytes from stdin into the reader's buffer
|
/// Feed raw bytes from stdin into the reader's buffer
|
||||||
pub fn feed_bytes(&mut self, bytes: &[u8]) {
|
pub fn feed_bytes(&mut self, bytes: &[u8]) {
|
||||||
let verbatim = self.mode.report_mode() == ModeReport::Verbatim;
|
self.reader.feed_bytes(bytes);
|
||||||
self.reader.feed_bytes(bytes, verbatim);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark that the display needs to be redrawn (e.g., after SIGWINCH)
|
/// Mark that the display needs to be redrawn (e.g., after SIGWINCH)
|
||||||
@@ -312,7 +371,7 @@ impl ShedVi {
|
|||||||
pub fn fix_column(&mut self) -> ShResult<()> {
|
pub fn fix_column(&mut self) -> ShResult<()> {
|
||||||
self
|
self
|
||||||
.writer
|
.writer
|
||||||
.fix_cursor_column(&mut TermReader::new(*TTY_FILENO))
|
.fix_cursor_column(&mut TermReader::new(self.tty))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_active_widget(&mut self, full_redraw: bool) -> ShResult<()> {
|
pub fn reset_active_widget(&mut self, full_redraw: bool) -> ShResult<()> {
|
||||||
@@ -320,6 +379,10 @@ impl ShedVi {
|
|||||||
self.completer.reset_stay_active();
|
self.completer.reset_stay_active();
|
||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else if self.focused_history().fuzzy_finder.is_active() {
|
||||||
|
self.focused_history().fuzzy_finder.reset_stay_active();
|
||||||
|
self.needs_redraw = true;
|
||||||
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
self.reset(full_redraw)
|
self.reset(full_redraw)
|
||||||
}
|
}
|
||||||
@@ -378,7 +441,7 @@ impl ShedVi {
|
|||||||
LexStream::new(Arc::clone(&input), LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<_>>>();
|
LexStream::new(Arc::clone(&input), LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<_>>>();
|
||||||
let lex_result2 =
|
let lex_result2 =
|
||||||
LexStream::new(Arc::clone(&input), LexFlags::empty()).collect::<ShResult<Vec<_>>>();
|
LexStream::new(Arc::clone(&input), LexFlags::empty()).collect::<ShResult<Vec<_>>>();
|
||||||
let is_top_level = self.editor.auto_indent_level == 0;
|
let is_top_level = self.editor.indent_ctx.ctx().is_empty();
|
||||||
|
|
||||||
let is_complete = match (lex_result1.is_err(), lex_result2.is_err()) {
|
let is_complete = match (lex_result1.is_err(), lex_result2.is_err()) {
|
||||||
(true, true) => {
|
(true, true) => {
|
||||||
@@ -406,20 +469,28 @@ impl ShedVi {
|
|||||||
// Process all available keys
|
// Process all available keys
|
||||||
while let Some(key) = self.reader.read_key()? {
|
while let Some(key) = self.reader.read_key()? {
|
||||||
// If completer or history search are active, delegate input to it
|
// If completer or history search are active, delegate input to it
|
||||||
if self.history.fuzzy_finder.is_active() {
|
if self.focused_history().fuzzy_finder.is_active() {
|
||||||
self.print_line(false)?;
|
self.print_line(false)?;
|
||||||
match self.history.fuzzy_finder.handle_key(key)? {
|
match self.focused_history().fuzzy_finder.handle_key(key)? {
|
||||||
SelectorResponse::Accept(cmd) => {
|
SelectorResponse::Accept(cmd) => {
|
||||||
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
|
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
|
||||||
|
|
||||||
self.editor.set_buffer(cmd.to_string());
|
{
|
||||||
self.editor.move_cursor_to_end();
|
let editor = self.focused_editor();
|
||||||
|
editor.set_buffer(cmd.to_string());
|
||||||
|
editor.move_cursor_to_end();
|
||||||
|
}
|
||||||
|
|
||||||
self
|
self
|
||||||
.history
|
.history
|
||||||
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
||||||
self.editor.set_hint(None);
|
self.editor.set_hint(None);
|
||||||
self.history.fuzzy_finder.clear(&mut self.writer)?;
|
{
|
||||||
self.history.fuzzy_finder.reset();
|
let mut writer = std::mem::take(&mut self.writer);
|
||||||
|
self.focused_history().fuzzy_finder.clear(&mut writer)?;
|
||||||
|
self.writer = writer;
|
||||||
|
}
|
||||||
|
self.focused_history().fuzzy_finder.reset();
|
||||||
|
|
||||||
with_vars([("_HIST_ENTRY".into(), cmd.clone())], || {
|
with_vars([("_HIST_ENTRY".into(), cmd.clone())], || {
|
||||||
post_cmds.exec_with(&cmd);
|
post_cmds.exec_with(&cmd);
|
||||||
@@ -442,7 +513,11 @@ impl ShedVi {
|
|||||||
post_cmds.exec();
|
post_cmds.exec();
|
||||||
|
|
||||||
self.editor.set_hint(None);
|
self.editor.set_hint(None);
|
||||||
self.history.fuzzy_finder.clear(&mut self.writer)?;
|
{
|
||||||
|
let mut writer = std::mem::take(&mut self.writer);
|
||||||
|
self.focused_history().fuzzy_finder.clear(&mut writer)?;
|
||||||
|
self.writer = writer;
|
||||||
|
}
|
||||||
write_vars(|v| {
|
write_vars(|v| {
|
||||||
v.set_var(
|
v.set_var(
|
||||||
"SHED_VI_MODE",
|
"SHED_VI_MODE",
|
||||||
@@ -469,8 +544,8 @@ impl ShedVi {
|
|||||||
let span_start = self.completer.token_span().0;
|
let span_start = self.completer.token_span().0;
|
||||||
let new_cursor = span_start + candidate.len();
|
let new_cursor = span_start + candidate.len();
|
||||||
let line = self.completer.get_completed_line(&candidate);
|
let line = self.completer.get_completed_line(&candidate);
|
||||||
self.editor.set_buffer(line);
|
self.focused_editor().set_buffer(line);
|
||||||
self.editor.cursor.set(new_cursor);
|
self.focused_editor().cursor.set(new_cursor);
|
||||||
// Don't reset yet — clear() needs old_layout to erase the selector.
|
// Don't reset yet — clear() needs old_layout to erase the selector.
|
||||||
|
|
||||||
if !self.history.at_pending() {
|
if !self.history.at_pending() {
|
||||||
@@ -485,6 +560,16 @@ impl ShedVi {
|
|||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
self.completer.reset();
|
self.completer.reset();
|
||||||
|
|
||||||
|
write_vars(|v| {
|
||||||
|
v.set_var(
|
||||||
|
"SHED_VI_MODE",
|
||||||
|
VarKind::Str(self.mode.report_mode().to_string()),
|
||||||
|
VarFlags::NONE,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
self.prompt.refresh();
|
||||||
|
|
||||||
with_vars([("_COMP_CANDIDATE".into(), candidate.clone())], || {
|
with_vars([("_COMP_CANDIDATE".into(), candidate.clone())], || {
|
||||||
post_cmds.exec_with(&candidate);
|
post_cmds.exec_with(&candidate);
|
||||||
});
|
});
|
||||||
@@ -577,10 +662,6 @@ impl ShedVi {
|
|||||||
|
|
||||||
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<Option<ReadlineEvent>> {
|
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<Option<ReadlineEvent>> {
|
||||||
if self.should_accept_hint(&key) {
|
if self.should_accept_hint(&key) {
|
||||||
log::debug!(
|
|
||||||
"Accepting hint on key {key:?} in mode {:?}",
|
|
||||||
self.mode.report_mode()
|
|
||||||
);
|
|
||||||
self.editor.accept_hint();
|
self.editor.accept_hint();
|
||||||
if !self.history.at_pending() {
|
if !self.history.at_pending() {
|
||||||
self.history.reset_to_pending();
|
self.history.reset_to_pending();
|
||||||
@@ -593,7 +674,8 @@ impl ShedVi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let KeyEvent(KeyCode::Tab, mod_keys) = key {
|
if let KeyEvent(KeyCode::Tab, mod_keys) = key {
|
||||||
if self.editor.attempt_history_expansion(&self.history) {
|
if self.mode.report_mode() != ModeReport::Ex
|
||||||
|
&& self.editor.attempt_history_expansion(&self.history) {
|
||||||
// If history expansion occurred, don't attempt completion yet
|
// If history expansion occurred, don't attempt completion yet
|
||||||
// allow the user to see the expanded command and accept or edit it before completing
|
// allow the user to see the expanded command and accept or edit it before completing
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -603,8 +685,8 @@ impl ShedVi {
|
|||||||
ModKeys::SHIFT => -1,
|
ModKeys::SHIFT => -1,
|
||||||
_ => 1,
|
_ => 1,
|
||||||
};
|
};
|
||||||
let line = self.editor.as_str().to_string();
|
let line = self.focused_editor().as_str().to_string();
|
||||||
let cursor_pos = self.editor.cursor_byte_pos();
|
let cursor_pos = self.focused_editor().cursor_byte_pos();
|
||||||
|
|
||||||
match self.completer.complete(line, cursor_pos, direction) {
|
match self.completer.complete(line, cursor_pos, direction) {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -628,8 +710,8 @@ impl ShedVi {
|
|||||||
.map(|c| c.len())
|
.map(|c| c.len())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
self.editor.set_buffer(line.clone());
|
self.focused_editor().set_buffer(line.clone());
|
||||||
self.editor.cursor.set(new_cursor);
|
self.focused_editor().cursor.set(new_cursor);
|
||||||
|
|
||||||
if !self.history.at_pending() {
|
if !self.history.at_pending() {
|
||||||
self.history.reset_to_pending();
|
self.history.reset_to_pending();
|
||||||
@@ -639,13 +721,37 @@ impl ShedVi {
|
|||||||
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
||||||
let hint = self.history.get_hint();
|
let hint = self.history.get_hint();
|
||||||
self.editor.set_hint(hint);
|
self.editor.set_hint(hint);
|
||||||
|
write_vars(|v| {
|
||||||
|
v.set_var(
|
||||||
|
"SHED_VI_MODE",
|
||||||
|
VarKind::Str(self.mode.report_mode().to_string()),
|
||||||
|
VarFlags::NONE,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// If we are here, we hit a case where pressing tab returned a single candidate
|
||||||
|
// So we can just go ahead and reset the completer after this
|
||||||
|
self.completer.reset();
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart));
|
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart));
|
||||||
|
let candidates = self.completer.all_candidates();
|
||||||
|
let num_candidates = candidates.len();
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("_NUM_MATCHES".into(), Into::<Var>::into(num_candidates)),
|
||||||
|
("_MATCHES".into(), Into::<Var>::into(candidates)),
|
||||||
|
(
|
||||||
|
"_SEARCH_STR".into(),
|
||||||
|
Into::<Var>::into(self.completer.token()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
post_cmds.exec();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
post_cmds.exec();
|
|
||||||
|
|
||||||
self.writer.send_bell().ok();
|
|
||||||
if self.completer.is_active() {
|
if self.completer.is_active() {
|
||||||
write_vars(|v| {
|
write_vars(|v| {
|
||||||
v.set_var(
|
v.set_var(
|
||||||
@@ -658,23 +764,27 @@ impl ShedVi {
|
|||||||
self.prompt.refresh();
|
self.prompt.refresh();
|
||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
self.editor.set_hint(None);
|
self.editor.set_hint(None);
|
||||||
|
} else {
|
||||||
|
self.writer.send_bell().ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
} else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key {
|
} else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key
|
||||||
let initial = self.editor.as_str();
|
&& matches!(self.mode.report_mode(), ModeReport::Insert | ModeReport::Ex)
|
||||||
match self.history.start_search(initial) {
|
{
|
||||||
|
let initial = self.focused_editor().as_str().to_string();
|
||||||
|
match self.focused_history().start_search(&initial) {
|
||||||
Some(entry) => {
|
Some(entry) => {
|
||||||
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
|
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
|
||||||
with_vars([("_HIST_ENTRY".into(), entry.clone())], || {
|
with_vars([("_HIST_ENTRY".into(), entry.clone())], || {
|
||||||
post_cmds.exec_with(&entry);
|
post_cmds.exec_with(&entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.editor.set_buffer(entry);
|
self.focused_editor().set_buffer(entry);
|
||||||
self.editor.move_cursor_to_end();
|
self.focused_editor().move_cursor_to_end();
|
||||||
self
|
self
|
||||||
.history
|
.history
|
||||||
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
||||||
@@ -682,10 +792,32 @@ impl ShedVi {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen));
|
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen));
|
||||||
post_cmds.exec();
|
let entries = self.focused_history().fuzzy_finder.candidates().to_vec();
|
||||||
|
let matches = self
|
||||||
|
.focused_history()
|
||||||
|
.fuzzy_finder
|
||||||
|
.filtered()
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|sc| sc.content)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
self.writer.send_bell().ok();
|
let num_entries = entries.len();
|
||||||
if self.history.fuzzy_finder.is_active() {
|
let num_matches = matches.len();
|
||||||
|
with_vars(
|
||||||
|
[
|
||||||
|
("_ENTRIES".into(), Into::<Var>::into(entries)),
|
||||||
|
("_NUM_ENTRIES".into(), Into::<Var>::into(num_entries)),
|
||||||
|
("_MATCHES".into(), Into::<Var>::into(matches)),
|
||||||
|
("_NUM_MATCHES".into(), Into::<Var>::into(num_matches)),
|
||||||
|
("_SEARCH_STR".into(), Into::<Var>::into(initial)),
|
||||||
|
],
|
||||||
|
|| {
|
||||||
|
post_cmds.exec();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.focused_history().fuzzy_finder.is_active() {
|
||||||
write_vars(|v| {
|
write_vars(|v| {
|
||||||
v.set_var(
|
v.set_var(
|
||||||
"SHED_VI_MODE",
|
"SHED_VI_MODE",
|
||||||
@@ -697,22 +829,17 @@ impl ShedVi {
|
|||||||
self.prompt.refresh();
|
self.prompt.refresh();
|
||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
self.editor.set_hint(None);
|
self.editor.set_hint(None);
|
||||||
|
} else {
|
||||||
|
self.writer.send_bell().ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let KeyEvent(KeyCode::Char('\\'), ModKeys::NONE) = key
|
|
||||||
&& !self.next_is_escaped
|
|
||||||
{
|
|
||||||
self.next_is_escaped = true;
|
|
||||||
} else {
|
|
||||||
self.next_is_escaped = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Ok(cmd) = self.mode.handle_key_fallible(key) else {
|
let Ok(cmd) = self.mode.handle_key_fallible(key) else {
|
||||||
// it's an ex mode error
|
// it's an ex mode error
|
||||||
self.mode = Box::new(ViNormal::new()) as Box<dyn ViMode>;
|
self.swap_mode(&mut (Box::new(ViNormal::new()) as Box<dyn ViMode>));
|
||||||
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -728,8 +855,7 @@ impl ShedVi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cmd.is_submit_action()
|
if cmd.is_submit_action()
|
||||||
&& !self.next_is_escaped
|
&& !self.editor.cursor_is_escaped()
|
||||||
&& !self.editor.buffer.ends_with('\\')
|
|
||||||
&& (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete))
|
&& (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete))
|
||||||
{
|
{
|
||||||
if self.editor.attempt_history_expansion(&self.history) {
|
if self.editor.attempt_history_expansion(&self.history) {
|
||||||
@@ -748,10 +874,10 @@ impl ShedVi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) {
|
if cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) {
|
||||||
if self.editor.buffer.is_empty() {
|
if self.focused_editor().buffer.is_empty() {
|
||||||
return Ok(Some(ReadlineEvent::Eof));
|
return Ok(Some(ReadlineEvent::Eof));
|
||||||
} else {
|
} else {
|
||||||
self.editor = LineBuf::new();
|
*self.focused_editor() = LineBuf::new();
|
||||||
self.mode = Box::new(ViInsert::new());
|
self.mode = Box::new(ViInsert::new());
|
||||||
self.needs_redraw = true;
|
self.needs_redraw = true;
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -759,9 +885,22 @@ impl ShedVi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit());
|
let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit());
|
||||||
|
let is_shell_cmd = cmd.verb().is_some_and(|v| matches!(v.1, Verb::ShellCmd(_)));
|
||||||
|
let is_ex_cmd = cmd.flags.contains(CmdFlags::IS_EX_CMD);
|
||||||
|
log::debug!("is_ex_cmd: {is_ex_cmd}");
|
||||||
|
if is_shell_cmd {
|
||||||
|
self.old_layout = None;
|
||||||
|
}
|
||||||
|
if is_ex_cmd {
|
||||||
|
self.ex_history.push(cmd.raw_seq.clone());
|
||||||
|
self.ex_history.reset();
|
||||||
|
log::debug!("ex_history: {:?}", self.ex_history.entries());
|
||||||
|
}
|
||||||
|
|
||||||
let before = self.editor.buffer.clone();
|
let before = self.editor.buffer.clone();
|
||||||
self.exec_cmd(cmd)?;
|
|
||||||
|
self.exec_cmd(cmd, false)?;
|
||||||
|
|
||||||
if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) {
|
if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
self.handle_key(key)?;
|
self.handle_key(key)?;
|
||||||
@@ -786,7 +925,7 @@ impl ShedVi {
|
|||||||
|
|
||||||
pub fn get_layout(&mut self, line: &str) -> Layout {
|
pub fn get_layout(&mut self, line: &str) -> Layout {
|
||||||
let to_cursor = self.editor.slice_to_cursor().unwrap_or_default();
|
let to_cursor = self.editor.slice_to_cursor().unwrap_or_default();
|
||||||
let (cols, _) = get_win_size(*TTY_FILENO);
|
let (cols, _) = get_win_size(self.tty);
|
||||||
Layout::from_parts(cols, self.prompt.get_ps1(), to_cursor, line)
|
Layout::from_parts(cols, self.prompt.get_ps1(), to_cursor, line)
|
||||||
}
|
}
|
||||||
pub fn scroll_history(&mut self, cmd: ViCmd) {
|
pub fn scroll_history(&mut self, cmd: ViCmd) {
|
||||||
@@ -893,7 +1032,11 @@ impl ShedVi {
|
|||||||
let one_line = new_layout.end.row == 0;
|
let one_line = new_layout.end.row == 0;
|
||||||
|
|
||||||
self.completer.clear(&mut self.writer)?;
|
self.completer.clear(&mut self.writer)?;
|
||||||
self.history.fuzzy_finder.clear(&mut self.writer)?;
|
{
|
||||||
|
let mut writer = std::mem::take(&mut self.writer);
|
||||||
|
self.focused_history().fuzzy_finder.clear(&mut writer)?;
|
||||||
|
self.writer = writer;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(layout) = self.old_layout.as_ref() {
|
if let Some(layout) = self.old_layout.as_ref() {
|
||||||
self.writer.clear_rows(layout)?;
|
self.writer.clear_rows(layout)?;
|
||||||
@@ -965,6 +1108,7 @@ impl ShedVi {
|
|||||||
let pending_seq = self.mode.pending_seq().unwrap_or_default();
|
let pending_seq = self.mode.pending_seq().unwrap_or_default();
|
||||||
write!(buf, "\n: {pending_seq}").unwrap();
|
write!(buf, "\n: {pending_seq}").unwrap();
|
||||||
new_layout.end.row += 1;
|
new_layout.end.row += 1;
|
||||||
|
new_layout.cursor.row += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
write!(buf, "{}", &self.mode.cursor_style()).unwrap();
|
write!(buf, "{}", &self.mode.cursor_style()).unwrap();
|
||||||
@@ -985,10 +1129,15 @@ impl ShedVi {
|
|||||||
self.completer.draw(&mut self.writer)?;
|
self.completer.draw(&mut self.writer)?;
|
||||||
|
|
||||||
self
|
self
|
||||||
.history
|
.focused_history()
|
||||||
.fuzzy_finder
|
.fuzzy_finder
|
||||||
.set_prompt_line_context(preceding_width, new_layout.cursor.col);
|
.set_prompt_line_context(preceding_width, new_layout.cursor.col);
|
||||||
self.history.fuzzy_finder.draw(&mut self.writer)?;
|
|
||||||
|
{
|
||||||
|
let mut writer = std::mem::take(&mut self.writer);
|
||||||
|
self.focused_history().fuzzy_finder.draw(&mut writer)?;
|
||||||
|
self.writer = writer;
|
||||||
|
}
|
||||||
|
|
||||||
self.old_layout = Some(new_layout);
|
self.old_layout = Some(new_layout);
|
||||||
self.needs_redraw = false;
|
self.needs_redraw = false;
|
||||||
@@ -1019,99 +1168,74 @@ impl ShedVi {
|
|||||||
post_mode_change.exec();
|
post_mode_change.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> {
|
fn exec_mode_transition(&mut self, cmd: ViCmd, from_replay: bool) -> ShResult<()> {
|
||||||
let mut select_mode = None;
|
let mut select_mode = None;
|
||||||
let mut is_insert_mode = false;
|
let mut is_insert_mode = false;
|
||||||
if cmd.is_mode_transition() {
|
let count = cmd.verb_count();
|
||||||
let count = cmd.verb_count();
|
|
||||||
|
|
||||||
let mut mode: Box<dyn ViMode> = if matches!(
|
let mut mode: Box<dyn ViMode> = if matches!(
|
||||||
self.mode.report_mode(),
|
self.mode.report_mode(),
|
||||||
ModeReport::Ex | ModeReport::Verbatim
|
ModeReport::Ex | ModeReport::Verbatim
|
||||||
) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE)
|
) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE)
|
||||||
{
|
{
|
||||||
if let Some(saved) = self.saved_mode.take() {
|
if let Some(saved) = self.saved_mode.take() {
|
||||||
saved
|
saved
|
||||||
} else {
|
|
||||||
Box::new(ViNormal::new())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
match cmd.verb().unwrap().1 {
|
Box::new(ViNormal::new())
|
||||||
Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => {
|
}
|
||||||
is_insert_mode = true;
|
} else {
|
||||||
Box::new(ViInsert::new().with_count(count as u16))
|
match cmd.verb().unwrap().1 {
|
||||||
}
|
Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => {
|
||||||
|
is_insert_mode = true;
|
||||||
Verb::ExMode => Box::new(ViEx::new()),
|
Box::new(
|
||||||
|
ViInsert::new()
|
||||||
Verb::VerbatimMode => Box::new(ViVerbatim::new().with_count(count as u16)),
|
.with_count(count as u16)
|
||||||
|
.record_cmd(cmd.clone()),
|
||||||
Verb::NormalMode => Box::new(ViNormal::new()),
|
|
||||||
|
|
||||||
Verb::ReplaceMode => Box::new(ViReplace::new()),
|
|
||||||
|
|
||||||
Verb::VisualModeSelectLast => {
|
|
||||||
if self.mode.report_mode() != ModeReport::Visual {
|
|
||||||
self
|
|
||||||
.editor
|
|
||||||
.start_selecting(SelectMode::Char(SelectAnchor::End));
|
|
||||||
}
|
|
||||||
let mut mode: Box<dyn ViMode> = Box::new(ViVisual::new());
|
|
||||||
self.swap_mode(&mut mode);
|
|
||||||
|
|
||||||
return self.editor.exec_cmd(cmd);
|
|
||||||
}
|
|
||||||
Verb::VisualMode => {
|
|
||||||
select_mode = Some(SelectMode::Char(SelectAnchor::End));
|
|
||||||
Box::new(ViVisual::new())
|
|
||||||
}
|
|
||||||
Verb::VisualModeLine => {
|
|
||||||
select_mode = Some(SelectMode::Line(SelectAnchor::End));
|
|
||||||
Box::new(ViVisual::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.swap_mode(&mut mode);
|
|
||||||
|
|
||||||
if matches!(
|
|
||||||
self.mode.report_mode(),
|
|
||||||
ModeReport::Ex | ModeReport::Verbatim
|
|
||||||
) {
|
|
||||||
self.saved_mode = Some(mode);
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var(
|
|
||||||
"SHED_VI_MODE",
|
|
||||||
VarKind::Str(self.mode.report_mode().to_string()),
|
|
||||||
VarFlags::NONE,
|
|
||||||
)
|
)
|
||||||
})?;
|
}
|
||||||
self.prompt.refresh();
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if mode.is_repeatable() {
|
Verb::ExMode => Box::new(ViEx::new(self.ex_history.clone())),
|
||||||
self.repeat_action = mode.as_replay();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set cursor clamp BEFORE executing the command so that motions
|
Verb::VerbatimMode => {
|
||||||
// (like EndOfLine for 'A') can reach positions valid in the new mode
|
self.reader.verbatim_single = true;
|
||||||
self.editor.set_cursor_clamp(self.mode.clamp_cursor());
|
Box::new(ViVerbatim::new().with_count(count as u16))
|
||||||
self.editor.exec_cmd(cmd)?;
|
}
|
||||||
|
|
||||||
if let Some(sel_mode) = select_mode {
|
Verb::NormalMode => Box::new(ViNormal::new()),
|
||||||
self.editor.start_selecting(sel_mode);
|
|
||||||
} else {
|
|
||||||
self.editor.stop_selecting();
|
|
||||||
}
|
|
||||||
if is_insert_mode {
|
|
||||||
self.editor.mark_insert_mode_start_pos();
|
|
||||||
} else {
|
|
||||||
self.editor.clear_insert_mode_start_pos();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Verb::ReplaceMode => Box::new(ViReplace::new()),
|
||||||
|
|
||||||
|
Verb::VisualModeSelectLast => {
|
||||||
|
if self.mode.report_mode() != ModeReport::Visual {
|
||||||
|
self
|
||||||
|
.editor
|
||||||
|
.start_selecting(SelectMode::Char(SelectAnchor::End));
|
||||||
|
}
|
||||||
|
let mut mode: Box<dyn ViMode> = Box::new(ViVisual::new());
|
||||||
|
self.swap_mode(&mut mode);
|
||||||
|
|
||||||
|
return self.editor.exec_cmd(cmd);
|
||||||
|
}
|
||||||
|
Verb::VisualMode => {
|
||||||
|
select_mode = Some(SelectMode::Char(SelectAnchor::End));
|
||||||
|
Box::new(ViVisual::new())
|
||||||
|
}
|
||||||
|
Verb::VisualModeLine => {
|
||||||
|
select_mode = Some(SelectMode::Line(SelectAnchor::End));
|
||||||
|
Box::new(ViVisual::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.swap_mode(&mut mode);
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
self.mode.report_mode(),
|
||||||
|
ModeReport::Ex | ModeReport::Verbatim
|
||||||
|
) {
|
||||||
|
self.saved_mode = Some(mode);
|
||||||
write_vars(|v| {
|
write_vars(|v| {
|
||||||
v.set_var(
|
v.set_var(
|
||||||
"SHED_VI_MODE",
|
"SHED_VI_MODE",
|
||||||
@@ -1120,8 +1244,56 @@ impl ShedVi {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
self.prompt.refresh();
|
self.prompt.refresh();
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode.is_repeatable() && !from_replay {
|
||||||
|
self.repeat_action = mode.as_replay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cursor clamp BEFORE executing the command so that motions
|
||||||
|
// (like EndOfLine for 'A') can reach positions valid in the new mode
|
||||||
|
self.editor.set_cursor_clamp(self.mode.clamp_cursor());
|
||||||
|
self.editor.exec_cmd(cmd)?;
|
||||||
|
|
||||||
|
if let Some(sel_mode) = select_mode {
|
||||||
|
self.editor.start_selecting(sel_mode);
|
||||||
|
} else {
|
||||||
|
self.editor.stop_selecting();
|
||||||
|
}
|
||||||
|
if is_insert_mode {
|
||||||
|
self.editor.mark_insert_mode_start_pos();
|
||||||
|
} else {
|
||||||
|
self.editor.clear_insert_mode_start_pos();
|
||||||
|
}
|
||||||
|
|
||||||
|
write_vars(|v| {
|
||||||
|
v.set_var(
|
||||||
|
"SHED_VI_MODE",
|
||||||
|
VarKind::Str(self.mode.report_mode().to_string()),
|
||||||
|
VarFlags::NONE,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
self.prompt.refresh();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_mode(&self) -> Box<dyn ViMode> {
|
||||||
|
match self.mode.report_mode() {
|
||||||
|
ModeReport::Normal => Box::new(ViNormal::new()),
|
||||||
|
ModeReport::Insert => Box::new(ViInsert::new()),
|
||||||
|
ModeReport::Visual => Box::new(ViVisual::new()),
|
||||||
|
ModeReport::Ex => Box::new(ViEx::new(self.ex_history.clone())),
|
||||||
|
ModeReport::Replace => Box::new(ViReplace::new()),
|
||||||
|
ModeReport::Verbatim => Box::new(ViVerbatim::new()),
|
||||||
|
ModeReport::Unknown => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exec_cmd(&mut self, mut cmd: ViCmd, from_replay: bool) -> ShResult<()> {
|
||||||
|
if cmd.is_mode_transition() {
|
||||||
|
return self.exec_mode_transition(cmd, from_replay);
|
||||||
} else if cmd.is_cmd_repeat() {
|
} else if cmd.is_cmd_repeat() {
|
||||||
let Some(replay) = self.repeat_action.clone() else {
|
let Some(replay) = self.repeat_action.clone() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1133,11 +1305,36 @@ impl ShedVi {
|
|||||||
if count > 1 {
|
if count > 1 {
|
||||||
repeat = count as u16;
|
repeat = count as u16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let old_mode = self.mode.report_mode();
|
||||||
|
|
||||||
for _ in 0..repeat {
|
for _ in 0..repeat {
|
||||||
let cmds = cmds.clone();
|
let cmds = cmds.clone();
|
||||||
for cmd in cmds {
|
for (i, cmd) in cmds.iter().enumerate() {
|
||||||
self.editor.exec_cmd(cmd)?
|
self.exec_cmd(cmd.clone(), true)?;
|
||||||
|
// After the first command, start merging so all subsequent
|
||||||
|
// edits fold into one undo entry (e.g. cw + inserted chars)
|
||||||
|
if i == 0
|
||||||
|
&& let Some(edit) = self.editor.undo_stack.last_mut()
|
||||||
|
{
|
||||||
|
edit.start_merge();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Stop merging at the end of the replay
|
||||||
|
if let Some(edit) = self.editor.undo_stack.last_mut() {
|
||||||
|
edit.stop_merge();
|
||||||
|
}
|
||||||
|
|
||||||
|
let old_mode_clone = match old_mode {
|
||||||
|
ModeReport::Normal => Box::new(ViNormal::new()) as Box<dyn ViMode>,
|
||||||
|
ModeReport::Insert => Box::new(ViInsert::new()) as Box<dyn ViMode>,
|
||||||
|
ModeReport::Visual => Box::new(ViVisual::new()) as Box<dyn ViMode>,
|
||||||
|
ModeReport::Ex => Box::new(ViEx::new(self.ex_history.clone())) as Box<dyn ViMode>,
|
||||||
|
ModeReport::Replace => Box::new(ViReplace::new()) as Box<dyn ViMode>,
|
||||||
|
ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box<dyn ViMode>,
|
||||||
|
ModeReport::Unknown => unreachable!(),
|
||||||
|
};
|
||||||
|
self.mode = old_mode_clone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CmdReplay::Single(mut cmd) => {
|
CmdReplay::Single(mut cmd) => {
|
||||||
@@ -1200,7 +1397,7 @@ impl ShedVi {
|
|||||||
self.swap_mode(&mut mode);
|
self.swap_mode(&mut mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if cmd.is_repeatable() {
|
if cmd.is_repeatable() && !from_replay {
|
||||||
if self.mode.report_mode() == ModeReport::Visual {
|
if self.mode.report_mode() == ModeReport::Visual {
|
||||||
// The motion is assigned in the line buffer execution, so we also have to
|
// The motion is assigned in the line buffer execution, so we also have to
|
||||||
// assign it here in order to be able to repeat it
|
// assign it here in order to be able to repeat it
|
||||||
@@ -1219,7 +1416,11 @@ impl ShedVi {
|
|||||||
|
|
||||||
self.editor.exec_cmd(cmd.clone())?;
|
self.editor.exec_cmd(cmd.clone())?;
|
||||||
|
|
||||||
if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some_and(|v| v.1.is_edit()) {
|
if self.mode.report_mode() == ModeReport::Visual
|
||||||
|
&& cmd
|
||||||
|
.verb()
|
||||||
|
.is_some_and(|v| v.1.is_edit() || v.1 == Verb::Yank)
|
||||||
|
{
|
||||||
self.editor.stop_selecting();
|
self.editor.stop_selecting();
|
||||||
let mut mode: Box<dyn ViMode> = Box::new(ViNormal::new());
|
let mut mode: Box<dyn ViMode> = Box::new(ViNormal::new());
|
||||||
self.swap_mode(&mut mode);
|
self.swap_mode(&mut mode);
|
||||||
@@ -1368,6 +1569,7 @@ pub fn get_insertions(input: &str) -> Vec<(usize, Marker)> {
|
|||||||
pub fn marker_for(class: &TkRule) -> Option<Marker> {
|
pub fn marker_for(class: &TkRule) -> Option<Marker> {
|
||||||
match class {
|
match class {
|
||||||
TkRule::Pipe
|
TkRule::Pipe
|
||||||
|
| TkRule::Bang
|
||||||
| TkRule::ErrPipe
|
| TkRule::ErrPipe
|
||||||
| TkRule::And
|
| TkRule::And
|
||||||
| TkRule::Or
|
| TkRule::Or
|
||||||
@@ -1459,6 +1661,12 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
|
|||||||
|
|
||||||
let mut insertions: Vec<(usize, Marker)> = vec![];
|
let mut insertions: Vec<(usize, Marker)> = vec![];
|
||||||
|
|
||||||
|
// Heredoc tokens have spans covering the body content far from the <<
|
||||||
|
// operator, which breaks position tracking after marker insertions
|
||||||
|
if token.flags.contains(TkFlags::IS_HEREDOC) {
|
||||||
|
return insertions;
|
||||||
|
}
|
||||||
|
|
||||||
if token.class != TkRule::Str
|
if token.class != TkRule::Str
|
||||||
&& let Some(marker) = marker_for(&token.class)
|
&& let Some(marker) = marker_for(&token.class)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ use std::{fmt::Display, sync::Mutex};
|
|||||||
|
|
||||||
pub static REGISTERS: Mutex<Registers> = Mutex::new(Registers::new());
|
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> {
|
pub fn read_register(ch: Option<char>) -> Option<RegisterContent> {
|
||||||
let lock = REGISTERS.lock().unwrap();
|
let lock = REGISTERS.lock().unwrap();
|
||||||
lock.get_reg(ch).map(|r| r.content().clone())
|
lock.get_reg(ch).map(|r| r.content().clone())
|
||||||
@@ -79,7 +97,7 @@ impl RegisterContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Clone, Debug)]
|
||||||
pub struct Registers {
|
pub struct Registers {
|
||||||
default: Register,
|
default: Register,
|
||||||
a: Register,
|
a: Register,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::{
|
|||||||
fmt::{Debug, Write},
|
fmt::{Debug, Write},
|
||||||
io::{BufRead, BufReader, Read},
|
io::{BufRead, BufReader, Read},
|
||||||
os::fd::{AsFd, BorrowedFd, RawFd},
|
os::fd::{AsFd, BorrowedFd, RawFd},
|
||||||
sync::Arc,
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
use nix::{
|
use nix::{
|
||||||
@@ -294,12 +294,14 @@ impl Read for TermBuffer {
|
|||||||
|
|
||||||
struct KeyCollector {
|
struct KeyCollector {
|
||||||
events: VecDeque<KeyEvent>,
|
events: VecDeque<KeyEvent>,
|
||||||
|
ss3_pending: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyCollector {
|
impl KeyCollector {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: VecDeque::new(),
|
events: VecDeque::new(),
|
||||||
|
ss3_pending: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +339,55 @@ impl Default for KeyCollector {
|
|||||||
|
|
||||||
impl Perform for KeyCollector {
|
impl Perform for KeyCollector {
|
||||||
fn print(&mut self, c: char) {
|
fn print(&mut self, c: char) {
|
||||||
|
log::trace!("print: {c:?}");
|
||||||
// vte routes 0x7f (DEL) to print instead of execute
|
// 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' {
|
if c == '\x7f' {
|
||||||
self.push(KeyEvent(KeyCode::Backspace, ModKeys::empty()));
|
self.push(KeyEvent(KeyCode::Backspace, ModKeys::empty()));
|
||||||
} else {
|
} else {
|
||||||
@@ -346,6 +396,7 @@ impl Perform for KeyCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn execute(&mut self, byte: u8) {
|
fn execute(&mut self, byte: u8) {
|
||||||
|
log::trace!("execute: {byte:#04x}");
|
||||||
let event = match byte {
|
let event = match byte {
|
||||||
0x00 => KeyEvent(KeyCode::Char(' '), ModKeys::CTRL), // Ctrl+Space / Ctrl+@
|
0x00 => KeyEvent(KeyCode::Char(' '), ModKeys::CTRL), // Ctrl+Space / Ctrl+@
|
||||||
0x09 => KeyEvent(KeyCode::Tab, ModKeys::empty()), // Tab (Ctrl+I)
|
0x09 => KeyEvent(KeyCode::Tab, ModKeys::empty()), // Tab (Ctrl+I)
|
||||||
@@ -370,6 +421,9 @@ impl Perform for KeyCollector {
|
|||||||
_ignore: bool,
|
_ignore: bool,
|
||||||
action: char,
|
action: char,
|
||||||
) {
|
) {
|
||||||
|
log::trace!(
|
||||||
|
"CSI dispatch: params={params:?}, intermediates={intermediates:?}, action={action:?}"
|
||||||
|
);
|
||||||
let params: Vec<u16> = params
|
let params: Vec<u16> = params
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.first().copied().unwrap_or(0))
|
.map(|p| p.first().copied().unwrap_or(0))
|
||||||
@@ -444,6 +498,8 @@ impl Perform for KeyCollector {
|
|||||||
21 => KeyCode::F(10),
|
21 => KeyCode::F(10),
|
||||||
23 => KeyCode::F(11),
|
23 => KeyCode::F(11),
|
||||||
24 => KeyCode::F(12),
|
24 => KeyCode::F(12),
|
||||||
|
200 => KeyCode::BracketedPasteStart,
|
||||||
|
201 => KeyCode::BracketedPasteEnd,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
KeyEvent(key, mods)
|
KeyEvent(key, mods)
|
||||||
@@ -479,16 +535,11 @@ impl Perform for KeyCollector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
|
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
|
||||||
// SS3 sequences (ESC O P/Q/R/S for F1-F4)
|
log::trace!("ESC dispatch: intermediates={intermediates:?}, byte={byte:#04x}");
|
||||||
if intermediates == [b'O'] {
|
// SS3 sequences
|
||||||
let key = match byte {
|
if byte == b'O' {
|
||||||
b'P' => KeyCode::F(1),
|
self.ss3_pending = true;
|
||||||
b'Q' => KeyCode::F(2),
|
return;
|
||||||
b'R' => KeyCode::F(3),
|
|
||||||
b'S' => KeyCode::F(4),
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
self.push(KeyEvent(key, ModKeys::empty()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,6 +547,9 @@ impl Perform for KeyCollector {
|
|||||||
pub struct PollReader {
|
pub struct PollReader {
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
collector: KeyCollector,
|
collector: KeyCollector,
|
||||||
|
byte_buf: VecDeque<u8>,
|
||||||
|
pub verbatim_single: bool,
|
||||||
|
pub verbatim: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PollReader {
|
impl PollReader {
|
||||||
@@ -503,25 +557,45 @@ impl PollReader {
|
|||||||
Self {
|
Self {
|
||||||
parser: Parser::new(),
|
parser: Parser::new(),
|
||||||
collector: KeyCollector::new(),
|
collector: KeyCollector::new(),
|
||||||
|
byte_buf: VecDeque::new(),
|
||||||
|
verbatim_single: false,
|
||||||
|
verbatim: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn feed_bytes(&mut self, bytes: &[u8], verbatim: bool) {
|
pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> {
|
||||||
if verbatim {
|
let end_marker = b"\x1b[201~";
|
||||||
let seq = String::from_utf8_lossy(bytes).to_string();
|
let mut raw = vec![];
|
||||||
self.collector.push(KeyEvent(
|
while let Some(byte) = self.byte_buf.pop_front() {
|
||||||
KeyCode::Verbatim(Arc::from(seq.as_str())),
|
raw.push(byte);
|
||||||
ModKeys::empty(),
|
if raw.ends_with(end_marker) {
|
||||||
));
|
// Strip the end marker from the raw sequence
|
||||||
} else if bytes == [b'\x1b'] {
|
raw.truncate(raw.len() - end_marker.len());
|
||||||
// Single escape byte - user pressed ESC key
|
let paste = String::from_utf8_lossy(&raw).to_string();
|
||||||
self
|
self.verbatim = false;
|
||||||
.collector
|
return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty()));
|
||||||
.push(KeyEvent(KeyCode::Esc, ModKeys::empty()));
|
}
|
||||||
} else {
|
|
||||||
// Feed all bytes through vte parser
|
|
||||||
self.parser.advance(&mut self.collector, bytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +607,43 @@ impl Default for PollReader {
|
|||||||
|
|
||||||
impl KeyReader for PollReader {
|
impl KeyReader for PollReader {
|
||||||
fn read_key(&mut self) -> Result<Option<KeyEvent>, ShErr> {
|
fn read_key(&mut self) -> Result<Option<KeyEvent>, ShErr> {
|
||||||
Ok(self.collector.pop())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +893,9 @@ impl Default for Layout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct TermWriter {
|
pub struct TermWriter {
|
||||||
|
last_bell: Option<Instant>,
|
||||||
out: RawFd,
|
out: RawFd,
|
||||||
pub t_cols: Col, // terminal width
|
pub t_cols: Col, // terminal width
|
||||||
buffer: String,
|
buffer: String,
|
||||||
@@ -793,6 +905,7 @@ impl TermWriter {
|
|||||||
pub fn new(out: RawFd) -> Self {
|
pub fn new(out: RawFd) -> Self {
|
||||||
let (t_cols, _) = get_win_size(out);
|
let (t_cols, _) = get_win_size(out);
|
||||||
Self {
|
Self {
|
||||||
|
last_bell: None,
|
||||||
out,
|
out,
|
||||||
t_cols,
|
t_cols,
|
||||||
buffer: String::new(),
|
buffer: String::new(),
|
||||||
@@ -1031,7 +1144,21 @@ impl LineWriter for TermWriter {
|
|||||||
|
|
||||||
fn send_bell(&mut self) -> ShResult<()> {
|
fn send_bell(&mut self) -> ShResult<()> {
|
||||||
if read_shopts(|o| o.core.bell_enabled) {
|
if read_shopts(|o| o.core.bell_enabled) {
|
||||||
self.flush_write("\x07")?;
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
518
src/readline/tests.rs
Normal file
518
src/readline/tests.rs
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
#![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.as_str(), $expected_text);
|
||||||
|
assert_eq!(vi.editor.cursor.get(), $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_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", 4;
|
||||||
|
vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 4;
|
||||||
|
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", 4;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.as_str(),
|
||||||
|
"func() {\n\tcase foo in\n\t\tbar)\n\t\t\twhile true; do\n\t\t\t\techo foo \\\n\t\t\t\t\tbar \\\n\t\t\t\t\tbiz \\\n\t\t\t\t\tbazz\n\t\t\t\tbreak\n\t\t\tdone\n\t\t;;\n\tesac\n}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use bitflags::bitflags;
|
use bitflags::bitflags;
|
||||||
|
|
||||||
|
use crate::readline::vimode::ex::SubFlags;
|
||||||
|
|
||||||
use super::register::{RegisterContent, append_register, read_register, write_register};
|
use super::register::{RegisterContent, append_register, read_register, write_register};
|
||||||
|
|
||||||
//TODO: write tests that take edit results and cursor positions from actual
|
//TODO: write tests that take edit results and cursor positions from actual
|
||||||
@@ -64,6 +68,7 @@ bitflags! {
|
|||||||
const VISUAL_LINE = 1<<1;
|
const VISUAL_LINE = 1<<1;
|
||||||
const VISUAL_BLOCK = 1<<2;
|
const VISUAL_BLOCK = 1<<2;
|
||||||
const EXIT_CUR_MODE = 1<<3;
|
const EXIT_CUR_MODE = 1<<3;
|
||||||
|
const IS_EX_CMD = 1<<4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +260,8 @@ pub enum Verb {
|
|||||||
Normal(String),
|
Normal(String),
|
||||||
Read(ReadSrc),
|
Read(ReadSrc),
|
||||||
Write(WriteDest),
|
Write(WriteDest),
|
||||||
Substitute(String, String, super::vimode::ex::SubFlags),
|
Edit(PathBuf),
|
||||||
|
Substitute(String, String, SubFlags),
|
||||||
RepeatSubstitute,
|
RepeatSubstitute,
|
||||||
RepeatGlobal,
|
RepeatGlobal,
|
||||||
}
|
}
|
||||||
@@ -301,6 +307,9 @@ impl Verb {
|
|||||||
| Self::JoinLines
|
| Self::JoinLines
|
||||||
| Self::InsertChar(_)
|
| Self::InsertChar(_)
|
||||||
| Self::Insert(_)
|
| Self::Insert(_)
|
||||||
|
| Self::Dedent
|
||||||
|
| Self::Indent
|
||||||
|
| Self::Equalize
|
||||||
| Self::Rot13
|
| Self::Rot13
|
||||||
| Self::EndOfFile
|
| Self::EndOfFile
|
||||||
| Self::IncrementNumber(_)
|
| Self::IncrementNumber(_)
|
||||||
@@ -332,18 +341,10 @@ pub enum Motion {
|
|||||||
ForwardCharForced,
|
ForwardCharForced,
|
||||||
LineUp,
|
LineUp,
|
||||||
LineUpCharwise,
|
LineUpCharwise,
|
||||||
ScreenLineUp,
|
|
||||||
ScreenLineUpCharwise,
|
|
||||||
LineDown,
|
LineDown,
|
||||||
LineDownCharwise,
|
LineDownCharwise,
|
||||||
ScreenLineDown,
|
|
||||||
ScreenLineDownCharwise,
|
|
||||||
BeginningOfScreenLine,
|
|
||||||
FirstGraphicalOnScreenLine,
|
|
||||||
HalfOfScreen,
|
|
||||||
HalfOfScreenLineText,
|
|
||||||
WholeBuffer,
|
WholeBuffer,
|
||||||
BeginningOfBuffer,
|
StartOfBuffer,
|
||||||
EndOfBuffer,
|
EndOfBuffer,
|
||||||
ToColumn,
|
ToColumn,
|
||||||
ToDelimMatch,
|
ToDelimMatch,
|
||||||
@@ -381,12 +382,8 @@ impl Motion {
|
|||||||
&self,
|
&self,
|
||||||
Self::BeginningOfLine
|
Self::BeginningOfLine
|
||||||
| Self::BeginningOfFirstWord
|
| Self::BeginningOfFirstWord
|
||||||
| Self::BeginningOfScreenLine
|
|
||||||
| Self::FirstGraphicalOnScreenLine
|
|
||||||
| Self::LineDownCharwise
|
| Self::LineDownCharwise
|
||||||
| Self::LineUpCharwise
|
| Self::LineUpCharwise
|
||||||
| Self::ScreenLineUpCharwise
|
|
||||||
| Self::ScreenLineDownCharwise
|
|
||||||
| Self::ToColumn
|
| Self::ToColumn
|
||||||
| Self::TextObj(TextObj::Sentence(_))
|
| Self::TextObj(TextObj::Sentence(_))
|
||||||
| Self::TextObj(TextObj::Paragraph(_))
|
| Self::TextObj(TextObj::Paragraph(_))
|
||||||
@@ -395,20 +392,13 @@ impl Motion {
|
|||||||
| Self::ToBrace(_)
|
| Self::ToBrace(_)
|
||||||
| Self::ToBracket(_)
|
| Self::ToBracket(_)
|
||||||
| Self::ToParen(_)
|
| Self::ToParen(_)
|
||||||
| Self::ScreenLineDown
|
|
||||||
| Self::ScreenLineUp
|
|
||||||
| Self::Range(_, _)
|
| Self::Range(_, _)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
pub fn is_linewise(&self) -> bool {
|
pub fn is_linewise(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
Self::WholeLineInclusive
|
Self::WholeLineInclusive | Self::WholeLineExclusive | Self::LineUp | Self::LineDown
|
||||||
| Self::WholeLineExclusive
|
|
||||||
| Self::LineUp
|
|
||||||
| Self::LineDown
|
|
||||||
| Self::ScreenLineDown
|
|
||||||
| Self::ScreenLineUp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ use std::str::Chars;
|
|||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
|
||||||
use crate::bitflags;
|
use crate::bitflags;
|
||||||
|
use crate::expand::{Expander, expand_raw};
|
||||||
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
|
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
|
||||||
|
use crate::parse::lex::TkFlags;
|
||||||
|
use crate::readline::complete::SimpleCompleter;
|
||||||
|
use crate::readline::history::History;
|
||||||
use crate::readline::keys::KeyEvent;
|
use crate::readline::keys::KeyEvent;
|
||||||
use crate::readline::linebuf::LineBuf;
|
use crate::readline::linebuf::LineBuf;
|
||||||
use crate::readline::vicmd::{
|
use crate::readline::vicmd::{
|
||||||
@@ -13,7 +17,7 @@ use crate::readline::vicmd::{
|
|||||||
WriteDest,
|
WriteDest,
|
||||||
};
|
};
|
||||||
use crate::readline::vimode::{ModeReport, ViInsert, ViMode};
|
use crate::readline::vimode::{ModeReport, ViInsert, ViMode};
|
||||||
use crate::state::write_meta;
|
use crate::state::{get_home, write_meta};
|
||||||
|
|
||||||
bitflags! {
|
bitflags! {
|
||||||
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
|
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
|
||||||
@@ -33,16 +37,64 @@ bitflags! {
|
|||||||
struct ExEditor {
|
struct ExEditor {
|
||||||
buf: LineBuf,
|
buf: LineBuf,
|
||||||
mode: ViInsert,
|
mode: ViInsert,
|
||||||
|
history: History,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExEditor {
|
impl ExEditor {
|
||||||
|
pub fn new(history: History) -> Self {
|
||||||
|
let mut new = Self {
|
||||||
|
history,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
new.buf.update_graphemes();
|
||||||
|
new
|
||||||
|
}
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
*self = Self::default()
|
*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::LineUpCharwise)))
|
||||||
|
&& self.buf.start_of_line() == 0)
|
||||||
|
|| (cmd
|
||||||
|
.motion()
|
||||||
|
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineDownCharwise)))
|
||||||
|
&& self.buf.end_of_line() == self.buf.cursor_max())
|
||||||
|
}
|
||||||
|
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::LineUpCharwise => -(*count as isize),
|
||||||
|
Motion::LineDownCharwise => *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<()> {
|
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<()> {
|
||||||
let Some(cmd) = self.mode.handle_key(key) else {
|
let Some(mut cmd) = self.mode.handle_key(key) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
cmd.alter_line_motion_if_no_verb();
|
||||||
|
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)
|
self.buf.exec_cmd(cmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,8 +105,10 @@ pub struct ViEx {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ViEx {
|
impl ViEx {
|
||||||
pub fn new() -> Self {
|
pub fn new(history: History) -> Self {
|
||||||
Self::default()
|
Self {
|
||||||
|
pending_cmd: ExEditor::new(history),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,18 +116,12 @@ impl ViMode for ViEx {
|
|||||||
// Ex mode can return errors, so we use this fallible method instead of the normal one
|
// 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>> {
|
fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult<Option<ViCmd>> {
|
||||||
use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M};
|
use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M};
|
||||||
log::debug!("[ViEx] handle_key_fallible: key={:?}", key);
|
|
||||||
match key {
|
match key {
|
||||||
E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => {
|
E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => {
|
||||||
let input = self.pending_cmd.buf.as_str();
|
let input = self.pending_cmd.buf.as_str();
|
||||||
log::debug!("[ViEx] Enter pressed, pending_cmd={:?}", input);
|
|
||||||
match parse_ex_cmd(input) {
|
match parse_ex_cmd(input) {
|
||||||
Ok(cmd) => {
|
Ok(cmd) => Ok(cmd),
|
||||||
log::debug!("[ViEx] parse_ex_cmd Ok: {:?}", cmd);
|
|
||||||
Ok(cmd)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("[ViEx] parse_ex_cmd Err: {:?}", e);
|
|
||||||
let msg = e.unwrap_or(format!("Not an editor command: {}", input));
|
let msg = e.unwrap_or(format!("Not an editor command: {}", input));
|
||||||
write_meta(|m| m.post_system_message(msg.clone()));
|
write_meta(|m| m.post_system_message(msg.clone()));
|
||||||
Err(ShErr::simple(ShErrKind::ParseErr, msg))
|
Err(ShErr::simple(ShErrKind::ParseErr, msg))
|
||||||
@@ -81,29 +129,21 @@ impl ViMode for ViEx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
E(C::Char('C'), M::CTRL) => {
|
E(C::Char('C'), M::CTRL) => {
|
||||||
log::debug!("[ViEx] Ctrl-C, clearing");
|
|
||||||
self.pending_cmd.clear();
|
self.pending_cmd.clear();
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
E(C::Esc, M::NONE) => {
|
E(C::Esc, M::NONE) => Ok(Some(ViCmd {
|
||||||
log::debug!("[ViEx] Esc, returning to normal mode");
|
register: RegisterName::default(),
|
||||||
Ok(Some(ViCmd {
|
verb: Some(VerbCmd(1, Verb::NormalMode)),
|
||||||
register: RegisterName::default(),
|
motion: None,
|
||||||
verb: Some(VerbCmd(1, Verb::NormalMode)),
|
flags: CmdFlags::empty(),
|
||||||
motion: None,
|
raw_seq: "".into(),
|
||||||
flags: CmdFlags::empty(),
|
})),
|
||||||
raw_seq: "".into(),
|
_ => self.pending_cmd.handle_key(key).map(|_| None),
|
||||||
}))
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::debug!("[ViEx] forwarding key to ExEditor");
|
|
||||||
self.pending_cmd.handle_key(key).map(|_| None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> {
|
fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> {
|
||||||
let result = self.handle_key_fallible(key);
|
let result = self.handle_key_fallible(key);
|
||||||
log::debug!("[ViEx] handle_key result: {:?}", result);
|
|
||||||
result.ok().flatten()
|
result.ok().flatten()
|
||||||
}
|
}
|
||||||
fn is_repeatable(&self) -> bool {
|
fn is_repeatable(&self) -> bool {
|
||||||
@@ -114,6 +154,14 @@ impl ViMode for ViEx {
|
|||||||
None
|
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 {
|
fn cursor_style(&self) -> String {
|
||||||
"\x1b[3 q".to_string()
|
"\x1b[3 q".to_string()
|
||||||
}
|
}
|
||||||
@@ -177,7 +225,7 @@ fn parse_ex_cmd(raw: &str) -> Result<Option<ViCmd>, Option<String>> {
|
|||||||
verb,
|
verb,
|
||||||
motion,
|
motion,
|
||||||
raw_seq: raw.to_string(),
|
raw_seq: raw.to_string(),
|
||||||
flags: CmdFlags::EXIT_CUR_MODE,
|
flags: CmdFlags::EXIT_CUR_MODE | CmdFlags::IS_EX_CMD,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +255,7 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
|
|||||||
let mut cmd_name = String::new();
|
let mut cmd_name = String::new();
|
||||||
|
|
||||||
while let Some(ch) = chars.peek() {
|
while let Some(ch) = chars.peek() {
|
||||||
if ch == &'!' {
|
if cmd_name.is_empty() && ch == &'!' {
|
||||||
cmd_name.push(*ch);
|
cmd_name.push(*ch);
|
||||||
chars.next();
|
chars.next();
|
||||||
break;
|
break;
|
||||||
@@ -224,12 +272,17 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
|
|||||||
let cmd = unescape_shell_cmd(&cmd);
|
let cmd = unescape_shell_cmd(&cmd);
|
||||||
Ok(Some(Verb::ShellCmd(cmd)))
|
Ok(Some(Verb::ShellCmd(cmd)))
|
||||||
}
|
}
|
||||||
|
_ if "help".starts_with(&cmd_name) => {
|
||||||
|
let cmd = "help ".to_string() + chars.collect::<String>().trim();
|
||||||
|
Ok(Some(Verb::ShellCmd(cmd)))
|
||||||
|
}
|
||||||
"normal!" => parse_normal(chars),
|
"normal!" => parse_normal(chars),
|
||||||
_ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)),
|
_ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)),
|
||||||
_ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)),
|
_ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)),
|
||||||
_ if "put".starts_with(&cmd_name) => Ok(Some(Verb::Put(Anchor::After))),
|
_ if "put".starts_with(&cmd_name) => Ok(Some(Verb::Put(Anchor::After))),
|
||||||
_ if "read".starts_with(&cmd_name) => parse_read(chars),
|
_ if "read".starts_with(&cmd_name) => parse_read(chars),
|
||||||
_ if "write".starts_with(&cmd_name) => parse_write(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),
|
_ if "substitute".starts_with(&cmd_name) => parse_substitute(chars),
|
||||||
_ => Err(None),
|
_ => Err(None),
|
||||||
}
|
}
|
||||||
@@ -244,6 +297,19 @@ fn parse_normal(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<
|
|||||||
Ok(Some(Verb::Normal(seq)))
|
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>> {
|
fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
|
||||||
chars
|
chars
|
||||||
.peeking_take_while(|c| c.is_whitespace())
|
.peeking_take_while(|c| c.is_whitespace())
|
||||||
@@ -266,23 +332,20 @@ fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<St
|
|||||||
if is_shell_read {
|
if is_shell_read {
|
||||||
Ok(Some(Verb::Read(ReadSrc::Cmd(arg))))
|
Ok(Some(Verb::Read(ReadSrc::Cmd(arg))))
|
||||||
} else {
|
} else {
|
||||||
let arg_path = get_path(arg.trim());
|
let arg_path = get_path(arg.trim())?;
|
||||||
Ok(Some(Verb::Read(ReadSrc::File(arg_path))))
|
Ok(Some(Verb::Read(ReadSrc::File(arg_path))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_path(path: &str) -> PathBuf {
|
fn get_path(path: &str) -> Result<PathBuf, Option<String>> {
|
||||||
if let Some(stripped) = path.strip_prefix("~/")
|
log::debug!("Expanding path: {}", path);
|
||||||
&& let Some(home) = std::env::var_os("HOME")
|
let expanded = Expander::from_raw(path, TkFlags::empty())
|
||||||
{
|
.map_err(|e| Some(format!("Error expanding path: {}", e)))?
|
||||||
return PathBuf::from(home).join(stripped);
|
.expand()
|
||||||
}
|
.map_err(|e| Some(format!("Error expanding path: {}", e)))?
|
||||||
if path == "~"
|
.join(" ");
|
||||||
&& let Some(home) = std::env::var_os("HOME")
|
log::debug!("Expanded path: {}", expanded);
|
||||||
{
|
Ok(PathBuf::from(&expanded))
|
||||||
return PathBuf::from(home);
|
|
||||||
}
|
|
||||||
PathBuf::from(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
|
fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
|
||||||
@@ -305,7 +368,7 @@ fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<S
|
|||||||
}
|
}
|
||||||
|
|
||||||
let arg: String = chars.collect();
|
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 {
|
let dest = if is_file_append {
|
||||||
WriteDest::FileAppend(arg_path)
|
WriteDest::FileAppend(arg_path)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ impl ViInsert {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
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 {
|
pub fn with_count(mut self, repeat_count: u16) -> Self {
|
||||||
self.repeat_count = repeat_count;
|
self.repeat_count = repeat_count;
|
||||||
self
|
self
|
||||||
@@ -61,6 +65,12 @@ impl ViMode for ViInsert {
|
|||||||
raw_seq: String::new(),
|
raw_seq: String::new(),
|
||||||
flags: Default::default(),
|
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::Char('W'), M::CTRL) => {
|
E(K::Char('W'), M::CTRL) => {
|
||||||
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
|
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
|
||||||
self.pending_cmd.set_motion(MotionCmd(
|
self.pending_cmd.set_motion(MotionCmd(
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ use std::fmt::Display;
|
|||||||
use unicode_segmentation::UnicodeSegmentation;
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
use crate::libsh::error::ShResult;
|
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::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};
|
use crate::readline::vicmd::{Motion, MotionCmd, To, Verb, VerbCmd, ViCmd};
|
||||||
|
|
||||||
pub mod ex;
|
pub mod ex;
|
||||||
@@ -82,6 +84,12 @@ pub trait ViMode {
|
|||||||
fn pending_cursor(&self) -> Option<usize> {
|
fn pending_cursor(&self) -> Option<usize> {
|
||||||
None
|
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 move_cursor_on_undo(&self) -> bool;
|
||||||
fn clamp_cursor(&self) -> bool;
|
fn clamp_cursor(&self) -> bool;
|
||||||
fn hist_scroll_start_pos(&self) -> Option<To>;
|
fn hist_scroll_start_pos(&self) -> Option<To>;
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ impl ViNormal {
|
|||||||
'g' => {
|
'g' => {
|
||||||
chars_clone.next();
|
chars_clone.next();
|
||||||
chars = chars_clone;
|
chars = chars_clone;
|
||||||
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer));
|
break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer));
|
||||||
}
|
}
|
||||||
'e' => {
|
'e' => {
|
||||||
chars = chars_clone;
|
chars = chars_clone;
|
||||||
@@ -450,26 +450,10 @@ impl ViNormal {
|
|||||||
Motion::WordMotion(To::End, Word::Big, Direction::Backward),
|
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;
|
chars = chars_clone;
|
||||||
break 'motion_parse Some(MotionCmd(count, Motion::EndOfLastWord));
|
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(),
|
_ => return self.quit_parse(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ impl ViVisual {
|
|||||||
let ch = chars_clone.next()?;
|
let ch = chars_clone.next()?;
|
||||||
return Some(ViCmd {
|
return Some(ViCmd {
|
||||||
register,
|
register,
|
||||||
verb: Some(VerbCmd(1, Verb::ReplaceChar(ch))),
|
verb: Some(VerbCmd(1, Verb::ReplaceCharInplace(ch, 1))),
|
||||||
motion: None,
|
motion: None,
|
||||||
raw_seq: self.take_cmd(),
|
raw_seq: self.take_cmd(),
|
||||||
flags: CmdFlags::empty(),
|
flags: CmdFlags::empty(),
|
||||||
@@ -237,6 +237,24 @@ impl ViVisual {
|
|||||||
flags: CmdFlags::empty(),
|
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' => {
|
'U' => {
|
||||||
return Some(ViCmd {
|
return Some(ViCmd {
|
||||||
register,
|
register,
|
||||||
@@ -283,8 +301,13 @@ impl ViVisual {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
'y' => {
|
'y' => {
|
||||||
chars = chars_clone;
|
return Some(ViCmd {
|
||||||
break 'verb_parse Some(VerbCmd(count, Verb::Yank));
|
register,
|
||||||
|
verb: Some(VerbCmd(count, Verb::Yank)),
|
||||||
|
motion: None,
|
||||||
|
raw_seq: self.take_cmd(),
|
||||||
|
flags: CmdFlags::empty(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
'd' => {
|
'd' => {
|
||||||
chars = chars_clone;
|
chars = chars_clone;
|
||||||
@@ -335,7 +358,7 @@ impl ViVisual {
|
|||||||
'g' => {
|
'g' => {
|
||||||
chars_clone.next();
|
chars_clone.next();
|
||||||
chars = chars_clone;
|
chars = chars_clone;
|
||||||
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer));
|
break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer));
|
||||||
}
|
}
|
||||||
'e' => {
|
'e' => {
|
||||||
chars_clone.next();
|
chars_clone.next();
|
||||||
@@ -353,16 +376,6 @@ impl ViVisual {
|
|||||||
Motion::WordMotion(To::End, Word::Big, Direction::Backward),
|
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(),
|
_ => return self.quit_parse(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
705
src/shopt.rs
705
src/shopt.rs
@@ -2,6 +2,35 @@ use std::{fmt::Display, str::FromStr};
|
|||||||
|
|
||||||
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
|
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)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub enum ShedBellStyle {
|
pub enum ShedBellStyle {
|
||||||
Audible,
|
Audible,
|
||||||
@@ -24,34 +53,97 @@ impl FromStr for ShedBellStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy, Debug)]
|
/// Generates a shopt group struct with `set`, `get`, `Display`, and `Default` impls.
|
||||||
pub enum ShedEditMode {
|
///
|
||||||
#[default]
|
/// Doc comments on each field become the description shown by `shopt get`.
|
||||||
Vi,
|
/// Every field type must implement `FromStr + Display`.
|
||||||
Emacs,
|
///
|
||||||
}
|
/// Optional per-field validation: `#[validate(|val| expr)]` runs after parsing
|
||||||
|
/// and must return `Result<(), String>` where the error string is the message.
|
||||||
impl FromStr for ShedEditMode {
|
macro_rules! shopt_group {
|
||||||
type Err = ShErr;
|
(
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
$(#[$struct_meta:meta])*
|
||||||
match s.to_ascii_lowercase().as_str() {
|
pub struct $name:ident ($group_name:literal) {
|
||||||
"vi" => Ok(Self::Vi),
|
$(
|
||||||
"emacs" => Ok(Self::Emacs),
|
$(#[doc = $desc:literal])*
|
||||||
_ => Err(ShErr::simple(
|
$(#[validate($validator:expr)])?
|
||||||
ShErrKind::SyntaxErr,
|
$field:ident : $ty:ty = $default:expr
|
||||||
format!("Invalid edit mode '{s}'"),
|
),* $(,)?
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
}
|
) => {
|
||||||
}
|
$(#[$struct_meta])*
|
||||||
|
pub struct $name {
|
||||||
impl Display for ShedEditMode {
|
$(pub $field: $ty,)*
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
ShedEditMode::Vi => write!(f, "vi"),
|
|
||||||
ShedEditMode::Emacs => write!(f, "emacs"),
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -82,8 +174,8 @@ impl ShOpts {
|
|||||||
|
|
||||||
pub fn display_opts(&mut self) -> ShResult<String> {
|
pub fn display_opts(&mut self) -> ShResult<String> {
|
||||||
let output = [
|
let output = [
|
||||||
format!("core:\n{}", self.query("core")?.unwrap_or_default()),
|
self.query("core")?.unwrap_or_default().to_string(),
|
||||||
format!("prompt:\n{}", self.query("prompt")?.unwrap_or_default()),
|
self.query("prompt")?.unwrap_or_default().to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
Ok(output.join("\n"))
|
Ok(output.join("\n"))
|
||||||
@@ -135,385 +227,202 @@ impl ShOpts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
shopt_group! {
|
||||||
pub struct ShOptCore {
|
#[derive(Clone, Debug)]
|
||||||
pub dotglob: bool,
|
pub struct ShOptCore ("core") {
|
||||||
pub autocd: bool,
|
/// Include hidden files in glob patterns
|
||||||
pub hist_ignore_dupes: bool,
|
dotglob: bool = false,
|
||||||
pub max_hist: isize,
|
|
||||||
pub interactive_comments: bool,
|
|
||||||
pub auto_hist: bool,
|
|
||||||
pub bell_enabled: bool,
|
|
||||||
pub max_recurse_depth: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ShOptCore {
|
/// Allow navigation to directories by passing the directory as a command directly
|
||||||
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
|
autocd: bool = false,
|
||||||
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}")));
|
|
||||||
}
|
|
||||||
|
|
||||||
match query {
|
/// Ignore consecutive duplicate command history entries
|
||||||
"dotglob" => {
|
hist_ignore_dupes: bool = true,
|
||||||
let mut output = String::from("Include hidden files in glob patterns\n");
|
|
||||||
output.push_str(&format!("{}", self.dotglob));
|
/// Maximum number of entries in the command history file (-1 for unlimited)
|
||||||
Ok(Some(output))
|
#[validate(|v: &isize| if *v < -1 {
|
||||||
}
|
Err("expected a non-negative integer or -1 for max_hist value".into())
|
||||||
"autocd" => {
|
} else {
|
||||||
let mut output = String::from(
|
Ok(())
|
||||||
"Allow navigation to directories by passing the directory as a command directly\n",
|
})]
|
||||||
);
|
max_hist: isize = 10_000,
|
||||||
output.push_str(&format!("{}", self.autocd));
|
|
||||||
Ok(Some(output))
|
/// Whether or not to allow comments in interactive mode
|
||||||
}
|
interactive_comments: bool = true,
|
||||||
"hist_ignore_dupes" => {
|
|
||||||
let mut output = String::from("Ignore consecutive duplicate command history entries\n");
|
/// Whether or not to automatically save commands to the command history file
|
||||||
output.push_str(&format!("{}", self.hist_ignore_dupes));
|
auto_hist: bool = true,
|
||||||
Ok(Some(output))
|
|
||||||
}
|
/// Whether or not to allow shed to trigger the terminal bell
|
||||||
"max_hist" => {
|
bell_enabled: bool = true,
|
||||||
let mut output = String::from(
|
|
||||||
"Maximum number of entries in the command history file (-1 for unlimited)\n",
|
/// Maximum limit of recursive shell function calls
|
||||||
);
|
max_recurse_depth: usize = 1000,
|
||||||
output.push_str(&format!("{}", self.max_hist));
|
|
||||||
Ok(Some(output))
|
/// Whether echo expands escape sequences by default
|
||||||
}
|
xpg_echo: bool = false,
|
||||||
"interactive_comments" => {
|
|
||||||
let mut output = String::from("Whether or not to allow comments in interactive mode\n");
|
/// Prevent > from overwriting existing files (use >| to override)
|
||||||
output.push_str(&format!("{}", self.interactive_comments));
|
noclobber: bool = false,
|
||||||
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}'"),
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for ShOptCore {
|
shopt_group! {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
#[derive(Clone, Debug)]
|
||||||
let mut output = vec![];
|
pub struct ShOptPrompt ("prompt") {
|
||||||
output.push(format!("dotglob = {}", self.dotglob));
|
/// Maximum number of path segments used in the '\W' prompt escape sequence
|
||||||
output.push(format!("autocd = {}", self.autocd));
|
trunc_prompt_path: usize = 4,
|
||||||
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));
|
|
||||||
|
|
||||||
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 {
|
#[cfg(test)]
|
||||||
fn default() -> Self {
|
mod tests {
|
||||||
ShOptCore {
|
use super::*;
|
||||||
dotglob: false,
|
|
||||||
autocd: false,
|
#[test]
|
||||||
hist_ignore_dupes: true,
|
fn all_core_fields_covered() {
|
||||||
max_hist: 10_000,
|
let ShOptCore {
|
||||||
interactive_comments: true,
|
dotglob,
|
||||||
auto_hist: true,
|
autocd,
|
||||||
bell_enabled: true,
|
hist_ignore_dupes,
|
||||||
max_recurse_depth: 1000,
|
max_hist,
|
||||||
}
|
interactive_comments,
|
||||||
}
|
auto_hist,
|
||||||
}
|
bell_enabled,
|
||||||
|
max_recurse_depth,
|
||||||
#[derive(Clone, Debug)]
|
xpg_echo,
|
||||||
pub struct ShOptPrompt {
|
noclobber,
|
||||||
pub trunc_prompt_path: usize,
|
} = ShOptCore::default();
|
||||||
pub edit_mode: ShedEditMode,
|
// If a field is added to the struct, this destructure fails to compile.
|
||||||
pub comp_limit: usize,
|
let _ = (
|
||||||
pub highlight: bool,
|
dotglob,
|
||||||
pub auto_indent: bool,
|
autocd,
|
||||||
pub linebreak_on_incomplete: bool,
|
hist_ignore_dupes,
|
||||||
pub leader: String,
|
max_hist,
|
||||||
pub line_numbers: bool,
|
interactive_comments,
|
||||||
}
|
auto_hist,
|
||||||
|
bell_enabled,
|
||||||
impl ShOptPrompt {
|
max_recurse_depth,
|
||||||
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
|
xpg_echo,
|
||||||
match opt {
|
noclobber,
|
||||||
"trunc_prompt_path" => {
|
);
|
||||||
let Ok(val) = val.parse::<usize>() else {
|
}
|
||||||
return Err(ShErr::simple(
|
|
||||||
ShErrKind::SyntaxErr,
|
#[test]
|
||||||
"shopt: expected a positive integer for trunc_prompt_path value",
|
fn set_and_get_core_bool() {
|
||||||
));
|
let mut opts = ShOpts::default();
|
||||||
};
|
assert!(!opts.core.dotglob);
|
||||||
self.trunc_prompt_path = val;
|
|
||||||
}
|
opts.set("core.dotglob", "true").unwrap();
|
||||||
"edit_mode" => {
|
assert!(opts.core.dotglob);
|
||||||
let Ok(val) = val.parse::<ShedEditMode>() else {
|
|
||||||
return Err(ShErr::simple(
|
opts.set("core.dotglob", "false").unwrap();
|
||||||
ShErrKind::SyntaxErr,
|
assert!(!opts.core.dotglob);
|
||||||
"shopt: expected 'vi' or 'emacs' for edit_mode value",
|
}
|
||||||
));
|
|
||||||
};
|
#[test]
|
||||||
self.edit_mode = val;
|
fn set_and_get_core_int() {
|
||||||
}
|
let mut opts = ShOpts::default();
|
||||||
"comp_limit" => {
|
assert_eq!(opts.core.max_hist, 10_000);
|
||||||
let Ok(val) = val.parse::<usize>() else {
|
|
||||||
return Err(ShErr::simple(
|
opts.set("core.max_hist", "500").unwrap();
|
||||||
ShErrKind::SyntaxErr,
|
assert_eq!(opts.core.max_hist, 500);
|
||||||
"shopt: expected a positive integer for comp_limit value",
|
|
||||||
));
|
opts.set("core.max_hist", "-1").unwrap();
|
||||||
};
|
assert_eq!(opts.core.max_hist, -1);
|
||||||
self.comp_limit = val;
|
|
||||||
}
|
assert!(opts.set("core.max_hist", "-500").is_err());
|
||||||
"highlight" => {
|
}
|
||||||
let Ok(val) = val.parse::<bool>() else {
|
|
||||||
return Err(ShErr::simple(
|
#[test]
|
||||||
ShErrKind::SyntaxErr,
|
fn set_and_get_prompt_opts() {
|
||||||
"shopt: expected 'true' or 'false' for highlight value",
|
let mut opts = ShOpts::default();
|
||||||
));
|
|
||||||
};
|
opts.set("prompt.comp_limit", "50").unwrap();
|
||||||
self.highlight = val;
|
assert_eq!(opts.prompt.comp_limit, 50);
|
||||||
}
|
|
||||||
"auto_indent" => {
|
opts.set("prompt.leader", "space").unwrap();
|
||||||
let Ok(val) = val.parse::<bool>() else {
|
assert_eq!(opts.prompt.leader, "space");
|
||||||
return Err(ShErr::simple(
|
}
|
||||||
ShErrKind::SyntaxErr,
|
|
||||||
"shopt: expected 'true' or 'false' for auto_indent value",
|
#[test]
|
||||||
));
|
fn query_set_returns_none() {
|
||||||
};
|
let mut opts = ShOpts::default();
|
||||||
self.auto_indent = val;
|
let result = opts.query("core.autocd=true").unwrap();
|
||||||
}
|
assert!(result.is_none());
|
||||||
"linebreak_on_incomplete" => {
|
assert!(opts.core.autocd);
|
||||||
let Ok(val) = val.parse::<bool>() else {
|
}
|
||||||
return Err(ShErr::simple(
|
|
||||||
ShErrKind::SyntaxErr,
|
#[test]
|
||||||
"shopt: expected 'true' or 'false' for linebreak_on_incomplete value",
|
fn query_get_returns_some() {
|
||||||
));
|
let opts = ShOpts::default();
|
||||||
};
|
let result = opts.get("core.dotglob").unwrap();
|
||||||
self.linebreak_on_incomplete = val;
|
assert!(result.is_some());
|
||||||
}
|
let text = result.unwrap();
|
||||||
"leader" => {
|
assert!(text.contains("false"));
|
||||||
self.leader = val.to_string();
|
}
|
||||||
}
|
|
||||||
"line_numbers" => {
|
#[test]
|
||||||
let Ok(val) = val.parse::<bool>() else {
|
fn invalid_category_errors() {
|
||||||
return Err(ShErr::simple(
|
let mut opts = ShOpts::default();
|
||||||
ShErrKind::SyntaxErr,
|
assert!(opts.set("bogus.dotglob", "true").is_err());
|
||||||
"shopt: expected 'true' or 'false' for line_numbers value",
|
assert!(opts.get("bogus.dotglob").is_err());
|
||||||
));
|
}
|
||||||
};
|
|
||||||
self.line_numbers = val;
|
#[test]
|
||||||
}
|
fn invalid_option_errors() {
|
||||||
"custom" => {
|
let mut opts = ShOpts::default();
|
||||||
todo!()
|
assert!(opts.set("core.nonexistent", "true").is_err());
|
||||||
}
|
assert!(opts.set("prompt.nonexistent", "true").is_err());
|
||||||
_ => {
|
}
|
||||||
return Err(ShErr::simple(
|
|
||||||
ShErrKind::SyntaxErr,
|
#[test]
|
||||||
format!("shopt: Unexpected 'prompt' option '{opt}'"),
|
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());
|
||||||
Ok(())
|
assert!(opts.set("core.max_recurse_depth", "-5").is_err());
|
||||||
}
|
assert!(opts.set("prompt.comp_limit", "abc").is_err());
|
||||||
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
|
}
|
||||||
if query.is_empty() {
|
|
||||||
return Ok(Some(format!("{self}")));
|
#[test]
|
||||||
}
|
fn get_category_lists_all() {
|
||||||
|
let opts = ShOpts::default();
|
||||||
match query {
|
let core_output = opts.get("core").unwrap().unwrap();
|
||||||
"trunc_prompt_path" => {
|
assert!(core_output.contains("dotglob"));
|
||||||
let mut output = String::from(
|
assert!(core_output.contains("autocd"));
|
||||||
"Maximum number of path segments used in the '\\W' prompt escape sequence\n",
|
assert!(core_output.contains("max_hist"));
|
||||||
);
|
assert!(core_output.contains("bell_enabled"));
|
||||||
output.push_str(&format!("{}", self.trunc_prompt_path));
|
|
||||||
Ok(Some(output))
|
let prompt_output = opts.get("prompt").unwrap().unwrap();
|
||||||
}
|
assert!(prompt_output.contains("comp_limit"));
|
||||||
"edit_mode" => {
|
assert!(prompt_output.contains("highlight"));
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{
|
use crate::{
|
||||||
builtin::trap::TrapTarget,
|
builtin::trap::TrapTarget,
|
||||||
jobs::{JobCmdFlags, JobID, take_term},
|
jobs::{Job, JobCmdFlags, JobID, take_term},
|
||||||
libsh::error::{ShErr, ShErrKind, ShResult},
|
libsh::error::{ShErr, ShErrKind, ShResult},
|
||||||
parse::execute::exec_input,
|
parse::execute::exec_input,
|
||||||
prelude::*,
|
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);
|
static SIGNALS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true);
|
pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||||
pub static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
|
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 JOB_DONE: AtomicBool = AtomicBool::new(false);
|
||||||
pub static QUIT_CODE: AtomicI32 = AtomicI32::new(0);
|
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::SIGILL,
|
||||||
Signal::SIGTRAP,
|
Signal::SIGTRAP,
|
||||||
Signal::SIGABRT,
|
Signal::SIGABRT,
|
||||||
Signal::SIGBUS,
|
Signal::SIGBUS,
|
||||||
Signal::SIGFPE,
|
Signal::SIGFPE,
|
||||||
Signal::SIGUSR1,
|
|
||||||
Signal::SIGSEGV,
|
Signal::SIGSEGV,
|
||||||
Signal::SIGUSR2,
|
Signal::SIGUSR2,
|
||||||
Signal::SIGPIPE,
|
Signal::SIGPIPE,
|
||||||
@@ -65,7 +79,7 @@ pub fn check_signals() -> ShResult<()> {
|
|||||||
if got_signal(Signal::SIGINT) {
|
if got_signal(Signal::SIGINT) {
|
||||||
interrupt()?;
|
interrupt()?;
|
||||||
run_trap(Signal::SIGINT)?;
|
run_trap(Signal::SIGINT)?;
|
||||||
return Err(ShErr::simple(ShErrKind::ClearReadline, ""));
|
return Err(ShErr::simple(ShErrKind::Interrupt, ""));
|
||||||
}
|
}
|
||||||
if got_signal(Signal::SIGHUP) {
|
if got_signal(Signal::SIGHUP) {
|
||||||
run_trap(Signal::SIGHUP)?;
|
run_trap(Signal::SIGHUP)?;
|
||||||
@@ -87,6 +101,10 @@ pub fn check_signals() -> ShResult<()> {
|
|||||||
GOT_SIGWINCH.store(true, Ordering::SeqCst);
|
GOT_SIGWINCH.store(true, Ordering::SeqCst);
|
||||||
run_trap(Signal::SIGWINCH)?;
|
run_trap(Signal::SIGWINCH)?;
|
||||||
}
|
}
|
||||||
|
if got_signal(Signal::SIGUSR1) {
|
||||||
|
GOT_SIGUSR1.store(true, Ordering::SeqCst);
|
||||||
|
run_trap(Signal::SIGUSR1)?;
|
||||||
|
}
|
||||||
|
|
||||||
for sig in MISC_SIGNALS {
|
for sig in MISC_SIGNALS {
|
||||||
if got_signal(sig) {
|
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
|
/// Called in child processes before exec so that the shell's custom
|
||||||
/// handlers and SIG_IGN dispositions don't leak into child programs.
|
/// 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());
|
let default = SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty());
|
||||||
unsafe {
|
unsafe {
|
||||||
for sig in Signal::iterator() {
|
for sig in Signal::iterator() {
|
||||||
@@ -165,6 +183,10 @@ pub fn reset_signals() {
|
|||||||
if sig == Signal::SIGKILL || sig == Signal::SIGSTOP {
|
if sig == Signal::SIGKILL || sig == Signal::SIGSTOP {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if is_fg && (sig == Signal::SIGTTIN || sig == Signal::SIGTTOU) {
|
||||||
|
log::debug!("Not resetting SIGTTIN/SIGTTOU in foreground child");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let _ = sigaction(sig, &default);
|
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());
|
let result = read_jobs(|j| j.query(JobID::Pgid(pgid)).cloned());
|
||||||
if let Some(job) = result {
|
if let Some(job) = result {
|
||||||
let job_complete_msg = job.display(&job_order, JobCmdFlags::PIDS).to_string();
|
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));
|
let post_job_hooks = read_logic(|l| l.get_autocmds(AutoCmdKind::OnJobFinish));
|
||||||
for cmd in post_job_hooks {
|
for cmd in post_job_hooks {
|
||||||
|
|||||||
265
src/state.rs
265
src/state.rs
@@ -8,7 +8,7 @@ use std::{
|
|||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use nix::unistd::{User, gethostname, getppid};
|
use nix::unistd::{User, gethostname, getppid, getuid};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -31,19 +31,27 @@ use crate::{
|
|||||||
},
|
},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
readline::{
|
readline::{
|
||||||
complete::{BashCompSpec, CompSpec},
|
complete::{BashCompSpec, Candidate, CompSpec},
|
||||||
keys::KeyEvent,
|
keys::KeyEvent,
|
||||||
markers,
|
markers,
|
||||||
},
|
},
|
||||||
shopt::ShOpts,
|
shopt::ShOpts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
pub static SHED: Shed = Shed::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub struct Shed {
|
pub struct Shed {
|
||||||
pub jobs: RefCell<JobTab>,
|
pub jobs: RefCell<JobTab>,
|
||||||
pub var_scopes: RefCell<ScopeStack>,
|
pub var_scopes: RefCell<ScopeStack>,
|
||||||
pub meta: RefCell<MetaTab>,
|
pub meta: RefCell<MetaTab>,
|
||||||
pub logic: RefCell<LogTab>,
|
pub logic: RefCell<LogTab>,
|
||||||
pub shopts: RefCell<ShOpts>,
|
pub shopts: RefCell<ShOpts>,
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
saved: RefCell<Option<Box<Self>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Shed {
|
impl Shed {
|
||||||
@@ -54,6 +62,9 @@ impl Shed {
|
|||||||
meta: RefCell::new(MetaTab::new()),
|
meta: RefCell::new(MetaTab::new()),
|
||||||
logic: RefCell::new(LogTab::new()),
|
logic: RefCell::new(LogTab::new()),
|
||||||
shopts: RefCell::new(ShOpts::default()),
|
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)]
|
#[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)]
|
||||||
pub enum ShellParam {
|
pub enum ShellParam {
|
||||||
// Global
|
// Global
|
||||||
@@ -165,7 +201,7 @@ impl ScopeStack {
|
|||||||
new
|
new
|
||||||
}
|
}
|
||||||
pub fn descend(&mut self, argv: Option<Vec<String>>) {
|
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 {
|
if let Some(argv) = argv {
|
||||||
for arg in argv {
|
for arg in argv {
|
||||||
new_vars.bpush_arg(arg);
|
new_vars.bpush_arg(arg);
|
||||||
@@ -285,6 +321,34 @@ impl ScopeStack {
|
|||||||
};
|
};
|
||||||
scope.set_var(var_name, val, flags)
|
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>> {
|
pub fn get_arr_elems(&self, var_name: &str) -> ShResult<Vec<String>> {
|
||||||
for scope in self.scopes.iter().rev() {
|
for scope in self.scopes.iter().rev() {
|
||||||
if scope.var_exists(var_name)
|
if scope.var_exists(var_name)
|
||||||
@@ -410,7 +474,9 @@ impl ScopeStack {
|
|||||||
pub fn try_get_var(&self, var_name: &str) -> Option<String> {
|
pub fn try_get_var(&self, var_name: &str) -> Option<String> {
|
||||||
// This version of get_var() is mainly used internally
|
// This version of get_var() is mainly used internally
|
||||||
// so that we have access to Option methods
|
// 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);
|
let val = self.get_param(param);
|
||||||
if !val.is_empty() {
|
if !val.is_empty() {
|
||||||
return Some(val);
|
return Some(val);
|
||||||
@@ -433,6 +499,9 @@ impl ScopeStack {
|
|||||||
var
|
var
|
||||||
}
|
}
|
||||||
pub fn get_var(&self, var_name: &str) -> String {
|
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>() {
|
if let Ok(param) = var_name.parse::<ShellParam>() {
|
||||||
return self.get_param(param);
|
return self.get_param(param);
|
||||||
}
|
}
|
||||||
@@ -464,6 +533,16 @@ impl ScopeStack {
|
|||||||
{
|
{
|
||||||
return val.clone();
|
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() {
|
for scope in self.scopes.iter().rev() {
|
||||||
let val = scope.get_param(param);
|
let val = scope.get_param(param);
|
||||||
if !val.is_empty() {
|
if !val.is_empty() {
|
||||||
@@ -491,10 +570,6 @@ impl ScopeStack {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
thread_local! {
|
|
||||||
pub static SHED: Shed = Shed::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ShAlias {
|
pub struct ShAlias {
|
||||||
pub body: String,
|
pub body: String,
|
||||||
@@ -920,18 +995,41 @@ impl Display for Var {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<String> for Var {
|
impl From<Vec<String>> for Var {
|
||||||
fn from(value: String) -> Self {
|
fn from(value: Vec<String>) -> Self {
|
||||||
Self::new(VarKind::Str(value), VarFlags::NONE)
|
Self::new(VarKind::Arr(value.into()), VarFlags::NONE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&str> for Var {
|
impl From<Vec<Candidate>> for Var {
|
||||||
fn from(value: &str) -> Self {
|
fn from(value: Vec<Candidate>) -> Self {
|
||||||
Self::new(VarKind::Str(value.into()), VarFlags::NONE)
|
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)]
|
#[derive(Default, Clone, Debug)]
|
||||||
pub struct VarTab {
|
pub struct VarTab {
|
||||||
vars: HashMap<String, Var>,
|
vars: HashMap<String, Var>,
|
||||||
@@ -942,8 +1040,16 @@ pub struct VarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn new() -> Self {
|
||||||
let vars = HashMap::new();
|
let vars = Self::init_sh_vars();
|
||||||
let params = Self::init_params();
|
let params = Self::init_params();
|
||||||
Self::init_env();
|
Self::init_env();
|
||||||
let mut var_tab = Self {
|
let mut var_tab = Self {
|
||||||
@@ -962,6 +1068,11 @@ impl VarTab {
|
|||||||
params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any)
|
params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any)
|
||||||
params
|
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() {
|
fn init_env() {
|
||||||
let pathbuf_to_string =
|
let pathbuf_to_string =
|
||||||
|pb: Result<PathBuf, std::io::Error>| pb.unwrap_or_default().to_string_lossy().to_string();
|
|pb: Result<PathBuf, std::io::Error>| pb.unwrap_or_default().to_string_lossy().to_string();
|
||||||
@@ -994,6 +1105,8 @@ impl VarTab {
|
|||||||
.map(|hname| hname.to_string_lossy().to_string())
|
.map(|hname| hname.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let help_paths = format!("/usr/share/shed/doc:{home}/.local/share/shed/doc");
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
env::set_var("IFS", " \t\n");
|
env::set_var("IFS", " \t\n");
|
||||||
env::set_var("HOST", hostname.clone());
|
env::set_var("HOST", hostname.clone());
|
||||||
@@ -1010,6 +1123,7 @@ impl VarTab {
|
|||||||
env::set_var("SHELL", pathbuf_to_string(std::env::current_exe()));
|
env::set_var("SHELL", pathbuf_to_string(std::env::current_exe()));
|
||||||
env::set_var("SHED_HIST", format!("{}/.shedhist", home));
|
env::set_var("SHED_HIST", format!("{}/.shedhist", home));
|
||||||
env::set_var("SHED_RC", format!("{}/.shedrc", home));
|
env::set_var("SHED_RC", format!("{}/.shedrc", home));
|
||||||
|
env::set_var("SHED_HPATH", help_paths);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn init_sh_argv(&mut self) {
|
pub fn init_sh_argv(&mut self) {
|
||||||
@@ -1226,6 +1340,15 @@ impl VarTab {
|
|||||||
.get(&ShellParam::Status)
|
.get(&ShellParam::Status)
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or("0".into()),
|
.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
|
_ => self
|
||||||
.params
|
.params
|
||||||
.get(¶m)
|
.get(¶m)
|
||||||
@@ -1236,8 +1359,11 @@ impl VarTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A table of metadata for the shell
|
/// A table of metadata for the shell
|
||||||
#[derive(Default, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct MetaTab {
|
pub struct MetaTab {
|
||||||
|
// Time when the shell was started, used for calculating shell uptime
|
||||||
|
shell_time: Instant,
|
||||||
|
|
||||||
// command running duration
|
// command running duration
|
||||||
runtime_start: Option<Instant>,
|
runtime_start: Option<Instant>,
|
||||||
runtime_stop: Option<Instant>,
|
runtime_stop: Option<Instant>,
|
||||||
@@ -1262,6 +1388,25 @@ pub struct MetaTab {
|
|||||||
pending_widget_keys: Vec<KeyEvent>,
|
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: vec![],
|
||||||
|
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 {
|
impl MetaTab {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -1269,6 +1414,9 @@ impl MetaTab {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn shell_time(&self) -> Instant {
|
||||||
|
self.shell_time
|
||||||
|
}
|
||||||
pub fn set_pending_widget_keys(&mut self, keys: &str) {
|
pub fn set_pending_widget_keys(&mut self, keys: &str) {
|
||||||
let exp = expand_keymap(keys);
|
let exp = expand_keymap(keys);
|
||||||
self.pending_widget_keys = exp;
|
self.pending_widget_keys = exp;
|
||||||
@@ -1713,6 +1861,15 @@ pub fn change_dir<P: AsRef<Path>>(dir: P) -> ShResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_separator() -> String {
|
||||||
|
env::var("IFS")
|
||||||
|
.unwrap_or(String::from(" "))
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_status() -> i32 {
|
pub fn get_status() -> i32 {
|
||||||
read_vars(|v| v.get_param(ShellParam::Status))
|
read_vars(|v| v.get_param(ShellParam::Status))
|
||||||
.parse::<i32>()
|
.parse::<i32>()
|
||||||
@@ -1722,19 +1879,44 @@ pub fn set_status(code: i32) {
|
|||||||
write_vars(|v| v.set_param(ShellParam::Status, &code.to_string()))
|
write_vars(|v| v.set_param(ShellParam::Status, &code.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn source_rc() -> ShResult<()> {
|
pub fn source_runtime_file(name: &str, env_var_name: Option<&str>) -> ShResult<()> {
|
||||||
let path = if let Ok(path) = env::var("SHED_RC") {
|
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)
|
PathBuf::from(&path)
|
||||||
|
} else if let Some(home) = get_home() {
|
||||||
|
home.join(format!(".{name}"))
|
||||||
} else {
|
} else {
|
||||||
let home = env::var("HOME").unwrap();
|
return Err(ShErr::simple(
|
||||||
PathBuf::from(format!("{home}/.shedrc"))
|
ShErrKind::InternalErr,
|
||||||
|
"could not determine home path",
|
||||||
|
));
|
||||||
};
|
};
|
||||||
if !path.exists() {
|
if !path.is_file() {
|
||||||
return Err(ShErr::simple(ShErrKind::InternalErr, ".shedrc not found"));
|
return Ok(());
|
||||||
}
|
}
|
||||||
source_file(path)
|
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<()> {
|
pub fn source_file(path: PathBuf) -> ShResult<()> {
|
||||||
let source_name = path.to_string_lossy().to_string();
|
let source_name = path.to_string_lossy().to_string();
|
||||||
let mut file = OpenOptions::new().read(true).open(path)?;
|
let mut file = OpenOptions::new().read(true).open(path)?;
|
||||||
@@ -1744,3 +1926,42 @@ pub fn source_file(path: PathBuf) -> ShResult<()> {
|
|||||||
exec_input(buf, None, false, Some(source_name))?;
|
exec_input(buf, None, false, Some(source_name))?;
|
||||||
Ok(())
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,767 +0,0 @@
|
|||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
use crate::prompt::readline::complete::Completer;
|
|
||||||
use crate::prompt::readline::markers;
|
|
||||||
use crate::state::{VarFlags, write_logic, write_vars};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Helper to create a temp directory with test files
|
|
||||||
fn setup_test_files() -> TempDir {
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
// Create some test files and directories
|
|
||||||
fs::write(path.join("file1.txt"), "").unwrap();
|
|
||||||
fs::write(path.join("file2.txt"), "").unwrap();
|
|
||||||
fs::write(path.join("script.sh"), "").unwrap();
|
|
||||||
fs::create_dir(path.join("subdir")).unwrap();
|
|
||||||
fs::write(path.join("subdir/nested.txt"), "").unwrap();
|
|
||||||
fs::create_dir(path.join("another_dir")).unwrap();
|
|
||||||
|
|
||||||
temp_dir
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to create a test directory in current dir for relative path tests
|
|
||||||
fn setup_local_test_files() -> TempDir {
|
|
||||||
let temp_dir = tempfile::tempdir_in(".").unwrap();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
fs::write(path.join("local1.txt"), "").unwrap();
|
|
||||||
fs::write(path.join("local2.txt"), "").unwrap();
|
|
||||||
fs::create_dir(path.join("localdir")).unwrap();
|
|
||||||
|
|
||||||
temp_dir
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Command Completion Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_command_from_path() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
|
|
||||||
// Try to complete "ec" - should find "echo" (which is in PATH)
|
|
||||||
let line = "ec".to_string();
|
|
||||||
let cursor_pos = 2;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
let completed = result.unwrap();
|
|
||||||
|
|
||||||
// Should have found something
|
|
||||||
assert!(completed.is_some());
|
|
||||||
let completed_line = completed.unwrap();
|
|
||||||
|
|
||||||
// Should contain "echo"
|
|
||||||
assert!(completed_line.starts_with("echo") || completer.candidates.iter().any(|c| c == "echo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_command_builtin() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
|
|
||||||
// Try to complete "ex" - should find "export" builtin
|
|
||||||
let line = "ex".to_string();
|
|
||||||
let cursor_pos = 2;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Check candidates include "export"
|
|
||||||
assert!(completer.candidates.iter().any(|c| c == "export"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Disabled - ShFunc constructor requires parsed AST which is complex to
|
|
||||||
// set up in tests TODO: Re-enable once we have a helper to create test
|
|
||||||
// functions
|
|
||||||
/*
|
|
||||||
#[test]
|
|
||||||
fn complete_command_function() {
|
|
||||||
write_logic(|l| {
|
|
||||||
// Add a test function - would need to parse "test_func() { echo test; }"
|
|
||||||
// and create proper ShFunc from it
|
|
||||||
// let func = ...;
|
|
||||||
// l.insert_func("test_func", func);
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "test_f".to_string();
|
|
||||||
let cursor_pos = 6;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Should find test_func
|
|
||||||
assert!(completer.candidates.iter().any(|c| c == "test_func"));
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
l.clear_functions();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_command_alias() {
|
|
||||||
// Add alias outside of completion call to avoid RefCell borrow conflict
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("ll", "ls -la");
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "l".to_string();
|
|
||||||
let cursor_pos = 1;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Should find ll and ls
|
|
||||||
assert!(completer.candidates.iter().any(|c| c == "ll"));
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
write_logic(|l| {
|
|
||||||
l.clear_aliases();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_command_no_matches() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
|
|
||||||
// Try to complete something that definitely doesn't exist
|
|
||||||
let line = "xyzabc123notacommand".to_string();
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
// Should return None when no matches
|
|
||||||
assert!(result.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Filename Completion Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_basic() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/fil", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Should have file1.txt and file2.txt as candidates
|
|
||||||
assert!(completer.candidates.len() >= 2);
|
|
||||||
assert!(completer.candidates.iter().any(|c| c.contains("file1.txt")));
|
|
||||||
assert!(completer.candidates.iter().any(|c| c.contains("file2.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_directory() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cd {}/sub", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Should find "subdir"
|
|
||||||
assert!(completer.candidates.iter().any(|c| c.contains("subdir")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_with_slash() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("ls {}/subdir/", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
// Should complete files in subdir/
|
|
||||||
if result.is_some() {
|
|
||||||
assert!(
|
|
||||||
completer
|
|
||||||
.candidates
|
|
||||||
.iter()
|
|
||||||
.any(|c| c.contains("nested.txt"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_preserves_trailing_slash() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cd {}/sub", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
let completed = result.unwrap();
|
|
||||||
// Directory completions should have trailing slash
|
|
||||||
assert!(completed.ends_with('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_relative_path() {
|
|
||||||
let _temp_dir = setup_local_test_files();
|
|
||||||
let dir_name = _temp_dir.path().file_name().unwrap().to_str().unwrap();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/local", dir_name);
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
if result.is_some() {
|
|
||||||
// Should find local1.txt and local2.txt
|
|
||||||
assert!(completer.candidates.len() >= 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_current_dir() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
|
|
||||||
// Complete files in current directory
|
|
||||||
let line = "cat ".to_string();
|
|
||||||
let cursor_pos = 4;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
// Should find something in current dir (at least Cargo.toml should exist)
|
|
||||||
if result.is_some() {
|
|
||||||
assert!(!completer.candidates.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_filename_with_dot_slash() {
|
|
||||||
let _temp_dir = setup_local_test_files();
|
|
||||||
let dir_name = _temp_dir.path().file_name().unwrap().to_str().unwrap();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("./{}/local", dir_name);
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
|
|
||||||
// Should preserve the ./
|
|
||||||
if let Ok(Some(completed)) = result {
|
|
||||||
assert!(completed.starts_with("./"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Completion After '=' Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_after_equals_assignment() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("FOO={}/fil", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Should complete filenames after =
|
|
||||||
assert!(completer.candidates.iter().any(|c| c.contains("file")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_after_equals_option() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("command --output={}/fil", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
|
|
||||||
// Should complete filenames after = in option
|
|
||||||
assert!(completer.candidates.iter().any(|c| c.contains("file")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_after_equals_empty() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "FOO=".to_string();
|
|
||||||
let cursor_pos = 4;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
// Should complete files in current directory when path is empty after =
|
|
||||||
if result.is_some() {
|
|
||||||
assert!(!completer.candidates.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Context Detection Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_command_position() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// At the beginning - command context
|
|
||||||
let (ctx, _) = completer.get_completion_context("ech", 3);
|
|
||||||
assert!(
|
|
||||||
ctx.last() == Some(&markers::COMMAND),
|
|
||||||
"Should be in command context at start"
|
|
||||||
);
|
|
||||||
|
|
||||||
// After whitespace - still command if no command yet
|
|
||||||
let (ctx, _) = completer.get_completion_context(" ech", 5);
|
|
||||||
assert!(
|
|
||||||
ctx.last() == Some(&markers::COMMAND),
|
|
||||||
"Should be in command context after whitespace"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_argument_position() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// After a complete command - argument context
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo hello", 10);
|
|
||||||
assert!(
|
|
||||||
ctx.last() != Some(&markers::COMMAND),
|
|
||||||
"Should be in argument context after command"
|
|
||||||
);
|
|
||||||
|
|
||||||
let (ctx, _) = completer.get_completion_context("ls -la /tmp", 11);
|
|
||||||
assert!(
|
|
||||||
ctx.last() != Some(&markers::COMMAND),
|
|
||||||
"Should be in argument context"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_nested_command_sub() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// Inside $() - should be command context
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo \"$(ech", 11);
|
|
||||||
assert!(
|
|
||||||
ctx.last() == Some(&markers::COMMAND),
|
|
||||||
"Should be in command context inside $()"
|
|
||||||
);
|
|
||||||
|
|
||||||
// After command in $() - argument context
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo \"$(echo hell", 17);
|
|
||||||
assert!(
|
|
||||||
ctx.last() != Some(&markers::COMMAND),
|
|
||||||
"Should be in argument context inside $()"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_pipe() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// After pipe - command context
|
|
||||||
let (ctx, _) = completer.get_completion_context("ls | gre", 8);
|
|
||||||
assert!(
|
|
||||||
ctx.last() == Some(&markers::COMMAND),
|
|
||||||
"Should be in command context after pipe"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_command_sep() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// After semicolon - command context
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo foo; l", 11);
|
|
||||||
assert!(
|
|
||||||
ctx.last() == Some(&markers::COMMAND),
|
|
||||||
"Should be in command context after semicolon"
|
|
||||||
);
|
|
||||||
|
|
||||||
// After && - command context
|
|
||||||
let (ctx, _) = completer.get_completion_context("true && l", 9);
|
|
||||||
assert!(
|
|
||||||
ctx.last() == Some(&markers::COMMAND),
|
|
||||||
"Should be in command context after &&"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_variable_substitution() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// $VAR at argument position - VAR_SUB should take priority over ARG
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo $HOM", 9);
|
|
||||||
assert_eq!(
|
|
||||||
ctx.last(),
|
|
||||||
Some(&markers::VAR_SUB),
|
|
||||||
"Should be in var_sub context for $HOM"
|
|
||||||
);
|
|
||||||
|
|
||||||
// $VAR at command position - VAR_SUB should take priority over COMMAND
|
|
||||||
let (ctx, _) = completer.get_completion_context("$HOM", 4);
|
|
||||||
assert_eq!(
|
|
||||||
ctx.last(),
|
|
||||||
Some(&markers::VAR_SUB),
|
|
||||||
"Should be in var_sub context for bare $HOM"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_variable_in_double_quotes() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// $VAR inside double quotes
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo \"$HOM", 10);
|
|
||||||
assert_eq!(
|
|
||||||
ctx.last(),
|
|
||||||
Some(&markers::VAR_SUB),
|
|
||||||
"Should be in var_sub context inside double quotes"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_stack_base_is_null() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// Empty input - only NULL on the stack
|
|
||||||
let (ctx, _) = completer.get_completion_context("", 0);
|
|
||||||
assert_eq!(
|
|
||||||
ctx,
|
|
||||||
vec![markers::NULL],
|
|
||||||
"Empty input should only have NULL marker"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_context_start_position() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// Command at start - ctx_start should be 0
|
|
||||||
let (_, ctx_start) = completer.get_completion_context("ech", 3);
|
|
||||||
assert_eq!(ctx_start, 0, "Command at start should have ctx_start=0");
|
|
||||||
|
|
||||||
// Argument after command - ctx_start should be at arg position
|
|
||||||
let (_, ctx_start) = completer.get_completion_context("echo hel", 8);
|
|
||||||
assert_eq!(ctx_start, 5, "Argument ctx_start should point to arg start");
|
|
||||||
|
|
||||||
// Variable sub - ctx_start should point to the $
|
|
||||||
let (_, ctx_start) = completer.get_completion_context("echo $HOM", 9);
|
|
||||||
assert_eq!(ctx_start, 5, "Var sub ctx_start should point to the $");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn context_detection_priority_ordering() {
|
|
||||||
let completer = Completer::new();
|
|
||||||
|
|
||||||
// COMMAND (priority 2) should override ARG (priority 1)
|
|
||||||
// After a pipe, the next token is a command even though it looks like an arg
|
|
||||||
let (ctx, _) = completer.get_completion_context("echo foo | gr", 13);
|
|
||||||
assert_eq!(
|
|
||||||
ctx.last(),
|
|
||||||
Some(&markers::COMMAND),
|
|
||||||
"COMMAND should win over ARG after pipe"
|
|
||||||
);
|
|
||||||
|
|
||||||
// VAR_SUB (priority 3) should override COMMAND (priority 2)
|
|
||||||
let (ctx, _) = completer.get_completion_context("$PA", 3);
|
|
||||||
assert_eq!(
|
|
||||||
ctx.last(),
|
|
||||||
Some(&markers::VAR_SUB),
|
|
||||||
"VAR_SUB should win over COMMAND"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Cycling Behavior Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_forward_through_candidates() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/file", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
// First tab
|
|
||||||
let result1 = completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
assert!(result1.is_some());
|
|
||||||
let first_candidate = completer.selected_candidate().unwrap().clone();
|
|
||||||
|
|
||||||
// Second tab - should cycle to next
|
|
||||||
let result2 = completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
assert!(result2.is_some());
|
|
||||||
let second_candidate = completer.selected_candidate().unwrap().clone();
|
|
||||||
|
|
||||||
// Should be different (if there are multiple candidates)
|
|
||||||
if completer.candidates.len() > 1 {
|
|
||||||
assert_ne!(first_candidate, second_candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_backward_with_shift_tab() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/file", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
// Forward twice
|
|
||||||
completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
let after_first = completer.selected_idx;
|
|
||||||
completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
// Backward once (shift-tab = direction -1)
|
|
||||||
completer.complete(line.clone(), cursor_pos, -1).unwrap();
|
|
||||||
let after_backward = completer.selected_idx;
|
|
||||||
|
|
||||||
// Should be back to first selection
|
|
||||||
assert_eq!(after_first, after_backward);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_wraps_around() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
// Get all candidates
|
|
||||||
completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
let num_candidates = completer.candidates.len();
|
|
||||||
|
|
||||||
if num_candidates > 1 {
|
|
||||||
// Cycle through all and one more
|
|
||||||
for _ in 0..num_candidates {
|
|
||||||
completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should wrap back to first (index 0)
|
|
||||||
assert_eq!(completer.selected_idx, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cycle_reset_on_input_change() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line1 = format!("cat {}/file", path.display());
|
|
||||||
|
|
||||||
// Complete once
|
|
||||||
completer.complete(line1.clone(), line1.len(), 1).unwrap();
|
|
||||||
let candidates_count = completer.candidates.len();
|
|
||||||
|
|
||||||
// Change input
|
|
||||||
let line2 = format!("cat {}/script", path.display());
|
|
||||||
completer.complete(line2.clone(), line2.len(), 1).unwrap();
|
|
||||||
|
|
||||||
// Should have different candidates
|
|
||||||
// (or at least should have reset the completer state)
|
|
||||||
assert!(completer.active);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reset_clears_state() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
// Use a prefix that will definitely have completions
|
|
||||||
let line = "ec".to_string();
|
|
||||||
|
|
||||||
let result = completer.complete(line, 2, 1).unwrap();
|
|
||||||
// Only check if we got completions
|
|
||||||
if result.is_some() {
|
|
||||||
// Should have candidates after completion
|
|
||||||
assert!(!completer.candidates.is_empty());
|
|
||||||
|
|
||||||
completer.reset();
|
|
||||||
|
|
||||||
// After reset, state should be cleared
|
|
||||||
assert!(!completer.active);
|
|
||||||
assert!(completer.candidates.is_empty());
|
|
||||||
assert_eq!(completer.selected_idx, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Edge Cases Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_empty_input() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "".to_string();
|
|
||||||
let cursor_pos = 0;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
// Empty input might return files in current dir or no completion
|
|
||||||
// Either is valid behavior
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_whitespace_only() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = " ".to_string();
|
|
||||||
let cursor_pos = 3;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
// Should handle gracefully
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_at_middle_of_word() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "echo hello world".to_string();
|
|
||||||
let cursor_pos = 7; // In the middle of "hello"
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
// Should handle cursor in middle of word
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_with_quotes() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat \"{}/fil", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
|
|
||||||
// Should handle quoted paths
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_incomplete_command_substitution() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "echo \"$(ech".to_string();
|
|
||||||
let cursor_pos = 11;
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
|
|
||||||
// Should not crash on incomplete command sub
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_with_multiple_spaces() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = "echo hello world".to_string();
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_special_characters_in_filename() {
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
// Create files with special characters
|
|
||||||
fs::write(path.join("file-with-dash.txt"), "").unwrap();
|
|
||||||
fs::write(path.join("file_with_underscore.txt"), "").unwrap();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/file", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
let result = completer.complete(line, cursor_pos, 1).unwrap();
|
|
||||||
|
|
||||||
if result.is_some() {
|
|
||||||
// Should handle special chars in filenames
|
|
||||||
assert!(
|
|
||||||
completer
|
|
||||||
.candidates
|
|
||||||
.iter()
|
|
||||||
.any(|c| c.contains("file-with-dash") || c.contains("file_with_underscore"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Integration Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_full_workflow() {
|
|
||||||
let temp_dir = setup_test_files();
|
|
||||||
let path = temp_dir.path();
|
|
||||||
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
let line = format!("cat {}/fil", path.display());
|
|
||||||
let cursor_pos = line.len();
|
|
||||||
|
|
||||||
// Tab 1: Get first completion
|
|
||||||
let result = completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
let completion1 = result.unwrap();
|
|
||||||
assert!(completion1.contains("file"));
|
|
||||||
|
|
||||||
// Tab 2: Cycle to next
|
|
||||||
let result = completer.complete(line.clone(), cursor_pos, 1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
let completion2 = result.unwrap();
|
|
||||||
|
|
||||||
// Shift-Tab: Go back
|
|
||||||
let result = completer.complete(line.clone(), cursor_pos, -1).unwrap();
|
|
||||||
assert!(result.is_some());
|
|
||||||
let completion3 = result.unwrap();
|
|
||||||
|
|
||||||
// Should be back to first
|
|
||||||
assert_eq!(completion1, completion3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_mixed_command_and_file() {
|
|
||||||
let mut completer = Completer::new();
|
|
||||||
|
|
||||||
// First part: command completion
|
|
||||||
let line1 = "ech".to_string();
|
|
||||||
let result1 = completer.complete(line1, 3, 1).unwrap();
|
|
||||||
assert!(result1.is_some());
|
|
||||||
|
|
||||||
// Reset for new completion
|
|
||||||
completer.reset();
|
|
||||||
|
|
||||||
// Second part: file completion
|
|
||||||
let line2 = "echo Cargo.tom".to_string();
|
|
||||||
let result2 = completer.complete(line2, 14, 1).unwrap();
|
|
||||||
|
|
||||||
// Both should work
|
|
||||||
assert!(result1.is_some());
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cmd_not_found() {
|
|
||||||
let input = "foo";
|
|
||||||
let token = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.next()
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let err = ShErr::at(ShErrKind::CmdNotFound, token.span, "");
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unclosed_subsh() {
|
|
||||||
let input = "(foo";
|
|
||||||
let token = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.nth(1)
|
|
||||||
.unwrap();
|
|
||||||
let Err(err) = token else {
|
|
||||||
panic!("{:?}", token);
|
|
||||||
};
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unclosed_dquote() {
|
|
||||||
let input = "\"foo bar";
|
|
||||||
let token = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.nth(1)
|
|
||||||
.unwrap();
|
|
||||||
let Err(err) = token else {
|
|
||||||
panic!();
|
|
||||||
};
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unclosed_squote() {
|
|
||||||
let input = "'foo bar";
|
|
||||||
let token = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.nth(1)
|
|
||||||
.unwrap();
|
|
||||||
let Err(err) = token else {
|
|
||||||
panic!();
|
|
||||||
};
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unclosed_brc_grp() {
|
|
||||||
let input = "{ foo bar";
|
|
||||||
let tokens =
|
|
||||||
LexStream::new(Arc::new(input.into()), LexFlags::empty()).collect::<ShResult<Vec<_>>>();
|
|
||||||
|
|
||||||
let Err(err) = tokens else {
|
|
||||||
panic!("Expected an error, got {:?}", tokens);
|
|
||||||
};
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn if_no_fi() {
|
|
||||||
let input = "if foo; then bar;";
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let node = ParseStream::new(tokens).next().unwrap();
|
|
||||||
let Err(e) = node else { panic!() };
|
|
||||||
|
|
||||||
let err_fmt = format!("{e}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn if_no_then() {
|
|
||||||
let input = "if foo; bar; fi";
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let node = ParseStream::new(tokens).next().unwrap();
|
|
||||||
let Err(e) = node else { panic!() };
|
|
||||||
|
|
||||||
let err_fmt = format!("{e}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loop_no_done() {
|
|
||||||
let input = "while true; do echo foo;";
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let node = ParseStream::new(tokens).next().unwrap();
|
|
||||||
let Err(e) = node else { panic!() };
|
|
||||||
|
|
||||||
let err_fmt = format!("{e}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn loop_no_do() {
|
|
||||||
let input = "while true; echo foo; done";
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let node = ParseStream::new(tokens).next().unwrap();
|
|
||||||
let Err(e) = node else { panic!() };
|
|
||||||
|
|
||||||
let err_fmt = format!("{e}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn case_no_esac() {
|
|
||||||
let input = "case foo in foo) bar;; bar) foo;;";
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let node = ParseStream::new(tokens).next().unwrap();
|
|
||||||
let Err(e) = node else { panic!() };
|
|
||||||
|
|
||||||
let err_fmt = format!("{e}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn case_no_in() {
|
|
||||||
let input = "case foo foo) bar;; bar) foo;; esac";
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let node = ParseStream::new(tokens).next().unwrap();
|
|
||||||
let Err(e) = node else { panic!() };
|
|
||||||
|
|
||||||
let err_fmt = format!("{e}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_with_notes() {
|
|
||||||
let err = ShErr::simple(ShErrKind::ExecFail, "Execution failed")
|
|
||||||
.with_note(Note::new("Execution failed for this reason"))
|
|
||||||
.with_note(Note::new("Here is how to fix it: blah blah blah"));
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_with_notes_and_sub_notes() {
|
|
||||||
let err = ShErr::simple(ShErrKind::ExecFail, "Execution failed")
|
|
||||||
.with_note(Note::new("Execution failed for this reason"))
|
|
||||||
.with_note(Note::new("Here is how to fix it:").with_sub_notes(vec!["blah", "blah", "blah"]));
|
|
||||||
|
|
||||||
let err_fmt = format!("{err}");
|
|
||||||
insta::assert_snapshot!(err_fmt)
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use crate::expand::perform_param_expansion;
|
|
||||||
use crate::prompt::readline::markers;
|
|
||||||
use crate::state::{VarFlags, VarKind};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn simple_expansion() {
|
|
||||||
let varsub = "$foo";
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var(
|
|
||||||
"foo",
|
|
||||||
VarKind::Str("this is the value of the variable".into()),
|
|
||||||
VarFlags::NONE,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut tokens: Vec<Tk> = LexStream::new(Arc::new(varsub.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.filter(|tk| !matches!(tk.class, TkRule::EOI | TkRule::SOI))
|
|
||||||
.collect();
|
|
||||||
let var_tk = tokens.pop().unwrap();
|
|
||||||
|
|
||||||
let exp_tk = var_tk.expand().unwrap();
|
|
||||||
insta::assert_debug_snapshot!(exp_tk.get_words())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unescape_string() {
|
|
||||||
let string = "echo $foo \\$bar";
|
|
||||||
let unescaped = unescape_str(string);
|
|
||||||
|
|
||||||
insta::assert_snapshot!(unescaped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_alias_simple() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "echo foo");
|
|
||||||
let input = String::from("foo");
|
|
||||||
|
|
||||||
let result = expand_aliases(input, HashSet::new(), l);
|
|
||||||
assert_eq!(result.as_str(), "echo foo");
|
|
||||||
l.clear_aliases();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_alias_in_if() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "echo foo");
|
|
||||||
let input = String::from("if foo; then echo bar; fi");
|
|
||||||
|
|
||||||
let result = expand_aliases(input, HashSet::new(), l);
|
|
||||||
assert_eq!(result.as_str(), "if echo foo; then echo bar; fi");
|
|
||||||
l.clear_aliases();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_alias_multiline() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "echo foo");
|
|
||||||
l.insert_alias("bar", "echo bar");
|
|
||||||
let input = String::from(
|
|
||||||
"
|
|
||||||
foo
|
|
||||||
if true; then
|
|
||||||
bar
|
|
||||||
fi
|
|
||||||
",
|
|
||||||
);
|
|
||||||
let expected = String::from(
|
|
||||||
"
|
|
||||||
echo foo
|
|
||||||
if true; then
|
|
||||||
echo bar
|
|
||||||
fi
|
|
||||||
",
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = expand_aliases(input, HashSet::new(), l);
|
|
||||||
assert_eq!(result, expected)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_multiple_aliases() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "echo foo");
|
|
||||||
l.insert_alias("bar", "echo bar");
|
|
||||||
l.insert_alias("biz", "echo biz");
|
|
||||||
let input = String::from("foo; bar; biz");
|
|
||||||
|
|
||||||
let result = expand_aliases(input, HashSet::new(), l);
|
|
||||||
assert_eq!(result.as_str(), "echo foo; echo bar; echo biz");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn alias_in_arg_position() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "echo foo");
|
|
||||||
let input = String::from("echo foo");
|
|
||||||
|
|
||||||
let result = expand_aliases(input.clone(), HashSet::new(), l);
|
|
||||||
assert_eq!(input, result);
|
|
||||||
l.clear_aliases();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_recursive_alias() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "echo foo");
|
|
||||||
l.insert_alias("bar", "foo bar");
|
|
||||||
|
|
||||||
let input = String::from("bar");
|
|
||||||
let result = expand_aliases(input, HashSet::new(), l);
|
|
||||||
assert_eq!(result.as_str(), "echo foo bar");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_infinite_recursive_alias() {
|
|
||||||
write_logic(|l| {
|
|
||||||
l.insert_alias("foo", "foo bar");
|
|
||||||
|
|
||||||
let input = String::from("foo");
|
|
||||||
let result = expand_aliases(input, HashSet::new(), l);
|
|
||||||
assert_eq!(result.as_str(), "foo bar");
|
|
||||||
l.clear_aliases();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_defaultunsetornull() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
v.set_var("set_var", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("unset:-default").unwrap();
|
|
||||||
assert_eq!(result, "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_defaultunset() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
v.set_var("set_var", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("unset-default").unwrap();
|
|
||||||
assert_eq!(result, "default");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_setdefaultunsetornull() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
v.set_var("set_var", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("unset:=assigned").unwrap();
|
|
||||||
assert_eq!(result, "assigned");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_setdefaultunset() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
v.set_var("set_var", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("unset=assigned").unwrap();
|
|
||||||
assert_eq!(result, "assigned");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_altsetnotnull() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
v.set_var("set_var", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("set_var:+alt").unwrap();
|
|
||||||
assert_eq!(result, "alt");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_altnotnull() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
v.set_var("set_var", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("set_var+alt").unwrap();
|
|
||||||
assert_eq!(result, "alt");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_len() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("#foo").unwrap();
|
|
||||||
assert_eq!(result, "3");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_substr() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo:1").unwrap();
|
|
||||||
assert_eq!(result, "oo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_substrlen() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo:0:2").unwrap();
|
|
||||||
assert_eq!(result, "fo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_remshortestprefix() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo#f*").unwrap();
|
|
||||||
assert_eq!(result, "oo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_remlongestprefix() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo##f*").unwrap();
|
|
||||||
assert_eq!(result, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_remshortestsuffix() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo%*o").unwrap();
|
|
||||||
assert_eq!(result, "fo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_remlongestsuffix() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo%%*o").unwrap();
|
|
||||||
assert_eq!(result, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_replacefirstmatch() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo/foo/X").unwrap();
|
|
||||||
assert_eq!(result, "X");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_replaceallmatches() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo//o/X").unwrap();
|
|
||||||
assert_eq!(result, "fXX");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_replaceprefix() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo/#f/X").unwrap();
|
|
||||||
assert_eq!(result, "Xoo");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn param_expansion_replacesuffix() {
|
|
||||||
write_vars(|v| {
|
|
||||||
v.set_var("foo", VarKind::Str("foo".into()), VarFlags::NONE);
|
|
||||||
});
|
|
||||||
let result = perform_param_expansion("foo/%o/X").unwrap();
|
|
||||||
assert_eq!(result, "foX");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Double-Quote Escape Tests (POSIX)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_escape_dollar() {
|
|
||||||
// "\$foo" should strip backslash, produce literal $foo (no expansion)
|
|
||||||
let result = unescape_str(r#""\$foo""#);
|
|
||||||
assert!(
|
|
||||||
!result.contains(markers::VAR_SUB),
|
|
||||||
"Escaped $ should not become VAR_SUB"
|
|
||||||
);
|
|
||||||
assert!(result.contains('$'), "Literal $ should be preserved");
|
|
||||||
assert!(!result.contains('\\'), "Backslash should be stripped");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_escape_backslash() {
|
|
||||||
// "\\" in double quotes should produce a single backslash
|
|
||||||
let result = unescape_str(r#""\\""#);
|
|
||||||
let inner: String = result
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c != markers::DUB_QUOTE)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
inner, "\\",
|
|
||||||
"Double backslash should produce single backslash"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_escape_quote() {
|
|
||||||
// "\"" should produce a literal double quote
|
|
||||||
let result = unescape_str(r#""\"""#);
|
|
||||||
let inner: String = result
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c != markers::DUB_QUOTE)
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
inner.contains('"'),
|
|
||||||
"Escaped quote should produce literal quote"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_escape_backtick() {
|
|
||||||
// "\`" should strip backslash, produce literal backtick
|
|
||||||
let result = unescape_str(r#""\`""#);
|
|
||||||
let inner: String = result
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c != markers::DUB_QUOTE)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
inner, "`",
|
|
||||||
"Escaped backtick should produce literal backtick"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_escape_nonspecial_preserves_backslash() {
|
|
||||||
// "\a" inside double quotes should preserve the backslash (a is not special)
|
|
||||||
let result = unescape_str(r#""\a""#);
|
|
||||||
let inner: String = result
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c != markers::DUB_QUOTE)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
inner, "\\a",
|
|
||||||
"Backslash before non-special char should be preserved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_unescaped_dollar_expands() {
|
|
||||||
// "$foo" inside double quotes should produce VAR_SUB (expansion marker)
|
|
||||||
let result = unescape_str(r#""$foo""#);
|
|
||||||
assert!(
|
|
||||||
result.contains(markers::VAR_SUB),
|
|
||||||
"Unescaped $ should become VAR_SUB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dquote_mixed_escapes() {
|
|
||||||
// "hello \$world \\end" should have literal $, single backslash
|
|
||||||
let result = unescape_str(r#""hello \$world \\end""#);
|
|
||||||
assert!(
|
|
||||||
!result.contains(markers::VAR_SUB),
|
|
||||||
"Escaped $ should not expand"
|
|
||||||
);
|
|
||||||
assert!(result.contains('$'), "Literal $ should be in output");
|
|
||||||
// Should have exactly one backslash (from \\)
|
|
||||||
let inner: String = result
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c != markers::DUB_QUOTE)
|
|
||||||
.collect();
|
|
||||||
let backslash_count = inner.chars().filter(|&c| c == '\\').count();
|
|
||||||
assert_eq!(backslash_count, 1, "\\\\ should produce one backslash");
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use getopt::{get_opts, get_opts_from_tokens};
|
|
||||||
use parse::NdRule;
|
|
||||||
use tests::get_nodes;
|
|
||||||
|
|
||||||
use crate::builtin::echo::ECHO_OPTS;
|
|
||||||
|
|
||||||
use super::super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn getopt_from_argv() {
|
|
||||||
let node = get_nodes("echo -n -e foo", |node| {
|
|
||||||
matches!(node.class, NdRule::Command { .. })
|
|
||||||
})
|
|
||||||
.pop()
|
|
||||||
.unwrap();
|
|
||||||
let NdRule::Command {
|
|
||||||
assignments: _,
|
|
||||||
argv,
|
|
||||||
} = node.class
|
|
||||||
else {
|
|
||||||
panic!()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (words, opts) = get_opts_from_tokens(argv, &ECHO_OPTS).expect("failed to get opts");
|
|
||||||
insta::assert_debug_snapshot!(words);
|
|
||||||
insta::assert_debug_snapshot!(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn getopt_simple() {
|
|
||||||
let raw = "echo -n foo"
|
|
||||||
.split_whitespace()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let (words, opts) = get_opts(raw);
|
|
||||||
insta::assert_debug_snapshot!(words);
|
|
||||||
insta::assert_debug_snapshot!(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn getopt_multiple_short() {
|
|
||||||
let raw = "echo -nre foo"
|
|
||||||
.split_whitespace()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let (words, opts) = get_opts(raw);
|
|
||||||
insta::assert_debug_snapshot!(words);
|
|
||||||
insta::assert_debug_snapshot!(opts);
|
|
||||||
}
|
|
||||||
@@ -1,668 +0,0 @@
|
|||||||
use crate::prompt::readline::{
|
|
||||||
annotate_input, annotate_input_recursive, highlight::Highlighter, markers,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Helper to check if a marker exists at any position in the annotated string
|
|
||||||
fn has_marker(annotated: &str, marker: char) -> bool {
|
|
||||||
annotated.contains(marker)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to find the position of a marker in the annotated string
|
|
||||||
fn find_marker(annotated: &str, marker: char) -> Option<usize> {
|
|
||||||
annotated.find(marker)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper to check if markers appear in the correct order
|
|
||||||
fn marker_before(annotated: &str, first: char, second: char) -> bool {
|
|
||||||
if let (Some(pos1), Some(pos2)) = (
|
|
||||||
find_marker(annotated, first),
|
|
||||||
find_marker(annotated, second),
|
|
||||||
) {
|
|
||||||
pos1 < pos2
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Basic Token-Level Annotation Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_simple_command() {
|
|
||||||
let input = "/bin/ls -la";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have COMMAND marker for "/bin/ls" (external command)
|
|
||||||
assert!(has_marker(&annotated, markers::COMMAND));
|
|
||||||
|
|
||||||
// Should have ARG marker for "-la"
|
|
||||||
assert!(has_marker(&annotated, markers::ARG));
|
|
||||||
|
|
||||||
// Should have RESET markers
|
|
||||||
assert!(has_marker(&annotated, markers::RESET));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_builtin_command() {
|
|
||||||
let input = "export FOO=bar";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should mark "export" as BUILTIN
|
|
||||||
assert!(has_marker(&annotated, markers::BUILTIN));
|
|
||||||
|
|
||||||
// Should mark assignment (or ARG if assignment isn't specifically marked
|
|
||||||
// separately)
|
|
||||||
assert!(has_marker(&annotated, markers::ASSIGNMENT) || has_marker(&annotated, markers::ARG));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_operator() {
|
|
||||||
let input = "ls | grep foo";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have OPERATOR marker for pipe
|
|
||||||
assert!(has_marker(&annotated, markers::OPERATOR));
|
|
||||||
|
|
||||||
// Should have COMMAND markers for both commands
|
|
||||||
let command_count = annotated.chars().filter(|&c| c == markers::COMMAND).count();
|
|
||||||
assert_eq!(command_count, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_redirect() {
|
|
||||||
let input = "echo hello > output.txt";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have REDIRECT marker
|
|
||||||
assert!(has_marker(&annotated, markers::REDIRECT));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_keyword() {
|
|
||||||
let input = "if true; then echo yes; fi";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have KEYWORD markers for if/then/fi
|
|
||||||
assert!(has_marker(&annotated, markers::KEYWORD));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_command_separator() {
|
|
||||||
let input = "echo foo; echo bar";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have CMD_SEP marker for semicolon
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SEP));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Sub-Token Annotation Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_variable_simple() {
|
|
||||||
let input = "echo $foo";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have VAR_SUB markers
|
|
||||||
assert!(has_marker(&annotated, markers::VAR_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::VAR_SUB_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_variable_braces() {
|
|
||||||
let input = "echo ${foo}";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have VAR_SUB markers for ${foo}
|
|
||||||
assert!(has_marker(&annotated, markers::VAR_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::VAR_SUB_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_double_quoted_string() {
|
|
||||||
let input = r#"echo "hello world""#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have STRING_DQ markers
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ));
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_single_quoted_string() {
|
|
||||||
let input = "echo 'hello world'";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have STRING_SQ markers
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_SQ));
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_SQ_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_variable_in_string() {
|
|
||||||
let input = r#"echo "hello $USER""#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have both STRING_DQ and VAR_SUB markers
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ));
|
|
||||||
assert!(has_marker(&annotated, markers::VAR_SUB));
|
|
||||||
|
|
||||||
// VAR_SUB should be inside STRING_DQ
|
|
||||||
assert!(marker_before(
|
|
||||||
&annotated,
|
|
||||||
markers::STRING_DQ,
|
|
||||||
markers::VAR_SUB
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_glob_asterisk() {
|
|
||||||
let input = "ls *.txt";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have GLOB marker for *
|
|
||||||
assert!(has_marker(&annotated, markers::GLOB));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_glob_question() {
|
|
||||||
let input = "ls file?.txt";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have GLOB marker for ?
|
|
||||||
assert!(has_marker(&annotated, markers::GLOB));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_glob_bracket() {
|
|
||||||
let input = "ls file[abc].txt";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have GLOB markers for bracket expression
|
|
||||||
let glob_count = annotated.chars().filter(|&c| c == markers::GLOB).count();
|
|
||||||
assert!(glob_count >= 2); // Opening and closing
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Command Substitution Tests (Flat)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_command_sub_basic() {
|
|
||||||
let input = "echo $(whoami)";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have CMD_SUB markers (but not recursively annotated yet)
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_subshell_basic() {
|
|
||||||
let input = "(cd /tmp && ls)";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have SUBSH markers
|
|
||||||
assert!(has_marker(&annotated, markers::SUBSH));
|
|
||||||
assert!(has_marker(&annotated, markers::SUBSH_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_process_sub_output() {
|
|
||||||
let input = "diff <(ls dir1) <(ls dir2)";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have PROC_SUB markers
|
|
||||||
assert!(has_marker(&annotated, markers::PROC_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::PROC_SUB_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Recursive Annotation Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_command_sub() {
|
|
||||||
let input = "echo $(whoami)";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have CMD_SUB markers
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB_END));
|
|
||||||
|
|
||||||
// Inside the command sub, "whoami" should be marked as COMMAND
|
|
||||||
// The recursive annotator should have processed the inside
|
|
||||||
assert!(has_marker(&annotated, markers::COMMAND));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_nested_command_sub() {
|
|
||||||
let input = "echo $(echo $(whoami))";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have multiple CMD_SUB markers (nested)
|
|
||||||
let cmd_sub_count = annotated.chars().filter(|&c| c == markers::CMD_SUB).count();
|
|
||||||
assert!(
|
|
||||||
cmd_sub_count >= 2,
|
|
||||||
"Should have at least 2 CMD_SUB markers for nested substitutions"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_command_sub_with_args() {
|
|
||||||
let input = "echo $(grep foo file.txt)";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have BUILTIN for echo and possibly COMMAND for grep (if in PATH)
|
|
||||||
// Just check that we have command-type markers
|
|
||||||
let builtin_count = annotated.chars().filter(|&c| c == markers::BUILTIN).count();
|
|
||||||
let command_count = annotated.chars().filter(|&c| c == markers::COMMAND).count();
|
|
||||||
assert!(
|
|
||||||
builtin_count + command_count >= 2,
|
|
||||||
"Expected at least 2 command markers (BUILTIN or COMMAND)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_subshell() {
|
|
||||||
let input = "(echo hello; echo world)";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have SUBSH markers
|
|
||||||
assert!(has_marker(&annotated, markers::SUBSH));
|
|
||||||
assert!(has_marker(&annotated, markers::SUBSH_END));
|
|
||||||
|
|
||||||
// Inside should be annotated with BUILTIN (echo is a builtin) and CMD_SEP
|
|
||||||
assert!(has_marker(&annotated, markers::BUILTIN));
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SEP));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_process_sub() {
|
|
||||||
let input = "diff <(ls -la)";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have PROC_SUB markers
|
|
||||||
assert!(has_marker(&annotated, markers::PROC_SUB));
|
|
||||||
|
|
||||||
// ls should be marked as COMMAND inside the process sub
|
|
||||||
assert!(has_marker(&annotated, markers::COMMAND));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_command_sub_in_string() {
|
|
||||||
let input = r#"echo "current user: $(whoami)""#;
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have STRING_DQ, CMD_SUB, and COMMAND markers
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ));
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::COMMAND));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_recursive_deeply_nested() {
|
|
||||||
let input = r#"echo "outer: $(echo "inner: $(whoami)")""#;
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have multiple STRING_DQ and CMD_SUB markers
|
|
||||||
let string_count = annotated
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c == markers::STRING_DQ)
|
|
||||||
.count();
|
|
||||||
let cmd_sub_count = annotated.chars().filter(|&c| c == markers::CMD_SUB).count();
|
|
||||||
|
|
||||||
assert!(string_count >= 2, "Should have multiple STRING_DQ markers");
|
|
||||||
assert!(cmd_sub_count >= 2, "Should have multiple CMD_SUB markers");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Marker Priority/Ordering Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn marker_priority_var_in_string() {
|
|
||||||
let input = r#""$foo""#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// STRING_DQ should come before VAR_SUB (outer before inner)
|
|
||||||
assert!(marker_before(
|
|
||||||
&annotated,
|
|
||||||
markers::STRING_DQ,
|
|
||||||
markers::VAR_SUB
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn marker_priority_arg_vs_string() {
|
|
||||||
let input = r#"echo "hello""#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Both ARG and STRING_DQ should be present
|
|
||||||
// STRING_DQ should be inside the ARG token's span
|
|
||||||
assert!(has_marker(&annotated, markers::ARG));
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn marker_priority_reset_placement() {
|
|
||||||
let input = "echo hello";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// RESET markers should appear after each token
|
|
||||||
// There should be multiple RESET markers
|
|
||||||
let reset_count = annotated.chars().filter(|&c| c == markers::RESET).count();
|
|
||||||
assert!(reset_count >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Highlighter Output Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_produces_ansi_codes() {
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
highlighter.load_input("echo hello", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output = highlighter.take();
|
|
||||||
|
|
||||||
// Should contain ANSI escape codes
|
|
||||||
assert!(
|
|
||||||
output.contains("\x1b["),
|
|
||||||
"Output should contain ANSI escape sequences"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should still contain the original text
|
|
||||||
assert!(output.contains("echo"));
|
|
||||||
assert!(output.contains("hello"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_handles_empty_input() {
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
highlighter.load_input("", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output = highlighter.take();
|
|
||||||
|
|
||||||
// Should not crash and should return empty or minimal output
|
|
||||||
assert!(output.len() < 10); // Just escape codes or empty
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_command_validation() {
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
|
|
||||||
// Valid command (echo exists)
|
|
||||||
highlighter.load_input("echo test", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let valid_output = highlighter.take();
|
|
||||||
|
|
||||||
// Invalid command (definitely doesn't exist)
|
|
||||||
highlighter.load_input("xyznotacommand123 test", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let invalid_output = highlighter.take();
|
|
||||||
|
|
||||||
// Both should have ANSI codes
|
|
||||||
assert!(valid_output.contains("\x1b["));
|
|
||||||
assert!(invalid_output.contains("\x1b["));
|
|
||||||
|
|
||||||
// The color codes should be different (green vs red)
|
|
||||||
// Valid commands should have \x1b[32m (green)
|
|
||||||
// Invalid commands should have \x1b[31m (red) or \x1b[1;31m (bold red)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_preserves_text_content() {
|
|
||||||
let input = "echo hello world";
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
highlighter.load_input(input, 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output = highlighter.take();
|
|
||||||
|
|
||||||
// Remove ANSI codes to check text content
|
|
||||||
let text_only: String = output
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_control() && *c != '\x1b')
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Should still contain the words (might have escape sequence fragments)
|
|
||||||
assert!(output.contains("echo"));
|
|
||||||
assert!(output.contains("hello"));
|
|
||||||
assert!(output.contains("world"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_multiple_tokens() {
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
highlighter.load_input("ls -la | grep foo", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output = highlighter.take();
|
|
||||||
|
|
||||||
// Should contain all tokens
|
|
||||||
assert!(output.contains("ls"));
|
|
||||||
assert!(output.contains("-la"));
|
|
||||||
assert!(output.contains("|"));
|
|
||||||
assert!(output.contains("grep"));
|
|
||||||
assert!(output.contains("foo"));
|
|
||||||
|
|
||||||
// Should have ANSI codes
|
|
||||||
assert!(output.contains("\x1b["));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_string_with_variable() {
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
highlighter.load_input(r#"echo "hello $USER""#, 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output = highlighter.take();
|
|
||||||
|
|
||||||
// Should contain the text
|
|
||||||
assert!(output.contains("echo"));
|
|
||||||
assert!(output.contains("hello"));
|
|
||||||
assert!(output.contains("USER"));
|
|
||||||
|
|
||||||
// Should have ANSI codes for different elements
|
|
||||||
assert!(output.contains("\x1b["));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn highlighter_reusable() {
|
|
||||||
let mut highlighter = Highlighter::new();
|
|
||||||
|
|
||||||
// First input
|
|
||||||
highlighter.load_input("echo first", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output1 = highlighter.take();
|
|
||||||
|
|
||||||
// Second input (reusing same highlighter)
|
|
||||||
highlighter.load_input("echo second", 0);
|
|
||||||
highlighter.highlight();
|
|
||||||
let output2 = highlighter.take();
|
|
||||||
|
|
||||||
// Both should work
|
|
||||||
assert!(output1.contains("first"));
|
|
||||||
assert!(output2.contains("second"));
|
|
||||||
|
|
||||||
// Should not contain each other's text
|
|
||||||
assert!(!output1.contains("second"));
|
|
||||||
assert!(!output2.contains("first"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Edge Cases
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_unclosed_string() {
|
|
||||||
let input = r#"echo "hello"#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should handle unclosed string gracefully
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ));
|
|
||||||
// May or may not have STRING_DQ_END depending on implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_unclosed_command_sub() {
|
|
||||||
let input = "echo $(whoami";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should handle unclosed command sub gracefully
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_empty_command_sub() {
|
|
||||||
let input = "echo $()";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should handle empty command sub
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB));
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB_END));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_escaped_characters() {
|
|
||||||
let input = r#"echo \$foo \`bar\` \"test\""#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should not mark escaped $ as variable
|
|
||||||
// This is tricky - the behavior depends on implementation
|
|
||||||
// At minimum, should not crash
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_special_variables() {
|
|
||||||
let input = "echo $0 $1 $2 $3 $4";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should mark positional parameters
|
|
||||||
let var_count = annotated.chars().filter(|&c| c == markers::VAR_SUB).count();
|
|
||||||
assert!(
|
|
||||||
var_count >= 5,
|
|
||||||
"Expected at least 5 VAR_SUB markers, found {}",
|
|
||||||
var_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_variable_no_expansion_in_single_quotes() {
|
|
||||||
let input = "echo '$foo'";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have STRING_SQ markers
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_SQ));
|
|
||||||
|
|
||||||
// Should NOT have VAR_SUB markers (variables don't expand in single quotes)
|
|
||||||
// Note: The annotator might still mark it - depends on implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_complex_pipeline() {
|
|
||||||
let input = "cat file.txt | grep pattern | sed 's/foo/bar/' | sort | uniq";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have multiple OPERATOR markers for pipes
|
|
||||||
let operator_count = annotated
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c == markers::OPERATOR)
|
|
||||||
.count();
|
|
||||||
assert!(operator_count >= 4);
|
|
||||||
|
|
||||||
// Should have multiple COMMAND markers
|
|
||||||
let command_count = annotated.chars().filter(|&c| c == markers::COMMAND).count();
|
|
||||||
assert!(command_count >= 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_assignment_with_command_sub() {
|
|
||||||
let input = "FOO=$(whoami)";
|
|
||||||
let annotated = annotate_input_recursive(input);
|
|
||||||
|
|
||||||
// Should have ASSIGNMENT marker
|
|
||||||
assert!(has_marker(&annotated, markers::ASSIGNMENT));
|
|
||||||
|
|
||||||
// Should have CMD_SUB marker
|
|
||||||
assert!(has_marker(&annotated, markers::CMD_SUB));
|
|
||||||
|
|
||||||
// Inside command sub should have COMMAND marker
|
|
||||||
assert!(has_marker(&annotated, markers::COMMAND));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_redirect_with_fd() {
|
|
||||||
let input = "command 2>&1";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have REDIRECT marker for the redirect operator
|
|
||||||
assert!(has_marker(&annotated, markers::REDIRECT));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_multiple_redirects() {
|
|
||||||
let input = "command > out.txt 2>&1";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have multiple REDIRECT markers
|
|
||||||
let redirect_count = annotated
|
|
||||||
.chars()
|
|
||||||
.filter(|&c| c == markers::REDIRECT)
|
|
||||||
.count();
|
|
||||||
assert!(redirect_count >= 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_here_string() {
|
|
||||||
let input = "cat <<< 'hello world'";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should have REDIRECT marker for <<<
|
|
||||||
assert!(has_marker(&annotated, markers::REDIRECT));
|
|
||||||
|
|
||||||
// Should have STRING_SQ markers
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_SQ));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotate_unicode_content() {
|
|
||||||
let input = "echo 'hello 世界 🌍'";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should handle unicode gracefully
|
|
||||||
assert!(has_marker(&annotated, markers::BUILTIN));
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_SQ));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Regression Tests (for bugs we've fixed)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn regression_arg_marker_at_position_zero() {
|
|
||||||
// Regression test: ARG marker was appearing at position 3 for input "ech"
|
|
||||||
// This was caused by SOI/EOI tokens falling through to ARG annotation
|
|
||||||
let input = "ech";
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// Should only have COMMAND marker, not ARG
|
|
||||||
// (incomplete command should still be marked as command attempt)
|
|
||||||
assert!(has_marker(&annotated, markers::COMMAND));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn regression_string_color_in_annotated_strings() {
|
|
||||||
// Regression test: ARG marker was overriding STRING_DQ color
|
|
||||||
let input = r#"echo "test""#;
|
|
||||||
let annotated = annotate_input(input);
|
|
||||||
|
|
||||||
// STRING_DQ should be present and properly positioned
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ));
|
|
||||||
assert!(has_marker(&annotated, markers::STRING_DQ_END));
|
|
||||||
|
|
||||||
// The string markers should come after the ARG marker
|
|
||||||
// (so they override it in the highlighting)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn lex_simple() {
|
|
||||||
let input = "echo hello world";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn lex_redir() {
|
|
||||||
let input = "echo foo > bar.txt";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn lex_redir_fds() {
|
|
||||||
let input = "echo foo 1>&2";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn lex_quote_str() {
|
|
||||||
let input = "echo \"foo bar\" biz baz";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn lex_with_keywords() {
|
|
||||||
let input = "if true; then echo foo; fi";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lex_multiline() {
|
|
||||||
let input = "echo hello world\necho foo bar\necho boo biz";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lex_case() {
|
|
||||||
let input = "case $foo in foo) bar;; bar) foo;; biz) baz;; esac";
|
|
||||||
let tokens: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty()).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(tokens)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::expand::{expand_aliases, unescape_str};
|
|
||||||
use crate::libsh::error::{Note, ShErr, ShErrKind};
|
|
||||||
use crate::parse::{
|
|
||||||
NdRule, Node, ParseStream,
|
|
||||||
lex::{LexFlags, LexStream, Tk, TkRule},
|
|
||||||
node_operation,
|
|
||||||
};
|
|
||||||
use crate::state::{write_logic, write_vars};
|
|
||||||
|
|
||||||
pub mod complete;
|
|
||||||
pub mod error;
|
|
||||||
pub mod expand;
|
|
||||||
pub mod getopt;
|
|
||||||
pub mod highlight;
|
|
||||||
pub mod lexer;
|
|
||||||
pub mod parser;
|
|
||||||
pub mod readline;
|
|
||||||
pub mod redir;
|
|
||||||
pub mod script;
|
|
||||||
pub mod state;
|
|
||||||
pub mod term;
|
|
||||||
|
|
||||||
/// Unsafe to use outside of tests
|
|
||||||
pub fn get_nodes<F1>(input: &str, filter: F1) -> Vec<Node>
|
|
||||||
where
|
|
||||||
F1: Fn(&Node) -> bool,
|
|
||||||
{
|
|
||||||
let mut nodes = vec![];
|
|
||||||
let tokens = LexStream::new(Arc::new(input.into()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let mut parsed_nodes = ParseStream::new(tokens)
|
|
||||||
.map(|nd| nd.unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
for node in parsed_nodes.iter_mut() {
|
|
||||||
node_operation(node, &filter, &mut |node: &mut Node| {
|
|
||||||
nodes.push(node.clone())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
nodes
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_simple() {
|
|
||||||
let input = "echo hello world";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_pipeline() {
|
|
||||||
let input = "echo foo | sed s/foo/bar";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_conjunction() {
|
|
||||||
let input = "echo foo && echo bar";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_conjunction_and_pipeline() {
|
|
||||||
let input = "echo foo | sed s/foo/bar/ && echo bar | sed s/bar/foo/ || echo foo bar | sed s/foo bar/bar foo/";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_multiline() {
|
|
||||||
let input = "
|
|
||||||
echo hello world
|
|
||||||
echo foo bar
|
|
||||||
echo boo biz";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_if_simple() {
|
|
||||||
let input = "if foo; then echo bar; fi";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_if_with_elif() {
|
|
||||||
let input = "if foo; then echo bar; elif bar; then echo foo; fi";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_if_multiple_elif() {
|
|
||||||
let input = "if foo; then echo bar; elif bar; then echo foo; elif biz; then echo baz; fi";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_if_multiline() {
|
|
||||||
let input = "
|
|
||||||
if foo; then
|
|
||||||
echo bar
|
|
||||||
elif bar; then
|
|
||||||
echo foo;
|
|
||||||
elif biz; then
|
|
||||||
echo baz
|
|
||||||
fi";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_loop_simple() {
|
|
||||||
let input = "while foo; do bar; done";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_loop_until() {
|
|
||||||
let input = "until foo; do bar; done";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_loop_multiline() {
|
|
||||||
let input = "
|
|
||||||
until foo; do
|
|
||||||
bar
|
|
||||||
done";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_case_simple() {
|
|
||||||
let input = "case foo in foo) bar;; bar) foo;; biz) baz;; esac";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_case_multiline() {
|
|
||||||
let input = "case foo in
|
|
||||||
foo) bar
|
|
||||||
;;
|
|
||||||
bar) foo
|
|
||||||
;;
|
|
||||||
biz) baz
|
|
||||||
;;
|
|
||||||
esac";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_case_nested() {
|
|
||||||
let input = "case foo in
|
|
||||||
foo)
|
|
||||||
if true; then
|
|
||||||
while true; do
|
|
||||||
echo foo
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
bar)
|
|
||||||
if false; then
|
|
||||||
until false; do
|
|
||||||
case foo in
|
|
||||||
foo)
|
|
||||||
if true; then
|
|
||||||
echo foo
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
bar)
|
|
||||||
if false; then
|
|
||||||
echo foo
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac";
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn parse_cursed() {
|
|
||||||
let input = "if if if if case foo in foo) if true; then true; fi;; esac; then case foo in foo) until true; do true; done;; esac; fi; then until if case foo in foo) true;; esac; then if true; then true; fi; fi; do until until true; do true; done; do case foo in foo) true;; esac; done; done; fi; then until until case foo in foo) true;; esac; do if true; then true; fi; done; do until true; do true; done; done; fi; then until case foo in foo) case foo in foo) true;; esac;; esac; do if if true; then true; fi; then until true; do true; done; fi; done; elif until until case foo in foo) true;; esac; do if true; then true; fi; done; do case foo in foo) until true; do true; done;; esac; done; then case foo in foo) if case foo in foo) true;; esac; then if true; then true; fi; fi;; esac; else case foo in foo) until until true; do true; done; do case foo in foo) true;; esac; done;; esac; fi";
|
|
||||||
|
|
||||||
let tk_stream: Vec<_> = LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
let nodes: Vec<_> = ParseStream::new(tk_stream).collect();
|
|
||||||
|
|
||||||
// 15,000 line snapshot file btw
|
|
||||||
insta::assert_debug_snapshot!(nodes)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn test_node_operation() {
|
|
||||||
let input = String::from("echo hello world; echo foo bar");
|
|
||||||
let mut check_nodes = vec![];
|
|
||||||
let tokens: Vec<Tk> = LexStream::new(input.into(), LexFlags::empty())
|
|
||||||
.map(|tk| tk.unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let nodes = ParseStream::new(tokens).map(|nd| nd.unwrap());
|
|
||||||
|
|
||||||
for mut node in nodes {
|
|
||||||
node_operation(
|
|
||||||
&mut node,
|
|
||||||
&|node: &Node| matches!(node.class, NdRule::Command { .. }),
|
|
||||||
&mut |node: &mut Node| check_nodes.push(node.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
insta::assert_debug_snapshot!(check_nodes)
|
|
||||||
}
|
|
||||||
@@ -1,700 +0,0 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
expand::expand_prompt,
|
|
||||||
libsh::{
|
|
||||||
error::ShErr,
|
|
||||||
term::{Style, Styled},
|
|
||||||
},
|
|
||||||
prompt::readline::{
|
|
||||||
Prompt, ShedVi,
|
|
||||||
history::History,
|
|
||||||
keys::{KeyCode, KeyEvent, ModKeys},
|
|
||||||
linebuf::LineBuf,
|
|
||||||
term::{KeyReader, LineWriter, raw_mode},
|
|
||||||
vimode::{ViInsert, ViMode, ViNormal},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
use super::super::*;
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
struct TestReader {
|
|
||||||
pub bytes: VecDeque<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TestReader {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
pub fn with_initial(mut self, bytes: &[u8]) -> Self {
|
|
||||||
let bytes = bytes.iter();
|
|
||||||
self.bytes.extend(bytes);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_esc_seq_from_bytes(&mut self) -> Option<KeyEvent> {
|
|
||||||
let mut seq = vec![0x1b];
|
|
||||||
let b1 = self.bytes.pop_front()?;
|
|
||||||
seq.push(b1);
|
|
||||||
|
|
||||||
match b1 {
|
|
||||||
b'[' => {
|
|
||||||
let b2 = self.bytes.pop_front()?;
|
|
||||||
seq.push(b2);
|
|
||||||
|
|
||||||
match b2 {
|
|
||||||
b'A' => Some(KeyEvent(KeyCode::Up, ModKeys::empty())),
|
|
||||||
b'B' => Some(KeyEvent(KeyCode::Down, ModKeys::empty())),
|
|
||||||
b'C' => Some(KeyEvent(KeyCode::Right, ModKeys::empty())),
|
|
||||||
b'D' => Some(KeyEvent(KeyCode::Left, ModKeys::empty())),
|
|
||||||
b'1'..=b'9' => {
|
|
||||||
let mut digits = vec![b2];
|
|
||||||
|
|
||||||
while let Some(&b) = self.bytes.front() {
|
|
||||||
seq.push(b);
|
|
||||||
self.bytes.pop_front();
|
|
||||||
|
|
||||||
if b == b'~' || b == b';' {
|
|
||||||
break;
|
|
||||||
} else if b.is_ascii_digit() {
|
|
||||||
digits.push(b);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let key = match digits.as_slice() {
|
|
||||||
[b'1'] => KeyCode::Home,
|
|
||||||
[b'3'] => KeyCode::Delete,
|
|
||||||
[b'4'] => KeyCode::End,
|
|
||||||
[b'5'] => KeyCode::PageUp,
|
|
||||||
[b'6'] => KeyCode::PageDown,
|
|
||||||
[b'7'] => KeyCode::Home, // xterm alternate
|
|
||||||
[b'8'] => KeyCode::End, // xterm alternate
|
|
||||||
|
|
||||||
[b'1', b'5'] => KeyCode::F(5),
|
|
||||||
[b'1', b'7'] => KeyCode::F(6),
|
|
||||||
[b'1', b'8'] => KeyCode::F(7),
|
|
||||||
[b'1', b'9'] => KeyCode::F(8),
|
|
||||||
[b'2', b'0'] => KeyCode::F(9),
|
|
||||||
[b'2', b'1'] => KeyCode::F(10),
|
|
||||||
[b'2', b'3'] => KeyCode::F(11),
|
|
||||||
[b'2', b'4'] => KeyCode::F(12),
|
|
||||||
_ => KeyCode::Esc,
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(KeyEvent(key, ModKeys::empty()))
|
|
||||||
}
|
|
||||||
_ => Some(KeyEvent(KeyCode::Esc, ModKeys::empty())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b'O' => {
|
|
||||||
let b2 = self.bytes.pop_front()?;
|
|
||||||
seq.push(b2);
|
|
||||||
|
|
||||||
let key = match b2 {
|
|
||||||
b'P' => KeyCode::F(1),
|
|
||||||
b'Q' => KeyCode::F(2),
|
|
||||||
b'R' => KeyCode::F(3),
|
|
||||||
b'S' => KeyCode::F(4),
|
|
||||||
_ => KeyCode::Esc,
|
|
||||||
};
|
|
||||||
|
|
||||||
Some(KeyEvent(key, ModKeys::empty()))
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => Some(KeyEvent(KeyCode::Esc, ModKeys::empty())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyReader for TestReader {
|
|
||||||
fn read_key(&mut self) -> Result<Option<KeyEvent>, ShErr> {
|
|
||||||
use core::str;
|
|
||||||
|
|
||||||
let mut collected = Vec::with_capacity(4);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let byte = self.bytes.pop_front();
|
|
||||||
if byte.is_none() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let byte = byte.unwrap();
|
|
||||||
collected.push(byte);
|
|
||||||
|
|
||||||
// If it's an escape sequence, delegate
|
|
||||||
if collected[0] == 0x1b && collected.len() == 1 {
|
|
||||||
if let Some(&_next @ (b'[' | b'0')) = self.bytes.front() {
|
|
||||||
println!("found escape seq");
|
|
||||||
let seq = self.parse_esc_seq_from_bytes();
|
|
||||||
println!("{seq:?}");
|
|
||||||
return Ok(seq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parse as valid UTF-8
|
|
||||||
if let Ok(s) = str::from_utf8(&collected) {
|
|
||||||
return Ok(Some(KeyEvent::new(s, ModKeys::empty())));
|
|
||||||
}
|
|
||||||
|
|
||||||
if collected.len() >= 4 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TestWriter {}
|
|
||||||
|
|
||||||
impl TestWriter {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LineWriter for TestWriter {
|
|
||||||
fn clear_rows(&mut self, _layout: &prompt::readline::term::Layout) -> libsh::error::ShResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn redraw(
|
|
||||||
&mut self,
|
|
||||||
_prompt: &str,
|
|
||||||
_line: &str,
|
|
||||||
_new_layout: &prompt::readline::term::Layout,
|
|
||||||
) -> libsh::error::ShResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_write(&mut self, _buf: &str) -> libsh::error::ShResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_bell(&mut self) -> ShResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: ShedVi structure has changed significantly and readline() method no
|
|
||||||
// longer exists These test helpers are disabled until they can be properly
|
|
||||||
// updated
|
|
||||||
/*
|
|
||||||
impl ShedVi {
|
|
||||||
pub fn new_test(prompt: Option<String>, input: &str, initial: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
reader: Box::new(TestReader::new().with_initial(input.as_bytes())),
|
|
||||||
writer: Box::new(TestWriter::new()),
|
|
||||||
prompt: prompt.unwrap_or("$ ".styled(Style::Green)),
|
|
||||||
mode: Box::new(ViInsert::new()),
|
|
||||||
old_layout: None,
|
|
||||||
repeat_action: None,
|
|
||||||
repeat_motion: None,
|
|
||||||
history: History::new().unwrap(),
|
|
||||||
editor: LineBuf::new().with_initial(initial, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn shedvi_test(input: &str, initial: &str) -> String {
|
|
||||||
let mut shedvi = ShedVi::new_test(None, input, initial);
|
|
||||||
let raw_mode = raw_mode();
|
|
||||||
let line = shedvi.readline().unwrap();
|
|
||||||
std::mem::drop(raw_mode);
|
|
||||||
line
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
fn normal_cmd(cmd: &str, buf: &str, cursor: usize) -> (String, usize) {
|
|
||||||
let cmd = ViNormal::new().cmds_from_raw(cmd).pop().unwrap();
|
|
||||||
let mut buf = LineBuf::new().with_initial(buf, cursor);
|
|
||||||
buf.exec_cmd(cmd).unwrap();
|
|
||||||
(buf.as_str().to_string(), buf.cursor.get())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vimode_insert_cmds() {
|
|
||||||
let raw = "abcdefghijklmnopqrstuvwxyz1234567890-=[];'<>/\\x1b";
|
|
||||||
let mut mode = ViInsert::new();
|
|
||||||
let cmds = mode.cmds_from_raw(raw);
|
|
||||||
insta::assert_debug_snapshot!(cmds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vimode_normal_cmds() {
|
|
||||||
let raw = "d2wg?5b2P5x";
|
|
||||||
let mut mode = ViNormal::new();
|
|
||||||
let cmds = mode.cmds_from_raw(raw);
|
|
||||||
insta::assert_debug_snapshot!(cmds)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_empty_linebuf() {
|
|
||||||
let mut buf = LineBuf::new();
|
|
||||||
assert_eq!(buf.as_str(), "");
|
|
||||||
buf.update_graphemes_lazy();
|
|
||||||
assert_eq!(buf.grapheme_indices(), &[]);
|
|
||||||
assert!(buf.slice(0..0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_ascii_content() {
|
|
||||||
let mut buf = LineBuf::new().with_initial("hello", 0);
|
|
||||||
|
|
||||||
buf.update_graphemes_lazy();
|
|
||||||
assert_eq!(buf.grapheme_indices(), &[0, 1, 2, 3, 4]);
|
|
||||||
|
|
||||||
assert_eq!(buf.grapheme_at(0), Some("h"));
|
|
||||||
assert_eq!(buf.grapheme_at(4), Some("o"));
|
|
||||||
assert_eq!(buf.slice(1..4), Some("ell"));
|
|
||||||
assert_eq!(buf.slice_to(2), Some("he"));
|
|
||||||
assert_eq!(buf.slice_from(2), Some("llo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expand_default_prompt() {
|
|
||||||
let prompt = expand_prompt(
|
|
||||||
"\\e[0m\\n\\e[1;0m\\u\\e[1;36m@\\e[1;31m\\h\\n\\e[1;36m\\W\\e[1;32m/\\n\\e[1;32m\\$\\e[0m "
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
insta::assert_debug_snapshot!(prompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_unicode_graphemes() {
|
|
||||||
let mut buf = LineBuf::new().with_initial("a🇺🇸b́c", 0);
|
|
||||||
|
|
||||||
buf.update_graphemes_lazy();
|
|
||||||
let indices = buf.grapheme_indices();
|
|
||||||
assert_eq!(indices.len(), 4); // 4 graphemes + 1 end marker
|
|
||||||
|
|
||||||
assert_eq!(buf.grapheme_at(0), Some("a"));
|
|
||||||
assert_eq!(buf.grapheme_at(1), Some("🇺🇸"));
|
|
||||||
assert_eq!(buf.grapheme_at(2), Some("b́")); // b + combining accent
|
|
||||||
assert_eq!(buf.grapheme_at(3), Some("c"));
|
|
||||||
assert_eq!(buf.grapheme_at(4), None); // out of bounds
|
|
||||||
|
|
||||||
assert_eq!(buf.slice(0..2), Some("a🇺🇸"));
|
|
||||||
assert_eq!(buf.slice(1..3), Some("🇺🇸b́"));
|
|
||||||
assert_eq!(buf.slice(2..4), Some("b́c"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_slice_to_from_cursor() {
|
|
||||||
let mut buf = LineBuf::new().with_initial("abçd", 2);
|
|
||||||
|
|
||||||
buf.update_graphemes_lazy();
|
|
||||||
assert_eq!(buf.slice_to_cursor(), Some("ab"));
|
|
||||||
assert_eq!(buf.slice_from_cursor(), Some("çd"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_out_of_bounds_slices() {
|
|
||||||
let mut buf = LineBuf::new().with_initial("test", 0);
|
|
||||||
|
|
||||||
buf.update_graphemes_lazy();
|
|
||||||
|
|
||||||
assert_eq!(buf.grapheme_at(5), None); // out of bounds
|
|
||||||
assert_eq!(buf.slice(2..5), None); // end out of bounds
|
|
||||||
assert_eq!(buf.slice(4..4), None); // valid but empty
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_this_line() {
|
|
||||||
let initial = "This is the first line\nThis is the second line\nThis is the third line\nThis is the fourth line";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 57);
|
|
||||||
let (start, end) = buf.this_line();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("This is the third line\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_prev_line() {
|
|
||||||
let initial = "This is the first line\nThis is the second line\nThis is the third line\nThis is the fourth line";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 57);
|
|
||||||
let (start, end) = buf.nth_prev_line(1).unwrap();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("This is the second line\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_prev_line_first_line_is_empty() {
|
|
||||||
let initial = "\nThis is the first line\nThis is the second line\nThis is the third line\nThis is the fourth line";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 36);
|
|
||||||
let (start, end) = buf.nth_prev_line(1).unwrap();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("This is the first line\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_next_line() {
|
|
||||||
let initial = "This is the first line\nThis is the second line\nThis is the third line\nThis is the fourth line";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 57);
|
|
||||||
let (start, end) = buf.nth_next_line(1).unwrap();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("This is the fourth line"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_next_line_last_line_is_empty() {
|
|
||||||
let initial = "This is the first line\nThis is the second line\nThis is the third line\nThis is the fourth line\n";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 57);
|
|
||||||
let (start, end) = buf.nth_next_line(1).unwrap();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("This is the fourth line\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_next_line_several_trailing_newlines() {
|
|
||||||
let initial = "This is the first line\nThis is the second line\nThis is the third line\nThis is the fourth line\n\n\n\n";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 81);
|
|
||||||
let (start, end) = buf.nth_next_line(1).unwrap();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_next_line_only_newlines() {
|
|
||||||
let initial = "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 7);
|
|
||||||
let (start, end) = buf.nth_next_line(1).unwrap();
|
|
||||||
assert_eq!(start, 8);
|
|
||||||
assert_eq!(buf.slice(start..end), Some("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_prev_line_only_newlines() {
|
|
||||||
let initial = "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n";
|
|
||||||
let mut buf = LineBuf::new().with_initial(initial, 7);
|
|
||||||
let (start, end) = buf.nth_prev_line(1).unwrap();
|
|
||||||
assert_eq!(buf.slice(start..end), Some("\n"));
|
|
||||||
assert_eq!(start, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linebuf_cursor_motion() {
|
|
||||||
let mut buf =
|
|
||||||
LineBuf::new().with_initial("Thé quíck 🦊 bröwn fóx jumpś óver the 💤 lázy dóg 🐶", 0);
|
|
||||||
|
|
||||||
buf.update_graphemes_lazy();
|
|
||||||
let total = buf.grapheme_indices.as_ref().unwrap().len();
|
|
||||||
|
|
||||||
for i in 0..total {
|
|
||||||
buf.cursor.set(i);
|
|
||||||
|
|
||||||
let expected_to = buf
|
|
||||||
.buffer
|
|
||||||
.get(..buf.grapheme_indices_owned()[i])
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
let expected_from = if i + 1 < total {
|
|
||||||
buf
|
|
||||||
.buffer
|
|
||||||
.get(buf.grapheme_indices_owned()[i]..)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
} else {
|
|
||||||
// last grapheme, ends at buffer end
|
|
||||||
buf
|
|
||||||
.buffer
|
|
||||||
.get(buf.grapheme_indices_owned()[i]..)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
let expected_at = {
|
|
||||||
let start = buf.grapheme_indices_owned()[i];
|
|
||||||
let end = buf
|
|
||||||
.grapheme_indices_owned()
|
|
||||||
.get(i + 1)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(buf.buffer.len());
|
|
||||||
buf.buffer.get(start..end).map(|slice| slice.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
buf.slice_to_cursor(),
|
|
||||||
Some(expected_to.as_str()),
|
|
||||||
"Failed at cursor position {i}: slice_to_cursor"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
buf.slice_from_cursor(),
|
|
||||||
Some(expected_from.as_str()),
|
|
||||||
"Failed at cursor position {i}: slice_from_cursor"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
buf.grapheme_at(i).map(|slice| slice.to_string()),
|
|
||||||
expected_at,
|
|
||||||
"Failed at cursor position {i}: grapheme_at"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_word() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("dw", "The quick brown fox jumps over the lazy dog", 16),
|
|
||||||
("The quick brown jumps over the lazy dog".into(), 16)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_backwards() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("2db", "The quick brown fox jumps over the lazy dog", 16),
|
|
||||||
("The fox jumps over the lazy dog".into(), 4)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_rot13_five_words_backwards() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("g?5b", "The quick brown fox jumps over the lazy dog", 31),
|
|
||||||
("The dhvpx oebja sbk whzcf bire the lazy dog".into(), 4)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_word_on_whitespace() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("dw", "The quick brown fox", 10), //on the whitespace between "quick" and "brown"
|
|
||||||
("The quick brown fox".into(), 10)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_5_words() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("5dw", "The quick brown fox jumps over the lazy dog", 16,),
|
|
||||||
("The quick brown dog".into(), 16)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_end_includes_last() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("de", "The quick brown fox::::jumps over the lazy dog", 16),
|
|
||||||
("The quick brown ::::jumps over the lazy dog".into(), 16)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_end_unicode_word() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("de", "naïve café world", 0),
|
|
||||||
(" café world".into(), 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_inplace_edit_cursor_position() {
|
|
||||||
assert_eq!(normal_cmd("5~", "foobar", 0), ("FOOBAr".into(), 4));
|
|
||||||
assert_eq!(normal_cmd("5rg", "foobar", 0), ("gggggr".into(), 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_insert_mode_not_clamped() {
|
|
||||||
assert_eq!(normal_cmd("a", "foobar", 5), ("foobar".into(), 6))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_overshooting_motions() {
|
|
||||||
assert_eq!(normal_cmd("5dw", "foo bar", 0), ("".into(), 0));
|
|
||||||
assert_eq!(normal_cmd("3db", "foo bar", 0), ("foo bar".into(), 0));
|
|
||||||
assert_eq!(normal_cmd("3dj", "foo bar", 0), ("foo bar".into(), 0));
|
|
||||||
assert_eq!(normal_cmd("3dk", "foo bar", 0), ("foo bar".into(), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_textobj_quoted() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("di\"", "this buffer has \"some \\\"quoted\" text", 0),
|
|
||||||
("this buffer has \"\" text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("da\"", "this buffer has \"some \\\"quoted\" text", 0),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("di'", "this buffer has 'some \\'quoted' text", 0),
|
|
||||||
("this buffer has '' text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("da'", "this buffer has 'some \\'quoted' text", 0),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("di`", "this buffer has `some \\`quoted` text", 0),
|
|
||||||
("this buffer has `` text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("da`", "this buffer has `some \\`quoted` text", 0),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_textobj_delimited() {
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"di)",
|
|
||||||
"this buffer has (some \\(\\)(inner) \\(\\)delimited) text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has () text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"da)",
|
|
||||||
"this buffer has (some \\(\\)(inner) \\(\\)delimited) text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"di]",
|
|
||||||
"this buffer has [some \\[\\][inner] \\[\\]delimited] text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has [] text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"da]",
|
|
||||||
"this buffer has [some \\[\\][inner] \\[\\]delimited] text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"di}",
|
|
||||||
"this buffer has {some \\{\\}{inner} \\{\\}delimited} text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has {} text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"da}",
|
|
||||||
"this buffer has {some \\{\\}{inner} \\{\\}delimited} text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"di>",
|
|
||||||
"this buffer has <some \\<\\><inner> \\<\\>delimited> text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has <> text".into(), 17)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd(
|
|
||||||
"da>",
|
|
||||||
"this buffer has <some \\<\\><inner> \\<\\>delimited> text",
|
|
||||||
0
|
|
||||||
),
|
|
||||||
("this buffer has text".into(), 16)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOREM_IPSUM: &str = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra.";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_delete_line_up() {
|
|
||||||
assert_eq!(normal_cmd(
|
|
||||||
"dk",
|
|
||||||
LOREM_IPSUM,
|
|
||||||
237),
|
|
||||||
("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra.".into(), 129,)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_insert_at_line_start() {
|
|
||||||
// I should move cursor to position 0 when line starts with non-whitespace
|
|
||||||
assert_eq!(normal_cmd("I", "hello world", 5), ("hello world".into(), 0));
|
|
||||||
// I should skip leading whitespace
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("I", " hello world", 8),
|
|
||||||
(" hello world".into(), 2)
|
|
||||||
);
|
|
||||||
// I should move to the first non-whitespace on the current line in a multiline
|
|
||||||
// buffer
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("I", "first line\nsecond line", 14),
|
|
||||||
("first line\nsecond line".into(), 11)
|
|
||||||
);
|
|
||||||
// I should land on position 0 when cursor is already at 0
|
|
||||||
assert_eq!(normal_cmd("I", "hello", 0), ("hello".into(), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn editor_f_char_from_position_zero() {
|
|
||||||
// f<char> at position 0 should skip the cursor and find the next occurrence
|
|
||||||
// Regression: previously at pos 0, f would match the char under the cursor
|
|
||||||
// itself
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("fa", "abcaef", 0),
|
|
||||||
("abcaef".into(), 3) // should find second 'a', not the 'a' at position 0
|
|
||||||
);
|
|
||||||
// f<char> from position 0 finding a char that only appears later
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("fo", "hello world", 0),
|
|
||||||
("hello world".into(), 4)
|
|
||||||
);
|
|
||||||
// f<char> from middle of buffer
|
|
||||||
assert_eq!(
|
|
||||||
normal_cmd("fd", "hello world", 5),
|
|
||||||
("hello world".into(), 10)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: These tests disabled because shedvi_test() helper is commented out
|
|
||||||
/*
|
|
||||||
#[test]
|
|
||||||
fn shedvi_test_simple() {
|
|
||||||
assert_eq!(shedvi_test("foo bar\x1bbdw\r", ""), "foo ")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shedvi_test_mode_change() {
|
|
||||||
assert_eq!(
|
|
||||||
shedvi_test("foo bar biz buzz\x1bbbb2cwbiz buzz bar\r", ""),
|
|
||||||
"foo biz buzz bar buzz"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shedvi_test_lorem_ipsum_1() {
|
|
||||||
assert_eq!(shedvi_test(
|
|
||||||
"\x1bwwwwwwww5dWdBdBjjdwjdwbbbcwasdasdasdasd\x1b\r",
|
|
||||||
LOREM_IPSUM),
|
|
||||||
"Lorem ipsum dolor sit amet, incididunt ut labore et dolore magna aliqua.\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in repin voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur asdasdasdasd occaecat cupinon proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shedvi_test_lorem_ipsum_undo() {
|
|
||||||
assert_eq!(
|
|
||||||
shedvi_test(
|
|
||||||
"\x1bwwwwwwwwainserting some characters now...\x1bu\r",
|
|
||||||
LOREM_IPSUM
|
|
||||||
),
|
|
||||||
LOREM_IPSUM
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shedvi_test_lorem_ipsum_ctrl_w() {
|
|
||||||
assert_eq!(shedvi_test(
|
|
||||||
"\x1bj5wiasdasdkjhaksjdhkajshd\x17wordswordswords\x17somemorewords\x17\x1b[D\x1b[D\x17\x1b\r",
|
|
||||||
LOREM_IPSUM),
|
|
||||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\nUt enim ad minim am, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\nCurabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::parse::{
|
|
||||||
NdRule, Node, ParseStream, Redir, RedirType,
|
|
||||||
lex::{LexFlags, LexStream},
|
|
||||||
};
|
|
||||||
use crate::procio::{IoFrame, IoMode, IoStack};
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Parser Tests - Redirection Syntax
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
fn parse_command(input: &str) -> Node {
|
|
||||||
let source = Arc::new(input.to_string());
|
|
||||||
let tokens = LexStream::new(source, LexFlags::empty())
|
|
||||||
.flatten()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let mut nodes = ParseStream::new(tokens).flatten().collect::<Vec<_>>();
|
|
||||||
|
|
||||||
assert_eq!(nodes.len(), 1, "Expected exactly one node");
|
|
||||||
let top_node = nodes.remove(0);
|
|
||||||
|
|
||||||
// Navigate to the actual Command node within the AST structure
|
|
||||||
// Structure is typically: Conjunction -> Pipeline -> Command
|
|
||||||
match top_node.class {
|
|
||||||
NdRule::Conjunction { elements } => {
|
|
||||||
let first_element = elements
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.expect("Expected at least one conjunction element");
|
|
||||||
match first_element.cmd.class {
|
|
||||||
NdRule::Pipeline { cmds, .. } => {
|
|
||||||
let mut commands = cmds;
|
|
||||||
assert_eq!(
|
|
||||||
commands.len(),
|
|
||||||
1,
|
|
||||||
"Expected exactly one command in pipeline"
|
|
||||||
);
|
|
||||||
commands.remove(0)
|
|
||||||
}
|
|
||||||
NdRule::Command { .. } => *first_element.cmd,
|
|
||||||
_ => panic!(
|
|
||||||
"Expected Command or Pipeline node, got {:?}",
|
|
||||||
first_element.cmd.class
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NdRule::Pipeline { cmds, .. } => {
|
|
||||||
let mut commands = cmds;
|
|
||||||
assert_eq!(
|
|
||||||
commands.len(),
|
|
||||||
1,
|
|
||||||
"Expected exactly one command in pipeline"
|
|
||||||
);
|
|
||||||
commands.remove(0)
|
|
||||||
}
|
|
||||||
NdRule::Command { .. } => top_node,
|
|
||||||
_ => panic!(
|
|
||||||
"Expected Conjunction, Pipeline, or Command node, got {:?}",
|
|
||||||
top_node.class
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_output_redirect() {
|
|
||||||
let node = parse_command("echo hello > output.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::Output));
|
|
||||||
assert!(matches!(redir.io_mode, IoMode::File { tgt_fd: 1, .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_append_redirect() {
|
|
||||||
let node = parse_command("echo hello >> output.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::Append));
|
|
||||||
assert!(matches!(redir.io_mode, IoMode::File { tgt_fd: 1, .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_input_redirect() {
|
|
||||||
let node = parse_command("cat < input.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::Input));
|
|
||||||
assert!(matches!(redir.io_mode, IoMode::File { tgt_fd: 0, .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_stderr_redirect() {
|
|
||||||
let node = parse_command("ls 2> errors.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::Output));
|
|
||||||
assert!(matches!(redir.io_mode, IoMode::File { tgt_fd: 2, .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_stderr_to_stdout() {
|
|
||||||
let node = parse_command("ls 2>&1");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
redir.io_mode,
|
|
||||||
IoMode::Fd {
|
|
||||||
tgt_fd: 2,
|
|
||||||
src_fd: 1
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_stdout_to_stderr() {
|
|
||||||
let node = parse_command("echo test 1>&2");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
redir.io_mode,
|
|
||||||
IoMode::Fd {
|
|
||||||
tgt_fd: 1,
|
|
||||||
src_fd: 2
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_multiple_redirects() {
|
|
||||||
let node = parse_command("cmd < input.txt > output.txt 2> errors.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 3);
|
|
||||||
|
|
||||||
// Input redirect
|
|
||||||
assert!(matches!(node.redirs[0].class, RedirType::Input));
|
|
||||||
assert!(matches!(
|
|
||||||
node.redirs[0].io_mode,
|
|
||||||
IoMode::File { tgt_fd: 0, .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
// Stdout redirect
|
|
||||||
assert!(matches!(node.redirs[1].class, RedirType::Output));
|
|
||||||
assert!(matches!(
|
|
||||||
node.redirs[1].io_mode,
|
|
||||||
IoMode::File { tgt_fd: 1, .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
// Stderr redirect
|
|
||||||
assert!(matches!(node.redirs[2].class, RedirType::Output));
|
|
||||||
assert!(matches!(
|
|
||||||
node.redirs[2].io_mode,
|
|
||||||
IoMode::File { tgt_fd: 2, .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_custom_fd_redirect() {
|
|
||||||
let node = parse_command("echo test 3> fd3.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::Output));
|
|
||||||
assert!(matches!(redir.io_mode, IoMode::File { tgt_fd: 3, .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_custom_fd_dup() {
|
|
||||||
let node = parse_command("cmd 3>&4");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
redir.io_mode,
|
|
||||||
IoMode::Fd {
|
|
||||||
tgt_fd: 3,
|
|
||||||
src_fd: 4
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_heredoc() {
|
|
||||||
let node = parse_command("cat << EOF");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::HereDoc));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_herestring() {
|
|
||||||
let node = parse_command("cat <<< 'hello world'");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
let redir = &node.redirs[0];
|
|
||||||
|
|
||||||
assert!(matches!(redir.class, RedirType::HereString));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_redirect_with_no_space() {
|
|
||||||
let node = parse_command("echo hello >output.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 1);
|
|
||||||
assert!(matches!(node.redirs[0].class, RedirType::Output));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_redirect_order_preserved() {
|
|
||||||
let node = parse_command("cmd 2>&1 > file.txt");
|
|
||||||
|
|
||||||
assert_eq!(node.redirs.len(), 2);
|
|
||||||
|
|
||||||
// First redirect: 2>&1
|
|
||||||
assert!(matches!(
|
|
||||||
node.redirs[0].io_mode,
|
|
||||||
IoMode::Fd {
|
|
||||||
tgt_fd: 2,
|
|
||||||
src_fd: 1
|
|
||||||
}
|
|
||||||
));
|
|
||||||
|
|
||||||
// Second redirect: > file.txt
|
|
||||||
assert!(matches!(node.redirs[1].class, RedirType::Output));
|
|
||||||
assert!(matches!(
|
|
||||||
node.redirs[1].io_mode,
|
|
||||||
IoMode::File { tgt_fd: 1, .. }
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// IoStack Tests - Data Structure Logic
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_new() {
|
|
||||||
let stack = IoStack::new();
|
|
||||||
|
|
||||||
assert_eq!(stack.len(), 1, "IoStack should start with one frame");
|
|
||||||
assert_eq!(stack.curr_frame().len(), 0, "Initial frame should be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_push_pop_frame() {
|
|
||||||
let mut stack = IoStack::new();
|
|
||||||
|
|
||||||
// Push a new frame
|
|
||||||
stack.push_frame(IoFrame::new());
|
|
||||||
assert_eq!(stack.len(), 2);
|
|
||||||
|
|
||||||
// Pop it back
|
|
||||||
let frame = stack.pop_frame();
|
|
||||||
assert_eq!(frame.len(), 0);
|
|
||||||
assert_eq!(stack.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_never_empties() {
|
|
||||||
let mut stack = IoStack::new();
|
|
||||||
|
|
||||||
// Try to pop the last frame
|
|
||||||
let frame = stack.pop_frame();
|
|
||||||
assert_eq!(frame.len(), 0);
|
|
||||||
|
|
||||||
// Stack should still have one frame
|
|
||||||
assert_eq!(stack.len(), 1);
|
|
||||||
|
|
||||||
// Pop again - should still have one frame
|
|
||||||
let frame = stack.pop_frame();
|
|
||||||
assert_eq!(frame.len(), 0);
|
|
||||||
assert_eq!(stack.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_push_to_frame() {
|
|
||||||
let mut stack = IoStack::new();
|
|
||||||
|
|
||||||
let redir = crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output);
|
|
||||||
|
|
||||||
stack.push_to_frame(redir);
|
|
||||||
assert_eq!(stack.curr_frame().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_append_to_frame() {
|
|
||||||
let mut stack = IoStack::new();
|
|
||||||
|
|
||||||
let redirs = vec![
|
|
||||||
crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output),
|
|
||||||
crate::parse::Redir::new(IoMode::fd(2, 1), RedirType::Output),
|
|
||||||
];
|
|
||||||
|
|
||||||
stack.append_to_frame(redirs);
|
|
||||||
assert_eq!(stack.curr_frame().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_frame_isolation() {
|
|
||||||
let mut stack = IoStack::new();
|
|
||||||
|
|
||||||
// Add redir to first frame
|
|
||||||
let redir1 = crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output);
|
|
||||||
stack.push_to_frame(redir1);
|
|
||||||
assert_eq!(stack.curr_frame().len(), 1);
|
|
||||||
|
|
||||||
// Push new frame
|
|
||||||
stack.push_frame(IoFrame::new());
|
|
||||||
assert_eq!(stack.curr_frame().len(), 0, "New frame should be empty");
|
|
||||||
|
|
||||||
// Add redir to second frame
|
|
||||||
let redir2 = crate::parse::Redir::new(IoMode::fd(2, 1), RedirType::Output);
|
|
||||||
stack.push_to_frame(redir2);
|
|
||||||
assert_eq!(stack.curr_frame().len(), 1);
|
|
||||||
|
|
||||||
// Pop second frame
|
|
||||||
let frame2 = stack.pop_frame();
|
|
||||||
assert_eq!(frame2.len(), 1);
|
|
||||||
|
|
||||||
// First frame should still have its redir
|
|
||||||
assert_eq!(stack.curr_frame().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iostack_flatten() {
|
|
||||||
let mut stack = IoStack::new();
|
|
||||||
|
|
||||||
// Add redir to first frame
|
|
||||||
let redir1 = crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output);
|
|
||||||
stack.push_to_frame(redir1);
|
|
||||||
|
|
||||||
// Push new frame with redir
|
|
||||||
let mut frame2 = IoFrame::new();
|
|
||||||
frame2.push(crate::parse::Redir::new(
|
|
||||||
IoMode::fd(2, 1),
|
|
||||||
RedirType::Output,
|
|
||||||
));
|
|
||||||
stack.push_frame(frame2);
|
|
||||||
|
|
||||||
// Push third frame with redir
|
|
||||||
let mut frame3 = IoFrame::new();
|
|
||||||
frame3.push(crate::parse::Redir::new(IoMode::fd(0, 3), RedirType::Input));
|
|
||||||
stack.push_frame(frame3);
|
|
||||||
|
|
||||||
assert_eq!(stack.len(), 3);
|
|
||||||
|
|
||||||
// Flatten
|
|
||||||
stack.flatten();
|
|
||||||
|
|
||||||
// Should have one frame with all redirects
|
|
||||||
assert_eq!(stack.len(), 1);
|
|
||||||
assert_eq!(stack.curr_frame().len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ioframe_new() {
|
|
||||||
let frame = IoFrame::new();
|
|
||||||
assert_eq!(frame.len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ioframe_from_redirs() {
|
|
||||||
let redirs = vec![
|
|
||||||
crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output),
|
|
||||||
crate::parse::Redir::new(IoMode::fd(2, 1), RedirType::Output),
|
|
||||||
];
|
|
||||||
|
|
||||||
let frame = IoFrame::from_redirs(redirs);
|
|
||||||
assert_eq!(frame.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ioframe_push() {
|
|
||||||
let mut frame = IoFrame::new();
|
|
||||||
|
|
||||||
let redir = crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output);
|
|
||||||
frame.push(redir);
|
|
||||||
|
|
||||||
assert_eq!(frame.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// IoMode Tests - Construction Logic
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iomode_fd_construction() {
|
|
||||||
let io_mode = IoMode::fd(2, 1);
|
|
||||||
|
|
||||||
match io_mode {
|
|
||||||
IoMode::Fd { tgt_fd, src_fd } => {
|
|
||||||
assert_eq!(tgt_fd, 2);
|
|
||||||
assert_eq!(src_fd, 1);
|
|
||||||
}
|
|
||||||
_ => panic!("Expected IoMode::Fd"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iomode_tgt_fd() {
|
|
||||||
let fd_mode = IoMode::fd(2, 1);
|
|
||||||
assert_eq!(fd_mode.tgt_fd(), 2);
|
|
||||||
|
|
||||||
let file_mode = IoMode::file(1, std::path::PathBuf::from("test.txt"), RedirType::Output);
|
|
||||||
assert_eq!(file_mode.tgt_fd(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn iomode_src_fd() {
|
|
||||||
let fd_mode = IoMode::fd(2, 1);
|
|
||||||
assert_eq!(fd_mode.src_fd(), 1);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
use std::process::{self, Output};
|
|
||||||
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
use super::super::*;
|
|
||||||
fn get_script_output(name: &str, args: &[&str]) -> Output {
|
|
||||||
// Resolve the path to the shed binary.
|
|
||||||
// Do not question me.
|
|
||||||
let mut shed_path = env::current_exe().expect("Failed to get test executable"); // The path to the test executable
|
|
||||||
shed_path.pop(); // Hocus pocus
|
|
||||||
shed_path.pop();
|
|
||||||
shed_path.push("shed"); // Abra Kadabra
|
|
||||||
|
|
||||||
if !shed_path.is_file() {
|
|
||||||
shed_path.pop();
|
|
||||||
shed_path.pop();
|
|
||||||
shed_path.push("release");
|
|
||||||
shed_path.push("shed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !shed_path.is_file() {
|
|
||||||
panic!("where the hell is the binary")
|
|
||||||
}
|
|
||||||
|
|
||||||
process::Command::new(shed_path) // Alakazam
|
|
||||||
.arg(name)
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.expect("Failed to run script")
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn script_hello_world() {
|
|
||||||
let output = get_script_output("./test_scripts/hello.sh", &[]);
|
|
||||||
assert!(output.status.success());
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
assert_eq!(stdout.trim(), "Hello, World!")
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn script_cmdsub() {
|
|
||||||
let output = get_script_output("./test_scripts/cmdsub.sh", &[]);
|
|
||||||
assert!(output.status.success());
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
assert_eq!(stdout.trim(), "foo Hello bar")
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn script_multiline() {
|
|
||||||
let output = get_script_output("./test_scripts/multiline.sh", &[]);
|
|
||||||
assert!(output.status.success());
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
assert_eq!(stdout.trim(), "foo\nbar\nbiz\nbuzz")
|
|
||||||
}
|
|
||||||
@@ -1,895 +0,0 @@
|
|||||||
use crate::state::{LogTab, MetaTab, ScopeStack, ShellParam, VarFlags, VarKind, VarTab};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ScopeStack Tests - Variable Scoping
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_new() {
|
|
||||||
let stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Should start with one global scope
|
|
||||||
assert!(stack.var_exists("PATH") || !stack.var_exists("PATH")); // Just check
|
|
||||||
// it doesn't
|
|
||||||
// panic
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_descend_ascend() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Set a global variable
|
|
||||||
stack.set_var("GLOBAL", VarKind::Str("value1".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(stack.get_var("GLOBAL"), "value1");
|
|
||||||
|
|
||||||
// Descend into a new scope
|
|
||||||
stack.descend(None);
|
|
||||||
|
|
||||||
// Global should still be visible
|
|
||||||
assert_eq!(stack.get_var("GLOBAL"), "value1");
|
|
||||||
|
|
||||||
// Set a local variable
|
|
||||||
stack.set_var("LOCAL", VarKind::Str("value2".into()), VarFlags::LOCAL);
|
|
||||||
assert_eq!(stack.get_var("LOCAL"), "value2");
|
|
||||||
|
|
||||||
// Ascend back to global scope
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// Global should still exist
|
|
||||||
assert_eq!(stack.get_var("GLOBAL"), "value1");
|
|
||||||
|
|
||||||
// Local should no longer be visible
|
|
||||||
assert_eq!(stack.get_var("LOCAL"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_variable_shadowing() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Set global variable
|
|
||||||
stack.set_var("VAR", VarKind::Str("global".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(stack.get_var("VAR"), "global");
|
|
||||||
|
|
||||||
// Descend into local scope
|
|
||||||
stack.descend(None);
|
|
||||||
|
|
||||||
// Set local variable with same name
|
|
||||||
stack.set_var("VAR", VarKind::Str("local".into()), VarFlags::LOCAL);
|
|
||||||
assert_eq!(stack.get_var("VAR"), "local", "Local should shadow global");
|
|
||||||
|
|
||||||
// Ascend back
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// Global should be restored
|
|
||||||
assert_eq!(
|
|
||||||
stack.get_var("VAR"),
|
|
||||||
"global",
|
|
||||||
"Global should be unchanged after ascend"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_local_vs_global_flag() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Descend into a local scope
|
|
||||||
stack.descend(None);
|
|
||||||
|
|
||||||
// Set with LOCAL flag - should go in current scope
|
|
||||||
stack.set_var("LOCAL_VAR", VarKind::Str("local".into()), VarFlags::LOCAL);
|
|
||||||
|
|
||||||
// Set without LOCAL flag - should go in global scope
|
|
||||||
stack.set_var("GLOBAL_VAR", VarKind::Str("global".into()), VarFlags::NONE);
|
|
||||||
|
|
||||||
// Both visible from local scope
|
|
||||||
assert_eq!(stack.get_var("LOCAL_VAR"), "local");
|
|
||||||
assert_eq!(stack.get_var("GLOBAL_VAR"), "global");
|
|
||||||
|
|
||||||
// Ascend to global
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// Only global var should be visible
|
|
||||||
assert_eq!(stack.get_var("GLOBAL_VAR"), "global");
|
|
||||||
assert_eq!(stack.get_var("LOCAL_VAR"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_multiple_levels() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
stack.set_var("LEVEL0", VarKind::Str("global".into()), VarFlags::NONE);
|
|
||||||
|
|
||||||
// Level 1
|
|
||||||
stack.descend(None);
|
|
||||||
stack.set_var("LEVEL1", VarKind::Str("first".into()), VarFlags::LOCAL);
|
|
||||||
|
|
||||||
// Level 2
|
|
||||||
stack.descend(None);
|
|
||||||
stack.set_var("LEVEL2", VarKind::Str("second".into()), VarFlags::LOCAL);
|
|
||||||
|
|
||||||
// All variables visible from deepest scope
|
|
||||||
assert_eq!(stack.get_var("LEVEL0"), "global");
|
|
||||||
assert_eq!(stack.get_var("LEVEL1"), "first");
|
|
||||||
assert_eq!(stack.get_var("LEVEL2"), "second");
|
|
||||||
|
|
||||||
// Ascend to level 1
|
|
||||||
stack.ascend();
|
|
||||||
assert_eq!(stack.get_var("LEVEL0"), "global");
|
|
||||||
assert_eq!(stack.get_var("LEVEL1"), "first");
|
|
||||||
assert_eq!(stack.get_var("LEVEL2"), "");
|
|
||||||
|
|
||||||
// Ascend to global
|
|
||||||
stack.ascend();
|
|
||||||
assert_eq!(stack.get_var("LEVEL0"), "global");
|
|
||||||
assert_eq!(stack.get_var("LEVEL1"), "");
|
|
||||||
assert_eq!(stack.get_var("LEVEL2"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_cannot_ascend_past_global() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
stack.set_var("VAR", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
|
|
||||||
// Try to ascend from global scope (should be no-op)
|
|
||||||
stack.ascend();
|
|
||||||
stack.ascend();
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// Variable should still exist
|
|
||||||
assert_eq!(stack.get_var("VAR"), "value");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_descend_with_args() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Get initial param values from global scope (test process args)
|
|
||||||
let global_param_1 = stack.get_param(ShellParam::Pos(1));
|
|
||||||
|
|
||||||
// Descend with positional parameters
|
|
||||||
let args = vec!["local_arg1".to_string(), "local_arg2".to_string()];
|
|
||||||
stack.descend(Some(args));
|
|
||||||
|
|
||||||
// In local scope, positional params come from the VarTab created during descend
|
|
||||||
// VarTab::new() initializes with process args, then our args are appended
|
|
||||||
// So we check that SOME positional parameter exists (implementation detail may
|
|
||||||
// vary)
|
|
||||||
let local_param = stack.get_param(ShellParam::Pos(1));
|
|
||||||
assert!(
|
|
||||||
!local_param.is_empty(),
|
|
||||||
"Should have positional parameters in local scope"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ascend back
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// Should be back to global scope parameters
|
|
||||||
assert_eq!(stack.get_param(ShellParam::Pos(1)), global_param_1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_global_parameters() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Set global parameters
|
|
||||||
stack.set_param(ShellParam::Status, "0");
|
|
||||||
stack.set_param(ShellParam::LastJob, "1234");
|
|
||||||
|
|
||||||
assert_eq!(stack.get_param(ShellParam::Status), "0");
|
|
||||||
assert_eq!(stack.get_param(ShellParam::LastJob), "1234");
|
|
||||||
|
|
||||||
// Descend into local scope
|
|
||||||
stack.descend(None);
|
|
||||||
|
|
||||||
// Global parameters should still be visible
|
|
||||||
assert_eq!(stack.get_param(ShellParam::Status), "0");
|
|
||||||
assert_eq!(stack.get_param(ShellParam::LastJob), "1234");
|
|
||||||
|
|
||||||
// Modify global parameter from local scope
|
|
||||||
stack.set_param(ShellParam::Status, "1");
|
|
||||||
assert_eq!(stack.get_param(ShellParam::Status), "1");
|
|
||||||
|
|
||||||
// Ascend
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// Global parameter should retain modified value
|
|
||||||
assert_eq!(stack.get_param(ShellParam::Status), "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_unset_var() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
stack.set_var("VAR", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(stack.get_var("VAR"), "value");
|
|
||||||
|
|
||||||
stack.unset_var("VAR");
|
|
||||||
assert_eq!(stack.get_var("VAR"), "");
|
|
||||||
assert!(!stack.var_exists("VAR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_unset_finds_innermost() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Set global
|
|
||||||
stack.set_var("VAR", VarKind::Str("global".into()), VarFlags::NONE);
|
|
||||||
|
|
||||||
// Descend and shadow
|
|
||||||
stack.descend(None);
|
|
||||||
stack.set_var("VAR", VarKind::Str("local".into()), VarFlags::LOCAL);
|
|
||||||
assert_eq!(stack.get_var("VAR"), "local");
|
|
||||||
|
|
||||||
// Unset should remove local, revealing global
|
|
||||||
stack.unset_var("VAR");
|
|
||||||
assert_eq!(stack.get_var("VAR"), "global");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_export_var() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
stack.set_var("VAR", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
|
|
||||||
// Export the variable
|
|
||||||
stack.export_var("VAR");
|
|
||||||
|
|
||||||
// Variable should still be accessible (flag is internal detail)
|
|
||||||
assert_eq!(stack.get_var("VAR"), "value");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_var_exists() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
assert!(!stack.var_exists("NONEXISTENT"));
|
|
||||||
|
|
||||||
stack.set_var("EXISTS", VarKind::Str("yes".into()), VarFlags::NONE);
|
|
||||||
assert!(stack.var_exists("EXISTS"));
|
|
||||||
|
|
||||||
stack.descend(None);
|
|
||||||
assert!(
|
|
||||||
stack.var_exists("EXISTS"),
|
|
||||||
"Global var should be visible in local scope"
|
|
||||||
);
|
|
||||||
|
|
||||||
stack.set_var("LOCAL", VarKind::Str("yes".into()), VarFlags::LOCAL);
|
|
||||||
assert!(stack.var_exists("LOCAL"));
|
|
||||||
|
|
||||||
stack.ascend();
|
|
||||||
assert!(
|
|
||||||
!stack.var_exists("LOCAL"),
|
|
||||||
"Local var should not exist after ascend"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_flatten_vars() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
stack.set_var("GLOBAL1", VarKind::Str("g1".into()), VarFlags::NONE);
|
|
||||||
stack.set_var("GLOBAL2", VarKind::Str("g2".into()), VarFlags::NONE);
|
|
||||||
|
|
||||||
stack.descend(None);
|
|
||||||
stack.set_var("LOCAL1", VarKind::Str("l1".into()), VarFlags::LOCAL);
|
|
||||||
|
|
||||||
let flattened = stack.flatten_vars();
|
|
||||||
|
|
||||||
// Should contain variables from all scopes
|
|
||||||
assert!(flattened.contains_key("GLOBAL1"));
|
|
||||||
assert!(flattened.contains_key("GLOBAL2"));
|
|
||||||
assert!(flattened.contains_key("LOCAL1"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_local_var_mutation() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Descend into function scope
|
|
||||||
stack.descend(None);
|
|
||||||
|
|
||||||
// `local foo="biz"` — create a local variable with initial value
|
|
||||||
stack.set_var("foo", VarKind::Str("biz".into()), VarFlags::LOCAL);
|
|
||||||
assert_eq!(stack.get_var("foo"), "biz");
|
|
||||||
|
|
||||||
// `foo="bar"` — reassign without LOCAL flag (plain assignment)
|
|
||||||
stack.set_var("foo", VarKind::Str("bar".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(
|
|
||||||
stack.get_var("foo"),
|
|
||||||
"bar",
|
|
||||||
"Local var should be mutated in place"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ascend back to global
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// foo should not exist in global scope
|
|
||||||
assert_eq!(
|
|
||||||
stack.get_var("foo"),
|
|
||||||
"",
|
|
||||||
"Local var should not leak to global scope"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn scopestack_local_var_uninitialized() {
|
|
||||||
let mut stack = ScopeStack::new();
|
|
||||||
|
|
||||||
// Descend into function scope
|
|
||||||
stack.descend(None);
|
|
||||||
|
|
||||||
// `local foo` — declare without a value
|
|
||||||
stack.set_var("foo", VarKind::Str("".into()), VarFlags::LOCAL);
|
|
||||||
assert_eq!(stack.get_var("foo"), "");
|
|
||||||
|
|
||||||
// `foo="bar"` — assign a value later
|
|
||||||
stack.set_var("foo", VarKind::Str("bar".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(
|
|
||||||
stack.get_var("foo"),
|
|
||||||
"bar",
|
|
||||||
"Uninitialized local should be assignable"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ascend back to global
|
|
||||||
stack.ascend();
|
|
||||||
|
|
||||||
// foo should not exist in global scope
|
|
||||||
assert_eq!(
|
|
||||||
stack.get_var("foo"),
|
|
||||||
"",
|
|
||||||
"Local var should not leak to global scope"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// LogTab Tests - Functions and Aliases
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_new() {
|
|
||||||
let logtab = LogTab::new();
|
|
||||||
assert_eq!(logtab.funcs().len(), 0);
|
|
||||||
assert_eq!(logtab.aliases().len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_insert_get_alias() {
|
|
||||||
let mut logtab = LogTab::new();
|
|
||||||
|
|
||||||
logtab.insert_alias("ll", "ls -la");
|
|
||||||
assert_eq!(logtab.get_alias("ll"), Some("ls -la".to_string()));
|
|
||||||
assert_eq!(logtab.get_alias("nonexistent"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_overwrite_alias() {
|
|
||||||
let mut logtab = LogTab::new();
|
|
||||||
|
|
||||||
logtab.insert_alias("ll", "ls -la");
|
|
||||||
assert_eq!(logtab.get_alias("ll"), Some("ls -la".to_string()));
|
|
||||||
|
|
||||||
logtab.insert_alias("ll", "ls -lah");
|
|
||||||
assert_eq!(logtab.get_alias("ll"), Some("ls -lah".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_remove_alias() {
|
|
||||||
let mut logtab = LogTab::new();
|
|
||||||
|
|
||||||
logtab.insert_alias("ll", "ls -la");
|
|
||||||
assert!(logtab.get_alias("ll").is_some());
|
|
||||||
|
|
||||||
logtab.remove_alias("ll");
|
|
||||||
assert!(logtab.get_alias("ll").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_clear_aliases() {
|
|
||||||
let mut logtab = LogTab::new();
|
|
||||||
|
|
||||||
logtab.insert_alias("ll", "ls -la");
|
|
||||||
logtab.insert_alias("la", "ls -A");
|
|
||||||
logtab.insert_alias("l", "ls -CF");
|
|
||||||
|
|
||||||
assert_eq!(logtab.aliases().len(), 3);
|
|
||||||
|
|
||||||
logtab.clear_aliases();
|
|
||||||
assert_eq!(logtab.aliases().len(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_multiple_aliases() {
|
|
||||||
let mut logtab = LogTab::new();
|
|
||||||
|
|
||||||
logtab.insert_alias("ll", "ls -la");
|
|
||||||
logtab.insert_alias("la", "ls -A");
|
|
||||||
logtab.insert_alias("grep", "grep --color=auto");
|
|
||||||
|
|
||||||
assert_eq!(logtab.aliases().len(), 3);
|
|
||||||
assert_eq!(logtab.get_alias("ll"), Some("ls -la".to_string()));
|
|
||||||
assert_eq!(logtab.get_alias("la"), Some("ls -A".to_string()));
|
|
||||||
assert_eq!(
|
|
||||||
logtab.get_alias("grep"),
|
|
||||||
Some("grep --color=auto".to_string())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Function tests are limited because ShFunc requires complex setup
|
|
||||||
// (parsed AST) We'll test the basic storage/retrieval mechanics
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn logtab_funcs_empty_initially() {
|
|
||||||
let logtab = LogTab::new();
|
|
||||||
assert_eq!(logtab.funcs().len(), 0);
|
|
||||||
assert!(logtab.get_func("nonexistent").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// VarTab Tests - Variable Storage
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_new() {
|
|
||||||
let vartab = VarTab::new();
|
|
||||||
// VarTab initializes with some default params, just check it doesn't panic
|
|
||||||
assert!(vartab.get_var("NONEXISTENT").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_set_get_var() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
vartab.set_var("TEST", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(vartab.get_var("TEST"), "value");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_overwrite_var() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
vartab.set_var("VAR", VarKind::Str("value1".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(vartab.get_var("VAR"), "value1");
|
|
||||||
|
|
||||||
vartab.set_var("VAR", VarKind::Str("value2".into()), VarFlags::NONE);
|
|
||||||
assert_eq!(vartab.get_var("VAR"), "value2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_var_exists() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
assert!(!vartab.var_exists("TEST"));
|
|
||||||
|
|
||||||
vartab.set_var("TEST", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
assert!(vartab.var_exists("TEST"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_unset_var() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
vartab.set_var("VAR", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
assert!(vartab.var_exists("VAR"));
|
|
||||||
|
|
||||||
vartab.unset_var("VAR");
|
|
||||||
assert!(!vartab.var_exists("VAR"));
|
|
||||||
assert_eq!(vartab.get_var("VAR"), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_export_var() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
vartab.set_var("VAR", VarKind::Str("value".into()), VarFlags::NONE);
|
|
||||||
vartab.export_var("VAR");
|
|
||||||
|
|
||||||
// Variable should still be accessible
|
|
||||||
assert_eq!(vartab.get_var("VAR"), "value");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_positional_params() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
// Get the current argv length
|
|
||||||
let initial_len = vartab.sh_argv().len();
|
|
||||||
|
|
||||||
// Clear and reinitialize with known args
|
|
||||||
vartab.clear_args(); // This keeps $0 as current exe
|
|
||||||
|
|
||||||
// After clear_args, should have just $0
|
|
||||||
// Push additional args
|
|
||||||
vartab.bpush_arg("test_arg1".to_string());
|
|
||||||
vartab.bpush_arg("test_arg2".to_string());
|
|
||||||
|
|
||||||
// Now sh_argv should be: [exe_path, test_arg1, test_arg2]
|
|
||||||
// Pos(0) = exe_path, Pos(1) = test_arg1, Pos(2) = test_arg2
|
|
||||||
let final_len = vartab.sh_argv().len();
|
|
||||||
assert!(
|
|
||||||
final_len > initial_len || final_len >= 1,
|
|
||||||
"Should have arguments"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Just verify we can retrieve the last args we pushed
|
|
||||||
let last_idx = final_len - 1;
|
|
||||||
assert_eq!(vartab.get_param(ShellParam::Pos(last_idx)), "test_arg2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vartab_shell_argv_operations() {
|
|
||||||
let mut vartab = VarTab::new();
|
|
||||||
|
|
||||||
// Clear initial args and set fresh ones
|
|
||||||
vartab.clear_args();
|
|
||||||
|
|
||||||
// Push args (clear_args leaves $0, so these become $1, $2, $3)
|
|
||||||
vartab.bpush_arg("arg1".to_string());
|
|
||||||
vartab.bpush_arg("arg2".to_string());
|
|
||||||
vartab.bpush_arg("arg3".to_string());
|
|
||||||
|
|
||||||
// Get initial arg count
|
|
||||||
let initial_len = vartab.sh_argv().len();
|
|
||||||
|
|
||||||
// Pop first arg (removes $0)
|
|
||||||
let popped = vartab.fpop_arg();
|
|
||||||
assert!(popped.is_some());
|
|
||||||
|
|
||||||
// Should have one fewer arg
|
|
||||||
assert_eq!(vartab.sh_argv().len(), initial_len - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// VarFlags Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn varflags_none() {
|
|
||||||
let flags = VarFlags::NONE;
|
|
||||||
assert!(!flags.contains(VarFlags::EXPORT));
|
|
||||||
assert!(!flags.contains(VarFlags::LOCAL));
|
|
||||||
assert!(!flags.contains(VarFlags::READONLY));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn varflags_export() {
|
|
||||||
let flags = VarFlags::EXPORT;
|
|
||||||
assert!(flags.contains(VarFlags::EXPORT));
|
|
||||||
assert!(!flags.contains(VarFlags::LOCAL));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn varflags_local() {
|
|
||||||
let flags = VarFlags::LOCAL;
|
|
||||||
assert!(!flags.contains(VarFlags::EXPORT));
|
|
||||||
assert!(flags.contains(VarFlags::LOCAL));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn varflags_combine() {
|
|
||||||
let flags = VarFlags::EXPORT | VarFlags::LOCAL;
|
|
||||||
assert!(flags.contains(VarFlags::EXPORT));
|
|
||||||
assert!(flags.contains(VarFlags::LOCAL));
|
|
||||||
assert!(!flags.contains(VarFlags::READONLY));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn varflags_readonly() {
|
|
||||||
let flags = VarFlags::READONLY;
|
|
||||||
assert!(flags.contains(VarFlags::READONLY));
|
|
||||||
assert!(!flags.contains(VarFlags::EXPORT));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// ShellParam Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shellparam_is_global() {
|
|
||||||
assert!(ShellParam::Status.is_global());
|
|
||||||
assert!(ShellParam::ShPid.is_global());
|
|
||||||
assert!(ShellParam::LastJob.is_global());
|
|
||||||
assert!(ShellParam::ShellName.is_global());
|
|
||||||
|
|
||||||
assert!(!ShellParam::Pos(1).is_global());
|
|
||||||
assert!(!ShellParam::AllArgs.is_global());
|
|
||||||
assert!(!ShellParam::AllArgsStr.is_global());
|
|
||||||
assert!(!ShellParam::ArgCount.is_global());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shellparam_from_str() {
|
|
||||||
assert!(matches!(
|
|
||||||
"?".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::Status
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
"$".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::ShPid
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
"!".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::LastJob
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
"0".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::ShellName
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
"@".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::AllArgs
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
"*".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::AllArgsStr
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
"#".parse::<ShellParam>().unwrap(),
|
|
||||||
ShellParam::ArgCount
|
|
||||||
));
|
|
||||||
|
|
||||||
match "1".parse::<ShellParam>().unwrap() {
|
|
||||||
ShellParam::Pos(n) => assert_eq!(n, 1),
|
|
||||||
_ => panic!("Expected Pos(1)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
match "42".parse::<ShellParam>().unwrap() {
|
|
||||||
ShellParam::Pos(n) => assert_eq!(n, 42),
|
|
||||||
_ => panic!("Expected Pos(42)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!("invalid".parse::<ShellParam>().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shellparam_display() {
|
|
||||||
assert_eq!(ShellParam::Status.to_string(), "?");
|
|
||||||
assert_eq!(ShellParam::ShPid.to_string(), "$");
|
|
||||||
assert_eq!(ShellParam::LastJob.to_string(), "!");
|
|
||||||
assert_eq!(ShellParam::ShellName.to_string(), "0");
|
|
||||||
assert_eq!(ShellParam::AllArgs.to_string(), "@");
|
|
||||||
assert_eq!(ShellParam::AllArgsStr.to_string(), "*");
|
|
||||||
assert_eq!(ShellParam::ArgCount.to_string(), "#");
|
|
||||||
assert_eq!(ShellParam::Pos(1).to_string(), "1");
|
|
||||||
assert_eq!(ShellParam::Pos(99).to_string(), "99");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MetaTab Directory Stack Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_push_pop() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
|
|
||||||
// push_front means /var is on top, /tmp is below
|
|
||||||
assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/var")));
|
|
||||||
|
|
||||||
let popped = meta.pop_dir();
|
|
||||||
assert_eq!(popped, Some(PathBuf::from("/var")));
|
|
||||||
assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/tmp")));
|
|
||||||
|
|
||||||
let popped = meta.pop_dir();
|
|
||||||
assert_eq!(popped, Some(PathBuf::from("/tmp")));
|
|
||||||
assert_eq!(meta.pop_dir(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_empty() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
assert_eq!(meta.dir_stack_top(), None);
|
|
||||||
assert_eq!(meta.pop_dir(), None);
|
|
||||||
assert!(meta.dirs().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_rotate_fwd() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Build stack: front=[A, B, C, D]=back
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/a"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/b"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/c"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/d"));
|
|
||||||
|
|
||||||
// rotate_left(1): [B, C, D, A]
|
|
||||||
meta.rotate_dirs_fwd(1);
|
|
||||||
assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/b")));
|
|
||||||
assert_eq!(meta.dirs().back(), Some(&PathBuf::from("/a")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_rotate_bkwd() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Build stack: front=[A, B, C, D]=back
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/a"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/b"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/c"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/d"));
|
|
||||||
|
|
||||||
// rotate_right(1): [D, A, B, C]
|
|
||||||
meta.rotate_dirs_bkwd(1);
|
|
||||||
assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/d")));
|
|
||||||
assert_eq!(meta.dirs().back(), Some(&PathBuf::from("/c")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_rotate_zero_is_noop() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/a"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/b"));
|
|
||||||
meta.dirs_mut().push_back(PathBuf::from("/c"));
|
|
||||||
|
|
||||||
meta.rotate_dirs_fwd(0);
|
|
||||||
assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/a")));
|
|
||||||
|
|
||||||
meta.rotate_dirs_bkwd(0);
|
|
||||||
assert_eq!(meta.dir_stack_top(), Some(&PathBuf::from("/a")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_pushd_rotation_with_cwd() {
|
|
||||||
// Simulates what pushd +N does: insert cwd, rotate, pop new top
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Stored stack: [/tmp, /var, /etc]
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// pushd +2 with cwd=/home:
|
|
||||||
// push_front(cwd): [/home, /tmp, /var, /etc]
|
|
||||||
// rotate_left(2): [/var, /etc, /home, /tmp]
|
|
||||||
// pop_front(): /var = new cwd
|
|
||||||
let cwd = PathBuf::from("/home");
|
|
||||||
let dirs = meta.dirs_mut();
|
|
||||||
dirs.push_front(cwd);
|
|
||||||
dirs.rotate_left(2);
|
|
||||||
let new_cwd = dirs.pop_front();
|
|
||||||
|
|
||||||
assert_eq!(new_cwd, Some(PathBuf::from("/var")));
|
|
||||||
let remaining: Vec<_> = meta.dirs().iter().collect();
|
|
||||||
assert_eq!(
|
|
||||||
remaining,
|
|
||||||
vec![
|
|
||||||
&PathBuf::from("/etc"),
|
|
||||||
&PathBuf::from("/home"),
|
|
||||||
&PathBuf::from("/tmp"),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_pushd_minus_zero_with_cwd() {
|
|
||||||
// pushd -0: bring bottom to top
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Stored stack: [/tmp, /var, /etc]
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// pushd -0 with cwd=/home:
|
|
||||||
// push_front(cwd): [/home, /tmp, /var, /etc]
|
|
||||||
// rotate_right(0+1=1): [/etc, /home, /tmp, /var]
|
|
||||||
// pop_front(): /etc = new cwd
|
|
||||||
let cwd = PathBuf::from("/home");
|
|
||||||
let dirs = meta.dirs_mut();
|
|
||||||
dirs.push_front(cwd);
|
|
||||||
dirs.rotate_right(1);
|
|
||||||
let new_cwd = dirs.pop_front();
|
|
||||||
|
|
||||||
assert_eq!(new_cwd, Some(PathBuf::from("/etc")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_pushd_plus_zero_noop() {
|
|
||||||
// pushd +0: should be a no-op (cwd stays the same)
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// pushd +0 with cwd=/home:
|
|
||||||
// push_front(cwd): [/home, /tmp, /var, /etc]
|
|
||||||
// rotate_left(0): no-op
|
|
||||||
// pop_front(): /home = cwd unchanged
|
|
||||||
let cwd = PathBuf::from("/home");
|
|
||||||
let dirs = meta.dirs_mut();
|
|
||||||
dirs.push_front(cwd.clone());
|
|
||||||
dirs.rotate_left(0);
|
|
||||||
let new_cwd = dirs.pop_front();
|
|
||||||
|
|
||||||
assert_eq!(new_cwd, Some(PathBuf::from("/home")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_popd_removes_from_top() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// popd (no args) or popd +0: pop from front
|
|
||||||
let popped = meta.pop_dir();
|
|
||||||
assert_eq!(popped, Some(PathBuf::from("/tmp")));
|
|
||||||
assert_eq!(meta.dirs().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_popd_plus_n_offset() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Stored: [/tmp, /var, /etc] (front to back)
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// popd +2: full stack is [cwd, /tmp, /var, /etc]
|
|
||||||
// +2 = /var, which is stored index 1 (n-1 = 2-1 = 1)
|
|
||||||
let removed = meta.dirs_mut().remove(1); // n-1 for +N
|
|
||||||
assert_eq!(removed, Some(PathBuf::from("/var")));
|
|
||||||
|
|
||||||
let remaining: Vec<_> = meta.dirs().iter().collect();
|
|
||||||
assert_eq!(
|
|
||||||
remaining,
|
|
||||||
vec![&PathBuf::from("/tmp"), &PathBuf::from("/etc"),]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_popd_minus_zero() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Stored: [/tmp, /var, /etc]
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// popd -0: remove bottom (back)
|
|
||||||
// actual = len - 1 - 0 = 2, via checked_sub(0+1) = checked_sub(1) = 2
|
|
||||||
let len = meta.dirs().len();
|
|
||||||
let actual = len.checked_sub(1).unwrap();
|
|
||||||
let removed = meta.dirs_mut().remove(actual);
|
|
||||||
assert_eq!(removed, Some(PathBuf::from("/etc")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_popd_minus_n() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
// Stored: [/tmp, /var, /etc, /usr]
|
|
||||||
meta.push_dir(PathBuf::from("/usr"));
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
|
|
||||||
// popd -1: second from bottom = /etc
|
|
||||||
// actual = len - (1+1) = 4 - 2 = 2
|
|
||||||
let len = meta.dirs().len();
|
|
||||||
let actual = len.checked_sub(2).unwrap(); // n+1 = 2
|
|
||||||
let removed = meta.dirs_mut().remove(actual);
|
|
||||||
assert_eq!(removed, Some(PathBuf::from("/etc")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dirstack_clear() {
|
|
||||||
let mut meta = MetaTab::new();
|
|
||||||
|
|
||||||
meta.push_dir(PathBuf::from("/tmp"));
|
|
||||||
meta.push_dir(PathBuf::from("/var"));
|
|
||||||
meta.push_dir(PathBuf::from("/etc"));
|
|
||||||
|
|
||||||
meta.dirs_mut().clear();
|
|
||||||
assert!(meta.dirs().is_empty());
|
|
||||||
assert_eq!(meta.dir_stack_top(), None);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use libsh::term::{Style, StyleSet, Styled};
|
|
||||||
|
|
||||||
use super::super::*;
|
|
||||||
#[test]
|
|
||||||
fn styled_simple() {
|
|
||||||
let input = "hello world";
|
|
||||||
let styled = input.styled(Style::Green);
|
|
||||||
|
|
||||||
insta::assert_snapshot!(styled)
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn styled_multiple() {
|
|
||||||
let input = "styled text";
|
|
||||||
let styled = input.styled(Style::Red | Style::Bold | Style::Underline);
|
|
||||||
insta::assert_snapshot!(styled);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn styled_rgb() {
|
|
||||||
let input = "RGB styled text";
|
|
||||||
let styled = input.styled(Style::RGB(255, 99, 71)); // Tomato color
|
|
||||||
insta::assert_snapshot!(styled);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn styled_background() {
|
|
||||||
let input = "text with background";
|
|
||||||
let styled = input.styled(Style::BgBlue | Style::Bold);
|
|
||||||
insta::assert_snapshot!(styled);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn styled_set() {
|
|
||||||
let input = "multi-style text";
|
|
||||||
let style_set = StyleSet::new()
|
|
||||||
.add_style(Style::Magenta)
|
|
||||||
.add_style(Style::Italic);
|
|
||||||
let styled = input.styled(style_set);
|
|
||||||
insta::assert_snapshot!(styled);
|
|
||||||
}
|
|
||||||
#[test]
|
|
||||||
fn styled_reset() {
|
|
||||||
let input = "reset test";
|
|
||||||
let styled = input.styled(Style::Bold | Style::Reset);
|
|
||||||
insta::assert_snapshot!(styled);
|
|
||||||
}
|
|
||||||
273
src/testutil.rs
Normal file
273
src/testutil.rs
Normal 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
319
tests/gen_vi_tests.lua
Normal 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!")
|
||||||
Reference in New Issue
Block a user