Compare commits
25 Commits
85e5fc2875
...
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 |
@@ -35,6 +35,7 @@ rand = "0.10.0"
|
||||
regex = "1.11.1"
|
||||
scopeguard = "1.2.0"
|
||||
serde_json = "1.0.149"
|
||||
tempfile = "3.24.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2.0"
|
||||
vte = "0.15"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# shed
|
||||
|
||||
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing.
|
||||
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing and general interactive UX improvements over existing shells.
|
||||
|
||||
<sub>btw if you don't use `vim` this probably isn't your shell</sub>
|
||||
|
||||
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/3945f663-a361-4418-bf20-0c4eaa2a36d2" />
|
||||
|
||||
@@ -8,7 +10,7 @@ A Linux shell written in Rust. The name is a nod to the original Unix utilities
|
||||
|
||||
### Line Editor
|
||||
|
||||
`shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt.
|
||||
`shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt than conventional `vi` mode implementations.
|
||||
|
||||
- **Normal mode** - motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts
|
||||
- **Insert mode** - insert, append, replace, with Ctrl+W word deletion and undo/redo
|
||||
@@ -40,6 +42,8 @@ gitbranch() { git branch --show-current 2>/dev/null; }
|
||||
export PS1='\u@\h \W \@gitbranch \$ '
|
||||
```
|
||||
|
||||
If `shed` receives `SIGUSR1` while in interactive mode, it will refresh and redraw the prompt. This can be used to create asynchronous, dynamic prompt content.
|
||||
|
||||
Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, similar to how the `-e` flag expands conventional escape sequences.
|
||||
|
||||
---
|
||||
|
||||
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|
|
||||
@@ -2,325 +2,14 @@
|
||||
|
||||
let
|
||||
cfg = config.programs.shed;
|
||||
boolToString = b:
|
||||
if b then "true" else "false";
|
||||
|
||||
mkAutoCmd = cfg:
|
||||
lib.concatLines (map (hook: "autocmd ${hook} ${lib.optionalString (cfg.pattern != null) "-p \"${cfg.pattern}\""} '${cfg.command}'") cfg.hooks);
|
||||
|
||||
|
||||
mkFunctionDef = name: body:
|
||||
let
|
||||
indented = "\t" + lib.concatStringsSep "\n\t" (lib.splitString "\n" body);
|
||||
in
|
||||
''
|
||||
${name}() {
|
||||
${indented}
|
||||
}'';
|
||||
|
||||
mkKeymapCmd = cfg: let
|
||||
flags = "-${lib.concatStrings cfg.modes}";
|
||||
keys = "'${cfg.keys}'";
|
||||
action = "'${cfg.command}'";
|
||||
in
|
||||
"keymap ${flags} ${keys} ${action}";
|
||||
|
||||
|
||||
mkCompleteCmd = name: cfg: let
|
||||
flags = lib.concatStrings [
|
||||
(lib.optionalString cfg.files " -f")
|
||||
(lib.optionalString cfg.dirs " -d")
|
||||
(lib.optionalString cfg.commands " -c")
|
||||
(lib.optionalString cfg.variables " -v")
|
||||
(lib.optionalString cfg.users " -u")
|
||||
(lib.optionalString cfg.jobs " -j")
|
||||
(lib.optionalString cfg.aliases " -a")
|
||||
(lib.optionalString cfg.signals " -S")
|
||||
(lib.optionalString cfg.noSpace " -n")
|
||||
(lib.optionalString (cfg.function != null) " -F ${cfg.function}")
|
||||
(lib.optionalString (cfg.fallback != "no") " -o ${cfg.fallback}")
|
||||
(lib.optionalString (cfg.wordList != []) " -W '${lib.concatStringsSep " " cfg.wordList}'")
|
||||
|
||||
];
|
||||
in "complete${flags} ${name}";
|
||||
in
|
||||
{
|
||||
options.programs.shed = {
|
||||
enable = lib.mkEnableOption "shed shell";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.shed;
|
||||
description = "The shed package to use";
|
||||
};
|
||||
|
||||
aliases = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = {};
|
||||
description = "Aliases to set when shed starts";
|
||||
};
|
||||
|
||||
functions = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = {};
|
||||
description = "Shell functions to set when shed starts";
|
||||
};
|
||||
|
||||
autocmds = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
hooks = lib.mkOption {
|
||||
type = lib.types.addCheck (lib.types.listOf (lib.types.enum [
|
||||
"pre-cmd"
|
||||
"post-cmd"
|
||||
"pre-change-dir"
|
||||
"post-change-dir"
|
||||
"on-job-finish"
|
||||
"pre-prompt"
|
||||
"post-prompt"
|
||||
"pre-mode-change"
|
||||
"post-mode-change"
|
||||
"on-exit"
|
||||
"on-history-open"
|
||||
"on-history-close"
|
||||
"on-history-select"
|
||||
"on-completion-start"
|
||||
"on-completion-cancel"
|
||||
"on-completion-select"
|
||||
])) (list: list != []);
|
||||
description = "The events that trigger this autocmd";
|
||||
};
|
||||
pattern = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "A regex pattern to use in the hook to determine whether it runs or not. What it's compared to differs by hook, for instance 'pre-change-dir' compares it to the new directory, pre-cmd compares it to the command, etc";
|
||||
};
|
||||
command = lib.mkOption {
|
||||
type = lib.types.addCheck lib.types.str (cmd: cmd != "");
|
||||
description = "The shell command to execute when the hook is triggered and the pattern (if provided) matches";
|
||||
};
|
||||
};
|
||||
|
||||
});
|
||||
default = [];
|
||||
description = "Custom autocmds to set when shed starts";
|
||||
};
|
||||
|
||||
keymaps = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.submodule {
|
||||
options = {
|
||||
modes = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.enum [ "n" "i" "x" "v" "o" "r" ]);
|
||||
default = [];
|
||||
description = "The editing modes this keymap can be used in";
|
||||
};
|
||||
keys = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "The sequence of keys that trigger this keymap";
|
||||
};
|
||||
command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "The sequence of characters to send to the line editor when the keymap is triggered.";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = [];
|
||||
description = "Custom keymaps to set when shed starts";
|
||||
};
|
||||
|
||||
extraCompletion = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.submodule {
|
||||
options = {
|
||||
files = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete file names in the current directory";
|
||||
};
|
||||
dirs = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete directory names in the current directory";
|
||||
};
|
||||
commands = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete executable commands in the PATH";
|
||||
};
|
||||
variables = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete variable names";
|
||||
};
|
||||
users = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete user names from /etc/passwd";
|
||||
};
|
||||
jobs = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete job names or pids from the current shell session";
|
||||
};
|
||||
aliases = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete alias names defined in the current shell session";
|
||||
};
|
||||
signals = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Complete signal names for commands like kill";
|
||||
};
|
||||
wordList = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [];
|
||||
description = "Complete from a custom list of words";
|
||||
};
|
||||
function = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Complete using a custom shell function (should be defined in extraCompletionPreConfig)";
|
||||
};
|
||||
noSpace = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Don't append a space after completion";
|
||||
};
|
||||
fallback = lib.mkOption {
|
||||
type = lib.types.enum [ "no" "default" "dirnames" ];
|
||||
default = "no";
|
||||
description = "Fallback behavior when no matches are found: 'no' means no fallback, 'default' means fall back to the default shell completion behavior, and 'directories' means fall back to completing directory names";
|
||||
};
|
||||
|
||||
};
|
||||
});
|
||||
default = {};
|
||||
description = "Additional completion scripts to source when shed starts (e.g. for custom tools or functions)";
|
||||
};
|
||||
|
||||
environmentVars = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = {};
|
||||
description = "Environment variables to set when shed starts";
|
||||
};
|
||||
|
||||
settings = {
|
||||
dotGlob = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to include hidden files in glob patterns";
|
||||
};
|
||||
autocd = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to automatically change into directories when they are entered as commands";
|
||||
};
|
||||
historyIgnoresDupes = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to ignore duplicate entries in the command history";
|
||||
};
|
||||
maxHistoryEntries = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 10000;
|
||||
description = "The maximum number of entries to keep in the command history";
|
||||
};
|
||||
interactiveComments = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to allow comments in interactive mode";
|
||||
};
|
||||
autoHistory = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to automatically add commands to the history as they are executed";
|
||||
};
|
||||
bellEnabled = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to allow shed to ring the terminal bell on certain events (e.g. command completion, errors, etc.)";
|
||||
};
|
||||
maxRecurseDepth = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 1000;
|
||||
description = "The maximum depth to allow when recursively executing shell functions";
|
||||
};
|
||||
|
||||
leaderKey = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "\\\\";
|
||||
description = "The leader key to use for custom keymaps (e.g. if set to '\\\\', then a keymap with keys='x' would be triggered by '\\x')";
|
||||
};
|
||||
promptPathSegments = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 4;
|
||||
description = "The maximum number of path segments to show in the prompt";
|
||||
};
|
||||
completionLimit = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 1000;
|
||||
description = "The maximum number of completion candidates to show before truncating the list";
|
||||
};
|
||||
syntaxHighlighting = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable syntax highlighting in the shell";
|
||||
};
|
||||
linebreakOnIncomplete = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to automatically insert a newline when the input is incomplete";
|
||||
};
|
||||
extraPostConfig = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Additional configuration to append to the shed configuration file";
|
||||
};
|
||||
extraPreConfig = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Additional configuration to prepend to the shed configuration file";
|
||||
};
|
||||
};
|
||||
};
|
||||
options.programs.shed = import ./shed_opts.nix { inherit pkgs lib; };
|
||||
|
||||
config =
|
||||
let
|
||||
completeLines = lib.concatLines (lib.mapAttrsToList mkCompleteCmd cfg.extraCompletion);
|
||||
keymapLines = lib.concatLines (map mkKeymapCmd cfg.keymaps);
|
||||
functionLines = lib.concatLines (lib.mapAttrsToList mkFunctionDef cfg.functions);
|
||||
autocmdLines = lib.concatLines (map mkAutoCmd cfg.autocmds);
|
||||
in
|
||||
lib.mkIf cfg.enable {
|
||||
home.packages = [ cfg.package ];
|
||||
|
||||
home.file.".shedrc".text = lib.concatLines [
|
||||
cfg.settings.extraPreConfig
|
||||
(lib.concatLines (lib.mapAttrsToList (name: value: "export ${name}=\"${value}\"") cfg.environmentVars))
|
||||
(lib.concatLines (lib.mapAttrsToList (name: value: "alias ${name}=\"${value}\"") cfg.aliases))
|
||||
(lib.concatLines [
|
||||
"shopt core.dotglob=${boolToString cfg.settings.dotGlob}"
|
||||
"shopt core.autocd=${boolToString cfg.settings.autocd}"
|
||||
"shopt core.hist_ignore_dupes=${boolToString cfg.settings.historyIgnoresDupes}"
|
||||
"shopt core.max_hist=${toString cfg.settings.maxHistoryEntries}"
|
||||
"shopt core.interactive_comments=${boolToString cfg.settings.interactiveComments}"
|
||||
"shopt core.auto_hist=${boolToString cfg.settings.autoHistory}"
|
||||
"shopt core.bell_enabled=${boolToString cfg.settings.bellEnabled}"
|
||||
"shopt core.max_recurse_depth=${toString cfg.settings.maxRecurseDepth}"
|
||||
|
||||
"shopt prompt.leader='${cfg.settings.leaderKey}'"
|
||||
"shopt prompt.trunc_prompt_path=${toString cfg.settings.promptPathSegments}"
|
||||
"shopt prompt.comp_limit=${toString cfg.settings.completionLimit}"
|
||||
"shopt prompt.highlight=${boolToString cfg.settings.syntaxHighlighting}"
|
||||
"shopt prompt.linebreak_on_incomplete=${boolToString cfg.settings.linebreakOnIncomplete}"
|
||||
functionLines
|
||||
completeLines
|
||||
keymapLines
|
||||
autocmdLines
|
||||
])
|
||||
cfg.settings.extraPostConfig
|
||||
];
|
||||
home.file.".shedrc".text = import ./render_rc.nix lib cfg;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,18 +4,11 @@ let
|
||||
cfg = config.programs.shed;
|
||||
in
|
||||
{
|
||||
options.programs.shed = {
|
||||
enable = lib.mkEnableOption "shed shell";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = pkgs.shed;
|
||||
description = "The shed package to use";
|
||||
};
|
||||
};
|
||||
options.programs.shed = import ./shed_opts.nix { inherit pkgs lib; };
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
environment.shells = [ cfg.package ];
|
||||
environment.etc."shed/shedrc".text = import ./render_rc.nix lib cfg;
|
||||
};
|
||||
}
|
||||
|
||||
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,7 +38,6 @@ pub fn alias(node: Node) -> ShResult<()> {
|
||||
write(stdout, alias_output.as_bytes())?; // Write it
|
||||
} else {
|
||||
for (arg, span) in argv {
|
||||
|
||||
let Some((name, body)) = arg.split_once('=') else {
|
||||
let Some(alias) = read_logic(|l| l.get_alias(&arg)) else {
|
||||
return Err(ShErr::at(
|
||||
@@ -59,7 +58,10 @@ pub fn alias(node: Node) -> ShResult<()> {
|
||||
return Err(ShErr::at(
|
||||
ShErrKind::ExecFail,
|
||||
span,
|
||||
format!("alias: Cannot assign alias to reserved name '{}'", name.fg(next_color())),
|
||||
format!(
|
||||
"alias: Cannot assign alias to reserved name '{}'",
|
||||
name.fg(next_color())
|
||||
),
|
||||
));
|
||||
}
|
||||
write_logic(|l| l.insert_alias(name, body, span.clone()));
|
||||
|
||||
@@ -229,9 +229,9 @@ pub fn get_arr_op_opts(opts: Vec<Opt>) -> ShResult<ArrOpOpts> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::VecDeque;
|
||||
use crate::state::{self, read_vars, write_vars, VarFlags, VarKind};
|
||||
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()));
|
||||
|
||||
@@ -159,7 +159,10 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(
|
||||
read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Pattern =====================
|
||||
@@ -205,7 +208,10 @@ mod tests {
|
||||
|
||||
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);
|
||||
assert_eq!(
|
||||
read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -245,12 +251,22 @@ mod tests {
|
||||
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"
|
||||
"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();
|
||||
|
||||
@@ -99,7 +99,10 @@ pub mod tests {
|
||||
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());
|
||||
assert_eq!(
|
||||
new_dir.display().to_string(),
|
||||
temp_dir.path().display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -111,7 +114,10 @@ pub mod tests {
|
||||
test_input("cd").unwrap();
|
||||
|
||||
let cwd = env::current_dir().unwrap();
|
||||
assert_eq!(cwd.display().to_string(), temp_dir.path().display().to_string());
|
||||
assert_eq!(
|
||||
cwd.display().to_string(),
|
||||
temp_dir.path().display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -316,10 +316,10 @@ pub fn get_comp_opts(opts: Vec<Opt>) -> ShResult<CompOpts> {
|
||||
|
||||
#[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;
|
||||
use crate::state::{self, read_meta, write_vars, VarFlags, VarKind};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
|
||||
// ===================== complete: Registration =====================
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::{
|
||||
|
||||
pub fn truncate_home_path(path: String) -> String {
|
||||
if let Ok(home) = env::var("HOME")
|
||||
&& path.starts_with(&home) {
|
||||
&& path.starts_with(&home)
|
||||
{
|
||||
let new = path.strip_prefix(&home).unwrap();
|
||||
return format!("~{new}");
|
||||
}
|
||||
@@ -376,8 +377,7 @@ pub fn dirs(node: Node) -> ShResult<()> {
|
||||
.map(|d| d.to_string_lossy().to_string());
|
||||
|
||||
if abbreviate_home {
|
||||
stack.map(truncate_home_path)
|
||||
.collect()
|
||||
stack.map(truncate_home_path).collect()
|
||||
} else {
|
||||
stack.collect()
|
||||
}
|
||||
@@ -428,9 +428,12 @@ pub fn dirs(node: Node) -> ShResult<()> {
|
||||
|
||||
#[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 crate::{state::{self, read_meta}, testutil::{TestGuard, test_input}};
|
||||
use pretty_assertions::{assert_ne,assert_eq};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -580,8 +583,14 @@ use tempfile::TempDir;
|
||||
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()));
|
||||
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]
|
||||
|
||||
@@ -57,7 +57,8 @@ pub fn echo(node: Node) -> ShResult<()> {
|
||||
let output_channel = borrow_fd(STDOUT_FILENO);
|
||||
let xpg_echo = read_shopts(|o| o.core.xpg_echo); // If true, echo expands escape sequences by default, and -E opts out
|
||||
|
||||
let use_escape = (xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE);
|
||||
let use_escape =
|
||||
(xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE);
|
||||
|
||||
let mut echo_output = prepare_echo_args(
|
||||
argv
|
||||
@@ -308,11 +309,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn prepare_multiple_args() {
|
||||
let result = prepare_echo_args(
|
||||
vec!["hello".into(), "world".into()],
|
||||
false,
|
||||
false,
|
||||
).unwrap();
|
||||
let result = prepare_echo_args(vec!["hello".into(), "world".into()], false, false).unwrap();
|
||||
assert_eq!(result, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn eval(node: Node) -> ShResult<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::state::{self, read_vars, write_vars, VarFlags, VarKind};
|
||||
use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
|
||||
// ===================== Basic =====================
|
||||
@@ -80,7 +80,8 @@ mod tests {
|
||||
#[test]
|
||||
fn eval_expands_variable() {
|
||||
let guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("CMD", VarKind::Str("echo evaluated".into()), VarFlags::NONE)).unwrap();
|
||||
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();
|
||||
|
||||
@@ -62,7 +62,9 @@ mod tests {
|
||||
#[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__________________");
|
||||
let result = test_input(
|
||||
"exec _____________no_such_______command_xyz_____________hopefully______this_doesnt______exist_____somewhere_in___your______PATH__________________",
|
||||
);
|
||||
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
|
||||
}
|
||||
@@ -180,8 +180,8 @@ pub fn keymap(node: Node) -> ShResult<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::getopt::Opt;
|
||||
use crate::expand::expand_keymap;
|
||||
use crate::getopt::Opt;
|
||||
use crate::state::{self, read_logic};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
|
||||
@@ -217,7 +217,8 @@ mod tests {
|
||||
let opts = KeyMapOpts::from_opts(&[
|
||||
Opt::Short('n'),
|
||||
Opt::LongWithArg("remove".into(), "jk".into()),
|
||||
]).unwrap();
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(opts.remove, Some("jk".into()));
|
||||
}
|
||||
|
||||
@@ -273,10 +274,7 @@ mod tests {
|
||||
let _g = TestGuard::new();
|
||||
test_input("keymap -n jk '<ESC>'").unwrap();
|
||||
|
||||
let maps = read_logic(|l| l.keymaps_filtered(
|
||||
KeyMapFlags::NORMAL,
|
||||
&expand_keymap("jk"),
|
||||
));
|
||||
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
|
||||
assert!(!maps.is_empty());
|
||||
}
|
||||
|
||||
@@ -285,10 +283,7 @@ mod tests {
|
||||
let _g = TestGuard::new();
|
||||
test_input("keymap -i jk '<ESC>'").unwrap();
|
||||
|
||||
let maps = read_logic(|l| l.keymaps_filtered(
|
||||
KeyMapFlags::INSERT,
|
||||
&expand_keymap("jk"),
|
||||
));
|
||||
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::INSERT, &expand_keymap("jk")));
|
||||
assert!(!maps.is_empty());
|
||||
}
|
||||
|
||||
@@ -298,10 +293,7 @@ mod tests {
|
||||
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"),
|
||||
));
|
||||
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
|
||||
assert_eq!(maps.len(), 1);
|
||||
assert_eq!(maps[0].action, "dd");
|
||||
}
|
||||
@@ -312,10 +304,7 @@ mod tests {
|
||||
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"),
|
||||
));
|
||||
let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
|
||||
assert!(maps.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ pub fn get_map_opts(opts: Vec<Opt>) -> MapOpts {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MapNode, MapFlags, get_map_opts};
|
||||
use super::{MapFlags, MapNode, get_map_opts};
|
||||
use crate::getopt::Opt;
|
||||
use crate::state::{self, read_vars};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
@@ -433,10 +433,7 @@ mod tests {
|
||||
#[test]
|
||||
fn mapnode_remove_nested() {
|
||||
let mut root = MapNode::default();
|
||||
root.set(
|
||||
&["a".into(), "b".into()],
|
||||
MapNode::StaticLeaf("val".into()),
|
||||
);
|
||||
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
|
||||
|
||||
@@ -11,26 +11,28 @@ pub mod eval;
|
||||
pub mod exec;
|
||||
pub mod flowctl;
|
||||
pub mod getopts;
|
||||
pub mod help;
|
||||
pub mod intro;
|
||||
pub mod jobctl;
|
||||
pub mod keymap;
|
||||
pub mod map;
|
||||
pub mod pwd;
|
||||
pub mod read;
|
||||
pub mod resource;
|
||||
pub mod seek;
|
||||
pub mod shift;
|
||||
pub mod shopt;
|
||||
pub mod source;
|
||||
pub mod test; // [[ ]] thing
|
||||
pub mod trap;
|
||||
pub mod varcmds;
|
||||
pub mod resource;
|
||||
|
||||
pub const BUILTINS: [&str; 49] = [
|
||||
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg", "disown",
|
||||
"alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
|
||||
pub const BUILTINS: [&str; 51] = [
|
||||
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
|
||||
"disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
|
||||
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
|
||||
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
|
||||
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask"
|
||||
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek", "help",
|
||||
];
|
||||
|
||||
pub fn true_builtin() -> ShResult<()> {
|
||||
@@ -50,7 +52,10 @@ pub fn noop_builtin() -> ShResult<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests {
|
||||
use crate::{state, testutil::{TestGuard, test_input}};
|
||||
use crate::{
|
||||
state,
|
||||
testutil::{TestGuard, test_input},
|
||||
};
|
||||
|
||||
// You can never be too sure!!!!!!
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@ pub fn pwd(node: Node) -> ShResult<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
use crate::state;
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn pwd_prints_cwd() {
|
||||
|
||||
@@ -367,7 +367,7 @@ pub fn get_read_key_opts(opts: Vec<Opt>) -> ShResult<ReadKeyOpts> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::state::{self, read_vars, write_vars, VarFlags, VarKind};
|
||||
use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
|
||||
// ===================== Basic read into REPLY =====================
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
use ariadne::Fmt;
|
||||
use nix::{libc::STDOUT_FILENO, sys::{resource::{Resource, getrlimit, setrlimit}, stat::{Mode, umask}}, unistd::write};
|
||||
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}
|
||||
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] {
|
||||
@@ -26,7 +37,7 @@ fn ulimit_opt_spec() -> [OptSpec;5] {
|
||||
OptSpec {
|
||||
opt: Opt::Short('v'), // virtual memory
|
||||
takes_arg: true,
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,39 +61,51 @@ fn get_ulimit_opts(opt: &[Opt]) -> ShResult<UlimitOpts> {
|
||||
for o in opt {
|
||||
match o {
|
||||
Opt::ShortWithArg('n', arg) => {
|
||||
opts.fds = Some(arg.parse().map_err(|_| ShErr::simple(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
)
|
||||
})?);
|
||||
}
|
||||
o => {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::ParseErr,
|
||||
format!("invalid option: {}", o.fg(next_color())),
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,68 +122,89 @@ pub fn ulimit(node: Node) -> ShResult<()> {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
let (_, opts) = get_opts_from_tokens_strict(argv, &ulimit_opt_spec()).promote_err(span.clone())?;
|
||||
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(
|
||||
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(
|
||||
)
|
||||
})?;
|
||||
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(
|
||||
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(
|
||||
)
|
||||
})?;
|
||||
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(
|
||||
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(
|
||||
)
|
||||
})?;
|
||||
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(
|
||||
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(
|
||||
)
|
||||
})?;
|
||||
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(
|
||||
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(
|
||||
)
|
||||
})?;
|
||||
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);
|
||||
@@ -172,11 +216,17 @@ pub fn umask_builtin(node: Node) -> ShResult<()> {
|
||||
let NdRule::Command {
|
||||
assignments: _,
|
||||
argv,
|
||||
} = node.class else { unreachable!() };
|
||||
} = node.class
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
let (argv, opts) = get_opts_from_tokens_strict(
|
||||
argv,
|
||||
&[OptSpec { opt: Opt::Short('S'), takes_arg: false }],
|
||||
&[OptSpec {
|
||||
opt: Opt::Short('S'),
|
||||
takes_arg: false,
|
||||
}],
|
||||
)?;
|
||||
let argv = &argv[1..]; // skip command name
|
||||
|
||||
@@ -195,17 +245,21 @@ pub fn umask_builtin(node: Node) -> ShResult<()> {
|
||||
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(
|
||||
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(
|
||||
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 {
|
||||
@@ -337,7 +391,6 @@ pub fn umask_builtin(node: Node) -> ShResult<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else if !opts.is_empty() {
|
||||
let u = (old_bits >> 6) & 0o7;
|
||||
let g = (old_bits >> 3) & 0o7;
|
||||
@@ -345,11 +398,7 @@ pub fn umask_builtin(node: Node) -> ShResult<()> {
|
||||
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),
|
||||
];
|
||||
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');
|
||||
@@ -423,7 +472,8 @@ mod tests {
|
||||
let opts = get_ulimit_opts(&[
|
||||
Opt::ShortWithArg('n', "256".into()),
|
||||
Opt::ShortWithArg('c', "0".into()),
|
||||
]).unwrap();
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(opts.fds, Some(256));
|
||||
assert_eq!(opts.core, Some(0));
|
||||
assert!(opts.procs.is_none());
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub fn shopt(node: Node) -> ShResult<()> {
|
||||
}
|
||||
|
||||
for (arg, span) in argv {
|
||||
let Some(mut output) = write_shopts(|s| s.query(&arg)).blame(span)? else {
|
||||
let Some(mut output) = write_shopts(|s| s.query(&arg)).promote_err(span)? else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ mod tests {
|
||||
assert!(out.contains("dotglob"));
|
||||
assert!(out.contains("autocd"));
|
||||
assert!(out.contains("max_hist"));
|
||||
assert!(out.contains("edit_mode"));
|
||||
assert!(out.contains("comp_limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -72,7 +72,7 @@ mod tests {
|
||||
assert!(out.contains("dotglob"));
|
||||
assert!(out.contains("autocd"));
|
||||
// Should not contain prompt opts
|
||||
assert!(!out.contains("edit_mode"));
|
||||
assert!(!out.contains("comp_limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -107,11 +107,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shopt_set_edit_mode() {
|
||||
fn shopt_set_completion_ignore_case() {
|
||||
let _g = TestGuard::new();
|
||||
test_input("shopt prompt.edit_mode=emacs").unwrap();
|
||||
let mode = read_shopts(|o| format!("{}", o.prompt.edit_mode));
|
||||
assert_eq!(mode, "emacs");
|
||||
test_input("shopt prompt.completion_ignore_case=true").unwrap();
|
||||
assert!(read_shopts(|o| o.prompt.completion_ignore_case));
|
||||
}
|
||||
|
||||
// ===================== Error cases =====================
|
||||
|
||||
@@ -46,9 +46,9 @@ pub fn source(node: Node) -> ShResult<()> {
|
||||
pub mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use tempfile::{NamedTempFile, TempDir};
|
||||
use crate::state::{self, read_logic, read_vars};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
use tempfile::{NamedTempFile, TempDir};
|
||||
|
||||
#[test]
|
||||
fn source_simple() {
|
||||
|
||||
@@ -94,7 +94,10 @@ impl FromStr for TestOp {
|
||||
"-ge" => Ok(Self::IntGe),
|
||||
"-le" => Ok(Self::IntLe),
|
||||
_ if TEST_UNARY_OPS.contains(&s) => Ok(Self::Unary(s.parse::<UnaryOp>()?)),
|
||||
_ => Err(ShErr::simple(ShErrKind::SyntaxErr, format!("Invalid test operator '{}'", s))),
|
||||
_ => Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
format!("Invalid test operator '{}'", s),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,10 +308,10 @@ pub fn double_bracket_test(node: Node) -> ShResult<bool> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use tempfile::{TempDir, NamedTempFile};
|
||||
use crate::state;
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
use std::fs;
|
||||
use tempfile::{NamedTempFile, TempDir};
|
||||
|
||||
// ===================== Unary: file tests =====================
|
||||
|
||||
@@ -590,9 +593,10 @@ mod tests {
|
||||
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"] {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +171,10 @@ pub fn trap(node: Node) -> ShResult<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TrapTarget;
|
||||
use std::str::FromStr;
|
||||
use nix::sys::signal::Signal;
|
||||
use crate::state::{self, read_logic};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
use nix::sys::signal::Signal;
|
||||
use std::str::FromStr;
|
||||
|
||||
// ===================== Pure: TrapTarget parsing =====================
|
||||
|
||||
@@ -231,7 +231,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn display_signal_roundtrip() {
|
||||
for name in &["INT", "QUIT", "TERM", "USR1", "USR2", "ALRM", "CHLD", "WINCH"] {
|
||||
for name in &[
|
||||
"INT", "QUIT", "TERM", "USR1", "USR2", "ALRM", "CHLD", "WINCH",
|
||||
] {
|
||||
let target = TrapTarget::from_str(name).unwrap();
|
||||
assert_eq!(target.to_string(), *name);
|
||||
}
|
||||
|
||||
@@ -245,8 +245,16 @@ mod tests {
|
||||
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));
|
||||
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]
|
||||
@@ -385,7 +393,11 @@ mod tests {
|
||||
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));
|
||||
assert!(
|
||||
read_vars(|v| v.get_var_flags("mylocal"))
|
||||
.unwrap()
|
||||
.contains(VarFlags::LOCAL)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
574
src/expand.rs
574
src/expand.rs
@@ -4,6 +4,7 @@ use std::str::{Chars, FromStr};
|
||||
|
||||
use ariadne::Fmt;
|
||||
use glob::Pattern;
|
||||
use nix::unistd::{Uid, User};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color};
|
||||
@@ -40,18 +41,26 @@ impl Tk {
|
||||
}
|
||||
|
||||
pub struct Expander {
|
||||
flags: TkFlags,
|
||||
raw: String,
|
||||
}
|
||||
|
||||
impl Expander {
|
||||
pub fn new(raw: Tk) -> ShResult<Self> {
|
||||
let raw = raw.span.as_str();
|
||||
Self::from_raw(raw)
|
||||
let tk_raw = raw.span.as_str();
|
||||
Self::from_raw(tk_raw, raw.flags)
|
||||
}
|
||||
pub fn from_raw(raw: &str) -> ShResult<Self> {
|
||||
pub fn from_raw(raw: &str, flags: TkFlags) -> ShResult<Self> {
|
||||
let raw = expand_braces_full(raw)?.join(" ");
|
||||
let unescaped = unescape_str(&raw);
|
||||
Ok(Self { raw: unescaped })
|
||||
let unescaped = if flags.contains(TkFlags::IS_HEREDOC) {
|
||||
unescape_heredoc(&raw)
|
||||
} else {
|
||||
unescape_str(&raw)
|
||||
};
|
||||
Ok(Self {
|
||||
raw: unescaped,
|
||||
flags,
|
||||
})
|
||||
}
|
||||
pub fn expand(&mut self) -> ShResult<Vec<String>> {
|
||||
let mut chars = self.raw.chars().peekable();
|
||||
@@ -75,8 +84,12 @@ impl Expander {
|
||||
self.raw.insert_str(0, "./");
|
||||
}
|
||||
|
||||
if self.flags.contains(TkFlags::IS_HEREDOC) {
|
||||
Ok(vec![self.raw.clone()])
|
||||
} else {
|
||||
Ok(self.split_words())
|
||||
}
|
||||
}
|
||||
pub fn split_words(&mut self) -> Vec<String> {
|
||||
let mut words = vec![];
|
||||
let mut chars = self.raw.chars();
|
||||
@@ -86,6 +99,11 @@ impl Expander {
|
||||
|
||||
'outer: while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
markers::ESCAPE => {
|
||||
if let Some(next_ch) = chars.next() {
|
||||
cur_word.push(next_ch);
|
||||
}
|
||||
}
|
||||
markers::DUB_QUOTE | markers::SNG_QUOTE | markers::SUBSH => {
|
||||
while let Some(q_ch) = chars.next() {
|
||||
match q_ch {
|
||||
@@ -456,7 +474,32 @@ pub fn expand_raw(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
markers::TILDE_SUB => {
|
||||
let home = env::var("HOME").unwrap_or_default();
|
||||
let mut username = String::new();
|
||||
while chars.peek().is_some_and(|ch| *ch != '/') {
|
||||
let ch = chars.next().unwrap();
|
||||
username.push(ch);
|
||||
}
|
||||
|
||||
let home = if username.is_empty() {
|
||||
// standard '~' expansion
|
||||
env::var("HOME").unwrap_or_default()
|
||||
} else if let Ok(result) = User::from_name(&username)
|
||||
&& let Some(user) = result
|
||||
{
|
||||
// username expansion like '~user'
|
||||
user.dir.to_string_lossy().to_string()
|
||||
} else if let Ok(id) = username.parse::<u32>()
|
||||
&& let Ok(result) = User::from_uid(Uid::from_raw(id))
|
||||
&& let Some(user) = result
|
||||
{
|
||||
// uid expansion like '~1000'
|
||||
// shed only feature btw B)
|
||||
user.dir.to_string_lossy().to_string()
|
||||
} else {
|
||||
// no match, use literal
|
||||
format!("~{username}")
|
||||
};
|
||||
|
||||
result.push_str(&home);
|
||||
}
|
||||
markers::PROC_SUB_OUT => {
|
||||
@@ -634,8 +677,12 @@ pub fn expand_glob(raw: &str) -> ShResult<String> {
|
||||
{
|
||||
let entry =
|
||||
entry.map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid filename found in glob"))?;
|
||||
let entry_raw = entry
|
||||
.to_str()
|
||||
.ok_or_else(|| ShErr::simple(ShErrKind::SyntaxErr, "Non-UTF8 filename found in glob"))?;
|
||||
let escaped = escape_str(entry_raw, true);
|
||||
|
||||
words.push(entry.to_str().unwrap().to_string())
|
||||
words.push(escaped)
|
||||
}
|
||||
Ok(words.join(" "))
|
||||
}
|
||||
@@ -973,6 +1020,11 @@ pub fn expand_cmd_sub(raw: &str) -> ShResult<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip ESCAPE markers from a string, leaving the characters they protect intact.
|
||||
fn strip_escape_markers(s: &str) -> String {
|
||||
s.replace(markers::ESCAPE, "")
|
||||
}
|
||||
|
||||
/// Processes strings into intermediate representations that are more readable
|
||||
/// by the program
|
||||
///
|
||||
@@ -989,6 +1041,7 @@ pub fn unescape_str(raw: &str) -> String {
|
||||
'~' if first_char => result.push(markers::TILDE_SUB),
|
||||
'\\' => {
|
||||
if let Some(next_ch) = chars.next() {
|
||||
result.push(markers::ESCAPE);
|
||||
result.push(next_ch)
|
||||
}
|
||||
}
|
||||
@@ -1139,6 +1192,25 @@ pub fn unescape_str(raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
'`' => {
|
||||
result.push(markers::VAR_SUB);
|
||||
result.push(markers::SUBSH);
|
||||
while let Some(bt_ch) = chars.next() {
|
||||
match bt_ch {
|
||||
'\\' => {
|
||||
result.push(bt_ch);
|
||||
if let Some(next_ch) = chars.next() {
|
||||
result.push(next_ch);
|
||||
}
|
||||
}
|
||||
'`' => {
|
||||
result.push(markers::SUBSH);
|
||||
break;
|
||||
}
|
||||
_ => result.push(bt_ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
result.push(markers::DUB_QUOTE);
|
||||
break;
|
||||
@@ -1152,11 +1224,13 @@ pub fn unescape_str(raw: &str) -> String {
|
||||
while let Some(q_ch) = chars.next() {
|
||||
match q_ch {
|
||||
'\\' => {
|
||||
if chars.peek() == Some(&'\'') {
|
||||
result.push('\'');
|
||||
chars.next();
|
||||
} else {
|
||||
result.push('\\');
|
||||
match chars.peek() {
|
||||
Some(&'\\') |
|
||||
Some(&'\'') => {
|
||||
let ch = chars.next().unwrap();
|
||||
result.push(ch);
|
||||
}
|
||||
_ => result.push(q_ch),
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
@@ -1303,6 +1377,25 @@ pub fn unescape_str(raw: &str) -> String {
|
||||
result.push('$');
|
||||
}
|
||||
}
|
||||
'`' => {
|
||||
result.push(markers::VAR_SUB);
|
||||
result.push(markers::SUBSH);
|
||||
while let Some(bt_ch) = chars.next() {
|
||||
match bt_ch {
|
||||
'\\' => {
|
||||
result.push(bt_ch);
|
||||
if let Some(next_ch) = chars.next() {
|
||||
result.push(next_ch);
|
||||
}
|
||||
}
|
||||
'`' => {
|
||||
result.push(markers::SUBSH);
|
||||
break;
|
||||
}
|
||||
_ => result.push(bt_ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => result.push(ch),
|
||||
}
|
||||
first_char = false;
|
||||
@@ -1311,6 +1404,134 @@ pub fn unescape_str(raw: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Like unescape_str but for heredoc bodies. Only processes:
|
||||
/// - $var / ${var} / $(cmd) substitution markers
|
||||
/// - Backslash escapes (only before $, `, \, and newline)
|
||||
///
|
||||
/// Everything else (quotes, tildes, globs, process subs, etc.) is literal.
|
||||
pub fn unescape_heredoc(raw: &str) -> String {
|
||||
let mut chars = raw.chars().peekable();
|
||||
let mut result = String::new();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\\' => {
|
||||
match chars.peek() {
|
||||
Some('$') | Some('`') | Some('\\') | Some('\n') => {
|
||||
let next_ch = chars.next().unwrap();
|
||||
if next_ch == '\n' {
|
||||
// line continuation — discard both backslash and newline
|
||||
continue;
|
||||
}
|
||||
result.push(markers::ESCAPE);
|
||||
result.push(next_ch);
|
||||
}
|
||||
_ => {
|
||||
// backslash is literal
|
||||
result.push('\\');
|
||||
}
|
||||
}
|
||||
}
|
||||
'$' if chars.peek() == Some(&'(') => {
|
||||
result.push(markers::VAR_SUB);
|
||||
chars.next(); // consume '('
|
||||
result.push(markers::SUBSH);
|
||||
let mut paren_count = 1;
|
||||
while let Some(subsh_ch) = chars.next() {
|
||||
match subsh_ch {
|
||||
'\\' => {
|
||||
result.push(subsh_ch);
|
||||
if let Some(next_ch) = chars.next() {
|
||||
result.push(next_ch);
|
||||
}
|
||||
}
|
||||
'(' => {
|
||||
paren_count += 1;
|
||||
result.push(subsh_ch);
|
||||
}
|
||||
')' => {
|
||||
paren_count -= 1;
|
||||
if paren_count == 0 {
|
||||
result.push(markers::SUBSH);
|
||||
break;
|
||||
} else {
|
||||
result.push(subsh_ch);
|
||||
}
|
||||
}
|
||||
_ => result.push(subsh_ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
'$' => {
|
||||
result.push(markers::VAR_SUB);
|
||||
if chars.peek() == Some(&'$') {
|
||||
chars.next();
|
||||
result.push('$');
|
||||
}
|
||||
}
|
||||
'`' => {
|
||||
result.push(markers::VAR_SUB);
|
||||
result.push(markers::SUBSH);
|
||||
while let Some(bt_ch) = chars.next() {
|
||||
match bt_ch {
|
||||
'\\' => {
|
||||
result.push(bt_ch);
|
||||
if let Some(next_ch) = chars.next() {
|
||||
result.push(next_ch);
|
||||
}
|
||||
}
|
||||
'`' => {
|
||||
result.push(markers::SUBSH);
|
||||
break;
|
||||
}
|
||||
_ => result.push(bt_ch),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => result.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Opposite of unescape_str - escapes a string to be executed as literal text
|
||||
/// Used for completion results, and glob filename matches.
|
||||
pub fn escape_str(raw: &str, use_marker: bool) -> String {
|
||||
let mut result = String::new();
|
||||
let mut chars = raw.chars();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\'' | '"' | '\\' | '|' | '&' | ';' | '(' | ')' | '<' | '>' | '$' | '*' | '!' | '`' | '{'
|
||||
| '?' | '[' | '#' | ' ' | '\t' | '\n' => {
|
||||
if use_marker {
|
||||
result.push(markers::ESCAPE);
|
||||
} else {
|
||||
result.push('\\');
|
||||
}
|
||||
result.push(ch);
|
||||
continue;
|
||||
}
|
||||
'~' if result.is_empty() => {
|
||||
if use_marker {
|
||||
result.push(markers::ESCAPE);
|
||||
} else {
|
||||
result.push('\\');
|
||||
}
|
||||
result.push(ch);
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
result.push(ch);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn unescape_math(raw: &str) -> String {
|
||||
let mut chars = raw.chars().peekable();
|
||||
let mut result = String::new();
|
||||
@@ -1364,6 +1585,10 @@ pub fn unescape_math(raw: &str) -> String {
|
||||
#[derive(Debug)]
|
||||
pub enum ParamExp {
|
||||
Len, // #var_name
|
||||
ToUpperFirst, // ^var_name
|
||||
ToUpperAll, // ^^var_name
|
||||
ToLowerFirst, // ,var_name
|
||||
ToLowerAll, // ,,var_name
|
||||
DefaultUnsetOrNull(String), // :-
|
||||
DefaultUnset(String), // -
|
||||
SetDefaultUnsetOrNull(String), // :=
|
||||
@@ -1399,6 +1624,19 @@ impl FromStr for ParamExp {
|
||||
))
|
||||
};
|
||||
|
||||
if s == "^^" {
|
||||
return Ok(ToUpperAll);
|
||||
}
|
||||
if s == "^" {
|
||||
return Ok(ToUpperFirst);
|
||||
}
|
||||
if s == ",," {
|
||||
return Ok(ToLowerAll);
|
||||
}
|
||||
if s == "," {
|
||||
return Ok(ToLowerFirst);
|
||||
}
|
||||
|
||||
// Handle indirect var expansion: ${!var}
|
||||
if let Some(var) = s.strip_prefix('!') {
|
||||
if var.ends_with('*') || var.ends_with('@') {
|
||||
@@ -1504,7 +1742,7 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'!' | '#' | '%' | ':' | '-' | '+' | '=' | '/' | '?' => {
|
||||
'!' | '#' | '%' | ':' | '-' | '+' | '^' | ',' | '=' | '/' | '?' => {
|
||||
rest.push(ch);
|
||||
rest.push_str(&chars.collect::<String>());
|
||||
break;
|
||||
@@ -1516,6 +1754,32 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
if let Ok(expansion) = rest.parse::<ParamExp>() {
|
||||
match expansion {
|
||||
ParamExp::Len => unreachable!(),
|
||||
ParamExp::ToUpperAll => {
|
||||
let value = vars.get_var(&var_name);
|
||||
Ok(value.to_uppercase())
|
||||
}
|
||||
ParamExp::ToUpperFirst => {
|
||||
let value = vars.get_var(&var_name);
|
||||
let mut chars = value.chars();
|
||||
let first = chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().to_string())
|
||||
.unwrap_or_default();
|
||||
Ok(first + chars.as_str())
|
||||
}
|
||||
ParamExp::ToLowerAll => {
|
||||
let value = vars.get_var(&var_name);
|
||||
Ok(value.to_lowercase())
|
||||
}
|
||||
ParamExp::ToLowerFirst => {
|
||||
let value = vars.get_var(&var_name);
|
||||
let mut chars = value.chars();
|
||||
let first = chars
|
||||
.next()
|
||||
.map(|c| c.to_lowercase().to_string())
|
||||
.unwrap_or_default();
|
||||
Ok(first + chars.as_str())
|
||||
}
|
||||
ParamExp::DefaultUnsetOrNull(default) => {
|
||||
match vars.try_get_var(&var_name).filter(|v| !v.is_empty()) {
|
||||
Some(val) => Ok(val),
|
||||
@@ -1588,7 +1852,8 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
ParamExp::RemShortestPrefix(prefix) => {
|
||||
let value = vars.get_var(&var_name);
|
||||
let unescaped = unescape_str(&prefix);
|
||||
let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix);
|
||||
let expanded =
|
||||
strip_escape_markers(&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix));
|
||||
let pattern = Pattern::new(&expanded).unwrap();
|
||||
for i in 0..=value.len() {
|
||||
let sliced = &value[..i];
|
||||
@@ -1601,7 +1866,8 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
ParamExp::RemLongestPrefix(prefix) => {
|
||||
let value = vars.get_var(&var_name);
|
||||
let unescaped = unescape_str(&prefix);
|
||||
let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix);
|
||||
let expanded =
|
||||
strip_escape_markers(&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix));
|
||||
let pattern = Pattern::new(&expanded).unwrap();
|
||||
for i in (0..=value.len()).rev() {
|
||||
let sliced = &value[..i];
|
||||
@@ -1614,7 +1880,8 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
ParamExp::RemShortestSuffix(suffix) => {
|
||||
let value = vars.get_var(&var_name);
|
||||
let unescaped = unescape_str(&suffix);
|
||||
let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix);
|
||||
let expanded =
|
||||
strip_escape_markers(&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix));
|
||||
let pattern = Pattern::new(&expanded).unwrap();
|
||||
for i in (0..=value.len()).rev() {
|
||||
let sliced = &value[i..];
|
||||
@@ -1627,8 +1894,9 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
ParamExp::RemLongestSuffix(suffix) => {
|
||||
let value = vars.get_var(&var_name);
|
||||
let unescaped = unescape_str(&suffix);
|
||||
let expanded_suffix =
|
||||
expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix.clone());
|
||||
let expanded_suffix = strip_escape_markers(
|
||||
&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix.clone()),
|
||||
);
|
||||
let pattern = Pattern::new(&expanded_suffix).unwrap();
|
||||
for i in 0..=value.len() {
|
||||
let sliced = &value[i..];
|
||||
@@ -1642,8 +1910,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
let value = vars.get_var(&var_name);
|
||||
let search = unescape_str(&search);
|
||||
let replace = unescape_str(&replace);
|
||||
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search);
|
||||
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace);
|
||||
let expanded_search =
|
||||
strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
|
||||
let expanded_replace =
|
||||
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
|
||||
let regex = glob_to_regex(&expanded_search, false); // unanchored pattern
|
||||
|
||||
if let Some(mat) = regex.find(&value) {
|
||||
@@ -1659,8 +1929,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
let value = vars.get_var(&var_name);
|
||||
let search = unescape_str(&search);
|
||||
let replace = unescape_str(&replace);
|
||||
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search);
|
||||
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace);
|
||||
let expanded_search =
|
||||
strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
|
||||
let expanded_replace =
|
||||
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
|
||||
let regex = glob_to_regex(&expanded_search, false);
|
||||
let mut result = String::new();
|
||||
let mut last_match_end = 0;
|
||||
@@ -1679,8 +1951,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
let value = vars.get_var(&var_name);
|
||||
let search = unescape_str(&search);
|
||||
let replace = unescape_str(&replace);
|
||||
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search);
|
||||
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace);
|
||||
let expanded_search =
|
||||
strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
|
||||
let expanded_replace =
|
||||
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
|
||||
let pattern = Pattern::new(&expanded_search).unwrap();
|
||||
for i in (0..=value.len()).rev() {
|
||||
let sliced = &value[..i];
|
||||
@@ -1694,8 +1968,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
|
||||
let value = vars.get_var(&var_name);
|
||||
let search = unescape_str(&search);
|
||||
let replace = unescape_str(&replace);
|
||||
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search);
|
||||
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace);
|
||||
let expanded_search =
|
||||
strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
|
||||
let expanded_replace =
|
||||
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
|
||||
let pattern = Pattern::new(&expanded_search).unwrap();
|
||||
for i in (0..=value.len()).rev() {
|
||||
let sliced = &value[i..];
|
||||
@@ -1740,6 +2016,11 @@ pub fn expand_case_pattern(raw: &str) -> ShResult<String> {
|
||||
markers::DUB_QUOTE | markers::SNG_QUOTE => {
|
||||
in_quote = !in_quote;
|
||||
}
|
||||
markers::ESCAPE => {
|
||||
if let Some(next_ch) = chars.next() {
|
||||
result.push(next_ch);
|
||||
}
|
||||
}
|
||||
'*' | '?' | '[' | ']' if in_quote => {
|
||||
result.push('\\');
|
||||
result.push(ch);
|
||||
@@ -2381,11 +2662,11 @@ pub fn parse_key_alias(alias: &str) -> Option<KeyEvent> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use crate::readline::keys::{KeyCode, KeyEvent, ModKeys};
|
||||
use crate::state::{write_vars, read_vars, ArrIndex, VarKind, VarFlags};
|
||||
use crate::parse::lex::Span;
|
||||
use crate::readline::keys::{KeyCode, KeyEvent, ModKeys};
|
||||
use crate::state::{ArrIndex, VarFlags, VarKind, read_vars, write_vars};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
use std::time::Duration;
|
||||
|
||||
// ===================== has_braces =====================
|
||||
|
||||
@@ -2525,10 +2806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn braces_simple_list() {
|
||||
assert_eq!(
|
||||
expand_braces_full("{a,b,c}").unwrap(),
|
||||
vec!["a", "b", "c"]
|
||||
);
|
||||
assert_eq!(expand_braces_full("{a,b,c}").unwrap(), vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2617,7 +2895,19 @@ mod tests {
|
||||
#[test]
|
||||
fn braces_cursed() {
|
||||
let result = expand_braces_full("foo{a,{1,2,3,{1..4},5},c}{5..1}bar").unwrap();
|
||||
assert_eq!(result, vec![ "fooa5bar", "fooa4bar", "fooa3bar", "fooa2bar", "fooa1bar", "foo15bar", "foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo15bar", "foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo45bar", "foo44bar", "foo43bar", "foo42bar", "foo41bar", "foo55bar", "foo54bar", "foo53bar", "foo52bar", "foo51bar", "fooc5bar", "fooc4bar", "fooc3bar", "fooc2bar", "fooc1bar", ])
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
"fooa5bar", "fooa4bar", "fooa3bar", "fooa2bar", "fooa1bar", "foo15bar", "foo14bar",
|
||||
"foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar",
|
||||
"foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo15bar",
|
||||
"foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar",
|
||||
"foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar",
|
||||
"foo45bar", "foo44bar", "foo43bar", "foo42bar", "foo41bar", "foo55bar", "foo54bar",
|
||||
"foo53bar", "foo52bar", "foo51bar", "fooc5bar", "fooc4bar", "fooc3bar", "fooc2bar",
|
||||
"fooc1bar",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
// ===================== Arithmetic =====================
|
||||
@@ -2858,7 +3148,8 @@ mod tests {
|
||||
#[test]
|
||||
fn unescape_backslash() {
|
||||
let result = unescape_str("hello\\nworld");
|
||||
assert_eq!(result, "hellonworld");
|
||||
let expected = format!("hello{}nworld", markers::ESCAPE);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3089,10 +3380,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn key_alias_arrows() {
|
||||
assert_eq!(parse_key_alias("UP").unwrap(), KeyEvent(KeyCode::Up, ModKeys::NONE));
|
||||
assert_eq!(parse_key_alias("DOWN").unwrap(), KeyEvent(KeyCode::Down, ModKeys::NONE));
|
||||
assert_eq!(parse_key_alias("LEFT").unwrap(), KeyEvent(KeyCode::Left, ModKeys::NONE));
|
||||
assert_eq!(parse_key_alias("RIGHT").unwrap(), KeyEvent(KeyCode::Right, ModKeys::NONE));
|
||||
assert_eq!(
|
||||
parse_key_alias("UP").unwrap(),
|
||||
KeyEvent(KeyCode::Up, ModKeys::NONE)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_key_alias("DOWN").unwrap(),
|
||||
KeyEvent(KeyCode::Down, ModKeys::NONE)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_key_alias("LEFT").unwrap(),
|
||||
KeyEvent(KeyCode::Left, ModKeys::NONE)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_key_alias("RIGHT").unwrap(),
|
||||
KeyEvent(KeyCode::Right, ModKeys::NONE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3104,7 +3407,13 @@ mod tests {
|
||||
#[test]
|
||||
fn key_alias_ctrl_shift_alt_modifier() {
|
||||
let key = parse_key_alias("C-S-A-b").unwrap();
|
||||
assert_eq!(key, KeyEvent(KeyCode::Char('B'), ModKeys::CTRL | ModKeys::SHIFT | ModKeys::ALT));
|
||||
assert_eq!(
|
||||
key,
|
||||
KeyEvent(
|
||||
KeyCode::Char('B'),
|
||||
ModKeys::CTRL | ModKeys::SHIFT | ModKeys::ALT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3296,7 +3605,14 @@ mod tests {
|
||||
#[test]
|
||||
fn param_remove_shortest_prefix() {
|
||||
let _guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("PATH", VarKind::Str("/usr/local/bin".into()), VarFlags::NONE)).unwrap();
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"PATH",
|
||||
VarKind::Str("/usr/local/bin".into()),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = perform_param_expansion("PATH#*/").unwrap();
|
||||
assert_eq!(result, "usr/local/bin");
|
||||
@@ -3305,7 +3621,14 @@ mod tests {
|
||||
#[test]
|
||||
fn param_remove_longest_prefix() {
|
||||
let _guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("PATH", VarKind::Str("/usr/local/bin".into()), VarFlags::NONE)).unwrap();
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"PATH",
|
||||
VarKind::Str("/usr/local/bin".into()),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = perform_param_expansion("PATH##*/").unwrap();
|
||||
assert_eq!(result, "bin");
|
||||
@@ -3419,7 +3742,10 @@ mod tests {
|
||||
fn word_split_default_ifs() {
|
||||
let _guard = TestGuard::new();
|
||||
|
||||
let mut exp = Expander { raw: "hello world\tfoo".to_string() };
|
||||
let mut exp = Expander {
|
||||
raw: "hello world\tfoo".to_string(),
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["hello", "world", "foo"]);
|
||||
}
|
||||
@@ -3427,9 +3753,14 @@ mod tests {
|
||||
#[test]
|
||||
fn word_split_custom_ifs() {
|
||||
let _guard = TestGuard::new();
|
||||
unsafe { std::env::set_var("IFS", ":"); }
|
||||
unsafe {
|
||||
std::env::set_var("IFS", ":");
|
||||
}
|
||||
|
||||
let mut exp = Expander { raw: "a:b:c".to_string() };
|
||||
let mut exp = Expander {
|
||||
raw: "a:b:c".to_string(),
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["a", "b", "c"]);
|
||||
}
|
||||
@@ -3437,9 +3768,14 @@ mod tests {
|
||||
#[test]
|
||||
fn word_split_empty_ifs() {
|
||||
let _guard = TestGuard::new();
|
||||
unsafe { std::env::set_var("IFS", ""); }
|
||||
unsafe {
|
||||
std::env::set_var("IFS", "");
|
||||
}
|
||||
|
||||
let mut exp = Expander { raw: "hello world".to_string() };
|
||||
let mut exp = Expander {
|
||||
raw: "hello world".to_string(),
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["hello world"]);
|
||||
}
|
||||
@@ -3449,11 +3785,82 @@ mod tests {
|
||||
let _guard = TestGuard::new();
|
||||
|
||||
let raw = format!("{}hello world{}", markers::DUB_QUOTE, markers::DUB_QUOTE);
|
||||
let mut exp = Expander { raw };
|
||||
let mut exp = Expander {
|
||||
raw,
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["hello world"]);
|
||||
}
|
||||
|
||||
// ===================== Escaped Word Splitting =====================
|
||||
|
||||
#[test]
|
||||
fn word_split_escaped_space() {
|
||||
let _guard = TestGuard::new();
|
||||
|
||||
let raw = format!("hello{}world", unescape_str("\\ "));
|
||||
let mut exp = Expander {
|
||||
raw,
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["hello world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_split_escaped_tab() {
|
||||
let _guard = TestGuard::new();
|
||||
|
||||
let raw = format!("hello{}world", unescape_str("\\\t"));
|
||||
let mut exp = Expander {
|
||||
raw,
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["hello\tworld"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_split_escaped_custom_ifs() {
|
||||
let _guard = TestGuard::new();
|
||||
unsafe {
|
||||
std::env::set_var("IFS", ":");
|
||||
}
|
||||
|
||||
let raw = format!("a{}b:c", unescape_str("\\:"));
|
||||
let mut exp = Expander {
|
||||
raw,
|
||||
flags: TkFlags::empty(),
|
||||
};
|
||||
let words = exp.split_words();
|
||||
assert_eq!(words, vec!["a:b", "c"]);
|
||||
}
|
||||
|
||||
// ===================== Parameter Expansion with Escapes (TestGuard) =====================
|
||||
|
||||
#[test]
|
||||
fn param_exp_prefix_removal_escaped() {
|
||||
let guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("branch", VarKind::Str("## main".into()), VarFlags::NONE)).unwrap();
|
||||
|
||||
test_input("echo \"${branch#\\#\\# }\"").unwrap();
|
||||
|
||||
let out = guard.read_output();
|
||||
assert_eq!(out, "main\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_exp_suffix_removal_escaped() {
|
||||
let guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("val", VarKind::Str("hello world!!".into()), VarFlags::NONE)).unwrap();
|
||||
|
||||
test_input("echo \"${val%\\!\\!}\"").unwrap();
|
||||
|
||||
let out = guard.read_output();
|
||||
assert_eq!(out, "hello world\n");
|
||||
}
|
||||
|
||||
// ===================== Arithmetic with Variables (TestGuard) =====================
|
||||
|
||||
#[test]
|
||||
@@ -3478,8 +3885,13 @@ mod tests {
|
||||
fn array_index_first() {
|
||||
let _guard = TestGuard::new();
|
||||
write_vars(|v| {
|
||||
v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE)
|
||||
}).unwrap();
|
||||
v.set_var(
|
||||
"arr",
|
||||
VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(0))).unwrap();
|
||||
assert_eq!(val, "a");
|
||||
@@ -3489,8 +3901,13 @@ mod tests {
|
||||
fn array_index_second() {
|
||||
let _guard = TestGuard::new();
|
||||
write_vars(|v| {
|
||||
v.set_var("arr", VarKind::arr_from_vec(vec!["x".into(), "y".into(), "z".into()]), VarFlags::NONE)
|
||||
}).unwrap();
|
||||
v.set_var(
|
||||
"arr",
|
||||
VarKind::arr_from_vec(vec!["x".into(), "y".into(), "z".into()]),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(1))).unwrap();
|
||||
assert_eq!(val, "y");
|
||||
@@ -3500,8 +3917,13 @@ mod tests {
|
||||
fn array_all_elems() {
|
||||
let _guard = TestGuard::new();
|
||||
write_vars(|v| {
|
||||
v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE)
|
||||
}).unwrap();
|
||||
v.set_var(
|
||||
"arr",
|
||||
VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap();
|
||||
assert_eq!(elems, vec!["a", "b", "c"]);
|
||||
@@ -3511,8 +3933,13 @@ mod tests {
|
||||
fn array_elem_count() {
|
||||
let _guard = TestGuard::new();
|
||||
write_vars(|v| {
|
||||
v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE)
|
||||
}).unwrap();
|
||||
v.set_var(
|
||||
"arr",
|
||||
VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap();
|
||||
assert_eq!(elems.len(), 3);
|
||||
@@ -3525,7 +3952,9 @@ mod tests {
|
||||
let _guard = TestGuard::new();
|
||||
let dummy_span = Span::default();
|
||||
crate::state::SHED.with(|s| {
|
||||
s.logic.borrow_mut().insert_alias("ll", "ls -la", dummy_span.clone());
|
||||
s.logic
|
||||
.borrow_mut()
|
||||
.insert_alias("ll", "ls -la", dummy_span.clone());
|
||||
});
|
||||
|
||||
let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone());
|
||||
@@ -3538,7 +3967,9 @@ mod tests {
|
||||
let _guard = TestGuard::new();
|
||||
let dummy_span = Span::default();
|
||||
crate::state::SHED.with(|s| {
|
||||
s.logic.borrow_mut().insert_alias("foo", "foo --verbose", dummy_span.clone());
|
||||
s.logic
|
||||
.borrow_mut()
|
||||
.insert_alias("foo", "foo --verbose", dummy_span.clone());
|
||||
});
|
||||
|
||||
let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone());
|
||||
@@ -3553,7 +3984,14 @@ mod tests {
|
||||
#[test]
|
||||
fn index_simple() {
|
||||
let guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("arr", VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])), VarFlags::NONE)).unwrap();
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"arr",
|
||||
VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
test_input("echo $arr").unwrap();
|
||||
|
||||
@@ -3564,8 +4002,22 @@ mod tests {
|
||||
#[test]
|
||||
fn index_cursed() {
|
||||
let guard = TestGuard::new();
|
||||
write_vars(|v| v.set_var("arr", VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])), VarFlags::NONE)).unwrap();
|
||||
write_vars(|v| v.set_var("i", VarKind::Arr(VecDeque::from(["0".into(), "1".into(), "2".into()])), VarFlags::NONE)).unwrap();
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"arr",
|
||||
VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"i",
|
||||
VarKind::Arr(VecDeque::from(["0".into(), "1".into(), "2".into()])),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
test_input("echo $echo ${var:-${arr[$(($(echo ${i[0]}) + 1))]}}").unwrap();
|
||||
|
||||
|
||||
129
src/getopt.rs
129
src/getopt.rs
@@ -3,7 +3,11 @@ use std::sync::Arc;
|
||||
use ariadne::Fmt;
|
||||
use fmt::Display;
|
||||
|
||||
use crate::{libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::lex::Tk, prelude::*};
|
||||
use crate::{
|
||||
libsh::error::{ShErr, ShErrKind, ShResult, next_color},
|
||||
parse::lex::Tk,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
pub type OptSet = Arc<[Opt]>;
|
||||
|
||||
@@ -82,17 +86,23 @@ pub fn get_opts_from_tokens(
|
||||
sort_tks(tokens, opt_specs, false)
|
||||
}
|
||||
|
||||
pub fn sort_tks(tokens: Vec<Tk>, opt_specs: &[OptSpec], strict: bool) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
|
||||
pub fn sort_tks(
|
||||
tokens: Vec<Tk>,
|
||||
opt_specs: &[OptSpec],
|
||||
strict: bool,
|
||||
) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
|
||||
let mut tokens_iter = tokens
|
||||
.into_iter()
|
||||
.map(|t| t.expand())
|
||||
.collect::<ShResult<Vec<_>>>()?
|
||||
.into_iter();
|
||||
.into_iter()
|
||||
.peekable();
|
||||
let mut opts = vec![];
|
||||
let mut non_opts = vec![];
|
||||
|
||||
while let Some(token) = tokens_iter.next() {
|
||||
if &token.to_string() == "--" {
|
||||
non_opts.push(token);
|
||||
non_opts.extend(tokens_iter);
|
||||
break;
|
||||
}
|
||||
@@ -140,7 +150,6 @@ pub fn sort_tks(tokens: Vec<Tk>, opt_specs: &[OptSpec], strict: bool) -> ShResul
|
||||
Ok((non_opts, opts))
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::parse::lex::{LexFlags, LexStream};
|
||||
@@ -156,7 +165,10 @@ use super::*;
|
||||
#[test]
|
||||
fn parse_short_combined() {
|
||||
let opts = Opt::parse("-abc");
|
||||
assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]);
|
||||
assert_eq!(
|
||||
opts,
|
||||
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -173,7 +185,12 @@ use super::*;
|
||||
|
||||
#[test]
|
||||
fn get_opts_basic() {
|
||||
let words = vec!["file.txt".into(), "-v".into(), "--help".into(), "arg".into()];
|
||||
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())]);
|
||||
@@ -191,7 +208,10 @@ use super::*;
|
||||
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!(
|
||||
opts,
|
||||
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
|
||||
);
|
||||
assert_eq!(non_opts, vec!["file"]);
|
||||
}
|
||||
|
||||
@@ -215,7 +235,10 @@ use super::*;
|
||||
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");
|
||||
assert_eq!(
|
||||
Opt::LongWithArg("output".into(), "file".into()).to_string(),
|
||||
"--output file"
|
||||
);
|
||||
}
|
||||
|
||||
fn lex(input: &str) -> Vec<Tk> {
|
||||
@@ -229,8 +252,14 @@ use super::*;
|
||||
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 },
|
||||
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();
|
||||
@@ -248,9 +277,10 @@ use super::*;
|
||||
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 opt_spec = vec![OptSpec {
|
||||
opt: Opt::Short('o'),
|
||||
takes_arg: true,
|
||||
}];
|
||||
|
||||
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
|
||||
|
||||
@@ -263,13 +293,17 @@ use super::*;
|
||||
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 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())]);
|
||||
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()));
|
||||
}
|
||||
@@ -279,8 +313,14 @@ use super::*;
|
||||
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 },
|
||||
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();
|
||||
@@ -296,29 +336,47 @@ use super::*;
|
||||
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 },
|
||||
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')]);
|
||||
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 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"));
|
||||
assert!(
|
||||
non_opts
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.any(|s| s == "-x" || s == "file")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -326,16 +384,25 @@ use super::*;
|
||||
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 },
|
||||
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![
|
||||
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()));
|
||||
}
|
||||
|
||||
51
src/jobs.rs
51
src/jobs.rs
@@ -1,4 +1,7 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use ariadne::Fmt;
|
||||
use nix::unistd::getpid;
|
||||
use scopeguard::defer;
|
||||
use yansi::Color;
|
||||
|
||||
@@ -10,7 +13,7 @@ use crate::{
|
||||
prelude::*,
|
||||
procio::{IoMode, borrow_fd},
|
||||
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;
|
||||
@@ -596,6 +599,29 @@ impl Job {
|
||||
.map(|chld| chld.stat())
|
||||
.collect::<Vec<WtStat>>()
|
||||
}
|
||||
pub fn pipe_status(stats: &[WtStat]) -> Option<Vec<i32>> {
|
||||
if stats.iter().any(|stat| {
|
||||
matches!(
|
||||
stat,
|
||||
WtStat::StillAlive | WtStat::Continued(_) | WtStat::PtraceSyscall(_)
|
||||
)
|
||||
}) || stats.len() <= 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
stats
|
||||
.iter()
|
||||
.map(|stat| match stat {
|
||||
WtStat::Exited(_, code) => *code,
|
||||
WtStat::Signaled(_, signal, _) => SIG_EXIT_OFFSET + *signal as i32,
|
||||
WtStat::Stopped(_, signal) => SIG_EXIT_OFFSET + *signal as i32,
|
||||
WtStat::PtraceEvent(_, signal, _) => SIG_EXIT_OFFSET + *signal as i32,
|
||||
WtStat::PtraceSyscall(_) | WtStat::Continued(_) | WtStat::StillAlive => unreachable!(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub fn get_pids(&self) -> Vec<Pid> {
|
||||
self
|
||||
.children
|
||||
@@ -839,22 +865,35 @@ pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> {
|
||||
enable_reaping();
|
||||
}
|
||||
let statuses = write_jobs(|j| j.new_fg(job))?;
|
||||
for status in statuses {
|
||||
code = code_from_status(&status).unwrap_or(0);
|
||||
for status in &statuses {
|
||||
code = code_from_status(status).unwrap_or(0);
|
||||
match status {
|
||||
WtStat::Stopped(_, _) => {
|
||||
was_stopped = true;
|
||||
write_jobs(|j| j.fg_to_bg(status))?;
|
||||
write_jobs(|j| j.fg_to_bg(*status))?;
|
||||
}
|
||||
WtStat::Signaled(_, sig, _) => {
|
||||
if sig == Signal::SIGTSTP {
|
||||
if *sig == Signal::SIGINT {
|
||||
// interrupt propagates to the shell
|
||||
// necessary for interrupting stuff like
|
||||
// while/for loops
|
||||
kill(getpid(), Signal::SIGINT)?;
|
||||
} else if *sig == Signal::SIGTSTP {
|
||||
was_stopped = true;
|
||||
write_jobs(|j| j.fg_to_bg(status))?;
|
||||
write_jobs(|j| j.fg_to_bg(*status))?;
|
||||
}
|
||||
}
|
||||
_ => { /* Do nothing */ }
|
||||
}
|
||||
}
|
||||
if let Some(pipe_status) = Job::pipe_status(&statuses) {
|
||||
let pipe_status = pipe_status
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<VecDeque<String>>();
|
||||
|
||||
write_vars(|v| v.set_var("PIPESTATUS", VarKind::Arr(pipe_status), VarFlags::NONE))?;
|
||||
}
|
||||
// If job wasn't stopped (moved to bg), clear the fg slot
|
||||
if !was_stopped {
|
||||
write_jobs(|j| {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use ariadne::{Color, Fmt};
|
||||
use ariadne::{Report, ReportKind};
|
||||
use rand::TryRng;
|
||||
use yansi::Paint;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fmt::Display;
|
||||
use yansi::Paint;
|
||||
|
||||
use crate::procio::RedirGuard;
|
||||
use crate::{
|
||||
@@ -201,6 +201,7 @@ impl ShErr {
|
||||
pub fn is_flow_control(&self) -> bool {
|
||||
self.kind.is_flow_control()
|
||||
}
|
||||
/// Promotes a shell error from a simple error to an error that blames a span
|
||||
pub fn promote(mut self, span: Span) -> Self {
|
||||
if self.notes.is_empty() {
|
||||
return self;
|
||||
@@ -208,6 +209,8 @@ impl ShErr {
|
||||
let first = self.notes[0].clone();
|
||||
if self.notes.len() > 1 {
|
||||
self.notes = self.notes[1..].to_vec();
|
||||
} else {
|
||||
self.notes = vec![];
|
||||
}
|
||||
|
||||
self.labeled(span, first)
|
||||
@@ -456,7 +459,7 @@ pub enum ShErrKind {
|
||||
FuncReturn(i32),
|
||||
LoopContinue(i32),
|
||||
LoopBreak(i32),
|
||||
ClearReadline,
|
||||
Interrupt,
|
||||
Null,
|
||||
}
|
||||
|
||||
@@ -468,7 +471,7 @@ impl ShErrKind {
|
||||
| Self::FuncReturn(_)
|
||||
| Self::LoopContinue(_)
|
||||
| Self::LoopBreak(_)
|
||||
| Self::ClearReadline
|
||||
| Self::Interrupt
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -493,7 +496,7 @@ impl Display for ShErrKind {
|
||||
Self::LoopBreak(_) => "Syntax Error",
|
||||
Self::ReadlineErr => "Readline Error",
|
||||
Self::ExCommand => "Ex Command Error",
|
||||
Self::ClearReadline => "",
|
||||
Self::Interrupt => "",
|
||||
Self::Null => "",
|
||||
};
|
||||
write!(f, "{output}")
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::collections::HashSet;
|
||||
use std::os::fd::{BorrowedFd, RawFd};
|
||||
|
||||
use nix::sys::termios::{self, LocalFlags, Termios, tcgetattr, tcsetattr};
|
||||
use nix::unistd::isatty;
|
||||
use nix::unistd::{isatty, write};
|
||||
use scopeguard::guard;
|
||||
|
||||
thread_local! {
|
||||
@@ -147,11 +147,10 @@ impl RawModeGuard {
|
||||
let orig = ORIG_TERMIOS
|
||||
.with(|cell| cell.borrow().clone())
|
||||
.expect("with_cooked_mode called before raw_mode()");
|
||||
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig)
|
||||
.expect("Failed to restore cooked mode");
|
||||
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, &orig).ok();
|
||||
let res = f();
|
||||
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, ¤t)
|
||||
.expect("Failed to restore raw mode");
|
||||
tcsetattr(borrow_fd(*TTY_FILENO), termios::SetArg::TCSANOW, ¤t).ok();
|
||||
unsafe { write(BorrowedFd::borrow_raw(*TTY_FILENO), b"\x1b[?1l\x1b>").ok() };
|
||||
res
|
||||
}
|
||||
}
|
||||
@@ -159,11 +158,12 @@ impl RawModeGuard {
|
||||
impl Drop for RawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
let _ = termios::tcsetattr(
|
||||
termios::tcsetattr(
|
||||
BorrowedFd::borrow_raw(self.fd),
|
||||
termios::SetArg::TCSANOW,
|
||||
&self.orig,
|
||||
);
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ use std::sync::LazyLock;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
/// Minimum fd number for shell-internal file descriptors.
|
||||
const MIN_INTERNAL_FD: RawFd = 10;
|
||||
|
||||
pub static TTY_FILENO: LazyLock<RawFd> = LazyLock::new(|| {
|
||||
open("/dev/tty", OFlag::O_RDWR, Mode::empty()).expect("Failed to open /dev/tty")
|
||||
let fd = open("/dev/tty", OFlag::O_RDWR, Mode::empty()).expect("Failed to open /dev/tty");
|
||||
// Move the tty fd above the user-accessible range so that
|
||||
// `exec 3>&-` and friends don't collide with shell internals.
|
||||
let high =
|
||||
fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD)).expect("Failed to dup /dev/tty high");
|
||||
close(fd).ok();
|
||||
high
|
||||
});
|
||||
|
||||
121
src/main.rs
121
src/main.rs
@@ -38,15 +38,18 @@ use crate::prelude::*;
|
||||
use crate::procio::borrow_fd;
|
||||
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
|
||||
use crate::readline::{Prompt, ReadlineEvent, ShedVi};
|
||||
use crate::signal::{GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending};
|
||||
use crate::state::{AutoCmdKind, read_logic, read_shopts, source_rc, write_jobs, write_meta};
|
||||
use crate::signal::{
|
||||
GOT_SIGUSR1, GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending,
|
||||
};
|
||||
use crate::state::{
|
||||
AutoCmdKind, read_logic, read_shopts, source_env, source_login, source_rc, write_jobs,
|
||||
write_meta, write_shopts,
|
||||
};
|
||||
use clap::Parser;
|
||||
use state::{read_vars, write_vars};
|
||||
use state::write_vars;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct ShedArgs {
|
||||
script: Option<String>,
|
||||
|
||||
#[arg(short)]
|
||||
command: Option<String>,
|
||||
|
||||
@@ -59,24 +62,13 @@ struct ShedArgs {
|
||||
#[arg(short)]
|
||||
interactive: bool,
|
||||
|
||||
#[arg(short)]
|
||||
stdin: bool,
|
||||
|
||||
#[arg(long, short)]
|
||||
login_shell: bool,
|
||||
}
|
||||
|
||||
/// Force evaluation of lazily-initialized values early in shell startup.
|
||||
///
|
||||
/// In particular, this ensures that the variable table is initialized, which
|
||||
/// populates environment variables from the system. If this initialization is
|
||||
/// deferred too long, features like prompt expansion may fail due to missing
|
||||
/// environment variables.
|
||||
///
|
||||
/// This function triggers initialization by calling `read_vars` with a no-op
|
||||
/// closure, which forces access to the variable table and causes its `LazyLock`
|
||||
/// constructor to run.
|
||||
fn kickstart_lazy_evals() {
|
||||
read_vars(|_| {});
|
||||
}
|
||||
|
||||
/// We need to make sure that even if we panic, our child processes get sighup
|
||||
fn setup_panic_handler() {
|
||||
let default_panic_hook = std::panic::take_hook();
|
||||
@@ -111,7 +103,6 @@ fn setup_panic_handler() {
|
||||
fn main() -> ExitCode {
|
||||
yansi::enable();
|
||||
env_logger::init();
|
||||
kickstart_lazy_evals();
|
||||
setup_panic_handler();
|
||||
|
||||
let mut args = ShedArgs::parse();
|
||||
@@ -133,16 +124,24 @@ fn main() -> ExitCode {
|
||||
// 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>() {
|
||||
&& 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) = if let Some(path) = args.script {
|
||||
run_script(path, args.script_args)
|
||||
} else if let Some(cmd) = args.command {
|
||||
if let Err(e) = source_env() {
|
||||
e.print_error();
|
||||
}
|
||||
|
||||
if let Err(e) = if let Some(cmd) = args.command {
|
||||
exec_dash_c(cmd)
|
||||
} else if args.stdin || !isatty(STDIN_FILENO).unwrap_or(false) {
|
||||
read_commands(args.script_args)
|
||||
} else if !args.script_args.is_empty() {
|
||||
let path = args.script_args.remove(0);
|
||||
run_script(path, args.script_args)
|
||||
} else {
|
||||
let res = shed_interactive(args);
|
||||
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
|
||||
@@ -164,6 +163,32 @@ fn main() -> ExitCode {
|
||||
ExitCode::from(QUIT_CODE.load(Ordering::SeqCst) as u8)
|
||||
}
|
||||
|
||||
fn read_commands(args: Vec<String>) -> ShResult<()> {
|
||||
let mut input = vec![];
|
||||
let mut read_buf = [0u8; 4096];
|
||||
loop {
|
||||
match read(STDIN_FILENO, &mut read_buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => input.extend_from_slice(&read_buf[..n]),
|
||||
Err(Errno::EINTR) => continue,
|
||||
Err(e) => {
|
||||
QUIT_CODE.store(1, Ordering::SeqCst);
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::CleanExit(1),
|
||||
format!("error reading from stdin: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let commands = String::from_utf8_lossy(&input).to_string();
|
||||
for arg in args {
|
||||
write_vars(|v| v.cur_scope_mut().bpush_arg(arg))
|
||||
}
|
||||
|
||||
exec_input(commands, None, false, None)
|
||||
}
|
||||
|
||||
fn run_script<P: AsRef<Path>>(path: P, args: Vec<String>) -> ShResult<()> {
|
||||
let path = path.as_ref();
|
||||
let path_raw = path.to_string_lossy().to_string();
|
||||
@@ -199,6 +224,12 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
||||
let _raw_mode = raw_mode(); // sets raw mode, restores termios on drop
|
||||
sig_setup(args.login_shell);
|
||||
|
||||
if args.login_shell
|
||||
&& let Err(e) = source_login()
|
||||
{
|
||||
e.print_error();
|
||||
}
|
||||
|
||||
if let Err(e) = source_rc() {
|
||||
e.print_error();
|
||||
}
|
||||
@@ -230,7 +261,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
||||
while signals_pending() {
|
||||
if let Err(e) = check_signals() {
|
||||
match e.kind() {
|
||||
ShErrKind::ClearReadline => {
|
||||
ShErrKind::Interrupt => {
|
||||
// We got Ctrl+C - clear current input and redraw
|
||||
readline.reset_active_widget(false)?;
|
||||
}
|
||||
@@ -256,22 +287,53 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
||||
readline.prompt_mut().refresh();
|
||||
}
|
||||
|
||||
if GOT_SIGUSR1.swap(false, Ordering::SeqCst) {
|
||||
log::info!("SIGUSR1 received: refreshing readline state");
|
||||
readline.mark_dirty();
|
||||
readline.prompt_mut().refresh();
|
||||
}
|
||||
|
||||
readline.print_line(false)?;
|
||||
|
||||
// Poll for stdin input
|
||||
// Poll for
|
||||
// stdin input
|
||||
let mut fds = [PollFd::new(
|
||||
unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) },
|
||||
PollFlags::POLLIN,
|
||||
)];
|
||||
|
||||
let mut exec_if_timeout = None;
|
||||
|
||||
let timeout = if readline.pending_keymap.is_empty() {
|
||||
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 {
|
||||
PollTimeout::from(1000u16)
|
||||
};
|
||||
|
||||
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) => {
|
||||
// Interrupted by signal, loop back to handle it
|
||||
continue;
|
||||
@@ -280,6 +342,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
|
||||
eprintln!("poll error: {e}");
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
}
|
||||
|
||||
// Timeout — resolve pending keymap ambiguity
|
||||
@@ -381,6 +444,10 @@ fn handle_readline_event(readline: &mut ShedVi, event: ShResult<ReadlineEvent>)
|
||||
}) {
|
||||
// CleanExit signals an intentional shell exit; any other error is printed.
|
||||
match e.kind() {
|
||||
ShErrKind::Interrupt => {
|
||||
// We got Ctrl+C during command execution
|
||||
// Just fall through here
|
||||
}
|
||||
ShErrKind::CleanExit(code) => {
|
||||
QUIT_CODE.store(*code, Ordering::SeqCst);
|
||||
return Ok(true);
|
||||
|
||||
@@ -8,7 +8,30 @@ use ariadne::Fmt;
|
||||
|
||||
use crate::{
|
||||
builtin::{
|
||||
alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}
|
||||
alias::{alias, unalias},
|
||||
arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate},
|
||||
autocmd::autocmd,
|
||||
cd::cd,
|
||||
complete::{compgen_builtin, complete_builtin},
|
||||
dirstack::{dirs, popd, pushd},
|
||||
echo::echo,
|
||||
eval, exec,
|
||||
flowctl::flowctl,
|
||||
getopts::getopts,
|
||||
help::help,
|
||||
intro,
|
||||
jobctl::{self, JobBehavior, continue_job, disown, jobs},
|
||||
keymap, map,
|
||||
pwd::pwd,
|
||||
read::{self, read_builtin},
|
||||
resource::{ulimit, umask_builtin},
|
||||
seek::seek,
|
||||
shift::shift,
|
||||
shopt::shopt,
|
||||
source::source,
|
||||
test::double_bracket_test,
|
||||
trap::{TrapTarget, trap},
|
||||
varcmds::{export, local, readonly, unset},
|
||||
},
|
||||
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
|
||||
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
|
||||
@@ -19,6 +42,7 @@ use crate::{
|
||||
},
|
||||
prelude::*,
|
||||
procio::{IoMode, IoStack, PipeGenerator},
|
||||
signal::{check_signals, signals_pending},
|
||||
state::{
|
||||
self, ShFunc, VarFlags, VarKind, read_logic, read_shopts, write_jobs, write_logic, write_vars,
|
||||
},
|
||||
@@ -136,10 +160,15 @@ pub fn exec_dash_c(input: String) -> ShResult<()> {
|
||||
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::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 { .. }),
|
||||
elements.len() == 1
|
||||
&& match &elements[0].cmd.class {
|
||||
NdRule::Pipeline { cmds } => {
|
||||
cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. })
|
||||
}
|
||||
NdRule::Command { .. } => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -151,8 +180,12 @@ pub fn exec_dash_c(input: String) -> ShResult<()> {
|
||||
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::Conjunction { mut elements } => {
|
||||
node = *elements.remove(0).cmd;
|
||||
}
|
||||
NdRule::Pipeline { mut cmds } => {
|
||||
node = cmds.remove(0);
|
||||
}
|
||||
NdRule::Command { .. } => break,
|
||||
_ => break,
|
||||
}
|
||||
@@ -241,6 +274,13 @@ impl Dispatcher {
|
||||
Ok(())
|
||||
}
|
||||
pub fn dispatch_node(&mut self, node: Node) -> ShResult<()> {
|
||||
while signals_pending() {
|
||||
// If we have received SIGINT,
|
||||
// this will stop the execution here
|
||||
// and propagate back to the functions in main.rs
|
||||
check_signals()?;
|
||||
}
|
||||
|
||||
match node.class {
|
||||
NdRule::Conjunction { .. } => self.exec_conjunction(node)?,
|
||||
NdRule::Pipeline { .. } => self.exec_pipeline(node)?,
|
||||
@@ -259,7 +299,13 @@ impl Dispatcher {
|
||||
}
|
||||
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))?;
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"LINENO",
|
||||
VarKind::Str((line + 1).to_string()),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
})?;
|
||||
|
||||
let Some(cmd) = node.get_command() else {
|
||||
return self.exec_cmd(node); // Argv is empty, probably an assignment
|
||||
@@ -304,24 +350,19 @@ impl Dispatcher {
|
||||
};
|
||||
|
||||
let mut elem_iter = elements.into_iter();
|
||||
let mut skip = false;
|
||||
while let Some(element) = elem_iter.next() {
|
||||
let ConjunctNode { cmd, operator } = element;
|
||||
if !skip {
|
||||
self.dispatch_node(*cmd)?;
|
||||
}
|
||||
|
||||
let status = state::get_status();
|
||||
match operator {
|
||||
ConjunctOp::And => {
|
||||
if status != 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ConjunctOp::Or => {
|
||||
if status == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
skip = match operator {
|
||||
ConjunctOp::And => status != 0,
|
||||
ConjunctOp::Or => status == 0,
|
||||
ConjunctOp::Null => break,
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -341,7 +382,11 @@ impl Dispatcher {
|
||||
};
|
||||
let body_span = body.get_span();
|
||||
let body = body_span.as_str().to_string();
|
||||
let name = name.span.as_str().strip_suffix("()").unwrap();
|
||||
let name = name
|
||||
.span
|
||||
.as_str()
|
||||
.strip_suffix("()")
|
||||
.unwrap_or(name.span.as_str());
|
||||
|
||||
if KEYWORDS.contains(&name) {
|
||||
return Err(ShErr::at(
|
||||
@@ -767,13 +812,16 @@ impl Dispatcher {
|
||||
self.job_stack.new_job();
|
||||
if cmds.len() == 1 {
|
||||
self.fg_job = !is_bg && self.interactive;
|
||||
let mut cmd = cmds.into_iter().next().unwrap();
|
||||
let cmd = cmds.into_iter().next().unwrap();
|
||||
if is_bg && !matches!(cmd.class, NdRule::Command { .. }) {
|
||||
self.run_fork(&cmd.get_command().map(|t| t.to_string()).unwrap_or_default(), |s| {
|
||||
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)?;
|
||||
}
|
||||
@@ -849,7 +897,10 @@ impl Dispatcher {
|
||||
|
||||
if fork_builtins {
|
||||
log::trace!("Forking builtin: {}", cmd_raw);
|
||||
let _guard = self.io_stack.pop_frame().redirect()?;
|
||||
let guard = self.io_stack.pop_frame().redirect()?;
|
||||
if cmd_raw.as_str() == "exec" {
|
||||
guard.persist();
|
||||
}
|
||||
self.run_fork(&cmd_raw, |s| {
|
||||
if let Err(e) = s.dispatch_builtin(cmd) {
|
||||
e.print_error();
|
||||
@@ -974,6 +1025,8 @@ impl Dispatcher {
|
||||
"autocmd" => autocmd(cmd),
|
||||
"ulimit" => ulimit(cmd),
|
||||
"umask" => umask_builtin(cmd),
|
||||
"seek" => seek(cmd),
|
||||
"help" => help(cmd),
|
||||
"true" | ":" => {
|
||||
state::set_status(0);
|
||||
Ok(())
|
||||
|
||||
310
src/parse/lex.rs
310
src/parse/lex.rs
@@ -19,7 +19,7 @@ use crate::{
|
||||
|
||||
pub const KEYWORDS: [&str; 17] = [
|
||||
"if", "then", "elif", "else", "fi", "while", "until", "select", "for", "in", "do", "done",
|
||||
"case", "esac", "[[", "]]", "!"
|
||||
"case", "esac", "[[", "]]", "!",
|
||||
];
|
||||
|
||||
pub const OPENERS: [&str; 6] = ["if", "while", "until", "for", "select", "case"];
|
||||
@@ -217,6 +217,31 @@ impl Tk {
|
||||
};
|
||||
self.span.as_str().trim() == ";;"
|
||||
}
|
||||
|
||||
pub fn is_opener(&self) -> bool {
|
||||
OPENERS.contains(&self.as_str())
|
||||
|| matches!(self.class, TkRule::BraceGrpStart)
|
||||
|| matches!(self.class, TkRule::CasePattern)
|
||||
}
|
||||
pub fn is_closer(&self) -> bool {
|
||||
matches!(self.as_str(), "fi" | "done" | "esac")
|
||||
|| self.has_double_semi()
|
||||
|| matches!(self.class, TkRule::BraceGrpEnd)
|
||||
}
|
||||
|
||||
pub fn is_closer_for(&self, other: &Tk) -> bool {
|
||||
if (matches!(other.class, TkRule::BraceGrpStart) && matches!(self.class, TkRule::BraceGrpEnd))
|
||||
|| (matches!(other.class, TkRule::CasePattern) && self.has_double_semi())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match other.as_str() {
|
||||
"for" | "while" | "until" => matches!(self.as_str(), "done"),
|
||||
"if" => matches!(self.as_str(), "fi"),
|
||||
"case" => matches!(self.as_str(), "esac"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Tk {
|
||||
@@ -241,20 +266,12 @@ bitflags! {
|
||||
const ASSIGN = 0b0000000001000000;
|
||||
const BUILTIN = 0b0000000010000000;
|
||||
const IS_PROCSUB = 0b0000000100000000;
|
||||
const IS_HEREDOC = 0b0000001000000000;
|
||||
const LIT_HEREDOC = 0b0000010000000000;
|
||||
const TAB_HEREDOC = 0b0000100000000000;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LexStream {
|
||||
source: Arc<String>,
|
||||
pub cursor: usize,
|
||||
pub name: String,
|
||||
quote_state: QuoteState,
|
||||
brc_grp_depth: usize,
|
||||
brc_grp_start: Option<usize>,
|
||||
case_depth: usize,
|
||||
flags: LexFlags,
|
||||
}
|
||||
|
||||
bitflags! {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct LexFlags: u32 {
|
||||
@@ -296,6 +313,18 @@ pub fn clean_input(input: &str) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
pub struct LexStream {
|
||||
source: Arc<String>,
|
||||
pub cursor: usize,
|
||||
pub name: String,
|
||||
quote_state: QuoteState,
|
||||
brc_grp_depth: usize,
|
||||
brc_grp_start: Option<usize>,
|
||||
case_depth: usize,
|
||||
heredoc_skip: Option<usize>,
|
||||
flags: LexFlags,
|
||||
}
|
||||
|
||||
impl LexStream {
|
||||
pub fn new(source: Arc<String>, flags: LexFlags) -> Self {
|
||||
let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD;
|
||||
@@ -307,6 +336,7 @@ impl LexStream {
|
||||
quote_state: QuoteState::default(),
|
||||
brc_grp_depth: 0,
|
||||
brc_grp_start: None,
|
||||
heredoc_skip: None,
|
||||
case_depth: 0,
|
||||
}
|
||||
}
|
||||
@@ -367,7 +397,7 @@ impl LexStream {
|
||||
}
|
||||
pub fn read_redir(&mut self) -> Option<ShResult<Tk>> {
|
||||
assert!(self.cursor <= self.source.len());
|
||||
let slice = self.slice(self.cursor..)?;
|
||||
let slice = self.slice(self.cursor..)?.to_string();
|
||||
let mut pos = self.cursor;
|
||||
let mut chars = slice.chars().peekable();
|
||||
let mut tk = Tk::default();
|
||||
@@ -379,20 +409,38 @@ impl LexStream {
|
||||
return None; // It's a process sub
|
||||
}
|
||||
pos += 1;
|
||||
if let Some('|') = chars.peek() {
|
||||
// noclobber force '>|'
|
||||
chars.next();
|
||||
pos += 1;
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some('>') = chars.peek() {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
}
|
||||
if let Some('&') = chars.peek() {
|
||||
let Some('&') = chars.peek() else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -406,10 +454,6 @@ impl LexStream {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if chars.peek() == Some(&'(') {
|
||||
@@ -417,14 +461,94 @@ impl LexStream {
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
for _ in 0..2 {
|
||||
if let Some('<') = chars.peek() {
|
||||
match chars.peek() {
|
||||
Some('<') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
|
||||
match chars.peek() {
|
||||
Some('<') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
Some(ch) => {
|
||||
let mut ch = *ch;
|
||||
while is_field_sep(ch) {
|
||||
let Some(next_ch) = chars.next() else {
|
||||
// Incomplete input — fall through to emit << as Redir
|
||||
break;
|
||||
};
|
||||
pos += next_ch.len_utf8();
|
||||
ch = next_ch;
|
||||
}
|
||||
|
||||
if is_field_sep(ch) {
|
||||
// Ran out of input while skipping whitespace — fall through
|
||||
} else {
|
||||
let saved_cursor = self.cursor;
|
||||
match self.read_heredoc(pos) {
|
||||
Ok(Some(heredoc_tk)) => {
|
||||
// cursor is set to after the delimiter word;
|
||||
// heredoc_skip is set to after the body
|
||||
pos = self.cursor;
|
||||
self.cursor = saved_cursor;
|
||||
tk = heredoc_tk;
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Incomplete heredoc — restore cursor and fall through
|
||||
self.cursor = saved_cursor;
|
||||
}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// No delimiter yet — input is incomplete
|
||||
// Fall through to emit the << as a Redir token
|
||||
}
|
||||
}
|
||||
}
|
||||
Some('>') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
Some('&') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
|
||||
let mut found_fd = false;
|
||||
if chars.peek().is_some_and(|ch| *ch == '-') {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
} else {
|
||||
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
let span_start = self.cursor;
|
||||
self.cursor = pos;
|
||||
return Some(Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(span_start..pos, self.source.clone()),
|
||||
"Invalid redirection",
|
||||
)));
|
||||
} else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
@@ -448,6 +572,133 @@ impl LexStream {
|
||||
self.cursor = pos;
|
||||
Some(Ok(tk))
|
||||
}
|
||||
pub fn read_heredoc(&mut self, mut pos: usize) -> ShResult<Option<Tk>> {
|
||||
let slice = self.slice(pos..).unwrap_or_default().to_string();
|
||||
let mut chars = slice.chars();
|
||||
let mut delim = String::new();
|
||||
let mut flags = TkFlags::empty();
|
||||
let mut first_char = true;
|
||||
// Parse the delimiter word, stripping quotes
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'-' if first_char => {
|
||||
pos += 1;
|
||||
flags |= TkFlags::TAB_HEREDOC;
|
||||
}
|
||||
'\"' => {
|
||||
pos += 1;
|
||||
self.quote_state.toggle_double();
|
||||
flags |= TkFlags::LIT_HEREDOC;
|
||||
}
|
||||
'\'' => {
|
||||
pos += 1;
|
||||
self.quote_state.toggle_single();
|
||||
flags |= TkFlags::LIT_HEREDOC;
|
||||
}
|
||||
_ if self.quote_state.in_quote() => {
|
||||
pos += ch.len_utf8();
|
||||
delim.push(ch);
|
||||
}
|
||||
ch if is_hard_sep(ch) => {
|
||||
break;
|
||||
}
|
||||
ch => {
|
||||
pos += ch.len_utf8();
|
||||
delim.push(ch);
|
||||
}
|
||||
}
|
||||
first_char = false;
|
||||
}
|
||||
|
||||
// pos is now right after the delimiter word — this is where
|
||||
// the cursor should return so the rest of the line gets lexed
|
||||
let cursor_after_delim = pos;
|
||||
|
||||
// Re-slice from cursor_after_delim so iterator and pos are in sync
|
||||
// (the old chars iterator consumed the hard_sep without advancing pos)
|
||||
let rest = self
|
||||
.slice(cursor_after_delim..)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut chars = rest.chars();
|
||||
|
||||
// Scan forward to the newline (or use heredoc_skip from a previous heredoc)
|
||||
let body_start = if let Some(skip) = self.heredoc_skip {
|
||||
// A previous heredoc on this line already read its body;
|
||||
// our body starts where that one ended
|
||||
let skip_offset = skip - cursor_after_delim;
|
||||
for _ in 0..skip_offset {
|
||||
chars.next();
|
||||
}
|
||||
skip
|
||||
} else {
|
||||
// Skip the rest of the current line to find where the body begins
|
||||
let mut scan = pos;
|
||||
let mut found_newline = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
scan += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
found_newline = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_newline {
|
||||
if self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
return Ok(None);
|
||||
} else {
|
||||
return Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(pos..pos, self.source.clone()),
|
||||
"Heredoc delimiter not found",
|
||||
));
|
||||
}
|
||||
}
|
||||
scan
|
||||
};
|
||||
|
||||
pos = body_start;
|
||||
let start = pos;
|
||||
|
||||
// Read lines until we find one that matches the delimiter exactly
|
||||
let mut line = String::new();
|
||||
let mut line_start = pos;
|
||||
while let Some(ch) = chars.next() {
|
||||
pos += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
let trimmed = line.trim_end_matches('\r');
|
||||
if trimmed == delim {
|
||||
let mut tk = self.get_token(start..line_start, TkRule::Redir);
|
||||
tk.flags |= TkFlags::IS_HEREDOC | flags;
|
||||
self.heredoc_skip = Some(pos);
|
||||
self.cursor = cursor_after_delim;
|
||||
return Ok(Some(tk));
|
||||
}
|
||||
line.clear();
|
||||
line_start = pos;
|
||||
} else {
|
||||
line.push(ch);
|
||||
}
|
||||
}
|
||||
// Check the last line (no trailing newline)
|
||||
let trimmed = line.trim_end_matches('\r');
|
||||
if trimmed == delim {
|
||||
let mut tk = self.get_token(start..line_start, TkRule::Redir);
|
||||
tk.flags |= TkFlags::IS_HEREDOC | flags;
|
||||
self.heredoc_skip = Some(pos);
|
||||
self.cursor = cursor_after_delim;
|
||||
return Ok(Some(tk));
|
||||
}
|
||||
|
||||
if !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(start..pos, self.source.clone()),
|
||||
format!("Heredoc delimiter '{}' not found", delim),
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
pub fn read_string(&mut self) -> ShResult<Tk> {
|
||||
assert!(self.cursor <= self.source.len());
|
||||
let slice = self.slice_from_cursor().unwrap().to_string();
|
||||
@@ -625,6 +876,16 @@ impl LexStream {
|
||||
));
|
||||
}
|
||||
}
|
||||
'(' if can_be_subshell && chars.peek() == Some(&')') => {
|
||||
// standalone "()" — function definition marker
|
||||
pos += 2;
|
||||
chars.next();
|
||||
let mut tk = self.get_token(self.cursor..pos, TkRule::Str);
|
||||
tk.mark(TkFlags::KEYWORD);
|
||||
self.cursor = pos;
|
||||
self.set_next_is_cmd(true);
|
||||
return Ok(tk);
|
||||
}
|
||||
'(' if self.next_is_cmd() && can_be_subshell => {
|
||||
pos += 1;
|
||||
let mut paren_count = 1;
|
||||
@@ -845,10 +1106,19 @@ impl Iterator for LexStream {
|
||||
|
||||
let token = match get_char(&self.source, self.cursor).unwrap() {
|
||||
'\r' | '\n' | ';' => {
|
||||
let ch = get_char(&self.source, self.cursor).unwrap();
|
||||
let ch_idx = self.cursor;
|
||||
self.cursor += 1;
|
||||
self.set_next_is_cmd(true);
|
||||
|
||||
// If a heredoc was parsed on this line, skip past the body
|
||||
// Only on newline — ';' is a command separator within the same line
|
||||
if (ch == '\n' || ch == '\r')
|
||||
&& let Some(skip) = self.heredoc_skip.take()
|
||||
{
|
||||
self.cursor = skip;
|
||||
}
|
||||
|
||||
while let Some(ch) = get_char(&self.source, self.cursor) {
|
||||
match ch {
|
||||
'\\' if get_char(&self.source, self.cursor + 1) == Some('\n') => {
|
||||
|
||||
609
src/parse/mod.rs
609
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 nix::{
|
||||
errno::Errno,
|
||||
fcntl::{OFlag, open},
|
||||
fcntl::{FcntlArg, OFlag, fcntl, open},
|
||||
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
|
||||
sys::{
|
||||
signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal},
|
||||
@@ -33,5 +33,4 @@ pub use nix::{
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Additional utilities, if needed, can be added here
|
||||
|
||||
188
src/procio.rs
188
src/procio.rs
@@ -8,15 +8,27 @@ use crate::{
|
||||
expand::Expander,
|
||||
libsh::{
|
||||
error::{ShErr, ShErrKind, ShResult},
|
||||
sys::TTY_FILENO,
|
||||
utils::RedirVecUtils,
|
||||
},
|
||||
parse::{Redir, RedirType, get_redir_file},
|
||||
parse::{Redir, RedirType, get_redir_file, lex::TkFlags},
|
||||
prelude::*,
|
||||
state,
|
||||
};
|
||||
|
||||
// Credit to fish-shell for many of the implementation ideas present in this
|
||||
// module https://fishshell.com/
|
||||
|
||||
/// Minimum fd number for shell-internal file descriptors.
|
||||
/// User-visible fds (0-9) are kept clear so `exec 3>&-` etc. work as expected.
|
||||
const MIN_INTERNAL_FD: RawFd = 10;
|
||||
|
||||
/// Like `dup()`, but places the new fd at `MIN_INTERNAL_FD` or above so it
|
||||
/// doesn't collide with user-managed fds.
|
||||
fn dup_high(fd: RawFd) -> nix::Result<RawFd> {
|
||||
fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum IoMode {
|
||||
Fd {
|
||||
@@ -37,8 +49,9 @@ pub enum IoMode {
|
||||
pipe: Arc<OwnedFd>,
|
||||
},
|
||||
Buffer {
|
||||
tgt_fd: RawFd,
|
||||
buf: String,
|
||||
pipe: Arc<OwnedFd>,
|
||||
flags: TkFlags, // so we can see if its a heredoc or not
|
||||
},
|
||||
Close {
|
||||
tgt_fd: RawFd,
|
||||
@@ -79,19 +92,37 @@ impl IoMode {
|
||||
if let IoMode::File { tgt_fd, path, mode } = self {
|
||||
let path_raw = path.as_os_str().to_str().unwrap_or_default().to_string();
|
||||
|
||||
let expanded_path = Expander::from_raw(&path_raw)?.expand()?.join(" "); // should just be one string, will have to find some way to handle a return of
|
||||
// multiple
|
||||
let expanded_path = Expander::from_raw(&path_raw, TkFlags::empty())?
|
||||
.expand()?
|
||||
.join(" "); // should just be one string, will have to find some way to handle a return of multiple paths
|
||||
|
||||
let expanded_pathbuf = PathBuf::from(expanded_path);
|
||||
|
||||
let file = get_redir_file(mode, expanded_pathbuf)?;
|
||||
// Move the opened fd above the user-accessible range so it never
|
||||
// collides with the target fd (e.g. `3>/tmp/foo` where open() returns 3,
|
||||
// causing dup2(3,3) to be a no-op and then OwnedFd drop closes it).
|
||||
let raw = file.as_raw_fd();
|
||||
let high = fcntl(raw, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD)).map_err(ShErr::from)?;
|
||||
drop(file); // closes the original low fd
|
||||
self = IoMode::OpenedFile {
|
||||
tgt_fd,
|
||||
file: Arc::new(OwnedFd::from(file)),
|
||||
file: Arc::new(unsafe { OwnedFd::from_raw_fd(high) }),
|
||||
}
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
pub fn buffer(tgt_fd: RawFd, buf: String, flags: TkFlags) -> ShResult<Self> {
|
||||
Ok(Self::Buffer { tgt_fd, buf, flags })
|
||||
}
|
||||
pub fn loaded_pipe(tgt_fd: RawFd, buf: &[u8]) -> ShResult<Self> {
|
||||
let (rpipe, wpipe) = nix::unistd::pipe()?;
|
||||
write(wpipe, buf)?;
|
||||
Ok(Self::Pipe {
|
||||
tgt_fd,
|
||||
pipe: rpipe.into(),
|
||||
})
|
||||
}
|
||||
pub fn get_pipes() -> (Self, Self) {
|
||||
let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap();
|
||||
(
|
||||
@@ -206,24 +237,107 @@ impl<'e> IoFrame {
|
||||
)
|
||||
}
|
||||
pub fn save(&'e mut self) {
|
||||
let saved_in = dup(STDIN_FILENO).unwrap();
|
||||
let saved_out = dup(STDOUT_FILENO).unwrap();
|
||||
let saved_err = dup(STDERR_FILENO).unwrap();
|
||||
let saved_in = dup_high(STDIN_FILENO).unwrap();
|
||||
let saved_out = dup_high(STDOUT_FILENO).unwrap();
|
||||
let saved_err = dup_high(STDERR_FILENO).unwrap();
|
||||
self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err));
|
||||
}
|
||||
pub fn redirect(mut self) -> ShResult<RedirGuard> {
|
||||
self.save();
|
||||
for redir in &mut self.redirs {
|
||||
let io_mode = &mut redir.io_mode;
|
||||
if let IoMode::File { .. } = io_mode {
|
||||
*io_mode = io_mode.clone().open_file()?;
|
||||
};
|
||||
let tgt_fd = io_mode.tgt_fd();
|
||||
let src_fd = io_mode.src_fd();
|
||||
dup2(src_fd, tgt_fd)?;
|
||||
if let Err(e) = self.apply_redirs() {
|
||||
// Restore saved fds before propagating the error so they don't leak.
|
||||
self.restore().ok();
|
||||
return Err(e);
|
||||
}
|
||||
Ok(RedirGuard::new(self))
|
||||
}
|
||||
fn apply_redirs(&mut self) -> ShResult<()> {
|
||||
for redir in &mut self.redirs {
|
||||
let io_mode = &mut redir.io_mode;
|
||||
match io_mode {
|
||||
IoMode::Close { tgt_fd } => {
|
||||
if *tgt_fd == *TTY_FILENO {
|
||||
// Don't let user close the shell's tty fd.
|
||||
continue;
|
||||
}
|
||||
close(*tgt_fd).ok();
|
||||
continue;
|
||||
}
|
||||
IoMode::File { .. } => match io_mode.clone().open_file() {
|
||||
Ok(file) => *io_mode = file,
|
||||
Err(e) => {
|
||||
if let Some(span) = redir.span.as_ref() {
|
||||
return Err(e.promote(span.clone()));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
},
|
||||
IoMode::Buffer { tgt_fd, buf, flags } => {
|
||||
let (rpipe, wpipe) = nix::unistd::pipe()?;
|
||||
let mut text = if flags.contains(TkFlags::LIT_HEREDOC) {
|
||||
buf.clone()
|
||||
} else {
|
||||
let words = Expander::from_raw(buf, *flags)?.expand()?;
|
||||
if flags.contains(TkFlags::IS_HEREDOC) {
|
||||
words.into_iter().next().unwrap_or_default()
|
||||
} else {
|
||||
let ifs = state::get_separator();
|
||||
words.join(&ifs).trim().to_string() + "\n"
|
||||
}
|
||||
};
|
||||
if flags.contains(TkFlags::TAB_HEREDOC) {
|
||||
let lines = text.lines();
|
||||
let mut min_tabs = usize::MAX;
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let line_len = line.len();
|
||||
let after_strip = line.trim_start_matches('\t').len();
|
||||
let delta = line_len - after_strip;
|
||||
min_tabs = min_tabs.min(delta);
|
||||
}
|
||||
if min_tabs == usize::MAX {
|
||||
// let's avoid possibly allocating a string with 18 quintillion tabs
|
||||
min_tabs = 0;
|
||||
}
|
||||
|
||||
if min_tabs > 0 {
|
||||
let stripped = text
|
||||
.lines()
|
||||
.fold(vec![], |mut acc, ln| {
|
||||
if ln.is_empty() {
|
||||
acc.push("");
|
||||
return acc;
|
||||
}
|
||||
let stripped_ln = ln.strip_prefix(&"\t".repeat(min_tabs)).unwrap();
|
||||
acc.push(stripped_ln);
|
||||
acc
|
||||
})
|
||||
.join("\n");
|
||||
text = stripped + "\n";
|
||||
}
|
||||
}
|
||||
write(wpipe, text.as_bytes())?;
|
||||
*io_mode = IoMode::Pipe {
|
||||
tgt_fd: *tgt_fd,
|
||||
pipe: rpipe.into(),
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let tgt_fd = io_mode.tgt_fd();
|
||||
let src_fd = io_mode.src_fd();
|
||||
if let Err(e) = dup2(src_fd, tgt_fd) {
|
||||
if let Some(span) = redir.span.as_ref() {
|
||||
return Err(ShErr::from(e).promote(span.clone()));
|
||||
} else {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn restore(&mut self) -> ShResult<()> {
|
||||
if let Some(saved) = self.saved_io.take() {
|
||||
dup2(saved.0, STDIN_FILENO)?;
|
||||
@@ -334,6 +448,8 @@ pub fn borrow_fd<'f>(fd: i32) -> BorrowedFd<'f> {
|
||||
}
|
||||
|
||||
type PipeFrames = Map<PipeGenerator, fn((Option<Redir>, Option<Redir>)) -> IoFrame>;
|
||||
|
||||
/// An iterator that lazily creates a specific number of pipes.
|
||||
pub struct PipeGenerator {
|
||||
num_cmds: usize,
|
||||
cursor: usize,
|
||||
@@ -394,7 +510,9 @@ pub mod tests {
|
||||
|
||||
#[test]
|
||||
fn pipeline_simple() {
|
||||
if !has_cmd("sed") { return };
|
||||
if !has_cmd("sed") {
|
||||
return;
|
||||
};
|
||||
let g = TestGuard::new();
|
||||
|
||||
test_input("echo foo | sed 's/foo/bar/'").unwrap();
|
||||
@@ -405,10 +523,9 @@ pub mod tests {
|
||||
|
||||
#[test]
|
||||
fn pipeline_multi() {
|
||||
if !has_cmds(&[
|
||||
"cut",
|
||||
"sed"
|
||||
]) { return; }
|
||||
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();
|
||||
@@ -419,10 +536,9 @@ pub mod tests {
|
||||
|
||||
#[test]
|
||||
fn rube_goldberg_pipeline() {
|
||||
if !has_cmds(&[
|
||||
"sed",
|
||||
"cat",
|
||||
]) { return }
|
||||
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();
|
||||
@@ -437,7 +553,9 @@ pub mod tests {
|
||||
|
||||
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(); });
|
||||
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");
|
||||
@@ -458,7 +576,9 @@ pub mod tests {
|
||||
|
||||
#[test]
|
||||
fn input_redir() {
|
||||
if !has_cmd("cat") { return; }
|
||||
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();
|
||||
@@ -487,7 +607,9 @@ pub mod tests {
|
||||
|
||||
#[test]
|
||||
fn pipe_and_stderr() {
|
||||
if !has_cmd("cat") { return; }
|
||||
if !has_cmd("cat") {
|
||||
return;
|
||||
}
|
||||
let g = TestGuard::new();
|
||||
|
||||
test_input("echo on stderr >&2 |& cat").unwrap();
|
||||
@@ -511,7 +633,9 @@ pub mod tests {
|
||||
|
||||
#[test]
|
||||
fn pipeline_preserves_exit_status() {
|
||||
if !has_cmd("cat") { return; }
|
||||
if !has_cmd("cat") {
|
||||
return;
|
||||
}
|
||||
let _g = TestGuard::new();
|
||||
|
||||
test_input("false | cat").unwrap();
|
||||
@@ -533,7 +657,11 @@ pub mod tests {
|
||||
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();
|
||||
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"));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,9 @@ impl Highlighter {
|
||||
while prefix_chars.peek().is_some() {
|
||||
match chars.next() {
|
||||
Some(c) if c == markers::VISUAL_MODE_START || c == markers::VISUAL_MODE_END => continue,
|
||||
Some(c) if Some(&c) == prefix_chars.peek() => { prefix_chars.next(); }
|
||||
Some(c) if Some(&c) == prefix_chars.peek() => {
|
||||
prefix_chars.next();
|
||||
}
|
||||
_ => return text.to_string(), // mismatch, return original
|
||||
}
|
||||
}
|
||||
@@ -104,7 +106,9 @@ impl Highlighter {
|
||||
let mut si = suffix_chars.len();
|
||||
|
||||
while si > 0 {
|
||||
if ti == 0 { return text.to_string(); }
|
||||
if ti == 0 {
|
||||
return text.to_string();
|
||||
}
|
||||
ti -= 1;
|
||||
if chars[ti] == markers::VISUAL_MODE_START || chars[ti] == markers::VISUAL_MODE_END {
|
||||
continue; // skip visual markers
|
||||
@@ -346,7 +350,9 @@ impl Highlighter {
|
||||
recursive_highlighter.highlight();
|
||||
// Read back visual state — selection may have started/ended inside
|
||||
self.in_selection = recursive_highlighter.in_selection;
|
||||
self.style_stack.append(&mut recursive_highlighter.style_stack);
|
||||
self
|
||||
.style_stack
|
||||
.append(&mut recursive_highlighter.style_stack);
|
||||
if selection_at_entry {
|
||||
self.emit_style(Style::BgWhite | Style::Black);
|
||||
self.output.push_str(prefix);
|
||||
|
||||
@@ -203,6 +203,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct History {
|
||||
path: PathBuf,
|
||||
pub pending: Option<LineBuf>, // command, cursor_pos
|
||||
@@ -214,6 +215,7 @@ pub struct History {
|
||||
//search_direction: Direction,
|
||||
ignore_dups: bool,
|
||||
max_size: Option<u32>,
|
||||
stateless: bool,
|
||||
}
|
||||
|
||||
impl History {
|
||||
@@ -229,6 +231,7 @@ impl History {
|
||||
//search_direction: Direction::Backward,
|
||||
ignore_dups: false,
|
||||
max_size: None,
|
||||
stateless: true,
|
||||
}
|
||||
}
|
||||
pub fn new() -> ShResult<Self> {
|
||||
@@ -266,6 +269,7 @@ impl History {
|
||||
//search_direction: Direction::Backward,
|
||||
ignore_dups,
|
||||
max_size,
|
||||
stateless: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,7 +284,7 @@ impl History {
|
||||
.search_mask
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|ent| ent.command().to_string());
|
||||
.map(|ent| super::complete::Candidate::from(ent.command()));
|
||||
self.fuzzy_finder.activate(raw_entries.collect());
|
||||
None
|
||||
}
|
||||
@@ -450,6 +454,9 @@ impl History {
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> ShResult<()> {
|
||||
if self.stateless {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
@@ -500,12 +507,8 @@ mod tests {
|
||||
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)
|
||||
},
|
||||
Some(v) => unsafe { env::set_var(key, v) },
|
||||
None => unsafe { env::remove_var(key) },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -522,12 +525,7 @@ mod tests {
|
||||
fn write_history_file(path: &Path) {
|
||||
fs::write(
|
||||
path,
|
||||
[
|
||||
": 1;1;first\n",
|
||||
": 2;1;second\n",
|
||||
": 3;1;third\n",
|
||||
]
|
||||
.concat(),
|
||||
[": 1;1;first\n", ": 2;1;second\n", ": 3;1;third\n"].concat(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -586,12 +584,7 @@ mod tests {
|
||||
let hist_path = tmp.path().join("history");
|
||||
fs::write(
|
||||
&hist_path,
|
||||
[
|
||||
": 1;1;repeat\n",
|
||||
": 2;1;unique\n",
|
||||
": 3;1;repeat\n",
|
||||
]
|
||||
.concat(),
|
||||
[": 1;1;repeat\n", ": 2;1;unique\n", ": 3;1;repeat\n"].concat(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@ use crate::readline::complete::{FuzzyCompleter, SelectorResponse};
|
||||
use crate::readline::term::{Pos, TermReader, calc_str_width};
|
||||
use crate::readline::vimode::{ViEx, ViVerbatim};
|
||||
use crate::state::{
|
||||
AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta, write_vars
|
||||
AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta,
|
||||
write_vars,
|
||||
};
|
||||
use crate::{
|
||||
libsh::error::ShResult,
|
||||
@@ -131,6 +132,18 @@ pub mod markers {
|
||||
pub fn is_marker(c: Marker) -> bool {
|
||||
('\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;
|
||||
|
||||
@@ -252,10 +265,10 @@ pub struct ShedVi {
|
||||
pub repeat_action: Option<CmdReplay>,
|
||||
pub repeat_motion: Option<MotionCmd>,
|
||||
pub editor: LineBuf,
|
||||
pub next_is_escaped: bool,
|
||||
|
||||
pub old_layout: Option<Layout>,
|
||||
pub history: History,
|
||||
pub ex_history: History,
|
||||
|
||||
pub needs_redraw: bool,
|
||||
}
|
||||
@@ -270,7 +283,6 @@ impl ShedVi {
|
||||
completer: Box::new(FuzzyCompleter::default()),
|
||||
highlighter: Highlighter::new(),
|
||||
mode: Box::new(ViInsert::new()),
|
||||
next_is_escaped: false,
|
||||
saved_mode: None,
|
||||
pending_keymap: Vec::new(),
|
||||
old_layout: None,
|
||||
@@ -278,6 +290,7 @@ impl ShedVi {
|
||||
repeat_motion: None,
|
||||
editor: LineBuf::new(),
|
||||
history: History::new()?,
|
||||
ex_history: History::empty(),
|
||||
needs_redraw: true,
|
||||
};
|
||||
write_vars(|v| {
|
||||
@@ -302,7 +315,6 @@ impl ShedVi {
|
||||
completer: Box::new(FuzzyCompleter::default()),
|
||||
highlighter: Highlighter::new(),
|
||||
mode: Box::new(ViInsert::new()),
|
||||
next_is_escaped: false,
|
||||
saved_mode: None,
|
||||
pending_keymap: Vec::new(),
|
||||
old_layout: None,
|
||||
@@ -310,6 +322,7 @@ impl ShedVi {
|
||||
repeat_motion: None,
|
||||
editor: LineBuf::new(),
|
||||
history: History::empty(),
|
||||
ex_history: History::empty(),
|
||||
needs_redraw: true,
|
||||
};
|
||||
write_vars(|v| {
|
||||
@@ -333,6 +346,18 @@ impl ShedVi {
|
||||
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
|
||||
pub fn feed_bytes(&mut self, bytes: &[u8]) {
|
||||
self.reader.feed_bytes(bytes);
|
||||
@@ -354,8 +379,8 @@ impl ShedVi {
|
||||
self.completer.reset_stay_active();
|
||||
self.needs_redraw = true;
|
||||
Ok(())
|
||||
} else if self.history.fuzzy_finder.is_active() {
|
||||
self.history.fuzzy_finder.reset_stay_active();
|
||||
} else if self.focused_history().fuzzy_finder.is_active() {
|
||||
self.focused_history().fuzzy_finder.reset_stay_active();
|
||||
self.needs_redraw = true;
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -416,7 +441,7 @@ impl ShedVi {
|
||||
LexStream::new(Arc::clone(&input), LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<_>>>();
|
||||
let lex_result2 =
|
||||
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()) {
|
||||
(true, true) => {
|
||||
@@ -443,22 +468,29 @@ impl ShedVi {
|
||||
|
||||
// Process all available keys
|
||||
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 self.history.fuzzy_finder.is_active() {
|
||||
if self.focused_history().fuzzy_finder.is_active() {
|
||||
self.print_line(false)?;
|
||||
match self.history.fuzzy_finder.handle_key(key)? {
|
||||
match self.focused_history().fuzzy_finder.handle_key(key)? {
|
||||
SelectorResponse::Accept(cmd) => {
|
||||
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
|
||||
.history
|
||||
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
||||
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())], || {
|
||||
post_cmds.exec_with(&cmd);
|
||||
@@ -481,7 +513,11 @@ impl ShedVi {
|
||||
post_cmds.exec();
|
||||
|
||||
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| {
|
||||
v.set_var(
|
||||
"SHED_VI_MODE",
|
||||
@@ -508,8 +544,8 @@ impl ShedVi {
|
||||
let span_start = self.completer.token_span().0;
|
||||
let new_cursor = span_start + candidate.len();
|
||||
let line = self.completer.get_completed_line(&candidate);
|
||||
self.editor.set_buffer(line);
|
||||
self.editor.cursor.set(new_cursor);
|
||||
self.focused_editor().set_buffer(line);
|
||||
self.focused_editor().cursor.set(new_cursor);
|
||||
// Don't reset yet — clear() needs old_layout to erase the selector.
|
||||
|
||||
if !self.history.at_pending() {
|
||||
@@ -626,10 +662,6 @@ impl ShedVi {
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<Option<ReadlineEvent>> {
|
||||
if self.should_accept_hint(&key) {
|
||||
log::debug!(
|
||||
"Accepting hint on key {key:?} in mode {:?}",
|
||||
self.mode.report_mode()
|
||||
);
|
||||
self.editor.accept_hint();
|
||||
if !self.history.at_pending() {
|
||||
self.history.reset_to_pending();
|
||||
@@ -642,7 +674,8 @@ impl ShedVi {
|
||||
}
|
||||
|
||||
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
|
||||
// allow the user to see the expanded command and accept or edit it before completing
|
||||
return Ok(None);
|
||||
@@ -652,8 +685,8 @@ impl ShedVi {
|
||||
ModKeys::SHIFT => -1,
|
||||
_ => 1,
|
||||
};
|
||||
let line = self.editor.as_str().to_string();
|
||||
let cursor_pos = self.editor.cursor_byte_pos();
|
||||
let line = self.focused_editor().as_str().to_string();
|
||||
let cursor_pos = self.focused_editor().cursor_byte_pos();
|
||||
|
||||
match self.completer.complete(line, cursor_pos, direction) {
|
||||
Err(e) => {
|
||||
@@ -677,8 +710,8 @@ impl ShedVi {
|
||||
.map(|c| c.len())
|
||||
.unwrap_or_default();
|
||||
|
||||
self.editor.set_buffer(line.clone());
|
||||
self.editor.cursor.set(new_cursor);
|
||||
self.focused_editor().set_buffer(line.clone());
|
||||
self.focused_editor().cursor.set(new_cursor);
|
||||
|
||||
if !self.history.at_pending() {
|
||||
self.history.reset_to_pending();
|
||||
@@ -694,7 +727,8 @@ impl ShedVi {
|
||||
VarKind::Str(self.mode.report_mode().to_string()),
|
||||
VarFlags::NONE,
|
||||
)
|
||||
}).ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
// If we are here, we hit a case where pressing tab returned a single candidate
|
||||
// So we can just go ahead and reset the completer after this
|
||||
@@ -704,13 +738,19 @@ impl ShedVi {
|
||||
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart));
|
||||
let candidates = self.completer.all_candidates();
|
||||
let num_candidates = candidates.len();
|
||||
with_vars([
|
||||
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())),
|
||||
], || {
|
||||
(
|
||||
"_SEARCH_STR".into(),
|
||||
Into::<Var>::into(self.completer.token()),
|
||||
),
|
||||
],
|
||||
|| {
|
||||
post_cmds.exec();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if self.completer.is_active() {
|
||||
write_vars(|v| {
|
||||
@@ -733,19 +773,18 @@ impl ShedVi {
|
||||
self.needs_redraw = true;
|
||||
return Ok(None);
|
||||
} else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key
|
||||
&& self.mode.report_mode() == ModeReport::Insert {
|
||||
let initial = self.editor.as_str();
|
||||
match self.history.start_search(initial) {
|
||||
&& matches!(self.mode.report_mode(), ModeReport::Insert | ModeReport::Ex)
|
||||
{
|
||||
let initial = self.focused_editor().as_str().to_string();
|
||||
match self.focused_history().start_search(&initial) {
|
||||
Some(entry) => {
|
||||
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);
|
||||
});
|
||||
|
||||
self.editor.set_buffer(entry);
|
||||
self.editor.move_cursor_to_end();
|
||||
self.focused_editor().set_buffer(entry);
|
||||
self.focused_editor().move_cursor_to_end();
|
||||
self
|
||||
.history
|
||||
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
|
||||
@@ -753,8 +792,10 @@ impl ShedVi {
|
||||
}
|
||||
None => {
|
||||
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen));
|
||||
let entries = self.history.fuzzy_finder.candidates();
|
||||
let matches = self.history.fuzzy_finder
|
||||
let entries = self.focused_history().fuzzy_finder.candidates().to_vec();
|
||||
let matches = self
|
||||
.focused_history()
|
||||
.fuzzy_finder
|
||||
.filtered()
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -763,17 +804,20 @@ impl ShedVi {
|
||||
|
||||
let num_entries = entries.len();
|
||||
let num_matches = matches.len();
|
||||
with_vars([
|
||||
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.history.fuzzy_finder.is_active() {
|
||||
if self.focused_history().fuzzy_finder.is_active() {
|
||||
write_vars(|v| {
|
||||
v.set_var(
|
||||
"SHED_VI_MODE",
|
||||
@@ -792,17 +836,10 @@ impl ShedVi {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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);
|
||||
};
|
||||
|
||||
@@ -818,8 +855,7 @@ impl ShedVi {
|
||||
}
|
||||
|
||||
if cmd.is_submit_action()
|
||||
&& !self.next_is_escaped
|
||||
&& !self.editor.buffer.ends_with('\\')
|
||||
&& !self.editor.cursor_is_escaped()
|
||||
&& (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete))
|
||||
{
|
||||
if self.editor.attempt_history_expansion(&self.history) {
|
||||
@@ -838,10 +874,10 @@ impl ShedVi {
|
||||
}
|
||||
|
||||
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));
|
||||
} else {
|
||||
self.editor = LineBuf::new();
|
||||
*self.focused_editor() = LineBuf::new();
|
||||
self.mode = Box::new(ViInsert::new());
|
||||
self.needs_redraw = true;
|
||||
return Ok(None);
|
||||
@@ -849,9 +885,22 @@ impl ShedVi {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
self.exec_cmd(cmd, false)?;
|
||||
|
||||
if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) {
|
||||
for key in keys {
|
||||
self.handle_key(key)?;
|
||||
@@ -983,7 +1032,11 @@ impl ShedVi {
|
||||
let one_line = new_layout.end.row == 0;
|
||||
|
||||
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() {
|
||||
self.writer.clear_rows(layout)?;
|
||||
@@ -1055,6 +1108,7 @@ impl ShedVi {
|
||||
let pending_seq = self.mode.pending_seq().unwrap_or_default();
|
||||
write!(buf, "\n: {pending_seq}").unwrap();
|
||||
new_layout.end.row += 1;
|
||||
new_layout.cursor.row += 1;
|
||||
}
|
||||
|
||||
write!(buf, "{}", &self.mode.cursor_style()).unwrap();
|
||||
@@ -1075,10 +1129,15 @@ impl ShedVi {
|
||||
self.completer.draw(&mut self.writer)?;
|
||||
|
||||
self
|
||||
.history
|
||||
.focused_history()
|
||||
.fuzzy_finder
|
||||
.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.needs_redraw = false;
|
||||
@@ -1128,10 +1187,14 @@ impl ShedVi {
|
||||
match cmd.verb().unwrap().1 {
|
||||
Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => {
|
||||
is_insert_mode = true;
|
||||
Box::new(ViInsert::new().with_count(count as u16).record_cmd(cmd.clone()))
|
||||
Box::new(
|
||||
ViInsert::new()
|
||||
.with_count(count as u16)
|
||||
.record_cmd(cmd.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
Verb::ExMode => Box::new(ViEx::new()),
|
||||
Verb::ExMode => Box::new(ViEx::new(self.ex_history.clone())),
|
||||
|
||||
Verb::VerbatimMode => {
|
||||
self.reader.verbatim_single = true;
|
||||
@@ -1221,7 +1284,7 @@ impl ShedVi {
|
||||
ModeReport::Normal => Box::new(ViNormal::new()),
|
||||
ModeReport::Insert => Box::new(ViInsert::new()),
|
||||
ModeReport::Visual => Box::new(ViVisual::new()),
|
||||
ModeReport::Ex => Box::new(ViEx::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!(),
|
||||
@@ -1248,12 +1311,12 @@ impl ShedVi {
|
||||
for _ in 0..repeat {
|
||||
let cmds = cmds.clone();
|
||||
for (i, cmd) in cmds.iter().enumerate() {
|
||||
log::debug!("Replaying command {cmd:?} in mode {:?}, replay {i}/{repeat}", self.mode.report_mode());
|
||||
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() {
|
||||
&& let Some(edit) = self.editor.undo_stack.last_mut()
|
||||
{
|
||||
edit.start_merge();
|
||||
}
|
||||
}
|
||||
@@ -1266,7 +1329,7 @@ impl ShedVi {
|
||||
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()) 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!(),
|
||||
@@ -1353,7 +1416,11 @@ impl ShedVi {
|
||||
|
||||
self.editor.exec_cmd(cmd.clone())?;
|
||||
|
||||
if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some_and(|v| v.1.is_edit() || v.1 == Verb::Yank) {
|
||||
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();
|
||||
let mut mode: Box<dyn ViMode> = Box::new(ViNormal::new());
|
||||
self.swap_mode(&mut mode);
|
||||
@@ -1594,6 +1661,12 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
|
||||
|
||||
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
|
||||
&& let Some(marker) = marker_for(&token.class)
|
||||
{
|
||||
|
||||
@@ -294,12 +294,14 @@ impl Read for TermBuffer {
|
||||
|
||||
struct KeyCollector {
|
||||
events: VecDeque<KeyEvent>,
|
||||
ss3_pending: bool,
|
||||
}
|
||||
|
||||
impl KeyCollector {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: VecDeque::new(),
|
||||
ss3_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +339,55 @@ impl Default for KeyCollector {
|
||||
|
||||
impl Perform for KeyCollector {
|
||||
fn print(&mut self, c: char) {
|
||||
log::trace!("print: {c:?}");
|
||||
// vte routes 0x7f (DEL) to print instead of execute
|
||||
if self.ss3_pending {
|
||||
self.ss3_pending = false;
|
||||
match c {
|
||||
'A' => {
|
||||
self.push(KeyEvent(KeyCode::Up, ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'B' => {
|
||||
self.push(KeyEvent(KeyCode::Down, ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'C' => {
|
||||
self.push(KeyEvent(KeyCode::Right, ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'D' => {
|
||||
self.push(KeyEvent(KeyCode::Left, ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'H' => {
|
||||
self.push(KeyEvent(KeyCode::Home, ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'F' => {
|
||||
self.push(KeyEvent(KeyCode::End, ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'P' => {
|
||||
self.push(KeyEvent(KeyCode::F(1), ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'Q' => {
|
||||
self.push(KeyEvent(KeyCode::F(2), ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'R' => {
|
||||
self.push(KeyEvent(KeyCode::F(3), ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
'S' => {
|
||||
self.push(KeyEvent(KeyCode::F(4), ModKeys::empty()));
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if c == '\x7f' {
|
||||
self.push(KeyEvent(KeyCode::Backspace, ModKeys::empty()));
|
||||
} else {
|
||||
@@ -346,6 +396,7 @@ impl Perform for KeyCollector {
|
||||
}
|
||||
|
||||
fn execute(&mut self, byte: u8) {
|
||||
log::trace!("execute: {byte:#04x}");
|
||||
let event = match byte {
|
||||
0x00 => KeyEvent(KeyCode::Char(' '), ModKeys::CTRL), // Ctrl+Space / Ctrl+@
|
||||
0x09 => KeyEvent(KeyCode::Tab, ModKeys::empty()), // Tab (Ctrl+I)
|
||||
@@ -370,6 +421,9 @@ impl Perform for KeyCollector {
|
||||
_ignore: bool,
|
||||
action: char,
|
||||
) {
|
||||
log::trace!(
|
||||
"CSI dispatch: params={params:?}, intermediates={intermediates:?}, action={action:?}"
|
||||
);
|
||||
let params: Vec<u16> = params
|
||||
.iter()
|
||||
.map(|p| p.first().copied().unwrap_or(0))
|
||||
@@ -481,16 +535,11 @@ impl Perform for KeyCollector {
|
||||
}
|
||||
|
||||
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
|
||||
// SS3 sequences (ESC O P/Q/R/S for F1-F4)
|
||||
if intermediates == [b'O'] {
|
||||
let key = match byte {
|
||||
b'P' => KeyCode::F(1),
|
||||
b'Q' => KeyCode::F(2),
|
||||
b'R' => KeyCode::F(3),
|
||||
b'S' => KeyCode::F(4),
|
||||
_ => return,
|
||||
};
|
||||
self.push(KeyEvent(key, ModKeys::empty()));
|
||||
log::trace!("ESC dispatch: intermediates={intermediates:?}, byte={byte:#04x}");
|
||||
// SS3 sequences
|
||||
if byte == b'O' {
|
||||
self.ss3_pending = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,7 +588,10 @@ impl PollReader {
|
||||
}
|
||||
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()))
|
||||
Some(KeyEvent(
|
||||
KeyCode::Verbatim(verbatim_str.into()),
|
||||
ModKeys::empty(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn feed_bytes(&mut self, bytes: &[u8]) {
|
||||
@@ -571,9 +623,7 @@ impl KeyReader for PollReader {
|
||||
} else if self.byte_buf.front() == Some(&b'\x1b') {
|
||||
// Escape: if it's the only byte, or the next byte isn't a valid
|
||||
// escape sequence prefix ([ or O), emit a standalone Escape
|
||||
if self.byte_buf.len() == 1
|
||||
|| !matches!(self.byte_buf.get(1), Some(b'[') | Some(b'O'))
|
||||
{
|
||||
if self.byte_buf.len() == 1 || !matches!(self.byte_buf.get(1), Some(b'[') | Some(b'O')) {
|
||||
self.byte_buf.pop_front();
|
||||
return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty())));
|
||||
}
|
||||
@@ -589,7 +639,7 @@ impl KeyReader for PollReader {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => return Ok(Some(key))
|
||||
_ => return Ok(Some(key)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -843,6 +893,7 @@ impl Default for Layout {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TermWriter {
|
||||
last_bell: Option<Instant>,
|
||||
out: RawFd,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
#![allow(non_snake_case)]
|
||||
use std::os::fd::AsRawFd;
|
||||
|
||||
use crate::{readline::{Prompt, ShedVi}, testutil::TestGuard};
|
||||
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 {
|
||||
@@ -23,6 +31,257 @@ macro_rules! vi_test {
|
||||
};
|
||||
}
|
||||
|
||||
// ===================== 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();
|
||||
@@ -193,9 +452,9 @@ vi_test! {
|
||||
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", 0;
|
||||
vi_indent_line : "hello" => ">>" => "\thello", 1;
|
||||
vi_dedent_line : "\thello" => "<<" => "hello", 0;
|
||||
vi_indent_double : "hello" => ">>>>" => "\t\thello", 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;
|
||||
@@ -226,5 +485,34 @@ vi_test! {
|
||||
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_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 crate::readline::vimode::ex::SubFlags;
|
||||
|
||||
use super::register::{RegisterContent, append_register, read_register, write_register};
|
||||
|
||||
//TODO: write tests that take edit results and cursor positions from actual
|
||||
@@ -64,6 +68,7 @@ bitflags! {
|
||||
const VISUAL_LINE = 1<<1;
|
||||
const VISUAL_BLOCK = 1<<2;
|
||||
const EXIT_CUR_MODE = 1<<3;
|
||||
const IS_EX_CMD = 1<<4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +260,8 @@ pub enum Verb {
|
||||
Normal(String),
|
||||
Read(ReadSrc),
|
||||
Write(WriteDest),
|
||||
Substitute(String, String, super::vimode::ex::SubFlags),
|
||||
Edit(PathBuf),
|
||||
Substitute(String, String, SubFlags),
|
||||
RepeatSubstitute,
|
||||
RepeatGlobal,
|
||||
}
|
||||
@@ -301,6 +307,9 @@ impl Verb {
|
||||
| Self::JoinLines
|
||||
| Self::InsertChar(_)
|
||||
| Self::Insert(_)
|
||||
| Self::Dedent
|
||||
| Self::Indent
|
||||
| Self::Equalize
|
||||
| Self::Rot13
|
||||
| Self::EndOfFile
|
||||
| Self::IncrementNumber(_)
|
||||
@@ -332,16 +341,8 @@ pub enum Motion {
|
||||
ForwardCharForced,
|
||||
LineUp,
|
||||
LineUpCharwise,
|
||||
ScreenLineUp,
|
||||
ScreenLineUpCharwise,
|
||||
LineDown,
|
||||
LineDownCharwise,
|
||||
ScreenLineDown,
|
||||
ScreenLineDownCharwise,
|
||||
BeginningOfScreenLine,
|
||||
FirstGraphicalOnScreenLine,
|
||||
HalfOfScreen,
|
||||
HalfOfScreenLineText,
|
||||
WholeBuffer,
|
||||
StartOfBuffer,
|
||||
EndOfBuffer,
|
||||
@@ -381,12 +382,8 @@ impl Motion {
|
||||
&self,
|
||||
Self::BeginningOfLine
|
||||
| Self::BeginningOfFirstWord
|
||||
| Self::BeginningOfScreenLine
|
||||
| Self::FirstGraphicalOnScreenLine
|
||||
| Self::LineDownCharwise
|
||||
| Self::LineUpCharwise
|
||||
| Self::ScreenLineUpCharwise
|
||||
| Self::ScreenLineDownCharwise
|
||||
| Self::ToColumn
|
||||
| Self::TextObj(TextObj::Sentence(_))
|
||||
| Self::TextObj(TextObj::Paragraph(_))
|
||||
@@ -395,20 +392,13 @@ impl Motion {
|
||||
| Self::ToBrace(_)
|
||||
| Self::ToBracket(_)
|
||||
| Self::ToParen(_)
|
||||
| Self::ScreenLineDown
|
||||
| Self::ScreenLineUp
|
||||
| Self::Range(_, _)
|
||||
)
|
||||
}
|
||||
pub fn is_linewise(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::WholeLineInclusive
|
||||
| Self::WholeLineExclusive
|
||||
| Self::LineUp
|
||||
| Self::LineDown
|
||||
| Self::ScreenLineDown
|
||||
| Self::ScreenLineUp
|
||||
Self::WholeLineInclusive | Self::WholeLineExclusive | Self::LineUp | Self::LineDown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ use std::str::Chars;
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::bitflags;
|
||||
use crate::expand::{Expander, expand_raw};
|
||||
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::linebuf::LineBuf;
|
||||
use crate::readline::vicmd::{
|
||||
@@ -13,7 +17,7 @@ use crate::readline::vicmd::{
|
||||
WriteDest,
|
||||
};
|
||||
use crate::readline::vimode::{ModeReport, ViInsert, ViMode};
|
||||
use crate::state::write_meta;
|
||||
use crate::state::{get_home, write_meta};
|
||||
|
||||
bitflags! {
|
||||
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
|
||||
@@ -33,16 +37,64 @@ bitflags! {
|
||||
struct ExEditor {
|
||||
buf: LineBuf,
|
||||
mode: ViInsert,
|
||||
history: History,
|
||||
}
|
||||
|
||||
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) {
|
||||
*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<()> {
|
||||
let Some(cmd) = self.mode.handle_key(key) else {
|
||||
let Some(mut cmd) = self.mode.handle_key(key) else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -53,8 +105,10 @@ pub struct ViEx {
|
||||
}
|
||||
|
||||
impl ViEx {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
pub fn new(history: History) -> Self {
|
||||
Self {
|
||||
pending_cmd: ExEditor::new(history),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,18 +116,12 @@ impl ViMode for ViEx {
|
||||
// Ex mode can return errors, so we use this fallible method instead of the normal one
|
||||
fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult<Option<ViCmd>> {
|
||||
use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M};
|
||||
log::debug!("[ViEx] handle_key_fallible: key={:?}", key);
|
||||
match key {
|
||||
E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => {
|
||||
let input = self.pending_cmd.buf.as_str();
|
||||
log::debug!("[ViEx] Enter pressed, pending_cmd={:?}", input);
|
||||
match parse_ex_cmd(input) {
|
||||
Ok(cmd) => {
|
||||
log::debug!("[ViEx] parse_ex_cmd Ok: {:?}", cmd);
|
||||
Ok(cmd)
|
||||
}
|
||||
Ok(cmd) => Ok(cmd),
|
||||
Err(e) => {
|
||||
log::debug!("[ViEx] parse_ex_cmd Err: {:?}", e);
|
||||
let msg = e.unwrap_or(format!("Not an editor command: {}", input));
|
||||
write_meta(|m| m.post_system_message(msg.clone()));
|
||||
Err(ShErr::simple(ShErrKind::ParseErr, msg))
|
||||
@@ -81,29 +129,21 @@ impl ViMode for ViEx {
|
||||
}
|
||||
}
|
||||
E(C::Char('C'), M::CTRL) => {
|
||||
log::debug!("[ViEx] Ctrl-C, clearing");
|
||||
self.pending_cmd.clear();
|
||||
Ok(None)
|
||||
}
|
||||
E(C::Esc, M::NONE) => {
|
||||
log::debug!("[ViEx] Esc, returning to normal mode");
|
||||
Ok(Some(ViCmd {
|
||||
E(C::Esc, M::NONE) => Ok(Some(ViCmd {
|
||||
register: RegisterName::default(),
|
||||
verb: Some(VerbCmd(1, Verb::NormalMode)),
|
||||
motion: None,
|
||||
flags: CmdFlags::empty(),
|
||||
raw_seq: "".into(),
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
log::debug!("[ViEx] forwarding key to ExEditor");
|
||||
self.pending_cmd.handle_key(key).map(|_| None)
|
||||
}
|
||||
})),
|
||||
_ => self.pending_cmd.handle_key(key).map(|_| None),
|
||||
}
|
||||
}
|
||||
fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> {
|
||||
let result = self.handle_key_fallible(key);
|
||||
log::debug!("[ViEx] handle_key result: {:?}", result);
|
||||
result.ok().flatten()
|
||||
}
|
||||
fn is_repeatable(&self) -> bool {
|
||||
@@ -114,6 +154,14 @@ impl ViMode for ViEx {
|
||||
None
|
||||
}
|
||||
|
||||
fn editor(&mut self) -> Option<&mut LineBuf> {
|
||||
Some(&mut self.pending_cmd.buf)
|
||||
}
|
||||
|
||||
fn history(&mut self) -> Option<&mut History> {
|
||||
Some(&mut self.pending_cmd.history)
|
||||
}
|
||||
|
||||
fn cursor_style(&self) -> String {
|
||||
"\x1b[3 q".to_string()
|
||||
}
|
||||
@@ -177,7 +225,7 @@ fn parse_ex_cmd(raw: &str) -> Result<Option<ViCmd>, Option<String>> {
|
||||
verb,
|
||||
motion,
|
||||
raw_seq: raw.to_string(),
|
||||
flags: CmdFlags::EXIT_CUR_MODE,
|
||||
flags: CmdFlags::EXIT_CUR_MODE | CmdFlags::IS_EX_CMD,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -207,7 +255,7 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
|
||||
let mut cmd_name = String::new();
|
||||
|
||||
while let Some(ch) = chars.peek() {
|
||||
if ch == &'!' {
|
||||
if cmd_name.is_empty() && ch == &'!' {
|
||||
cmd_name.push(*ch);
|
||||
chars.next();
|
||||
break;
|
||||
@@ -224,12 +272,17 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
|
||||
let cmd = unescape_shell_cmd(&cmd);
|
||||
Ok(Some(Verb::ShellCmd(cmd)))
|
||||
}
|
||||
_ if "help".starts_with(&cmd_name) => {
|
||||
let cmd = "help ".to_string() + chars.collect::<String>().trim();
|
||||
Ok(Some(Verb::ShellCmd(cmd)))
|
||||
}
|
||||
"normal!" => parse_normal(chars),
|
||||
_ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)),
|
||||
_ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)),
|
||||
_ if "put".starts_with(&cmd_name) => Ok(Some(Verb::Put(Anchor::After))),
|
||||
_ if "read".starts_with(&cmd_name) => parse_read(chars),
|
||||
_ if "write".starts_with(&cmd_name) => parse_write(chars),
|
||||
_ if "edit".starts_with(&cmd_name) => parse_edit(chars),
|
||||
_ if "substitute".starts_with(&cmd_name) => parse_substitute(chars),
|
||||
_ => Err(None),
|
||||
}
|
||||
@@ -244,6 +297,19 @@ fn parse_normal(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<
|
||||
Ok(Some(Verb::Normal(seq)))
|
||||
}
|
||||
|
||||
fn parse_edit(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
|
||||
chars
|
||||
.peeking_take_while(|c| c.is_whitespace())
|
||||
.for_each(drop);
|
||||
|
||||
let arg: String = chars.collect();
|
||||
if arg.trim().is_empty() {
|
||||
return Err(Some("Expected file path after ':edit'".into()));
|
||||
}
|
||||
let arg_path = get_path(arg.trim())?;
|
||||
Ok(Some(Verb::Edit(arg_path)))
|
||||
}
|
||||
|
||||
fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
|
||||
chars
|
||||
.peeking_take_while(|c| c.is_whitespace())
|
||||
@@ -266,23 +332,20 @@ fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<St
|
||||
if is_shell_read {
|
||||
Ok(Some(Verb::Read(ReadSrc::Cmd(arg))))
|
||||
} else {
|
||||
let arg_path = get_path(arg.trim());
|
||||
let arg_path = get_path(arg.trim())?;
|
||||
Ok(Some(Verb::Read(ReadSrc::File(arg_path))))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_path(path: &str) -> PathBuf {
|
||||
if let Some(stripped) = path.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(stripped);
|
||||
}
|
||||
if path == "~"
|
||||
&& let Some(home) = std::env::var_os("HOME")
|
||||
{
|
||||
return PathBuf::from(home);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
fn get_path(path: &str) -> Result<PathBuf, Option<String>> {
|
||||
log::debug!("Expanding path: {}", path);
|
||||
let expanded = Expander::from_raw(path, TkFlags::empty())
|
||||
.map_err(|e| Some(format!("Error expanding path: {}", e)))?
|
||||
.expand()
|
||||
.map_err(|e| Some(format!("Error expanding path: {}", e)))?
|
||||
.join(" ");
|
||||
log::debug!("Expanded path: {}", expanded);
|
||||
Ok(PathBuf::from(&expanded))
|
||||
}
|
||||
|
||||
fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
|
||||
@@ -305,7 +368,7 @@ fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<S
|
||||
}
|
||||
|
||||
let arg: String = chars.collect();
|
||||
let arg_path = get_path(arg.trim());
|
||||
let arg_path = get_path(arg.trim())?;
|
||||
|
||||
let dest = if is_file_append {
|
||||
WriteDest::FileAppend(arg_path)
|
||||
|
||||
@@ -66,7 +66,9 @@ impl ViMode for ViInsert {
|
||||
flags: Default::default(),
|
||||
}),
|
||||
E(K::Verbatim(seq), _) => {
|
||||
self.pending_cmd.set_verb(VerbCmd(1, Verb::Insert(seq.to_string())));
|
||||
self
|
||||
.pending_cmd
|
||||
.set_verb(VerbCmd(1, Verb::Insert(seq.to_string())));
|
||||
self.register_and_return()
|
||||
}
|
||||
E(K::Char('W'), M::CTRL) => {
|
||||
|
||||
@@ -3,7 +3,9 @@ use std::fmt::Display;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::libsh::error::ShResult;
|
||||
use crate::readline::history::History;
|
||||
use crate::readline::keys::{KeyCode as K, KeyEvent as E, ModKeys as M};
|
||||
use crate::readline::linebuf::LineBuf;
|
||||
use crate::readline::vicmd::{Motion, MotionCmd, To, Verb, VerbCmd, ViCmd};
|
||||
|
||||
pub mod ex;
|
||||
@@ -82,6 +84,12 @@ pub trait ViMode {
|
||||
fn pending_cursor(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
fn editor(&mut self) -> Option<&mut LineBuf> {
|
||||
None
|
||||
}
|
||||
fn history(&mut self) -> Option<&mut History> {
|
||||
None
|
||||
}
|
||||
fn move_cursor_on_undo(&self) -> bool;
|
||||
fn clamp_cursor(&self) -> bool;
|
||||
fn hist_scroll_start_pos(&self) -> Option<To>;
|
||||
|
||||
@@ -450,26 +450,10 @@ impl ViNormal {
|
||||
Motion::WordMotion(To::End, Word::Big, Direction::Backward),
|
||||
));
|
||||
}
|
||||
'k' => {
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineUp));
|
||||
}
|
||||
'j' => {
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineDown));
|
||||
}
|
||||
'_' => {
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::EndOfLastWord));
|
||||
}
|
||||
'0' => {
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfScreenLine));
|
||||
}
|
||||
'^' => {
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::FirstGraphicalOnScreenLine));
|
||||
}
|
||||
_ => return self.quit_parse(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,16 +376,6 @@ impl ViVisual {
|
||||
Motion::WordMotion(To::End, Word::Big, Direction::Backward),
|
||||
));
|
||||
}
|
||||
'k' => {
|
||||
chars_clone.next();
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineUp));
|
||||
}
|
||||
'j' => {
|
||||
chars_clone.next();
|
||||
chars = chars_clone;
|
||||
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineDown));
|
||||
}
|
||||
_ => return self.quit_parse(),
|
||||
}
|
||||
} else {
|
||||
|
||||
593
src/shopt.rs
593
src/shopt.rs
@@ -2,6 +2,35 @@ use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
|
||||
|
||||
/// Escapes a string for embedding inside single quotes.
|
||||
/// Only escapes unescaped `\` and `'` characters.
|
||||
pub fn escape_for_single_quote(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
match chars.peek() {
|
||||
Some(&'\\') | Some(&'\'') => {
|
||||
// Already escaped — pass through both characters
|
||||
result.push(ch);
|
||||
result.push(chars.next().unwrap());
|
||||
}
|
||||
_ => {
|
||||
// Lone backslash — escape it
|
||||
result.push('\\');
|
||||
result.push('\\');
|
||||
}
|
||||
}
|
||||
} else if ch == '\'' {
|
||||
result.push('\\');
|
||||
result.push('\'');
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ShedBellStyle {
|
||||
Audible,
|
||||
@@ -24,34 +53,97 @@ impl FromStr for ShedBellStyle {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Copy, Debug)]
|
||||
pub enum ShedEditMode {
|
||||
#[default]
|
||||
Vi,
|
||||
Emacs,
|
||||
/// Generates a shopt group struct with `set`, `get`, `Display`, and `Default` impls.
|
||||
///
|
||||
/// Doc comments on each field become the description shown by `shopt get`.
|
||||
/// Every field type must implement `FromStr + Display`.
|
||||
///
|
||||
/// Optional per-field validation: `#[validate(|val| expr)]` runs after parsing
|
||||
/// and must return `Result<(), String>` where the error string is the message.
|
||||
macro_rules! shopt_group {
|
||||
(
|
||||
$(#[$struct_meta:meta])*
|
||||
pub struct $name:ident ($group_name:literal) {
|
||||
$(
|
||||
$(#[doc = $desc:literal])*
|
||||
$(#[validate($validator:expr)])?
|
||||
$field:ident : $ty:ty = $default:expr
|
||||
),* $(,)?
|
||||
}
|
||||
) => {
|
||||
$(#[$struct_meta])*
|
||||
pub struct $name {
|
||||
$(pub $field: $ty,)*
|
||||
}
|
||||
|
||||
impl FromStr for ShedEditMode {
|
||||
type Err = ShErr;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"vi" => Ok(Self::Vi),
|
||||
"emacs" => Ok(Self::Emacs),
|
||||
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!("Invalid edit mode '{s}'"),
|
||||
format!("shopt: unexpected '{}' option '{query}'", $group_name),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ShedEditMode {
|
||||
impl Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ShedEditMode::Vi => write!(f, "vi"),
|
||||
ShedEditMode::Emacs => write!(f, "emacs"),
|
||||
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)]
|
||||
@@ -82,8 +174,8 @@ impl ShOpts {
|
||||
|
||||
pub fn display_opts(&mut self) -> ShResult<String> {
|
||||
let output = [
|
||||
format!("core:\n{}", self.query("core")?.unwrap_or_default()),
|
||||
format!("prompt:\n{}", self.query("prompt")?.unwrap_or_default()),
|
||||
self.query("core")?.unwrap_or_default().to_string(),
|
||||
self.query("prompt")?.unwrap_or_default().to_string(),
|
||||
];
|
||||
|
||||
Ok(output.join("\n"))
|
||||
@@ -135,409 +227,78 @@ impl ShOpts {
|
||||
}
|
||||
}
|
||||
|
||||
shopt_group! {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShOptCore {
|
||||
pub dotglob: bool,
|
||||
pub autocd: bool,
|
||||
pub hist_ignore_dupes: bool,
|
||||
pub max_hist: isize,
|
||||
pub interactive_comments: bool,
|
||||
pub auto_hist: bool,
|
||||
pub bell_enabled: bool,
|
||||
pub max_recurse_depth: usize,
|
||||
pub xpg_echo: bool,
|
||||
}
|
||||
pub struct ShOptCore ("core") {
|
||||
/// Include hidden files in glob patterns
|
||||
dotglob: bool = false,
|
||||
|
||||
impl ShOptCore {
|
||||
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
|
||||
match opt {
|
||||
"dotglob" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for dotglob value",
|
||||
));
|
||||
};
|
||||
self.dotglob = val;
|
||||
}
|
||||
"autocd" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for autocd value",
|
||||
));
|
||||
};
|
||||
self.autocd = val;
|
||||
}
|
||||
"hist_ignore_dupes" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for hist_ignore_dupes value",
|
||||
));
|
||||
};
|
||||
self.hist_ignore_dupes = val;
|
||||
}
|
||||
"max_hist" => {
|
||||
let Ok(val) = val.parse::<isize>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected an integer for max_hist value (-1 for unlimited)",
|
||||
));
|
||||
};
|
||||
if val < -1 {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected a non-negative integer or -1 for max_hist value",
|
||||
));
|
||||
}
|
||||
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;
|
||||
}
|
||||
"xpg_echo" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for xpg_echo value",
|
||||
));
|
||||
};
|
||||
self.xpg_echo = val;
|
||||
}
|
||||
_ => {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
format!("shopt: Unexpected 'core' option '{opt}'"),
|
||||
));
|
||||
}
|
||||
}
|
||||
/// Allow navigation to directories by passing the directory as a command directly
|
||||
autocd: bool = false,
|
||||
|
||||
/// Ignore consecutive duplicate command history entries
|
||||
hist_ignore_dupes: bool = true,
|
||||
|
||||
/// Maximum number of entries in the command history file (-1 for unlimited)
|
||||
#[validate(|v: &isize| if *v < -1 {
|
||||
Err("expected a non-negative integer or -1 for max_hist value".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
|
||||
if query.is_empty() {
|
||||
return Ok(Some(format!("{self}")));
|
||||
}
|
||||
})]
|
||||
max_hist: isize = 10_000,
|
||||
|
||||
match query {
|
||||
"dotglob" => {
|
||||
let mut output = String::from("Include hidden files in glob patterns\n");
|
||||
output.push_str(&format!("{}", self.dotglob));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"autocd" => {
|
||||
let mut output = String::from(
|
||||
"Allow navigation to directories by passing the directory as a command directly\n",
|
||||
);
|
||||
output.push_str(&format!("{}", self.autocd));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"hist_ignore_dupes" => {
|
||||
let mut output = String::from("Ignore consecutive duplicate command history entries\n");
|
||||
output.push_str(&format!("{}", self.hist_ignore_dupes));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"max_hist" => {
|
||||
let mut output = String::from(
|
||||
"Maximum number of entries in the command history file (-1 for unlimited)\n",
|
||||
);
|
||||
output.push_str(&format!("{}", self.max_hist));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"interactive_comments" => {
|
||||
let mut output = String::from("Whether or not to allow comments in interactive mode\n");
|
||||
output.push_str(&format!("{}", self.interactive_comments));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"auto_hist" => {
|
||||
let mut output = String::from(
|
||||
"Whether or not to automatically save commands to the command history file\n",
|
||||
);
|
||||
output.push_str(&format!("{}", self.auto_hist));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"bell_enabled" => {
|
||||
let mut output = String::from("Whether or not to allow shed to trigger the terminal bell");
|
||||
output.push_str(&format!("{}", self.bell_enabled));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"max_recurse_depth" => {
|
||||
let mut output = String::from("Maximum limit of recursive shell function calls\n");
|
||||
output.push_str(&format!("{}", self.max_recurse_depth));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"xpg_echo" => {
|
||||
let mut output = String::from("Whether echo expands escape sequences by default\n");
|
||||
output.push_str(&format!("{}", self.xpg_echo));
|
||||
Ok(Some(output))
|
||||
}
|
||||
_ => Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
format!("shopt: Unexpected 'core' option '{query}'"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ShOptCore {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut output = vec![];
|
||||
output.push(format!("dotglob = {}", self.dotglob));
|
||||
output.push(format!("autocd = {}", self.autocd));
|
||||
output.push(format!("hist_ignore_dupes = {}", self.hist_ignore_dupes));
|
||||
output.push(format!("max_hist = {}", self.max_hist));
|
||||
output.push(format!(
|
||||
"interactive_comments = {}",
|
||||
self.interactive_comments
|
||||
));
|
||||
output.push(format!("auto_hist = {}", self.auto_hist));
|
||||
output.push(format!("bell_enabled = {}", self.bell_enabled));
|
||||
output.push(format!("max_recurse_depth = {}", self.max_recurse_depth));
|
||||
output.push(format!("xpg_echo = {}", self.xpg_echo));
|
||||
|
||||
let final_output = output.join("\n");
|
||||
|
||||
writeln!(f, "{final_output}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShOptCore {
|
||||
fn default() -> Self {
|
||||
ShOptCore {
|
||||
dotglob: false,
|
||||
autocd: false,
|
||||
hist_ignore_dupes: true,
|
||||
max_hist: 10_000,
|
||||
interactive_comments: true,
|
||||
auto_hist: true,
|
||||
bell_enabled: true,
|
||||
max_recurse_depth: 1000,
|
||||
xpg_echo: false,
|
||||
}
|
||||
/// Whether or not to allow comments in interactive mode
|
||||
interactive_comments: bool = true,
|
||||
|
||||
/// Whether or not to automatically save commands to the command history file
|
||||
auto_hist: bool = true,
|
||||
|
||||
/// Whether or not to allow shed to trigger the terminal bell
|
||||
bell_enabled: bool = true,
|
||||
|
||||
/// Maximum limit of recursive shell function calls
|
||||
max_recurse_depth: usize = 1000,
|
||||
|
||||
/// Whether echo expands escape sequences by default
|
||||
xpg_echo: bool = false,
|
||||
|
||||
/// Prevent > from overwriting existing files (use >| to override)
|
||||
noclobber: bool = false,
|
||||
}
|
||||
}
|
||||
|
||||
shopt_group! {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ShOptPrompt {
|
||||
pub trunc_prompt_path: usize,
|
||||
pub edit_mode: ShedEditMode,
|
||||
pub comp_limit: usize,
|
||||
pub highlight: bool,
|
||||
pub auto_indent: bool,
|
||||
pub linebreak_on_incomplete: bool,
|
||||
pub leader: String,
|
||||
pub line_numbers: bool,
|
||||
}
|
||||
pub struct ShOptPrompt ("prompt") {
|
||||
/// Maximum number of path segments used in the '\W' prompt escape sequence
|
||||
trunc_prompt_path: usize = 4,
|
||||
|
||||
impl ShOptPrompt {
|
||||
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
|
||||
match opt {
|
||||
"trunc_prompt_path" => {
|
||||
let Ok(val) = val.parse::<usize>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected a positive integer for trunc_prompt_path value",
|
||||
));
|
||||
};
|
||||
self.trunc_prompt_path = val;
|
||||
}
|
||||
"edit_mode" => {
|
||||
let Ok(val) = val.parse::<ShedEditMode>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'vi' or 'emacs' for edit_mode value",
|
||||
));
|
||||
};
|
||||
self.edit_mode = val;
|
||||
}
|
||||
"comp_limit" => {
|
||||
let Ok(val) = val.parse::<usize>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected a positive integer for comp_limit value",
|
||||
));
|
||||
};
|
||||
self.comp_limit = val;
|
||||
}
|
||||
"highlight" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for highlight value",
|
||||
));
|
||||
};
|
||||
self.highlight = val;
|
||||
}
|
||||
"auto_indent" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for auto_indent value",
|
||||
));
|
||||
};
|
||||
self.auto_indent = val;
|
||||
}
|
||||
"linebreak_on_incomplete" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for linebreak_on_incomplete value",
|
||||
));
|
||||
};
|
||||
self.linebreak_on_incomplete = val;
|
||||
}
|
||||
"leader" => {
|
||||
self.leader = val.to_string();
|
||||
}
|
||||
"line_numbers" => {
|
||||
let Ok(val) = val.parse::<bool>() else {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
"shopt: expected 'true' or 'false' for line_numbers value",
|
||||
));
|
||||
};
|
||||
self.line_numbers = val;
|
||||
}
|
||||
"custom" => {
|
||||
todo!()
|
||||
}
|
||||
_ => {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
format!("shopt: Unexpected 'prompt' option '{opt}'"),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
|
||||
if query.is_empty() {
|
||||
return Ok(Some(format!("{self}")));
|
||||
}
|
||||
/// Maximum number of completion candidates displayed upon pressing tab
|
||||
comp_limit: usize = 100,
|
||||
|
||||
match query {
|
||||
"trunc_prompt_path" => {
|
||||
let mut output = String::from(
|
||||
"Maximum number of path segments used in the '\\W' prompt escape sequence\n",
|
||||
);
|
||||
output.push_str(&format!("{}", self.trunc_prompt_path));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"edit_mode" => {
|
||||
let mut output =
|
||||
String::from("The style of editor shortcuts used in the line-editing of the prompt\n");
|
||||
output.push_str(&format!("{}", self.edit_mode));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"comp_limit" => {
|
||||
let mut output =
|
||||
String::from("Maximum number of completion candidates displayed upon pressing tab\n");
|
||||
output.push_str(&format!("{}", self.comp_limit));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"highlight" => {
|
||||
let mut output =
|
||||
String::from("Whether to enable or disable syntax highlighting on the prompt\n");
|
||||
output.push_str(&format!("{}", self.highlight));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"auto_indent" => {
|
||||
let mut output =
|
||||
String::from("Whether to automatically indent new lines in multiline commands\n");
|
||||
output.push_str(&format!("{}", self.auto_indent));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"linebreak_on_incomplete" => {
|
||||
let mut output =
|
||||
String::from("Whether to automatically insert a newline when the input is incomplete\n");
|
||||
output.push_str(&format!("{}", self.linebreak_on_incomplete));
|
||||
Ok(Some(output))
|
||||
}
|
||||
"leader" => {
|
||||
let mut output = String::from("The leader key sequence used in keymap bindings\n");
|
||||
output.push_str(&self.leader);
|
||||
Ok(Some(output))
|
||||
}
|
||||
"line_numbers" => {
|
||||
let mut output = String::from("Whether to display line numbers in multiline input\n");
|
||||
output.push_str(&format!("{}", self.line_numbers));
|
||||
Ok(Some(output))
|
||||
}
|
||||
_ => Err(ShErr::simple(
|
||||
ShErrKind::SyntaxErr,
|
||||
format!("shopt: Unexpected 'prompt' option '{query}'"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether to enable or disable syntax highlighting on the prompt
|
||||
highlight: bool = true,
|
||||
|
||||
impl Display for ShOptPrompt {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let mut output = vec![];
|
||||
/// Whether to automatically indent new lines in multiline commands
|
||||
auto_indent: bool = true,
|
||||
|
||||
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));
|
||||
/// Whether to automatically insert a newline when the input is incomplete
|
||||
linebreak_on_incomplete: bool = true,
|
||||
|
||||
let final_output = output.join("\n");
|
||||
/// The leader key sequence used in keymap bindings
|
||||
leader: String = " ".to_string(),
|
||||
|
||||
writeln!(f, "{final_output}")
|
||||
}
|
||||
}
|
||||
/// Whether to display line numbers in multiline input
|
||||
line_numbers: bool = true,
|
||||
|
||||
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,
|
||||
}
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,9 +309,16 @@ mod tests {
|
||||
#[test]
|
||||
fn all_core_fields_covered() {
|
||||
let ShOptCore {
|
||||
dotglob, autocd, hist_ignore_dupes, max_hist,
|
||||
interactive_comments, auto_hist, bell_enabled, max_recurse_depth,
|
||||
dotglob,
|
||||
autocd,
|
||||
hist_ignore_dupes,
|
||||
max_hist,
|
||||
interactive_comments,
|
||||
auto_hist,
|
||||
bell_enabled,
|
||||
max_recurse_depth,
|
||||
xpg_echo,
|
||||
noclobber,
|
||||
} = ShOptCore::default();
|
||||
// If a field is added to the struct, this destructure fails to compile.
|
||||
let _ = (
|
||||
@@ -563,6 +331,7 @@ mod tests {
|
||||
bell_enabled,
|
||||
max_recurse_depth,
|
||||
xpg_echo,
|
||||
noclobber,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -596,12 +365,6 @@ mod tests {
|
||||
fn set_and_get_prompt_opts() {
|
||||
let mut opts = ShOpts::default();
|
||||
|
||||
opts.set("prompt.edit_mode", "emacs").unwrap();
|
||||
assert!(matches!(opts.prompt.edit_mode, ShedEditMode::Emacs));
|
||||
|
||||
opts.set("prompt.edit_mode", "vi").unwrap();
|
||||
assert!(matches!(opts.prompt.edit_mode, ShedEditMode::Vi));
|
||||
|
||||
opts.set("prompt.comp_limit", "50").unwrap();
|
||||
assert_eq!(opts.prompt.comp_limit, 50);
|
||||
|
||||
@@ -646,7 +409,6 @@ mod tests {
|
||||
assert!(opts.set("core.dotglob", "notabool").is_err());
|
||||
assert!(opts.set("core.max_hist", "notanint").is_err());
|
||||
assert!(opts.set("core.max_recurse_depth", "-5").is_err());
|
||||
assert!(opts.set("prompt.edit_mode", "notepad").is_err());
|
||||
assert!(opts.set("prompt.comp_limit", "abc").is_err());
|
||||
}
|
||||
|
||||
@@ -660,7 +422,6 @@ mod tests {
|
||||
assert!(core_output.contains("bell_enabled"));
|
||||
|
||||
let prompt_output = opts.get("prompt").unwrap().unwrap();
|
||||
assert!(prompt_output.contains("edit_mode"));
|
||||
assert!(prompt_output.contains("comp_limit"));
|
||||
assert!(prompt_output.contains("highlight"));
|
||||
}
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use nix::sys::signal::{SaFlags, SigAction, sigaction};
|
||||
use nix::{
|
||||
sys::signal::{SaFlags, SigAction, sigaction},
|
||||
unistd::getpid,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
builtin::trap::TrapTarget,
|
||||
jobs::{JobCmdFlags, JobID, take_term},
|
||||
jobs::{Job, JobCmdFlags, JobID, take_term},
|
||||
libsh::error::{ShErr, ShErrKind, ShResult},
|
||||
parse::execute::exec_input,
|
||||
prelude::*,
|
||||
state::{AutoCmd, AutoCmdKind, read_jobs, read_logic, write_jobs, write_meta},
|
||||
state::{
|
||||
AutoCmd, AutoCmdKind, VarFlags, VarKind, read_jobs, read_logic, write_jobs, write_meta,
|
||||
write_vars,
|
||||
},
|
||||
};
|
||||
|
||||
static SIGNALS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
pub static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
|
||||
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
|
||||
pub static JOB_DONE: AtomicBool = AtomicBool::new(false);
|
||||
pub static QUIT_CODE: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
const MISC_SIGNALS: [Signal; 22] = [
|
||||
/// Window size change signal
|
||||
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// SIGUSR1 tells the prompt that it needs to fully refresh.
|
||||
/// Useful for dynamic prompt content and asynchronous refreshing
|
||||
pub static GOT_SIGUSR1: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
const MISC_SIGNALS: [Signal; 21] = [
|
||||
Signal::SIGILL,
|
||||
Signal::SIGTRAP,
|
||||
Signal::SIGABRT,
|
||||
Signal::SIGBUS,
|
||||
Signal::SIGFPE,
|
||||
Signal::SIGUSR1,
|
||||
Signal::SIGSEGV,
|
||||
Signal::SIGUSR2,
|
||||
Signal::SIGPIPE,
|
||||
@@ -65,7 +79,7 @@ pub fn check_signals() -> ShResult<()> {
|
||||
if got_signal(Signal::SIGINT) {
|
||||
interrupt()?;
|
||||
run_trap(Signal::SIGINT)?;
|
||||
return Err(ShErr::simple(ShErrKind::ClearReadline, ""));
|
||||
return Err(ShErr::simple(ShErrKind::Interrupt, ""));
|
||||
}
|
||||
if got_signal(Signal::SIGHUP) {
|
||||
run_trap(Signal::SIGHUP)?;
|
||||
@@ -87,6 +101,10 @@ pub fn check_signals() -> ShResult<()> {
|
||||
GOT_SIGWINCH.store(true, Ordering::SeqCst);
|
||||
run_trap(Signal::SIGWINCH)?;
|
||||
}
|
||||
if got_signal(Signal::SIGUSR1) {
|
||||
GOT_SIGUSR1.store(true, Ordering::SeqCst);
|
||||
run_trap(Signal::SIGUSR1)?;
|
||||
}
|
||||
|
||||
for sig in MISC_SIGNALS {
|
||||
if got_signal(sig) {
|
||||
@@ -316,6 +334,25 @@ pub fn child_exited(pid: Pid, status: WtStat) -> ShResult<()> {
|
||||
let result = read_jobs(|j| j.query(JobID::Pgid(pgid)).cloned());
|
||||
if let Some(job) = result {
|
||||
let job_complete_msg = job.display(&job_order, JobCmdFlags::PIDS).to_string();
|
||||
let statuses = job.get_stats();
|
||||
|
||||
for status in &statuses {
|
||||
if let WtStat::Signaled(_, sig, _) = status
|
||||
&& *sig == Signal::SIGINT
|
||||
{
|
||||
// Necessary to interrupt stuff like shell loops
|
||||
kill(getpid(), Signal::SIGINT).ok();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pipe_status) = Job::pipe_status(&statuses) {
|
||||
let pipe_status = pipe_status
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<VecDeque<String>>();
|
||||
|
||||
write_vars(|v| v.set_var("PIPESTATUS", VarKind::Arr(pipe_status), VarFlags::NONE))?;
|
||||
}
|
||||
|
||||
let post_job_hooks = read_logic(|l| l.get_autocmds(AutoCmdKind::OnJobFinish));
|
||||
for cmd in post_job_hooks {
|
||||
|
||||
144
src/state.rs
144
src/state.rs
@@ -1,8 +1,14 @@
|
||||
use std::{
|
||||
cell::RefCell, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, fmt::Display, ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign}, os::unix::fs::PermissionsExt, str::FromStr, time::Duration
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet, VecDeque, hash_map::Entry},
|
||||
fmt::Display,
|
||||
ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign},
|
||||
os::unix::fs::PermissionsExt,
|
||||
str::FromStr,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use nix::unistd::{User, gethostname, getppid};
|
||||
use nix::unistd::{User, gethostname, getppid, getuid};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::{
|
||||
@@ -25,7 +31,7 @@ use crate::{
|
||||
},
|
||||
prelude::*,
|
||||
readline::{
|
||||
complete::{BashCompSpec, CompSpec},
|
||||
complete::{BashCompSpec, Candidate, CompSpec},
|
||||
keys::KeyEvent,
|
||||
markers,
|
||||
},
|
||||
@@ -340,7 +346,7 @@ impl ScopeStack {
|
||||
let random = rand::random_range(0..32768);
|
||||
Some(random.to_string())
|
||||
}
|
||||
_ => None
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn get_arr_elems(&self, var_name: &str) -> ShResult<Vec<String>> {
|
||||
@@ -528,7 +534,10 @@ impl ScopeStack {
|
||||
return val.clone();
|
||||
}
|
||||
// Positional params are scope-local; only check the current scope
|
||||
if matches!(param, ShellParam::Pos(_) | ShellParam::AllArgs | ShellParam::AllArgsStr | ShellParam::ArgCount) {
|
||||
if matches!(
|
||||
param,
|
||||
ShellParam::Pos(_) | ShellParam::AllArgs | ShellParam::AllArgsStr | ShellParam::ArgCount
|
||||
) {
|
||||
if let Some(scope) = self.scopes.last() {
|
||||
return scope.get_param(param);
|
||||
}
|
||||
@@ -992,6 +1001,13 @@ impl From<Vec<String>> for Var {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<Candidate>> for Var {
|
||||
fn from(value: Vec<Candidate>) -> Self {
|
||||
let as_strs = value.into_iter().map(|c| c.0).collect::<Vec<_>>();
|
||||
Self::new(VarKind::Arr(as_strs.into()), VarFlags::NONE)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&[String]> for Var {
|
||||
fn from(value: &[String]) -> Self {
|
||||
let mut new = VecDeque::new();
|
||||
@@ -1011,19 +1027,7 @@ macro_rules! impl_var_from {
|
||||
}
|
||||
|
||||
impl_var_from!(
|
||||
i8,
|
||||
i16,
|
||||
i32,
|
||||
i64,
|
||||
isize,
|
||||
u8,
|
||||
u16,
|
||||
u32,
|
||||
u64,
|
||||
usize,
|
||||
String,
|
||||
&str,
|
||||
bool
|
||||
i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, String, &str, bool
|
||||
);
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
@@ -1045,7 +1049,7 @@ impl VarTab {
|
||||
}
|
||||
}
|
||||
pub fn new() -> Self {
|
||||
let vars = HashMap::new();
|
||||
let vars = Self::init_sh_vars();
|
||||
let params = Self::init_params();
|
||||
Self::init_env();
|
||||
let mut var_tab = Self {
|
||||
@@ -1064,6 +1068,11 @@ impl VarTab {
|
||||
params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any)
|
||||
params
|
||||
}
|
||||
fn init_sh_vars() -> HashMap<String, Var> {
|
||||
let mut vars = HashMap::new();
|
||||
vars.insert("COMP_WORDBREAKS".into(), " \t\n\"'@><=;|&(".into());
|
||||
vars
|
||||
}
|
||||
fn init_env() {
|
||||
let pathbuf_to_string =
|
||||
|pb: Result<PathBuf, std::io::Error>| pb.unwrap_or_default().to_string_lossy().to_string();
|
||||
@@ -1096,6 +1105,8 @@ impl VarTab {
|
||||
.map(|hname| hname.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let help_paths = format!("/usr/share/shed/doc:{home}/.local/share/shed/doc");
|
||||
|
||||
unsafe {
|
||||
env::set_var("IFS", " \t\n");
|
||||
env::set_var("HOST", hostname.clone());
|
||||
@@ -1112,6 +1123,7 @@ impl VarTab {
|
||||
env::set_var("SHELL", pathbuf_to_string(std::env::current_exe()));
|
||||
env::set_var("SHED_HIST", format!("{}/.shedhist", home));
|
||||
env::set_var("SHED_RC", format!("{}/.shedrc", home));
|
||||
env::set_var("SHED_HPATH", help_paths);
|
||||
}
|
||||
}
|
||||
pub fn init_sh_argv(&mut self) {
|
||||
@@ -1328,6 +1340,15 @@ impl VarTab {
|
||||
.get(&ShellParam::Status)
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or("0".into()),
|
||||
ShellParam::AllArgsStr => {
|
||||
let ifs = get_separator();
|
||||
self
|
||||
.params
|
||||
.get(&ShellParam::AllArgs)
|
||||
.map(|s| s.replace(markers::ARG_SEP, &ifs).to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
_ => self
|
||||
.params
|
||||
.get(¶m)
|
||||
@@ -1840,6 +1861,15 @@ pub fn change_dir<P: AsRef<Path>>(dir: P) -> ShResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_separator() -> String {
|
||||
env::var("IFS")
|
||||
.unwrap_or(String::from(" "))
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn get_status() -> i32 {
|
||||
read_vars(|v| v.get_param(ShellParam::Status))
|
||||
.parse::<i32>()
|
||||
@@ -1849,19 +1879,44 @@ pub fn set_status(code: i32) {
|
||||
write_vars(|v| v.set_param(ShellParam::Status, &code.to_string()))
|
||||
}
|
||||
|
||||
pub fn source_rc() -> ShResult<()> {
|
||||
let path = if let Ok(path) = env::var("SHED_RC") {
|
||||
pub fn source_runtime_file(name: &str, env_var_name: Option<&str>) -> ShResult<()> {
|
||||
let etc_path = PathBuf::from(format!("/etc/shed/{name}"));
|
||||
if etc_path.is_file()
|
||||
&& let Err(e) = source_file(etc_path)
|
||||
{
|
||||
e.print_error();
|
||||
}
|
||||
|
||||
let path = if let Some(name) = env_var_name
|
||||
&& let Ok(path) = env::var(name)
|
||||
{
|
||||
PathBuf::from(&path)
|
||||
} else if let Some(home) = get_home() {
|
||||
home.join(format!(".{name}"))
|
||||
} else {
|
||||
let home = env::var("HOME").unwrap();
|
||||
PathBuf::from(format!("{home}/.shedrc"))
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::InternalErr,
|
||||
"could not determine home path",
|
||||
));
|
||||
};
|
||||
if !path.exists() {
|
||||
return Err(ShErr::simple(ShErrKind::InternalErr, ".shedrc not found"));
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
source_file(path)
|
||||
}
|
||||
|
||||
pub fn source_rc() -> ShResult<()> {
|
||||
source_runtime_file("shedrc", Some("SHED_RC"))
|
||||
}
|
||||
|
||||
pub fn source_login() -> ShResult<()> {
|
||||
source_runtime_file("shed_profile", Some("SHED_PROFILE"))
|
||||
}
|
||||
|
||||
pub fn source_env() -> ShResult<()> {
|
||||
source_runtime_file("shedenv", Some("SHED_ENV"))
|
||||
}
|
||||
|
||||
pub fn source_file(path: PathBuf) -> ShResult<()> {
|
||||
let source_name = path.to_string_lossy().to_string();
|
||||
let mut file = OpenOptions::new().read(true).open(path)?;
|
||||
@@ -1871,3 +1926,42 @@ pub fn source_file(path: PathBuf) -> ShResult<()> {
|
||||
exec_input(buf, None, false, Some(source_name))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn get_home_unchecked() -> PathBuf {
|
||||
if let Some(home) = get_home() {
|
||||
home
|
||||
} else {
|
||||
let caller = std::panic::Location::caller();
|
||||
panic!(
|
||||
"get_home_unchecked: could not determine home directory (called from {}:{})",
|
||||
caller.file(),
|
||||
caller.line()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
pub fn get_home_str_unchecked() -> String {
|
||||
if let Some(home) = get_home() {
|
||||
home.to_string_lossy().to_string()
|
||||
} else {
|
||||
let caller = std::panic::Location::caller();
|
||||
panic!(
|
||||
"get_home_str_unchecked: could not determine home directory (called from {}:{})",
|
||||
caller.file(),
|
||||
caller.line()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_home() -> Option<PathBuf> {
|
||||
env::var("HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| User::from_uid(getuid()).ok().flatten().map(|u| u.dir))
|
||||
}
|
||||
|
||||
pub fn get_home_str() -> Option<String> {
|
||||
get_home().map(|h| h.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -14,7 +14,12 @@ use nix::{
|
||||
};
|
||||
|
||||
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}
|
||||
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(());
|
||||
@@ -40,7 +45,7 @@ pub struct TestGuard {
|
||||
pty_master: OwnedFd,
|
||||
pty_slave: OwnedFd,
|
||||
|
||||
cleanups: Vec<Box<dyn FnOnce()>>
|
||||
cleanups: Vec<Box<dyn FnOnce()>>,
|
||||
}
|
||||
|
||||
impl TestGuard {
|
||||
@@ -54,33 +59,27 @@ impl TestGuard {
|
||||
tcsetattr(&pty_slave, SetArg::TCSANOW, &attrs).unwrap();
|
||||
|
||||
let mut frame = IoFrame::new();
|
||||
frame.push(
|
||||
Redir::new(
|
||||
frame.push(Redir::new(
|
||||
IoMode::Fd {
|
||||
tgt_fd: 0,
|
||||
src_fd: pty_slave.as_raw_fd(),
|
||||
},
|
||||
RedirType::Input,
|
||||
),
|
||||
);
|
||||
frame.push(
|
||||
Redir::new(
|
||||
));
|
||||
frame.push(Redir::new(
|
||||
IoMode::Fd {
|
||||
tgt_fd: 1,
|
||||
src_fd: pty_slave.as_raw_fd(),
|
||||
},
|
||||
RedirType::Output,
|
||||
),
|
||||
);
|
||||
frame.push(
|
||||
Redir::new(
|
||||
));
|
||||
frame.push(Redir::new(
|
||||
IoMode::Fd {
|
||||
tgt_fd: 2,
|
||||
src_fd: pty_slave.as_raw_fd(),
|
||||
},
|
||||
RedirType::Output,
|
||||
),
|
||||
);
|
||||
));
|
||||
|
||||
let _redir_guard = frame.redirect().unwrap();
|
||||
|
||||
@@ -99,7 +98,7 @@ impl TestGuard {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pty_slave(&self) -> BorrowedFd {
|
||||
pub fn pty_slave(&self) -> BorrowedFd<'_> {
|
||||
unsafe { BorrowedFd::borrow_raw(self.pty_slave.as_raw_fd()) }
|
||||
}
|
||||
|
||||
@@ -113,7 +112,8 @@ impl TestGuard {
|
||||
fcntl(
|
||||
self.pty_master.as_raw_fd(),
|
||||
FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK),
|
||||
).unwrap();
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut out = vec![];
|
||||
let mut buf = [0; 4096];
|
||||
@@ -125,10 +125,7 @@ impl TestGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fcntl(
|
||||
self.pty_master.as_raw_fd(),
|
||||
FcntlArg::F_SETFL(flags),
|
||||
).unwrap();
|
||||
fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_SETFL(flags)).unwrap();
|
||||
|
||||
String::from_utf8_lossy(&out).to_string()
|
||||
}
|
||||
@@ -144,10 +141,14 @@ 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); }
|
||||
unsafe {
|
||||
env::remove_var(&k);
|
||||
}
|
||||
}
|
||||
for (k, v) in &self.saved_env {
|
||||
unsafe { env::set_var(k, v); }
|
||||
unsafe {
|
||||
env::set_var(k, v);
|
||||
}
|
||||
}
|
||||
for cleanup in self.cleanups.drain(..).rev() {
|
||||
cleanup();
|
||||
@@ -166,13 +167,18 @@ pub fn get_ast(input: &str) -> ShResult<Vec<crate::parse::Node>> {
|
||||
.with_lex_flags(LexFlags::empty())
|
||||
.with_name(source_name.clone());
|
||||
|
||||
parser.parse_src().map_err(|e| e.into_iter().next().unwrap())?;
|
||||
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> {
|
||||
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![];
|
||||
@@ -182,7 +188,11 @@ impl crate::parse::Node {
|
||||
let expected_rule = expected.next();
|
||||
full_structure.push(s.class.as_nd_kind());
|
||||
|
||||
if offender.is_none() && expected_rule.as_ref().map_or(true, |e| *e != 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());
|
||||
@@ -191,23 +201,34 @@ impl crate::parse::Node {
|
||||
}
|
||||
});
|
||||
|
||||
assert!(expected.next().is_none(), "Expected structure has more nodes than actual structure");
|
||||
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()
|
||||
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 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!(
|
||||
"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");
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
Err(output)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user