Compare commits
36 Commits
dc0ff23903
...
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 |
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"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
<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" />
|
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/3945f663-a361-4418-bf20-0c4eaa2a36d2" />
|
||||||
|
|
||||||
@@ -8,7 +10,7 @@ A Linux shell written in Rust. The name is a nod to the original Unix utilities
|
|||||||
|
|
||||||
### Line Editor
|
### 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
|
||||||
@@ -40,6 +42,8 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
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|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
149
src/main.rs
149
src/main.rs
@@ -17,6 +17,9 @@ pub mod shopt;
|
|||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod testutil;
|
||||||
|
|
||||||
use std::os::fd::BorrowedFd;
|
use std::os::fd::BorrowedFd;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
@@ -30,20 +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::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>,
|
||||||
|
|
||||||
@@ -56,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();
|
||||||
@@ -108,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();
|
||||||
@@ -127,14 +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.
|
||||||
|
// This var represents how many nested shell instances we're in
|
||||||
|
if let Ok(var) = env::var("SHLVL")
|
||||||
|
&& let Ok(lvl) = var.parse::<u32>()
|
||||||
|
{
|
||||||
|
unsafe { env::set_var("SHLVL", (lvl + 1).to_string()) };
|
||||||
|
} else {
|
||||||
|
unsafe { env::set_var("SHLVL", "1") };
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = source_env() {
|
||||||
|
e.print_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = if let Some(cmd) = args.command {
|
||||||
|
exec_dash_c(cmd)
|
||||||
|
} else if args.stdin || !isatty(STDIN_FILENO).unwrap_or(false) {
|
||||||
|
read_commands(args.script_args)
|
||||||
|
} else if !args.script_args.is_empty() {
|
||||||
|
let path = args.script_args.remove(0);
|
||||||
run_script(path, args.script_args)
|
run_script(path, args.script_args)
|
||||||
} else if let Some(cmd) = args.command {
|
|
||||||
exec_input(cmd, None, false, None)
|
|
||||||
} else {
|
} else {
|
||||||
let res = shed_interactive(args);
|
let res = shed_interactive(args);
|
||||||
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
|
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
|
||||||
res
|
res
|
||||||
} {
|
} {
|
||||||
e.print_error();
|
e.print_error();
|
||||||
};
|
};
|
||||||
@@ -152,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();
|
||||||
@@ -187,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();
|
||||||
}
|
}
|
||||||
@@ -204,7 +247,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode
|
readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode
|
||||||
|
|
||||||
// Main poll loop
|
// Main poll loop
|
||||||
loop {
|
loop {
|
||||||
@@ -218,14 +261,14 @@ 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 => {
|
||||||
// We got 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) => {
|
||||||
QUIT_CODE.store(*code, Ordering::SeqCst);
|
QUIT_CODE.store(*code, Ordering::SeqCst);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
_ => e.print_error(),
|
_ => e.print_error(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,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;
|
||||||
@@ -268,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
|
||||||
@@ -361,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},
|
||||||
@@ -40,6 +42,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
prelude::*,
|
prelude::*,
|
||||||
procio::{IoMode, IoStack, PipeGenerator},
|
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,7 +409,7 @@ 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!()
|
||||||
};
|
};
|
||||||
@@ -374,7 +470,8 @@ 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);
|
||||||
@@ -412,7 +509,6 @@ impl Dispatcher {
|
|||||||
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);
|
||||||
if self.interactive {}
|
|
||||||
let guard = self.io_stack.pop_frame().redirect()?;
|
let guard = self.io_stack.pop_frame().redirect()?;
|
||||||
let brc_grp_logic = |s: &mut Self| -> ShResult<()> {
|
let brc_grp_logic = |s: &mut Self| -> ShResult<()> {
|
||||||
for node in body {
|
for node in body {
|
||||||
@@ -541,6 +637,7 @@ impl Dispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
state::set_status(0);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -677,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,7 +813,18 @@ impl Dispatcher {
|
|||||||
if cmds.len() == 1 {
|
if cmds.len() == 1 {
|
||||||
self.fg_job = !is_bg && self.interactive;
|
self.fg_job = !is_bg && self.interactive;
|
||||||
let cmd = cmds.into_iter().next().unwrap();
|
let cmd = cmds.into_iter().next().unwrap();
|
||||||
self.dispatch_node(cmd)?;
|
if is_bg && !matches!(cmd.class, NdRule::Command { .. }) {
|
||||||
|
self.run_fork(
|
||||||
|
&cmd.get_command().map(|t| t.to_string()).unwrap_or_default(),
|
||||||
|
|s| {
|
||||||
|
if let Err(e) = s.dispatch_node(cmd) {
|
||||||
|
e.print_error();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
self.dispatch_node(cmd)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Give the pipeline terminal control as soon as the first child
|
// 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
|
||||||
@@ -785,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();
|
||||||
@@ -873,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),
|
||||||
@@ -885,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),
|
||||||
@@ -909,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(())
|
||||||
@@ -946,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(());
|
||||||
}
|
}
|
||||||
@@ -959,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;
|
||||||
@@ -1049,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())
|
||||||
}
|
}
|
||||||
@@ -1160,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;
|
||||||
|
|||||||
1251
src/parse/mod.rs
1251
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
|
||||||
|
|||||||
317
src/procio.rs
317
src/procio.rs
@@ -8,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 {
|
||||||
@@ -37,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,
|
||||||
@@ -79,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();
|
||||||
(
|
(
|
||||||
@@ -206,24 +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)?;
|
|
||||||
}
|
}
|
||||||
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)?;
|
||||||
@@ -333,6 +447,9 @@ 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 {
|
pub struct PipeGenerator {
|
||||||
num_cmds: usize,
|
num_cmds: usize,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
@@ -347,7 +464,7 @@ impl PipeGenerator {
|
|||||||
last_rpipe: None,
|
last_rpipe: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn as_io_frames(self) -> Map<Self, fn((Option<Redir>, Option<Redir>)) -> IoFrame> {
|
pub fn as_io_frames(self) -> PipeFrames {
|
||||||
self.map(|(r, w)| {
|
self.map(|(r, w)| {
|
||||||
let mut frame = IoFrame::new();
|
let mut frame = IoFrame::new();
|
||||||
if let Some(r) = r {
|
if let Some(r) = r {
|
||||||
@@ -385,3 +502,169 @@ impl Iterator for PipeGenerator {
|
|||||||
Some((rpipe, Some(wpipe)))
|
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
@@ -88,7 +88,9 @@ impl Highlighter {
|
|||||||
while prefix_chars.peek().is_some() {
|
while prefix_chars.peek().is_some() {
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some(c) if c == markers::VISUAL_MODE_START || c == markers::VISUAL_MODE_END => continue,
|
Some(c) if c == markers::VISUAL_MODE_START || c == markers::VISUAL_MODE_END => continue,
|
||||||
Some(c) if Some(&c) == prefix_chars.peek() => { prefix_chars.next(); }
|
Some(c) if Some(&c) == prefix_chars.peek() => {
|
||||||
|
prefix_chars.next();
|
||||||
|
}
|
||||||
_ => return text.to_string(), // mismatch, return original
|
_ => return text.to_string(), // mismatch, return original
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,9 @@ impl Highlighter {
|
|||||||
let mut si = suffix_chars.len();
|
let mut si = suffix_chars.len();
|
||||||
|
|
||||||
while si > 0 {
|
while si > 0 {
|
||||||
if ti == 0 { return text.to_string(); }
|
if ti == 0 {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
ti -= 1;
|
ti -= 1;
|
||||||
if chars[ti] == markers::VISUAL_MODE_START || chars[ti] == markers::VISUAL_MODE_END {
|
if chars[ti] == markers::VISUAL_MODE_START || chars[ti] == markers::VISUAL_MODE_END {
|
||||||
continue; // skip visual markers
|
continue; // skip visual markers
|
||||||
@@ -346,7 +350,9 @@ impl Highlighter {
|
|||||||
recursive_highlighter.highlight();
|
recursive_highlighter.highlight();
|
||||||
// Read back visual state — selection may have started/ended inside
|
// Read back visual state — selection may have started/ended inside
|
||||||
self.in_selection = recursive_highlighter.in_selection;
|
self.in_selection = recursive_highlighter.in_selection;
|
||||||
self.style_stack.append(&mut recursive_highlighter.style_stack);
|
self
|
||||||
|
.style_stack
|
||||||
|
.append(&mut recursive_highlighter.style_stack);
|
||||||
if selection_at_entry {
|
if selection_at_entry {
|
||||||
self.emit_style(Style::BgWhite | Style::Black);
|
self.emit_style(Style::BgWhite | Style::Black);
|
||||||
self.output.push_str(prefix);
|
self.output.push_str(prefix);
|
||||||
|
|||||||
@@ -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,9 +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]) {
|
||||||
self.reader.feed_bytes(bytes);
|
self.reader.feed_bytes(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark that the display needs to be redrawn (e.g., after SIGWINCH)
|
/// Mark that the display needs to be redrawn (e.g., after SIGWINCH)
|
||||||
@@ -311,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<()> {
|
||||||
@@ -319,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)
|
||||||
}
|
}
|
||||||
@@ -377,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) => {
|
||||||
@@ -404,22 +468,29 @@ 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()? {
|
||||||
log::debug!("Read key: {key:?} in mode {:?}, self.reader.verbatim = {}", self.mode.report_mode(), self.reader.verbatim);
|
|
||||||
// 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,6 +721,14 @@ 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
|
// 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
|
// So we can just go ahead and reset the completer after this
|
||||||
@@ -646,10 +736,22 @@ impl ShedVi {
|
|||||||
}
|
}
|
||||||
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(
|
||||||
@@ -662,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()));
|
||||||
@@ -686,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",
|
||||||
@@ -701,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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -732,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) {
|
||||||
@@ -752,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);
|
||||||
@@ -763,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)?;
|
||||||
@@ -790,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) {
|
||||||
@@ -897,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)?;
|
||||||
@@ -969,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();
|
||||||
@@ -989,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;
|
||||||
@@ -1023,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::read_one().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",
|
||||||
@@ -1124,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(());
|
||||||
@@ -1137,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) => {
|
||||||
@@ -1204,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
|
||||||
@@ -1223,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);
|
||||||
@@ -1372,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
|
||||||
@@ -1463,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,
|
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,8 +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,
|
200 => KeyCode::BracketedPasteStart,
|
||||||
201 => KeyCode::BracketedPasteEnd,
|
201 => KeyCode::BracketedPasteEnd,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
KeyEvent(key, mods)
|
KeyEvent(key, mods)
|
||||||
@@ -481,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()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,8 +547,9 @@ impl Perform for KeyCollector {
|
|||||||
pub struct PollReader {
|
pub struct PollReader {
|
||||||
parser: Parser,
|
parser: Parser,
|
||||||
collector: KeyCollector,
|
collector: KeyCollector,
|
||||||
byte_buf: VecDeque<u8>,
|
byte_buf: VecDeque<u8>,
|
||||||
pub verbatim: bool,
|
pub verbatim_single: bool,
|
||||||
|
pub verbatim: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PollReader {
|
impl PollReader {
|
||||||
@@ -507,32 +557,45 @@ impl PollReader {
|
|||||||
Self {
|
Self {
|
||||||
parser: Parser::new(),
|
parser: Parser::new(),
|
||||||
collector: KeyCollector::new(),
|
collector: KeyCollector::new(),
|
||||||
byte_buf: VecDeque::new(),
|
byte_buf: VecDeque::new(),
|
||||||
verbatim: false,
|
verbatim_single: false,
|
||||||
|
verbatim: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> {
|
pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> {
|
||||||
let end_marker = b"\x1b[201~";
|
let end_marker = b"\x1b[201~";
|
||||||
let mut raw = vec![];
|
let mut raw = vec![];
|
||||||
while let Some(byte) = self.byte_buf.pop_front() {
|
while let Some(byte) = self.byte_buf.pop_front() {
|
||||||
raw.push(byte);
|
raw.push(byte);
|
||||||
if raw.ends_with(end_marker) {
|
if raw.ends_with(end_marker) {
|
||||||
// Strip the end marker from the raw sequence
|
// Strip the end marker from the raw sequence
|
||||||
raw.truncate(raw.len() - end_marker.len());
|
raw.truncate(raw.len() - end_marker.len());
|
||||||
let paste = String::from_utf8_lossy(&raw).to_string();
|
let paste = String::from_utf8_lossy(&raw).to_string();
|
||||||
self.verbatim = false;
|
self.verbatim = false;
|
||||||
return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty()));
|
return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.verbatim = true;
|
self.verbatim = true;
|
||||||
self.byte_buf.extend(raw);
|
self.byte_buf.extend(raw);
|
||||||
None
|
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]) {
|
pub fn feed_bytes(&mut self, bytes: &[u8]) {
|
||||||
self.byte_buf.extend(bytes);
|
self.byte_buf.extend(bytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,33 +607,42 @@ 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> {
|
||||||
if self.verbatim {
|
if self.verbatim_single {
|
||||||
if let Some(paste) = self.handle_bracket_paste() {
|
if let Some(key) = self.read_one_verbatim() {
|
||||||
return Ok(Some(paste));
|
self.verbatim_single = false;
|
||||||
}
|
return Ok(Some(key));
|
||||||
// If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys
|
}
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
} else if self.byte_buf.len() == 1
|
}
|
||||||
&& self.byte_buf.front() == Some(&b'\x1b') {
|
if self.verbatim {
|
||||||
// User pressed escape
|
if let Some(paste) = self.handle_bracket_paste() {
|
||||||
self.byte_buf.pop_front(); // Consume the escape byte
|
return Ok(Some(paste));
|
||||||
return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty())));
|
}
|
||||||
}
|
// If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys
|
||||||
while let Some(byte) = self.byte_buf.pop_front() {
|
return Ok(None);
|
||||||
self.parser.advance(&mut self.collector, &[byte]);
|
} else if self.byte_buf.front() == Some(&b'\x1b') {
|
||||||
if let Some(key) = self.collector.pop() {
|
// Escape: if it's the only byte, or the next byte isn't a valid
|
||||||
match key {
|
// escape sequence prefix ([ or O), emit a standalone Escape
|
||||||
KeyEvent(KeyCode::BracketedPasteStart, _) => {
|
if self.byte_buf.len() == 1 || !matches!(self.byte_buf.get(1), Some(b'[') | Some(b'O')) {
|
||||||
if let Some(paste) = self.handle_bracket_paste() {
|
self.byte_buf.pop_front();
|
||||||
return Ok(Some(paste));
|
return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty())));
|
||||||
} else {
|
}
|
||||||
continue;
|
}
|
||||||
}
|
while let Some(byte) = self.byte_buf.pop_front() {
|
||||||
}
|
self.parser.advance(&mut self.collector, &[byte]);
|
||||||
_ => return Ok(Some(key))
|
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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -821,8 +893,9 @@ impl Default for Layout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct TermWriter {
|
pub struct TermWriter {
|
||||||
last_bell: Option<Instant>,
|
last_bell: Option<Instant>,
|
||||||
out: RawFd,
|
out: RawFd,
|
||||||
pub t_cols: Col, // terminal width
|
pub t_cols: Col, // terminal width
|
||||||
buffer: String,
|
buffer: String,
|
||||||
@@ -832,7 +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,
|
last_bell: None,
|
||||||
out,
|
out,
|
||||||
t_cols,
|
t_cols,
|
||||||
buffer: String::new(),
|
buffer: String::new(),
|
||||||
@@ -1069,24 +1142,24 @@ impl LineWriter for TermWriter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
// we use a cooldown because I don't like having my ears assaulted by 1 million bells
|
// 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.
|
// whenever i finish clearing the line using backspace.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
// surprisingly, a fixed cooldown like '100' is actually more annoying than 1 million bells.
|
// 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
|
// I've found this range of 50-150 to be the best balance
|
||||||
let cooldown = rand::random_range(50..150);
|
let cooldown = rand::random_range(50..150);
|
||||||
let should_send = match self.last_bell {
|
let should_send = match self.last_bell {
|
||||||
None => true,
|
None => true,
|
||||||
Some(time) => now.duration_since(time).as_millis() > cooldown,
|
Some(time) => now.duration_since(time).as_millis() > cooldown,
|
||||||
};
|
};
|
||||||
if should_send {
|
if should_send {
|
||||||
self.flush_write("\x07")?;
|
self.flush_write("\x07")?;
|
||||||
self.last_bell = Some(now);
|
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,10 +65,12 @@ impl ViMode for ViInsert {
|
|||||||
raw_seq: String::new(),
|
raw_seq: String::new(),
|
||||||
flags: Default::default(),
|
flags: Default::default(),
|
||||||
}),
|
}),
|
||||||
E(K::Verbatim(seq), _) => {
|
E(K::Verbatim(seq), _) => {
|
||||||
self.pending_cmd.set_verb(VerbCmd(1, Verb::Insert(seq.to_string())));
|
self
|
||||||
self.register_and_return()
|
.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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,11 @@ use crate::readline::vicmd::{CmdFlags, RegisterName, To, Verb, VerbCmd, ViCmd};
|
|||||||
|
|
||||||
#[derive(Default, Clone, Debug)]
|
#[derive(Default, Clone, Debug)]
|
||||||
pub struct ViVerbatim {
|
pub struct ViVerbatim {
|
||||||
pending_seq: String,
|
|
||||||
sent_cmd: Vec<ViCmd>,
|
sent_cmd: Vec<ViCmd>,
|
||||||
repeat_count: u16,
|
repeat_count: u16,
|
||||||
read_one: bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ViVerbatim {
|
impl ViVerbatim {
|
||||||
pub fn read_one() -> Self {
|
|
||||||
Self {
|
|
||||||
read_one: true,
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
@@ -31,7 +23,7 @@ impl ViVerbatim {
|
|||||||
impl ViMode for ViVerbatim {
|
impl ViMode for ViVerbatim {
|
||||||
fn handle_key(&mut self, key: E) -> Option<ViCmd> {
|
fn handle_key(&mut self, key: E) -> Option<ViCmd> {
|
||||||
match key {
|
match key {
|
||||||
E(K::Verbatim(seq), _mods) if self.read_one => {
|
E(K::Verbatim(seq), _mods) => {
|
||||||
log::debug!("Received verbatim key sequence: {:?}", seq);
|
log::debug!("Received verbatim key sequence: {:?}", seq);
|
||||||
let cmd = ViCmd {
|
let cmd = ViCmd {
|
||||||
register: RegisterName::default(),
|
register: RegisterName::default(),
|
||||||
@@ -43,22 +35,6 @@ impl ViMode for ViVerbatim {
|
|||||||
self.sent_cmd.push(cmd.clone());
|
self.sent_cmd.push(cmd.clone());
|
||||||
Some(cmd)
|
Some(cmd)
|
||||||
}
|
}
|
||||||
E(K::Verbatim(seq), _mods) => {
|
|
||||||
self.pending_seq.push_str(&seq);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
E(K::BracketedPasteEnd, _mods) => {
|
|
||||||
log::debug!("Received verbatim paste: {:?}", self.pending_seq);
|
|
||||||
let cmd = ViCmd {
|
|
||||||
register: RegisterName::default(),
|
|
||||||
verb: Some(VerbCmd(1, Verb::Insert(self.pending_seq.clone()))),
|
|
||||||
motion: None,
|
|
||||||
raw_seq: std::mem::take(&mut self.pending_seq),
|
|
||||||
flags: CmdFlags::EXIT_CUR_MODE,
|
|
||||||
};
|
|
||||||
self.sent_cmd.push(cmd.clone());
|
|
||||||
Some(cmd)
|
|
||||||
}
|
|
||||||
_ => common_cmds(key),
|
_ => common_cmds(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
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