Compare commits

...

18 Commits

Author SHA1 Message Date
782a3820da added global shedrc option configuration to the nixos module
extracted options and rc file renderer into their own files
2026-03-17 01:47:42 -04:00
b0325b6bbb Add Candidate type for case-insensitive completion, shopt_group macro, escape fixes, and vi mode tweaks 2026-03-17 01:25:55 -04:00
bce6cd10f7 Merge branch 'main' of github.com:km-clay/shed 2026-03-16 23:32:00 -04:00
ac8940f936 Implement = (equalize/auto-indent) verb, fix dedent indexing, remove unimplemented screen-line motions, and clean up unreachable match arms 2026-03-16 23:31:54 -04:00
3705986169 Update README
Update README
2026-03-16 19:10:47 -04:00
db3f1b5108 Propagate SIGINT from foreground jobs to interrupt shell loops, add SIGUSR1 for async prompt refresh, and support SHED_HPAGER override 2026-03-16 19:08:38 -04:00
958dad9942 implemented ex mode :w/:e commands
implemented tab completion and history search for the ex mode prompt as well

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

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

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

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

deferred heredoc expansion until redir time instead of parse time

implemented "$*" expansions

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

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

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

properly implemented fd close syntax

fixed saved fds being leaked into exec'd programs
2026-03-14 20:04:20 -04:00
5173e1908d heredocs and herestrings implemented
added more tests to the test suite
2026-03-14 13:40:00 -04:00
1f9c96f24e more improvements to auto indent depth tracking
added test cases for the auto indent/dedent feature
2026-03-14 01:14:30 -04:00
40 changed files with 4513 additions and 1618 deletions

View File

@@ -35,6 +35,7 @@ rand = "0.10.0"
regex = "1.11.1" regex = "1.11.1"
scopeguard = "1.2.0" scopeguard = "1.2.0"
serde_json = "1.0.149" serde_json = "1.0.149"
tempfile = "3.24.0"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
unicode-width = "0.2.0" unicode-width = "0.2.0"
vte = "0.15" vte = "0.15"

View File

@@ -1,6 +1,8 @@
# shed # shed
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing. A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing and general interactive UX improvements over existing shells.
<sub>btw if you don't use `vim` this probably isn't your shell</sub>
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/3945f663-a361-4418-bf20-0c4eaa2a36d2" /> <img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/3945f663-a361-4418-bf20-0c4eaa2a36d2" />
@@ -8,7 +10,7 @@ A Linux shell written in Rust. The name is a nod to the original Unix utilities
### Line Editor ### Line Editor
`shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt. `shed` includes a built-in `vim` emulator as its line editor, written from scratch. It aims to provide a more precise vim-like editing experience at the shell prompt than conventional `vi` mode implementations.
- **Normal mode** - motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts - **Normal mode** - motions (`w`, `b`, `e`, `f`, `t`, `%`, `0`, `$`, etc.), verbs (`d`, `c`, `y`, `p`, `r`, `x`, `~`, etc.), text objects (`iw`, `aw`, `i"`, `a{`, `is`, etc.), registers, `.` repeat, `;`/`,` repeat, and counts
- **Insert mode** - insert, append, replace, with Ctrl+W word deletion and undo/redo - **Insert mode** - insert, append, replace, with Ctrl+W word deletion and undo/redo
@@ -40,6 +42,8 @@ gitbranch() { git branch --show-current 2>/dev/null; }
export PS1='\u@\h \W \@gitbranch \$ ' export PS1='\u@\h \W \@gitbranch \$ '
``` ```
If `shed` receives `SIGUSR1` while in interactive mode, it will refresh and redraw the prompt. This can be used to create asynchronous, dynamic prompt content.
Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, similar to how the `-e` flag expands conventional escape sequences. Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, similar to how the `-e` flag expands conventional escape sequences.
--- ---

76
doc/arith.txt Normal file
View File

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

155
doc/glob.txt Normal file
View File

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

197
doc/param.txt Normal file
View File

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

181
doc/redirect.txt Normal file
View File

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

View File

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

View File

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

83
nix/render_rc.nix Normal file
View 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
View 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";
};
};
}

300
src/builtin/help.rs Normal file
View 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
}

View File

@@ -11,6 +11,7 @@ pub mod eval;
pub mod exec; pub mod exec;
pub mod flowctl; pub mod flowctl;
pub mod getopts; pub mod getopts;
pub mod help;
pub mod intro; pub mod intro;
pub mod jobctl; pub mod jobctl;
pub mod keymap; pub mod keymap;
@@ -18,6 +19,7 @@ pub mod map;
pub mod pwd; pub mod pwd;
pub mod read; pub mod read;
pub mod resource; pub mod resource;
pub mod seek;
pub mod shift; pub mod shift;
pub mod shopt; pub mod shopt;
pub mod source; pub mod source;
@@ -25,12 +27,12 @@ pub mod test; // [[ ]] thing
pub mod trap; pub mod trap;
pub mod varcmds; pub mod varcmds;
pub const BUILTINS: [&str; 49] = [ pub const BUILTINS: [&str; 51] = [
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg", "echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
"disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin", "disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin",
"command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly", "command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly",
"unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type", "unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type",
"getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek", "help",
]; ];
pub fn true_builtin() -> ShResult<()> { pub fn true_builtin() -> ShResult<()> {

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

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

View File

@@ -32,7 +32,7 @@ pub fn shopt(node: Node) -> ShResult<()> {
} }
for (arg, span) in argv { for (arg, span) in argv {
let Some(mut output) = write_shopts(|s| s.query(&arg)).blame(span)? else { let Some(mut output) = write_shopts(|s| s.query(&arg)).promote_err(span)? else {
continue; continue;
}; };
@@ -61,7 +61,7 @@ mod tests {
assert!(out.contains("dotglob")); assert!(out.contains("dotglob"));
assert!(out.contains("autocd")); assert!(out.contains("autocd"));
assert!(out.contains("max_hist")); assert!(out.contains("max_hist"));
assert!(out.contains("edit_mode")); assert!(out.contains("comp_limit"));
} }
#[test] #[test]
@@ -72,7 +72,7 @@ mod tests {
assert!(out.contains("dotglob")); assert!(out.contains("dotglob"));
assert!(out.contains("autocd")); assert!(out.contains("autocd"));
// Should not contain prompt opts // Should not contain prompt opts
assert!(!out.contains("edit_mode")); assert!(!out.contains("comp_limit"));
} }
#[test] #[test]
@@ -107,11 +107,10 @@ mod tests {
} }
#[test] #[test]
fn shopt_set_edit_mode() { fn shopt_set_completion_ignore_case() {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("shopt prompt.edit_mode=emacs").unwrap(); test_input("shopt prompt.completion_ignore_case=true").unwrap();
let mode = read_shopts(|o| format!("{}", o.prompt.edit_mode)); assert!(read_shopts(|o| o.prompt.completion_ignore_case));
assert_eq!(mode, "emacs");
} }
// ===================== Error cases ===================== // ===================== Error cases =====================

View File

@@ -4,6 +4,7 @@ use std::str::{Chars, FromStr};
use ariadne::Fmt; use ariadne::Fmt;
use glob::Pattern; use glob::Pattern;
use nix::unistd::{Uid, User};
use regex::Regex; use regex::Regex;
use crate::libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}; use crate::libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color};
@@ -40,18 +41,26 @@ impl Tk {
} }
pub struct Expander { pub struct Expander {
flags: TkFlags,
raw: String, raw: String,
} }
impl Expander { impl Expander {
pub fn new(raw: Tk) -> ShResult<Self> { pub fn new(raw: Tk) -> ShResult<Self> {
let raw = raw.span.as_str(); let tk_raw = raw.span.as_str();
Self::from_raw(raw) 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 raw = expand_braces_full(raw)?.join(" ");
let unescaped = unescape_str(&raw); let unescaped = if flags.contains(TkFlags::IS_HEREDOC) {
Ok(Self { raw: unescaped }) unescape_heredoc(&raw)
} else {
unescape_str(&raw)
};
Ok(Self {
raw: unescaped,
flags,
})
} }
pub fn expand(&mut self) -> ShResult<Vec<String>> { pub fn expand(&mut self) -> ShResult<Vec<String>> {
let mut chars = self.raw.chars().peekable(); let mut chars = self.raw.chars().peekable();
@@ -75,7 +84,11 @@ impl Expander {
self.raw.insert_str(0, "./"); self.raw.insert_str(0, "./");
} }
Ok(self.split_words()) 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> { pub fn split_words(&mut self) -> Vec<String> {
let mut words = vec![]; let mut words = vec![];
@@ -461,7 +474,32 @@ pub fn expand_raw(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
markers::TILDE_SUB => { 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); result.push_str(&home);
} }
markers::PROC_SUB_OUT => { markers::PROC_SUB_OUT => {
@@ -1154,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); result.push(markers::DUB_QUOTE);
break; break;
@@ -1166,14 +1223,16 @@ pub fn unescape_str(raw: &str) -> String {
result.push(markers::SNG_QUOTE); result.push(markers::SNG_QUOTE);
while let Some(q_ch) = chars.next() { while let Some(q_ch) = chars.next() {
match q_ch { match q_ch {
'\\' => { '\\' => {
if chars.peek() == Some(&'\'') { match chars.peek() {
result.push('\''); Some(&'\\') |
chars.next(); Some(&'\'') => {
} else { let ch = chars.next().unwrap();
result.push('\\'); result.push(ch);
} }
} _ => result.push(q_ch),
}
}
'\'' => { '\'' => {
result.push(markers::SNG_QUOTE); result.push(markers::SNG_QUOTE);
break; break;
@@ -1318,6 +1377,25 @@ pub fn unescape_str(raw: &str) -> String {
result.push('$'); 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.push(ch),
} }
first_char = false; first_char = false;
@@ -1326,6 +1404,97 @@ pub fn unescape_str(raw: &str) -> String {
result 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 /// Opposite of unescape_str - escapes a string to be executed as literal text
/// Used for completion results, and glob filename matches. /// Used for completion results, and glob filename matches.
pub fn escape_str(raw: &str, use_marker: bool) -> String { pub fn escape_str(raw: &str, use_marker: bool) -> String {
@@ -1416,6 +1585,10 @@ pub fn unescape_math(raw: &str) -> String {
#[derive(Debug)] #[derive(Debug)]
pub enum ParamExp { pub enum ParamExp {
Len, // #var_name Len, // #var_name
ToUpperFirst, // ^var_name
ToUpperAll, // ^^var_name
ToLowerFirst, // ,var_name
ToLowerAll, // ,,var_name
DefaultUnsetOrNull(String), // :- DefaultUnsetOrNull(String), // :-
DefaultUnset(String), // - DefaultUnset(String), // -
SetDefaultUnsetOrNull(String), // := SetDefaultUnsetOrNull(String), // :=
@@ -1451,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} // Handle indirect var expansion: ${!var}
if let Some(var) = s.strip_prefix('!') { if let Some(var) = s.strip_prefix('!') {
if var.ends_with('*') || var.ends_with('@') { if var.ends_with('*') || var.ends_with('@') {
@@ -1556,7 +1742,7 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
match ch { match ch {
'!' | '#' | '%' | ':' | '-' | '+' | '=' | '/' | '?' => { '!' | '#' | '%' | ':' | '-' | '+' | '^' | ',' | '=' | '/' | '?' => {
rest.push(ch); rest.push(ch);
rest.push_str(&chars.collect::<String>()); rest.push_str(&chars.collect::<String>());
break; break;
@@ -1568,6 +1754,32 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
if let Ok(expansion) = rest.parse::<ParamExp>() { if let Ok(expansion) = rest.parse::<ParamExp>() {
match expansion { match expansion {
ParamExp::Len => unreachable!(), 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) => { ParamExp::DefaultUnsetOrNull(default) => {
match vars.try_get_var(&var_name).filter(|v| !v.is_empty()) { match vars.try_get_var(&var_name).filter(|v| !v.is_empty()) {
Some(val) => Ok(val), Some(val) => Ok(val),
@@ -3532,6 +3744,7 @@ mod tests {
let mut exp = Expander { let mut exp = Expander {
raw: "hello world\tfoo".to_string(), raw: "hello world\tfoo".to_string(),
flags: TkFlags::empty(),
}; };
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["hello", "world", "foo"]); assert_eq!(words, vec!["hello", "world", "foo"]);
@@ -3546,6 +3759,7 @@ mod tests {
let mut exp = Expander { let mut exp = Expander {
raw: "a:b:c".to_string(), raw: "a:b:c".to_string(),
flags: TkFlags::empty(),
}; };
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["a", "b", "c"]); assert_eq!(words, vec!["a", "b", "c"]);
@@ -3560,6 +3774,7 @@ mod tests {
let mut exp = Expander { let mut exp = Expander {
raw: "hello world".to_string(), raw: "hello world".to_string(),
flags: TkFlags::empty(),
}; };
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["hello world"]); assert_eq!(words, vec!["hello world"]);
@@ -3570,7 +3785,10 @@ mod tests {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let raw = format!("{}hello world{}", markers::DUB_QUOTE, markers::DUB_QUOTE); 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(); let words = exp.split_words();
assert_eq!(words, vec!["hello world"]); assert_eq!(words, vec!["hello world"]);
} }
@@ -3582,7 +3800,10 @@ mod tests {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let raw = format!("hello{}world", unescape_str("\\ ")); let raw = format!("hello{}world", unescape_str("\\ "));
let mut exp = Expander { raw }; let mut exp = Expander {
raw,
flags: TkFlags::empty(),
};
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["hello world"]); assert_eq!(words, vec!["hello world"]);
} }
@@ -3592,7 +3813,10 @@ mod tests {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let raw = format!("hello{}world", unescape_str("\\\t")); let raw = format!("hello{}world", unescape_str("\\\t"));
let mut exp = Expander { raw }; let mut exp = Expander {
raw,
flags: TkFlags::empty(),
};
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["hello\tworld"]); assert_eq!(words, vec!["hello\tworld"]);
} }
@@ -3605,7 +3829,10 @@ mod tests {
} }
let raw = format!("a{}b:c", unescape_str("\\:")); let raw = format!("a{}b:c", unescape_str("\\:"));
let mut exp = Expander { raw }; let mut exp = Expander {
raw,
flags: TkFlags::empty(),
};
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["a:b", "c"]); assert_eq!(words, vec!["a:b", "c"]);
} }

View File

@@ -95,12 +95,14 @@ pub fn sort_tks(
.into_iter() .into_iter()
.map(|t| t.expand()) .map(|t| t.expand())
.collect::<ShResult<Vec<_>>>()? .collect::<ShResult<Vec<_>>>()?
.into_iter(); .into_iter()
.peekable();
let mut opts = vec![]; let mut opts = vec![];
let mut non_opts = vec![]; let mut non_opts = vec![];
while let Some(token) = tokens_iter.next() { while let Some(token) = tokens_iter.next() {
if &token.to_string() == "--" { if &token.to_string() == "--" {
non_opts.push(token);
non_opts.extend(tokens_iter); non_opts.extend(tokens_iter);
break; break;
} }

View File

@@ -1,4 +1,7 @@
use std::collections::VecDeque;
use ariadne::Fmt; use ariadne::Fmt;
use nix::unistd::getpid;
use scopeguard::defer; use scopeguard::defer;
use yansi::Color; use yansi::Color;
@@ -10,7 +13,7 @@ use crate::{
prelude::*, prelude::*,
procio::{IoMode, borrow_fd}, procio::{IoMode, borrow_fd},
signal::{disable_reaping, enable_reaping}, signal::{disable_reaping, enable_reaping},
state::{self, ShellParam, set_status, write_jobs, write_vars}, state::{self, ShellParam, Var, VarFlags, VarKind, set_status, write_jobs, write_vars},
}; };
pub const SIG_EXIT_OFFSET: i32 = 128; pub const SIG_EXIT_OFFSET: i32 = 128;
@@ -596,6 +599,29 @@ impl Job {
.map(|chld| chld.stat()) .map(|chld| chld.stat())
.collect::<Vec<WtStat>>() .collect::<Vec<WtStat>>()
} }
pub fn pipe_status(stats: &[WtStat]) -> Option<Vec<i32>> {
if stats.iter().any(|stat| {
matches!(
stat,
WtStat::StillAlive | WtStat::Continued(_) | WtStat::PtraceSyscall(_)
)
}) || stats.len() <= 1
{
return None;
}
Some(
stats
.iter()
.map(|stat| match stat {
WtStat::Exited(_, code) => *code,
WtStat::Signaled(_, signal, _) => SIG_EXIT_OFFSET + *signal as i32,
WtStat::Stopped(_, signal) => SIG_EXIT_OFFSET + *signal as i32,
WtStat::PtraceEvent(_, signal, _) => SIG_EXIT_OFFSET + *signal as i32,
WtStat::PtraceSyscall(_) | WtStat::Continued(_) | WtStat::StillAlive => unreachable!(),
})
.collect(),
)
}
pub fn get_pids(&self) -> Vec<Pid> { pub fn get_pids(&self) -> Vec<Pid> {
self self
.children .children
@@ -839,22 +865,35 @@ pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> {
enable_reaping(); enable_reaping();
} }
let statuses = write_jobs(|j| j.new_fg(job))?; let statuses = write_jobs(|j| j.new_fg(job))?;
for status in statuses { for status in &statuses {
code = code_from_status(&status).unwrap_or(0); code = code_from_status(status).unwrap_or(0);
match status { match status {
WtStat::Stopped(_, _) => { WtStat::Stopped(_, _) => {
was_stopped = true; was_stopped = true;
write_jobs(|j| j.fg_to_bg(status))?; write_jobs(|j| j.fg_to_bg(*status))?;
} }
WtStat::Signaled(_, sig, _) => { WtStat::Signaled(_, sig, _) => {
if sig == Signal::SIGTSTP { if *sig == Signal::SIGINT {
// interrupt propagates to the shell
// necessary for interrupting stuff like
// while/for loops
kill(getpid(), Signal::SIGINT)?;
} else if *sig == Signal::SIGTSTP {
was_stopped = true; was_stopped = true;
write_jobs(|j| j.fg_to_bg(status))?; write_jobs(|j| j.fg_to_bg(*status))?;
} }
} }
_ => { /* Do nothing */ } _ => { /* Do nothing */ }
} }
} }
if let Some(pipe_status) = Job::pipe_status(&statuses) {
let pipe_status = pipe_status
.into_iter()
.map(|s| s.to_string())
.collect::<VecDeque<String>>();
write_vars(|v| v.set_var("PIPESTATUS", VarKind::Arr(pipe_status), VarFlags::NONE))?;
}
// If job wasn't stopped (moved to bg), clear the fg slot // If job wasn't stopped (moved to bg), clear the fg slot
if !was_stopped { if !was_stopped {
write_jobs(|j| { write_jobs(|j| {

View File

@@ -201,6 +201,7 @@ impl ShErr {
pub fn is_flow_control(&self) -> bool { pub fn is_flow_control(&self) -> bool {
self.kind.is_flow_control() self.kind.is_flow_control()
} }
/// Promotes a shell error from a simple error to an error that blames a span
pub fn promote(mut self, span: Span) -> Self { pub fn promote(mut self, span: Span) -> Self {
if self.notes.is_empty() { if self.notes.is_empty() {
return self; return self;
@@ -208,6 +209,8 @@ impl ShErr {
let first = self.notes[0].clone(); let first = self.notes[0].clone();
if self.notes.len() > 1 { if self.notes.len() > 1 {
self.notes = self.notes[1..].to_vec(); self.notes = self.notes[1..].to_vec();
} else {
self.notes = vec![];
} }
self.labeled(span, first) self.labeled(span, first)
@@ -456,7 +459,7 @@ pub enum ShErrKind {
FuncReturn(i32), FuncReturn(i32),
LoopContinue(i32), LoopContinue(i32),
LoopBreak(i32), LoopBreak(i32),
ClearReadline, Interrupt,
Null, Null,
} }
@@ -468,7 +471,7 @@ impl ShErrKind {
| Self::FuncReturn(_) | Self::FuncReturn(_)
| Self::LoopContinue(_) | Self::LoopContinue(_)
| Self::LoopBreak(_) | Self::LoopBreak(_)
| Self::ClearReadline | Self::Interrupt
) )
} }
} }
@@ -493,7 +496,7 @@ impl Display for ShErrKind {
Self::LoopBreak(_) => "Syntax Error", Self::LoopBreak(_) => "Syntax Error",
Self::ReadlineErr => "Readline Error", Self::ReadlineErr => "Readline Error",
Self::ExCommand => "Ex Command Error", Self::ExCommand => "Ex Command Error",
Self::ClearReadline => "", Self::Interrupt => "",
Self::Null => "", Self::Null => "",
}; };
write!(f, "{output}") write!(f, "{output}")

View File

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

View File

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

View File

@@ -38,17 +38,18 @@ use crate::prelude::*;
use crate::procio::borrow_fd; use crate::procio::borrow_fd;
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode}; use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
use crate::readline::{Prompt, ReadlineEvent, ShedVi}; use crate::readline::{Prompt, ReadlineEvent, ShedVi};
use crate::signal::{GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending}; use crate::signal::{
GOT_SIGUSR1, GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending,
};
use crate::state::{ use crate::state::{
AutoCmdKind, read_logic, read_shopts, source_rc, write_jobs, write_meta, write_shopts, AutoCmdKind, read_logic, read_shopts, source_env, source_login, source_rc, write_jobs,
write_meta, write_shopts,
}; };
use clap::Parser; use clap::Parser;
use state::write_vars; use state::write_vars;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct ShedArgs { struct ShedArgs {
script: Option<String>,
#[arg(short)] #[arg(short)]
command: Option<String>, command: Option<String>,
@@ -61,6 +62,9 @@ struct ShedArgs {
#[arg(short)] #[arg(short)]
interactive: bool, interactive: bool,
#[arg(short)]
stdin: bool,
#[arg(long, short)] #[arg(long, short)]
login_shell: bool, login_shell: bool,
} }
@@ -127,10 +131,17 @@ fn main() -> ExitCode {
unsafe { env::set_var("SHLVL", "1") }; unsafe { env::set_var("SHLVL", "1") };
} }
if let Err(e) = if let Some(path) = args.script { if let Err(e) = source_env() {
run_script(path, args.script_args) e.print_error();
} else if let Some(cmd) = args.command { }
if let Err(e) = if let Some(cmd) = args.command {
exec_dash_c(cmd) 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 { } else {
let res = shed_interactive(args); let res = shed_interactive(args);
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
@@ -152,6 +163,32 @@ fn main() -> ExitCode {
ExitCode::from(QUIT_CODE.load(Ordering::SeqCst) as u8) ExitCode::from(QUIT_CODE.load(Ordering::SeqCst) as u8)
} }
fn read_commands(args: Vec<String>) -> ShResult<()> {
let mut input = vec![];
let mut read_buf = [0u8; 4096];
loop {
match read(STDIN_FILENO, &mut read_buf) {
Ok(0) => break,
Ok(n) => input.extend_from_slice(&read_buf[..n]),
Err(Errno::EINTR) => continue,
Err(e) => {
QUIT_CODE.store(1, Ordering::SeqCst);
return Err(ShErr::simple(
ShErrKind::CleanExit(1),
format!("error reading from stdin: {e}"),
));
}
}
}
let commands = String::from_utf8_lossy(&input).to_string();
for arg in args {
write_vars(|v| v.cur_scope_mut().bpush_arg(arg))
}
exec_input(commands, None, false, None)
}
fn run_script<P: AsRef<Path>>(path: P, args: Vec<String>) -> ShResult<()> { fn run_script<P: AsRef<Path>>(path: P, args: Vec<String>) -> ShResult<()> {
let path = path.as_ref(); let path = path.as_ref();
let path_raw = path.to_string_lossy().to_string(); let path_raw = path.to_string_lossy().to_string();
@@ -187,6 +224,12 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
let _raw_mode = raw_mode(); // sets raw mode, restores termios on drop let _raw_mode = raw_mode(); // sets raw mode, restores termios on drop
sig_setup(args.login_shell); sig_setup(args.login_shell);
if args.login_shell
&& let Err(e) = source_login()
{
e.print_error();
}
if let Err(e) = source_rc() { if let Err(e) = source_rc() {
e.print_error(); e.print_error();
} }
@@ -218,7 +261,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
while signals_pending() { while signals_pending() {
if let Err(e) = check_signals() { if let Err(e) = check_signals() {
match e.kind() { match e.kind() {
ShErrKind::ClearReadline => { ShErrKind::Interrupt => {
// We got Ctrl+C - clear current input and redraw // We got Ctrl+C - clear current input and redraw
readline.reset_active_widget(false)?; readline.reset_active_widget(false)?;
} }
@@ -244,9 +287,16 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
readline.prompt_mut().refresh(); readline.prompt_mut().refresh();
} }
if GOT_SIGUSR1.swap(false, Ordering::SeqCst) {
log::info!("SIGUSR1 received: refreshing readline state");
readline.mark_dirty();
readline.prompt_mut().refresh();
}
readline.print_line(false)?; readline.print_line(false)?;
// Poll for stdin input // Poll for
// stdin input
let mut fds = [PollFd::new( let mut fds = [PollFd::new(
unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) }, unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) },
PollFlags::POLLIN, PollFlags::POLLIN,
@@ -394,6 +444,10 @@ fn handle_readline_event(readline: &mut ShedVi, event: ShResult<ReadlineEvent>)
}) { }) {
// CleanExit signals an intentional shell exit; any other error is printed. // CleanExit signals an intentional shell exit; any other error is printed.
match e.kind() { match e.kind() {
ShErrKind::Interrupt => {
// We got Ctrl+C during command execution
// Just fall through here
}
ShErrKind::CleanExit(code) => { ShErrKind::CleanExit(code) => {
QUIT_CODE.store(*code, Ordering::SeqCst); QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(true); return Ok(true);

View File

@@ -18,12 +18,14 @@ use crate::{
eval, exec, eval, exec,
flowctl::flowctl, flowctl::flowctl,
getopts::getopts, getopts::getopts,
help::help,
intro, intro,
jobctl::{self, JobBehavior, continue_job, disown, jobs}, jobctl::{self, JobBehavior, continue_job, disown, jobs},
keymap, map, keymap, map,
pwd::pwd, pwd::pwd,
read::{self, read_builtin}, read::{self, read_builtin},
resource::{ulimit, umask_builtin}, resource::{ulimit, umask_builtin},
seek::seek,
shift::shift, shift::shift,
shopt::shopt, shopt::shopt,
source::source, source::source,
@@ -40,6 +42,7 @@ use crate::{
}, },
prelude::*, prelude::*,
procio::{IoMode, IoStack, PipeGenerator}, procio::{IoMode, IoStack, PipeGenerator},
signal::{check_signals, signals_pending},
state::{ state::{
self, ShFunc, VarFlags, VarKind, read_logic, read_shopts, write_jobs, write_logic, write_vars, self, ShFunc, VarFlags, VarKind, read_logic, read_shopts, write_jobs, write_logic, write_vars,
}, },
@@ -271,6 +274,13 @@ impl Dispatcher {
Ok(()) Ok(())
} }
pub fn dispatch_node(&mut self, node: Node) -> ShResult<()> { pub fn dispatch_node(&mut self, node: Node) -> ShResult<()> {
while signals_pending() {
// If we have received SIGINT,
// this will stop the execution here
// and propagate back to the functions in main.rs
check_signals()?;
}
match node.class { match node.class {
NdRule::Conjunction { .. } => self.exec_conjunction(node)?, NdRule::Conjunction { .. } => self.exec_conjunction(node)?,
NdRule::Pipeline { .. } => self.exec_pipeline(node)?, NdRule::Pipeline { .. } => self.exec_pipeline(node)?,
@@ -340,24 +350,19 @@ impl Dispatcher {
}; };
let mut elem_iter = elements.into_iter(); let mut elem_iter = elements.into_iter();
let mut skip = false;
while let Some(element) = elem_iter.next() { while let Some(element) = elem_iter.next() {
let ConjunctNode { cmd, operator } = element; let ConjunctNode { cmd, operator } = element;
self.dispatch_node(*cmd)?; if !skip {
self.dispatch_node(*cmd)?;
}
let status = state::get_status(); let status = state::get_status();
match operator { skip = match operator {
ConjunctOp::And => { ConjunctOp::And => status != 0,
if status != 0 { ConjunctOp::Or => status == 0,
break;
}
}
ConjunctOp::Or => {
if status == 0 {
break;
}
}
ConjunctOp::Null => break, ConjunctOp::Null => break,
} };
} }
Ok(()) Ok(())
} }
@@ -377,7 +382,11 @@ impl Dispatcher {
}; };
let body_span = body.get_span(); let body_span = body.get_span();
let body = body_span.as_str().to_string(); let body = body_span.as_str().to_string();
let name = name.span.as_str().strip_suffix("()").unwrap(); let name = name
.span
.as_str()
.strip_suffix("()")
.unwrap_or(name.span.as_str());
if KEYWORDS.contains(&name) { if KEYWORDS.contains(&name) {
return Err(ShErr::at( return Err(ShErr::at(
@@ -888,7 +897,10 @@ impl Dispatcher {
if fork_builtins { if fork_builtins {
log::trace!("Forking builtin: {}", cmd_raw); log::trace!("Forking builtin: {}", cmd_raw);
let _guard = self.io_stack.pop_frame().redirect()?; let guard = self.io_stack.pop_frame().redirect()?;
if cmd_raw.as_str() == "exec" {
guard.persist();
}
self.run_fork(&cmd_raw, |s| { self.run_fork(&cmd_raw, |s| {
if let Err(e) = s.dispatch_builtin(cmd) { if let Err(e) = s.dispatch_builtin(cmd) {
e.print_error(); e.print_error();
@@ -1013,6 +1025,8 @@ impl Dispatcher {
"autocmd" => autocmd(cmd), "autocmd" => autocmd(cmd),
"ulimit" => ulimit(cmd), "ulimit" => ulimit(cmd),
"umask" => umask_builtin(cmd), "umask" => umask_builtin(cmd),
"seek" => seek(cmd),
"help" => help(cmd),
"true" | ":" => { "true" | ":" => {
state::set_status(0); state::set_status(0);
Ok(()) Ok(())

View File

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

View File

@@ -13,6 +13,7 @@ use crate::{
parse::lex::clean_input, parse::lex::clean_input,
prelude::*, prelude::*,
procio::IoMode, procio::IoMode,
state::read_shopts,
}; };
pub mod execute; pub mod execute;
@@ -87,7 +88,6 @@ impl ParsedSrc {
Err(error) => return Err(vec![error]), Err(error) => return Err(vec![error]),
} }
} }
log::trace!("Tokens: {:#?}", tokens);
let mut errors = vec![]; let mut errors = vec![];
let mut nodes = vec![]; let mut nodes = vec![];
@@ -280,11 +280,20 @@ bitflags! {
pub struct Redir { pub struct Redir {
pub io_mode: IoMode, pub io_mode: IoMode,
pub class: RedirType, pub class: RedirType,
pub span: Option<Span>,
} }
impl Redir { impl Redir {
pub fn new(io_mode: IoMode, class: RedirType) -> Self { pub fn new(io_mode: IoMode, class: RedirType) -> Self {
Self { io_mode, class } Self {
io_mode,
class,
span: None,
}
}
pub fn with_span(mut self, span: Span) -> Self {
self.span = Some(span);
self
} }
} }
@@ -293,6 +302,7 @@ pub struct RedirBldr {
pub io_mode: Option<IoMode>, pub io_mode: Option<IoMode>,
pub class: Option<RedirType>, pub class: Option<RedirType>,
pub tgt_fd: Option<RawFd>, pub tgt_fd: Option<RawFd>,
pub span: Option<Span>,
} }
impl RedirBldr { impl RedirBldr {
@@ -300,48 +310,41 @@ impl RedirBldr {
Default::default() Default::default()
} }
pub fn with_io_mode(self, io_mode: IoMode) -> Self { pub fn with_io_mode(self, io_mode: IoMode) -> Self {
let Self {
io_mode: _,
class,
tgt_fd,
} = self;
Self { Self {
io_mode: Some(io_mode), io_mode: Some(io_mode),
class, ..self
tgt_fd,
} }
} }
pub fn with_class(self, class: RedirType) -> Self { pub fn with_class(self, class: RedirType) -> Self {
let Self {
io_mode,
class: _,
tgt_fd,
} = self;
Self { Self {
io_mode,
class: Some(class), class: Some(class),
tgt_fd, ..self
} }
} }
pub fn with_tgt(self, tgt_fd: RawFd) -> Self { pub fn with_tgt(self, tgt_fd: RawFd) -> Self {
let Self {
io_mode,
class,
tgt_fd: _,
} = self;
Self { Self {
io_mode,
class,
tgt_fd: Some(tgt_fd), tgt_fd: Some(tgt_fd),
..self
}
}
pub fn with_span(self, span: Span) -> Self {
Self {
span: Some(span),
..self
} }
} }
pub fn build(self) -> Redir { pub fn build(self) -> Redir {
Redir::new(self.io_mode.unwrap(), self.class.unwrap()) let new = Redir::new(self.io_mode.unwrap(), self.class.unwrap());
if let Some(span) = self.span {
new.with_span(span)
} else {
new
}
} }
} }
impl FromStr for RedirBldr { impl FromStr for RedirBldr {
type Err = (); type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut chars = s.chars().peekable(); let mut chars = s.chars().peekable();
let mut src_fd = String::new(); let mut src_fd = String::new();
@@ -355,15 +358,23 @@ impl FromStr for RedirBldr {
if let Some('>') = chars.peek() { if let Some('>') = chars.peek() {
chars.next(); chars.next();
redir = redir.with_class(RedirType::Append); redir = redir.with_class(RedirType::Append);
} else if let Some('|') = chars.peek() {
chars.next();
redir = redir.with_class(RedirType::OutputForce);
} }
} }
'<' => { '<' => {
redir = redir.with_class(RedirType::Input); redir = redir.with_class(RedirType::Input);
let mut count = 0; let mut count = 0;
while count < 2 && matches!(chars.peek(), Some('<')) { if chars.peek() == Some(&'>') {
chars.next(); chars.next(); // consume the '>'
count += 1; redir = redir.with_class(RedirType::ReadWrite);
} else {
while count < 2 && matches!(chars.peek(), Some('<')) {
chars.next();
count += 1;
}
} }
redir = match count { redir = match count {
@@ -373,15 +384,23 @@ impl FromStr for RedirBldr {
}; };
} }
'&' => { '&' => {
while let Some(next_ch) = chars.next() { if chars.peek() == Some(&'-') {
if next_ch.is_ascii_digit() { chars.next();
src_fd.push(next_ch) src_fd.push('-');
} else { } else {
break; while let Some(next_ch) = chars.next() {
if next_ch.is_ascii_digit() {
src_fd.push(next_ch)
} else {
break;
}
} }
} }
if src_fd.is_empty() { if src_fd.is_empty() {
return Err(()); return Err(ShErr::simple(
ShErrKind::ParseErr,
format!("Invalid character '{}' in redirection operator", ch),
));
} }
} }
_ if ch.is_ascii_digit() && tgt_fd.is_empty() => { _ if ch.is_ascii_digit() && tgt_fd.is_empty() => {
@@ -395,19 +414,26 @@ impl FromStr for RedirBldr {
} }
} }
} }
_ => return Err(()), _ => {
return Err(ShErr::simple(
ShErrKind::ParseErr,
format!("Invalid character '{}' in redirection operator", ch),
));
}
} }
} }
// FIXME: I am 99.999999999% sure that tgt_fd and src_fd are backwards here
let tgt_fd = tgt_fd let tgt_fd = tgt_fd
.parse::<i32>() .parse::<i32>()
.unwrap_or_else(|_| match redir.class.unwrap() { .unwrap_or_else(|_| match redir.class.unwrap() {
RedirType::Input | RedirType::HereDoc | RedirType::HereString => 0, RedirType::Input | RedirType::ReadWrite | RedirType::HereDoc | RedirType::HereString => 0,
_ => 1, _ => 1,
}); });
redir = redir.with_tgt(tgt_fd); redir = redir.with_tgt(tgt_fd);
if let Ok(src_fd) = src_fd.parse::<i32>() { if src_fd.as_str() == "-" {
let io_mode = IoMode::Close { tgt_fd };
redir = redir.with_io_mode(io_mode);
} else if let Ok(src_fd) = src_fd.parse::<i32>() {
let io_mode = IoMode::fd(tgt_fd, src_fd); let io_mode = IoMode::fd(tgt_fd, src_fd);
redir = redir.with_io_mode(io_mode); redir = redir.with_io_mode(io_mode);
} }
@@ -415,16 +441,41 @@ impl FromStr for RedirBldr {
} }
} }
impl TryFrom<Tk> for RedirBldr {
type Error = ShErr;
fn try_from(tk: Tk) -> Result<Self, Self::Error> {
let span = tk.span.clone();
if tk.flags.contains(TkFlags::IS_HEREDOC) {
let flags = tk.flags;
Ok(RedirBldr {
io_mode: Some(IoMode::buffer(0, tk.to_string(), flags)?),
class: Some(RedirType::HereDoc),
tgt_fd: Some(0),
span: Some(span),
})
} else {
match Self::from_str(tk.as_str()) {
Ok(bldr) => Ok(bldr.with_span(span)),
Err(e) => Err(e.promote(span)),
}
}
}
}
#[derive(PartialEq, Clone, Copy, Debug)] #[derive(PartialEq, Clone, Copy, Debug)]
pub enum RedirType { pub enum RedirType {
Null, // Default Null, // Default
Pipe, // | Pipe, // |
PipeAnd, // |&, redirs stderr and stdout PipeAnd, // |&, redirs stderr and stdout
Input, // < Input, // <
Output, // > Output, // >
Append, // >> OutputForce, // >|
HereDoc, // << Append, // >>
HereString, // <<< HereDoc, // <<
IndentHereDoc, // <<-, strips leading tabs
HereString, // <<<
ReadWrite, // <>, fd is opened for reading and writing
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -837,13 +888,28 @@ impl ParseStream {
let mut node_tks: Vec<Tk> = vec![]; let mut node_tks: Vec<Tk> = vec![];
let body; let body;
if !is_func_name(self.peek_tk()) { // Two forms: "name()" as one token, or "name" followed by "()" as separate tokens
let spaced_form = !is_func_name(self.peek_tk())
&& self
.peek_tk()
.is_some_and(|tk| tk.flags.contains(TkFlags::IS_CMD))
&& is_func_parens(self.tokens.get(1));
if !is_func_name(self.peek_tk()) && !spaced_form {
return Ok(None); return Ok(None);
} }
let name_tk = self.next_tk().unwrap(); let name_tk = self.next_tk().unwrap();
node_tks.push(name_tk.clone()); node_tks.push(name_tk.clone());
let name = name_tk.clone(); let name = name_tk.clone();
let name_raw = name.to_string(); let name_raw = if spaced_form {
// Consume the "()" token
let parens_tk = self.next_tk().unwrap();
node_tks.push(parens_tk);
name.to_string()
} else {
name.to_string()
};
let mut src = name_tk.span.span_source().clone(); let mut src = name_tk.span.span_source().clone();
src.rename(name_raw.clone()); src.rename(name_raw.clone());
let color = next_color(); let color = next_color();
@@ -971,7 +1037,6 @@ impl ParseStream {
Ok(Some(node)) Ok(Some(node))
} }
fn parse_brc_grp(&mut self, from_func_def: bool) -> ShResult<Option<Node>> { fn parse_brc_grp(&mut self, from_func_def: bool) -> ShResult<Option<Node>> {
log::debug!("Trying to parse a brace group");
let mut node_tks: Vec<Tk> = vec![]; let mut node_tks: Vec<Tk> = vec![];
let mut body: Vec<Node> = vec![]; let mut body: Vec<Node> = vec![];
let mut redirs: Vec<Redir> = vec![]; let mut redirs: Vec<Redir> = vec![];
@@ -984,7 +1049,6 @@ impl ParseStream {
self.catch_separator(&mut node_tks); self.catch_separator(&mut node_tks);
loop { loop {
log::debug!("Parsing a brace group body");
if *self.next_tk_class() == TkRule::BraceGrpEnd { if *self.next_tk_class() == TkRule::BraceGrpEnd {
node_tks.push(self.next_tk().unwrap()); node_tks.push(self.next_tk().unwrap());
break; break;
@@ -993,25 +1057,24 @@ impl ParseStream {
node_tks.extend(node.tokens.clone()); node_tks.extend(node.tokens.clone());
body.push(node); body.push(node);
} else if *self.next_tk_class() != TkRule::BraceGrpEnd { } else if *self.next_tk_class() != TkRule::BraceGrpEnd {
let next = self.peek_tk().cloned(); let next = self.peek_tk().cloned();
let err = match next { let err = match next {
Some(tk) => Err(parse_err_full( Some(tk) => Err(parse_err_full(
&format!("Unexpected token '{}' in brace group body", tk.as_str()), &format!("Unexpected token '{}' in brace group body", tk.as_str()),
&tk.span, &tk.span,
self.context.clone(), self.context.clone(),
)), )),
None => Err(parse_err_full( None => Err(parse_err_full(
"Unexpected end of input while parsing brace group body", "Unexpected end of input while parsing brace group body",
&node_tks.get_span().unwrap(), &node_tks.get_span().unwrap(),
self.context.clone(), self.context.clone(),
)), )),
}; };
self.panic_mode(&mut node_tks); self.panic_mode(&mut node_tks);
return err; return err;
} }
self.catch_separator(&mut node_tks); self.catch_separator(&mut node_tks);
if !self.next_tk_is_some() { if !self.next_tk_is_some() {
log::debug!("Hit end of input while parsing a brace group body, entering panic mode");
self.panic_mode(&mut node_tks); self.panic_mode(&mut node_tks);
return Err(parse_err_full( return Err(parse_err_full(
"Expected a closing brace for this brace group", "Expected a closing brace for this brace group",
@@ -1021,14 +1084,10 @@ impl ParseStream {
} }
} }
log::debug!("Finished parsing brace group body, now looking for redirections if it's not a function definition");
if !from_func_def { if !from_func_def {
self.parse_redir(&mut redirs, &mut node_tks)?; self.parse_redir(&mut redirs, &mut node_tks)?;
} }
log::debug!("Finished parsing brace group redirections, constructing node");
let node = Node { let node = Node {
class: NdRule::BraceGrp { body }, class: NdRule::BraceGrp { body },
flags: NdFlags::empty(), flags: NdFlags::empty(),
@@ -1038,36 +1097,65 @@ impl ParseStream {
}; };
Ok(Some(node)) Ok(Some(node))
} }
fn build_redir<F: FnMut() -> Option<Tk>>(
redir_tk: &Tk,
mut next: F,
node_tks: &mut Vec<Tk>,
context: LabelCtx,
) -> ShResult<Redir> {
let redir_bldr = RedirBldr::try_from(redir_tk.clone()).unwrap();
let next_tk = if redir_bldr.io_mode.is_none() {
next()
} else {
None
};
if redir_bldr.io_mode.is_some() {
return Ok(redir_bldr.build());
}
let Some(redir_type) = redir_bldr.class else {
return Err(parse_err_full(
"Malformed redirection operator",
&redir_tk.span,
context.clone(),
));
};
match redir_type {
RedirType::HereString => {
if next_tk.as_ref().is_none_or(|tk| tk.class == TkRule::EOI) {
return Err(ShErr::at(
ShErrKind::ParseErr,
next_tk.unwrap_or(redir_tk.clone()).span.clone(),
"Expected a string after this redirection",
));
}
let mut string = next_tk.unwrap().expand()?.get_words().join(" ");
string.push('\n');
let io_mode = IoMode::buffer(redir_bldr.tgt_fd.unwrap_or(0), string, redir_tk.flags)?;
Ok(redir_bldr.with_io_mode(io_mode).build())
}
_ => {
if next_tk.as_ref().is_none_or(|tk| tk.class == TkRule::EOI) {
return Err(ShErr::at(
ShErrKind::ParseErr,
redir_tk.span.clone(),
"Expected a filename after this redirection",
));
}
let path_tk = next_tk.unwrap();
node_tks.push(path_tk.clone());
let pathbuf = PathBuf::from(path_tk.span.as_str());
let io_mode = IoMode::file(redir_bldr.tgt_fd.unwrap(), pathbuf, redir_type);
Ok(redir_bldr.with_io_mode(io_mode).build())
}
}
}
fn parse_redir(&mut self, redirs: &mut Vec<Redir>, node_tks: &mut Vec<Tk>) -> ShResult<()> { fn parse_redir(&mut self, redirs: &mut Vec<Redir>, node_tks: &mut Vec<Tk>) -> ShResult<()> {
while self.check_redir() { while self.check_redir() {
let tk = self.next_tk().unwrap(); let tk = self.next_tk().unwrap();
node_tks.push(tk.clone()); node_tks.push(tk.clone());
let redir_bldr = tk.span.as_str().parse::<RedirBldr>().unwrap(); let ctx = self.context.clone();
if redir_bldr.io_mode.is_none() { let redir = Self::build_redir(&tk, || self.next_tk(), node_tks, ctx)?;
let path_tk = self.next_tk(); redirs.push(redir);
if path_tk.clone().is_none_or(|tk| tk.class == TkRule::EOI) {
return Err(ShErr::at(
ShErrKind::ParseErr,
tk.span.clone(),
"Expected a filename after this redirection",
));
};
let path_tk = path_tk.unwrap();
node_tks.push(path_tk.clone());
let redir_class = redir_bldr.class.unwrap();
let pathbuf = PathBuf::from(path_tk.span.as_str());
let io_mode = IoMode::file(redir_bldr.tgt_fd.unwrap(), pathbuf, redir_class);
let redir_bldr = redir_bldr.with_io_mode(io_mode);
let redir = redir_bldr.build();
redirs.push(redir);
} else {
// io_mode is already set (e.g., for fd redirections like 2>&1)
let redir = redir_bldr.build();
redirs.push(redir);
}
} }
Ok(()) Ok(())
} }
@@ -1573,7 +1661,7 @@ impl ParseStream {
node_tks.push(prefix_tk.clone()); node_tks.push(prefix_tk.clone());
assignments.push(assign) assignments.push(assign)
} else if is_keyword { } else if is_keyword {
return Ok(None) return Ok(None);
} else if prefix_tk.class == TkRule::Sep { } else if prefix_tk.class == TkRule::Sep {
// Separator ends the prefix section - add it so commit() consumes it // Separator ends the prefix section - add it so commit() consumes it
node_tks.push(prefix_tk.clone()); node_tks.push(prefix_tk.clone());
@@ -1631,33 +1719,9 @@ impl ParseStream {
} }
TkRule::Redir => { TkRule::Redir => {
node_tks.push(tk.clone()); node_tks.push(tk.clone());
let redir_bldr = tk.span.as_str().parse::<RedirBldr>().unwrap(); let ctx = self.context.clone();
if redir_bldr.io_mode.is_none() { let redir = Self::build_redir(tk, || tk_iter.next().cloned(), &mut node_tks, ctx)?;
let path_tk = tk_iter.next(); redirs.push(redir);
if path_tk.is_none_or(|tk| tk.class == TkRule::EOI) {
self.panic_mode(&mut node_tks);
return Err(ShErr::at(
ShErrKind::ParseErr,
tk.span.clone(),
"Expected a filename after this redirection",
));
};
let path_tk = path_tk.unwrap();
node_tks.push(path_tk.clone());
let redir_class = redir_bldr.class.unwrap();
let pathbuf = PathBuf::from(path_tk.span.as_str());
let io_mode = IoMode::file(redir_bldr.tgt_fd.unwrap(), pathbuf, redir_class);
let redir_bldr = redir_bldr.with_io_mode(io_mode);
let redir = redir_bldr.build();
redirs.push(redir);
} else {
// io_mode is already set (e.g., for fd redirections like 2>&1)
let redir = redir_bldr.build();
redirs.push(redir);
}
} }
_ => unimplemented!("Unexpected token rule `{:?}` in parse_cmd()", tk.class), _ => unimplemented!("Unexpected token rule `{:?}` in parse_cmd()", tk.class),
} }
@@ -1816,13 +1880,35 @@ pub fn get_redir_file<P: AsRef<Path>>(class: RedirType, path: P) -> ShResult<Fil
let path = path.as_ref(); let path = path.as_ref();
let result = match class { let result = match class {
RedirType::Input => OpenOptions::new().read(true).open(Path::new(&path)), RedirType::Input => OpenOptions::new().read(true).open(Path::new(&path)),
RedirType::Output => OpenOptions::new() RedirType::Output => {
if read_shopts(|o| o.core.noclobber) && path.is_file() {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!(
"shopt core.noclobber is set, refusing to overwrite existing file `{}`",
path.display()
),
));
}
OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
}
RedirType::ReadWrite => OpenOptions::new()
.write(true)
.read(true)
.create(true)
.truncate(false)
.open(path),
RedirType::OutputForce => OpenOptions::new()
.write(true) .write(true)
.create(true) .create(true)
.truncate(true) .truncate(true)
.open(path), .open(path),
RedirType::Append => OpenOptions::new().create(true).append(true).open(path), RedirType::Append => OpenOptions::new().create(true).append(true).open(path),
_ => unimplemented!(), _ => unimplemented!("Unimplemented redir type: {:?}", class),
}; };
Ok(result?) Ok(result?)
} }
@@ -1846,6 +1932,10 @@ fn is_func_name(tk: Option<&Tk>) -> bool {
}) })
} }
fn is_func_parens(tk: Option<&Tk>) -> bool {
tk.is_some_and(|tk| tk.flags.contains(TkFlags::KEYWORD) && tk.span.as_str() == "()")
}
/// Perform an operation on the child nodes of a given node /// Perform an operation on the child nodes of a given node
/// ///
/// # Parameters /// # Parameters
@@ -2594,4 +2684,247 @@ pub mod tests {
let input = "{ echo bar case foo in bar) echo fizz ;; buzz) echo buzz ;; esac }"; let input = "{ echo bar case foo in bar) echo fizz ;; buzz) echo buzz ;; esac }";
assert!(get_ast(input).is_err()); assert!(get_ast(input).is_err());
} }
// ===================== Heredocs =====================
#[test]
fn parse_basic_heredoc() {
let input = "cat <<EOF\nhello world\nEOF";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_heredoc_with_tab_strip() {
let input = "cat <<-EOF\n\t\thello\n\t\tworld\nEOF";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_literal_heredoc() {
let input = "cat <<'EOF'\nhello $world\nEOF";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_herestring() {
let input = "cat <<< \"hello world\"";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_heredoc_in_pipeline() {
let input = "cat <<EOF | grep hello\nhello world\ngoodbye world\nEOF";
let expected = &mut [
NdKind::Conjunction,
NdKind::Pipeline,
NdKind::Command,
NdKind::Command,
]
.into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_heredoc_in_conjunction() {
let input = "cat <<EOF && echo done\nhello\nEOF";
let expected = &mut [
NdKind::Conjunction,
NdKind::Pipeline,
NdKind::Command,
NdKind::Pipeline,
NdKind::Command,
]
.into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_heredoc_double_quoted_delimiter() {
let input = "cat <<\"EOF\"\nhello $world\nEOF";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_heredoc_empty_body() {
let input = "cat <<EOF\nEOF";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_heredoc_multiword_delimiter() {
// delimiter should only be the first word
let input = "cat <<DELIM\nsome content\nDELIM";
let expected = &mut [NdKind::Conjunction, NdKind::Pipeline, NdKind::Command].into_iter();
let ast = get_ast(input).unwrap();
let mut node = ast[0].clone();
if let Err(e) = node.assert_structure(expected) {
panic!("{}", e);
}
}
#[test]
fn parse_two_heredocs_on_one_line() {
let input = "cat <<A; cat <<B\nfoo\nA\nbar\nB";
let ast = get_ast(input).unwrap();
assert_eq!(ast.len(), 2);
}
// ===================== Heredoc Execution =====================
use crate::state::{VarFlags, VarKind, write_vars};
use crate::testutil::{TestGuard, test_input};
#[test]
fn heredoc_basic_output() {
let guard = TestGuard::new();
test_input("cat <<EOF\nhello world\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
#[test]
fn heredoc_multiline_output() {
let guard = TestGuard::new();
test_input("cat <<EOF\nline one\nline two\nline three\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "line one\nline two\nline three\n");
}
#[test]
fn heredoc_variable_expansion() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("NAME", VarKind::Str("world".into()), VarFlags::NONE)).unwrap();
test_input("cat <<EOF\nhello $NAME\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
#[test]
fn heredoc_literal_no_expansion() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("NAME", VarKind::Str("world".into()), VarFlags::NONE)).unwrap();
test_input("cat <<'EOF'\nhello $NAME\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello $NAME\n");
}
#[test]
fn heredoc_tab_stripping() {
let guard = TestGuard::new();
test_input("cat <<-EOF\n\t\thello\n\t\tworld\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\nworld\n");
}
#[test]
fn heredoc_tab_stripping_uneven() {
let guard = TestGuard::new();
test_input("cat <<-EOF\n\t\t\thello\n\tworld\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "\t\thello\nworld\n");
}
#[test]
fn heredoc_empty_body() {
let guard = TestGuard::new();
test_input("cat <<EOF\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "");
}
#[test]
fn heredoc_in_pipeline() {
let guard = TestGuard::new();
test_input("cat <<EOF | grep hello\nhello world\ngoodbye world\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
#[test]
fn herestring_basic() {
let guard = TestGuard::new();
test_input("cat <<< \"hello world\"".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
#[test]
fn herestring_variable_expansion() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("MSG", VarKind::Str("hi there".into()), VarFlags::NONE)).unwrap();
test_input("cat <<< $MSG".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hi there\n");
}
#[test]
fn heredoc_double_quoted_delimiter_is_literal() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("X", VarKind::Str("val".into()), VarFlags::NONE)).unwrap();
test_input("cat <<\"EOF\"\nhello $X\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello $X\n");
}
#[test]
fn heredoc_preserves_blank_lines() {
let guard = TestGuard::new();
test_input("cat <<EOF\nfirst\n\nsecond\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "first\n\nsecond\n");
}
#[test]
fn heredoc_tab_strip_preserves_empty_lines() {
let guard = TestGuard::new();
test_input("cat <<-EOF\n\thello\n\n\tworld\nEOF".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "hello\n\nworld\n");
}
#[test]
fn heredoc_two_on_one_line() {
let guard = TestGuard::new();
test_input("cat <<A; cat <<B\nfoo\nA\nbar\nB".to_string()).unwrap();
let out = guard.read_output();
assert_eq!(out, "foo\nbar\n");
}
} }

View File

@@ -19,7 +19,7 @@ pub use std::os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd,
pub use bitflags::bitflags; pub use bitflags::bitflags;
pub use nix::{ pub use nix::{
errno::Errno, errno::Errno,
fcntl::{OFlag, open}, fcntl::{FcntlArg, OFlag, fcntl, open},
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO}, libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
sys::{ sys::{
signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal}, signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal},

View File

@@ -8,15 +8,27 @@ use crate::{
expand::Expander, expand::Expander,
libsh::{ libsh::{
error::{ShErr, ShErrKind, ShResult}, error::{ShErr, ShErrKind, ShResult},
sys::TTY_FILENO,
utils::RedirVecUtils, utils::RedirVecUtils,
}, },
parse::{Redir, RedirType, get_redir_file}, parse::{Redir, RedirType, get_redir_file, lex::TkFlags},
prelude::*, prelude::*,
state,
}; };
// Credit to fish-shell for many of the implementation ideas present in this // Credit to fish-shell for many of the implementation ideas present in this
// module https://fishshell.com/ // module https://fishshell.com/
/// Minimum fd number for shell-internal file descriptors.
/// User-visible fds (0-9) are kept clear so `exec 3>&-` etc. work as expected.
const MIN_INTERNAL_FD: RawFd = 10;
/// Like `dup()`, but places the new fd at `MIN_INTERNAL_FD` or above so it
/// doesn't collide with user-managed fds.
fn dup_high(fd: RawFd) -> nix::Result<RawFd> {
fcntl(fd, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD))
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum IoMode { pub enum IoMode {
Fd { Fd {
@@ -37,8 +49,9 @@ pub enum IoMode {
pipe: Arc<OwnedFd>, pipe: Arc<OwnedFd>,
}, },
Buffer { Buffer {
tgt_fd: RawFd,
buf: String, buf: String,
pipe: Arc<OwnedFd>, flags: TkFlags, // so we can see if its a heredoc or not
}, },
Close { Close {
tgt_fd: RawFd, tgt_fd: RawFd,
@@ -79,19 +92,37 @@ impl IoMode {
if let IoMode::File { tgt_fd, path, mode } = self { if let IoMode::File { tgt_fd, path, mode } = self {
let path_raw = path.as_os_str().to_str().unwrap_or_default().to_string(); let path_raw = path.as_os_str().to_str().unwrap_or_default().to_string();
let expanded_path = Expander::from_raw(&path_raw)?.expand()?.join(" "); // should just be one string, will have to find some way to handle a return of let expanded_path = Expander::from_raw(&path_raw, TkFlags::empty())?
// multiple .expand()?
.join(" "); // should just be one string, will have to find some way to handle a return of multiple paths
let expanded_pathbuf = PathBuf::from(expanded_path); let expanded_pathbuf = PathBuf::from(expanded_path);
let file = get_redir_file(mode, expanded_pathbuf)?; let file = get_redir_file(mode, expanded_pathbuf)?;
// Move the opened fd above the user-accessible range so it never
// collides with the target fd (e.g. `3>/tmp/foo` where open() returns 3,
// causing dup2(3,3) to be a no-op and then OwnedFd drop closes it).
let raw = file.as_raw_fd();
let high = fcntl(raw, FcntlArg::F_DUPFD_CLOEXEC(MIN_INTERNAL_FD)).map_err(ShErr::from)?;
drop(file); // closes the original low fd
self = IoMode::OpenedFile { self = IoMode::OpenedFile {
tgt_fd, tgt_fd,
file: Arc::new(OwnedFd::from(file)), file: Arc::new(unsafe { OwnedFd::from_raw_fd(high) }),
} }
} }
Ok(self) Ok(self)
} }
pub fn buffer(tgt_fd: RawFd, buf: String, flags: TkFlags) -> ShResult<Self> {
Ok(Self::Buffer { tgt_fd, buf, flags })
}
pub fn loaded_pipe(tgt_fd: RawFd, buf: &[u8]) -> ShResult<Self> {
let (rpipe, wpipe) = nix::unistd::pipe()?;
write(wpipe, buf)?;
Ok(Self::Pipe {
tgt_fd,
pipe: rpipe.into(),
})
}
pub fn get_pipes() -> (Self, Self) { pub fn get_pipes() -> (Self, Self) {
let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap(); let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap();
( (
@@ -206,24 +237,107 @@ impl<'e> IoFrame {
) )
} }
pub fn save(&'e mut self) { pub fn save(&'e mut self) {
let saved_in = dup(STDIN_FILENO).unwrap(); let saved_in = dup_high(STDIN_FILENO).unwrap();
let saved_out = dup(STDOUT_FILENO).unwrap(); let saved_out = dup_high(STDOUT_FILENO).unwrap();
let saved_err = dup(STDERR_FILENO).unwrap(); let saved_err = dup_high(STDERR_FILENO).unwrap();
self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err)); self.saved_io = Some(IoGroup(saved_in, saved_out, saved_err));
} }
pub fn redirect(mut self) -> ShResult<RedirGuard> { pub fn redirect(mut self) -> ShResult<RedirGuard> {
self.save(); self.save();
for redir in &mut self.redirs { if let Err(e) = self.apply_redirs() {
let io_mode = &mut redir.io_mode; // Restore saved fds before propagating the error so they don't leak.
if let IoMode::File { .. } = io_mode { self.restore().ok();
*io_mode = io_mode.clone().open_file()?; return Err(e);
};
let tgt_fd = io_mode.tgt_fd();
let src_fd = io_mode.src_fd();
dup2(src_fd, tgt_fd)?;
} }
Ok(RedirGuard::new(self)) Ok(RedirGuard::new(self))
} }
fn apply_redirs(&mut self) -> ShResult<()> {
for redir in &mut self.redirs {
let io_mode = &mut redir.io_mode;
match io_mode {
IoMode::Close { tgt_fd } => {
if *tgt_fd == *TTY_FILENO {
// Don't let user close the shell's tty fd.
continue;
}
close(*tgt_fd).ok();
continue;
}
IoMode::File { .. } => match io_mode.clone().open_file() {
Ok(file) => *io_mode = file,
Err(e) => {
if let Some(span) = redir.span.as_ref() {
return Err(e.promote(span.clone()));
}
return Err(e);
}
},
IoMode::Buffer { tgt_fd, buf, flags } => {
let (rpipe, wpipe) = nix::unistd::pipe()?;
let mut text = if flags.contains(TkFlags::LIT_HEREDOC) {
buf.clone()
} else {
let words = Expander::from_raw(buf, *flags)?.expand()?;
if flags.contains(TkFlags::IS_HEREDOC) {
words.into_iter().next().unwrap_or_default()
} else {
let ifs = state::get_separator();
words.join(&ifs).trim().to_string() + "\n"
}
};
if flags.contains(TkFlags::TAB_HEREDOC) {
let lines = text.lines();
let mut min_tabs = usize::MAX;
for line in lines {
if line.is_empty() {
continue;
}
let line_len = line.len();
let after_strip = line.trim_start_matches('\t').len();
let delta = line_len - after_strip;
min_tabs = min_tabs.min(delta);
}
if min_tabs == usize::MAX {
// let's avoid possibly allocating a string with 18 quintillion tabs
min_tabs = 0;
}
if min_tabs > 0 {
let stripped = text
.lines()
.fold(vec![], |mut acc, ln| {
if ln.is_empty() {
acc.push("");
return acc;
}
let stripped_ln = ln.strip_prefix(&"\t".repeat(min_tabs)).unwrap();
acc.push(stripped_ln);
acc
})
.join("\n");
text = stripped + "\n";
}
}
write(wpipe, text.as_bytes())?;
*io_mode = IoMode::Pipe {
tgt_fd: *tgt_fd,
pipe: rpipe.into(),
};
}
_ => {}
}
let tgt_fd = io_mode.tgt_fd();
let src_fd = io_mode.src_fd();
if let Err(e) = dup2(src_fd, tgt_fd) {
if let Some(span) = redir.span.as_ref() {
return Err(ShErr::from(e).promote(span.clone()));
} else {
return Err(e.into());
}
}
}
Ok(())
}
pub fn restore(&mut self) -> ShResult<()> { pub fn restore(&mut self) -> ShResult<()> {
if let Some(saved) = self.saved_io.take() { if let Some(saved) = self.saved_io.take() {
dup2(saved.0, STDIN_FILENO)?; dup2(saved.0, STDIN_FILENO)?;
@@ -334,6 +448,8 @@ pub fn borrow_fd<'f>(fd: i32) -> BorrowedFd<'f> {
} }
type PipeFrames = Map<PipeGenerator, fn((Option<Redir>, Option<Redir>)) -> IoFrame>; type PipeFrames = Map<PipeGenerator, fn((Option<Redir>, Option<Redir>)) -> IoFrame>;
/// An iterator that lazily creates a specific number of pipes.
pub struct PipeGenerator { pub struct PipeGenerator {
num_cmds: usize, num_cmds: usize,
cursor: usize, cursor: usize,

View File

@@ -1,6 +1,6 @@
use std::{ use std::{
collections::HashSet, collections::HashSet,
fmt::{Debug, Write}, fmt::{Debug, Display, Write},
path::PathBuf, path::PathBuf,
sync::Arc, sync::Arc,
}; };
@@ -29,7 +29,103 @@ use crate::{
}, },
}; };
pub fn complete_signals(start: &str) -> Vec<String> { #[derive(Debug, Clone)]
pub struct Candidate(pub String);
impl Eq for Candidate {}
impl PartialEq for Candidate {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl PartialOrd for Candidate {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Candidate {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.cmp(&other.0)
}
}
impl From<String> for Candidate {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&String> for Candidate {
fn from(value: &String) -> Self {
Self(value.clone())
}
}
impl From<&str> for Candidate {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl Display for Candidate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", &self.0)
}
}
impl AsRef<str> for Candidate {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for Candidate {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl Candidate {
pub fn is_match(&self, other: &str) -> bool {
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
if ignore_case {
let other_lower = other.to_lowercase();
let self_lower = self.0.to_lowercase();
self_lower.starts_with(&other_lower)
} else {
self.0.starts_with(other)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
pub fn starts_with(&self, pat: char) -> bool {
self.0.starts_with(pat)
}
pub fn strip_prefix(&self, prefix: &str) -> Option<String> {
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
if ignore_case {
let old_len = self.0.len();
let prefix_lower = prefix.to_lowercase();
let self_lower = self.0.to_lowercase();
let stripped = self_lower.strip_prefix(&prefix_lower)?;
let new_len = stripped.len();
let delta = old_len - new_len;
Some(self.0[delta..].to_string())
} else {
self.0.strip_prefix(prefix).map(|s| s.to_string())
}
}
}
pub fn complete_signals(start: &str) -> Vec<Candidate> {
Signal::iterator() Signal::iterator()
.map(|s| { .map(|s| {
s.to_string() s.to_string()
@@ -37,29 +133,31 @@ pub fn complete_signals(start: &str) -> Vec<String> {
.unwrap_or(s.as_ref()) .unwrap_or(s.as_ref())
.to_string() .to_string()
}) })
.filter(|s| s.starts_with(start)) .map(Candidate::from)
.filter(|s| s.is_match(start))
.collect() .collect()
} }
pub fn complete_aliases(start: &str) -> Vec<String> { pub fn complete_aliases(start: &str) -> Vec<Candidate> {
read_logic(|l| { read_logic(|l| {
l.aliases() l.aliases()
.iter() .keys()
.filter(|a| a.0.starts_with(start)) .map(Candidate::from)
.map(|a| a.0.clone()) .filter(|a| a.is_match(start))
.collect() .collect()
}) })
} }
pub fn complete_jobs(start: &str) -> Vec<String> { pub fn complete_jobs(start: &str) -> Vec<Candidate> {
if let Some(prefix) = start.strip_prefix('%') { if let Some(prefix) = start.strip_prefix('%') {
read_jobs(|j| { read_jobs(|j| {
j.jobs() j.jobs()
.iter() .iter()
.filter_map(|j| j.as_ref()) .filter_map(|j| j.as_ref())
.filter_map(|j| j.name()) .filter_map(|j| j.name())
.filter(|name| name.starts_with(prefix)) .map(Candidate::from)
.map(|name| format!("%{name}")) .filter(|name| name.is_match(prefix))
.map(|name| format!("%{name}").into())
.collect() .collect()
}) })
} else { } else {
@@ -67,26 +165,26 @@ pub fn complete_jobs(start: &str) -> Vec<String> {
j.jobs() j.jobs()
.iter() .iter()
.filter_map(|j| j.as_ref()) .filter_map(|j| j.as_ref())
.map(|j| j.pgid().to_string()) .map(|j| Candidate::from(j.pgid().to_string()))
.filter(|pgid| pgid.starts_with(start)) .filter(|pgid| pgid.is_match(start))
.collect() .collect()
}) })
} }
} }
pub fn complete_users(start: &str) -> Vec<String> { pub fn complete_users(start: &str) -> Vec<Candidate> {
let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else { let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else {
return vec![]; return vec![];
}; };
passwd passwd
.lines() .lines()
.filter_map(|line| line.split(':').next()) .filter_map(|line| line.split(':').next())
.filter(|username| username.starts_with(start)) .map(Candidate::from)
.map(|s| s.to_string()) .filter(|username| username.is_match(start))
.collect() .collect()
} }
pub fn complete_vars(start: &str) -> Vec<String> { pub fn complete_vars(start: &str) -> Vec<Candidate> {
let Some((var_name, name_start, _end)) = extract_var_name(start) else { let Some((var_name, name_start, _end)) = extract_var_name(start) else {
return vec![]; return vec![];
}; };
@@ -101,11 +199,12 @@ pub fn complete_vars(start: &str) -> Vec<String> {
.keys() .keys()
.filter(|k| k.starts_with(&var_name) && *k != &var_name) .filter(|k| k.starts_with(&var_name) && *k != &var_name)
.map(|k| format!("{prefix}{k}")) .map(|k| format!("{prefix}{k}"))
.map(Candidate::from)
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
} }
pub fn complete_vars_raw(raw: &str) -> Vec<String> { pub fn complete_vars_raw(raw: &str) -> Vec<Candidate> {
if !read_vars(|v| v.get_var(raw)).is_empty() { if !read_vars(|v| v.get_var(raw)).is_empty() {
return vec![]; return vec![];
} }
@@ -115,7 +214,7 @@ pub fn complete_vars_raw(raw: &str) -> Vec<String> {
v.flatten_vars() v.flatten_vars()
.keys() .keys()
.filter(|k| k.starts_with(raw) && *k != raw) .filter(|k| k.starts_with(raw) && *k != raw)
.map(|k| k.to_string()) .map(Candidate::from)
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
} }
@@ -168,12 +267,12 @@ pub fn extract_var_name(text: &str) -> Option<(String, usize, usize)> {
Some((name, name_start, name_end)) Some((name, name_start, name_end))
} }
fn complete_commands(start: &str) -> Vec<String> { fn complete_commands(start: &str) -> Vec<Candidate> {
let mut candidates: Vec<String> = read_meta(|m| { let mut candidates: Vec<Candidate> = read_meta(|m| {
m.cached_cmds() m.cached_cmds()
.iter() .iter()
.filter(|c| c.starts_with(start)) .map(Candidate::from)
.cloned() .filter(|c| c.is_match(start))
.collect() .collect()
}); });
@@ -186,15 +285,16 @@ fn complete_commands(start: &str) -> Vec<String> {
candidates candidates
} }
fn complete_dirs(start: &str) -> Vec<String> { fn complete_dirs(start: &str) -> Vec<Candidate> {
let filenames = complete_filename(start); let filenames = complete_filename(start);
filenames filenames
.into_iter() .into_iter()
.filter(|f| std::fs::metadata(f).map(|m| m.is_dir()).unwrap_or(false)) .filter(|f| std::fs::metadata(&f.0).map(|m| m.is_dir()).unwrap_or(false))
.collect() .collect()
} }
fn complete_filename(start: &str) -> Vec<String> { fn complete_filename(start: &str) -> Vec<Candidate> {
let mut candidates = vec![]; let mut candidates = vec![];
let has_dotslash = start.starts_with("./"); let has_dotslash = start.starts_with("./");
@@ -202,18 +302,18 @@ fn complete_filename(start: &str) -> Vec<String> {
// Use "." if start is empty (e.g., after "foo=") // Use "." if start is empty (e.g., after "foo=")
let path = PathBuf::from(if start.is_empty() { "." } else { start }); let path = PathBuf::from(if start.is_empty() { "." } else { start });
let (dir, prefix) = if start.ends_with('/') || start.is_empty() { let (dir, prefix) = if start.ends_with('/') || start.is_empty() {
// Completing inside a directory: "src/" dir="src/", prefix="" // Completing inside a directory: "src/" -> dir="src/", prefix=""
(path, "") (path, "")
} else if let Some(parent) = path.parent() } else if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty() && !parent.as_os_str().is_empty()
{ {
// Has directory component: "src/ma" dir="src", prefix="ma" // Has directory component: "src/ma" -> dir="src", prefix="ma"
( (
parent.to_path_buf(), parent.to_path_buf(),
path.file_name().unwrap().to_str().unwrap_or(""), path.file_name().unwrap().to_str().unwrap_or(""),
) )
} else { } else {
// No directory: "fil" dir=".", prefix="fil" // No directory: "fil" -> dir=".", prefix="fil"
(PathBuf::from("."), start) (PathBuf::from("."), start)
}; };
@@ -223,14 +323,16 @@ fn complete_filename(start: &str) -> Vec<String> {
for entry in entries.flatten() { for entry in entries.flatten() {
let file_name = entry.file_name(); let file_name = entry.file_name();
let file_str = file_name.to_string_lossy(); let file_str: Candidate = file_name.to_string_lossy().to_string().into();
// Skip hidden files unless explicitly requested // Skip hidden files unless explicitly requested
if !prefix.starts_with('.') && file_str.starts_with('.') { if !prefix.starts_with('.') && file_str.0.starts_with('.') {
continue; continue;
} }
if file_str.starts_with(prefix) { if file_str.is_match(prefix) {
// Reconstruct full path // Reconstruct full path
let mut full_path = dir.join(&file_name); let mut full_path = dir.join(&file_name);
@@ -244,7 +346,7 @@ fn complete_filename(start: &str) -> Vec<String> {
path_raw = path_raw.trim_start_matches("./").to_string(); path_raw = path_raw.trim_start_matches("./").to_string();
} }
candidates.push(path_raw); candidates.push(path_raw.into());
} }
} }
@@ -363,7 +465,7 @@ impl BashCompSpec {
source: String::new(), source: String::new(),
} }
} }
pub fn exec_comp_func(&self, ctx: &CompContext) -> ShResult<Vec<String>> { pub fn exec_comp_func(&self, ctx: &CompContext) -> ShResult<Vec<Candidate>> {
let mut vars_to_unset = HashSet::new(); let mut vars_to_unset = HashSet::new();
for var in [ for var in [
"COMP_WORDS", "COMP_WORDS",
@@ -426,13 +528,19 @@ impl BashCompSpec {
); );
exec_input(input, None, false, Some("comp_function".into()))?; exec_input(input, None, false, Some("comp_function".into()))?;
Ok(read_vars(|v| v.get_arr_elems("COMPREPLY")).unwrap_or_default()) let comp_reply = read_vars(|v| v.get_arr_elems("COMPREPLY"))
.unwrap_or_default()
.into_iter()
.map(Candidate::from)
.collect();
Ok(comp_reply)
} }
} }
impl CompSpec for BashCompSpec { impl CompSpec for BashCompSpec {
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<String>> { fn complete(&self, ctx: &CompContext) -> ShResult<Vec<Candidate>> {
let mut candidates = vec![]; let mut candidates: Vec<Candidate> = vec![];
let prefix = &ctx.words[ctx.cword]; let prefix = &ctx.words[ctx.cword];
let expanded = prefix.clone().expand()?.get_words().join(" "); let expanded = prefix.clone().expand()?.get_words().join(" ");
@@ -461,7 +569,7 @@ impl CompSpec for BashCompSpec {
candidates.extend(complete_signals(&expanded)); candidates.extend(complete_signals(&expanded));
} }
if let Some(words) = &self.wordlist { if let Some(words) = &self.wordlist {
candidates.extend(words.iter().filter(|w| w.starts_with(&expanded)).cloned()); candidates.extend(words.iter().map(Candidate::from).filter(|w| w.is_match(&expanded)));
} }
if self.function.is_some() { if self.function.is_some() {
candidates.extend(self.exec_comp_func(ctx)?); candidates.extend(self.exec_comp_func(ctx)?);
@@ -469,12 +577,12 @@ impl CompSpec for BashCompSpec {
candidates = candidates candidates = candidates
.into_iter() .into_iter()
.map(|c| { .map(|c| {
let stripped = c.strip_prefix(&expanded).unwrap_or_default(); let stripped = c.0.strip_prefix(&expanded).unwrap_or_default();
format!("{prefix}{stripped}") format!("{prefix}{stripped}").into()
}) })
.collect(); .collect();
candidates.sort_by_key(|c| c.len()); // sort by length to prioritize shorter completions, ties are then sorted alphabetically candidates.sort_by_key(|c| c.0.len()); // sort by length to prioritize shorter completions, ties are then sorted alphabetically
Ok(candidates) Ok(candidates)
} }
@@ -489,7 +597,7 @@ impl CompSpec for BashCompSpec {
} }
pub trait CompSpec: Debug + CloneCompSpec { pub trait CompSpec: Debug + CloneCompSpec {
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<String>>; fn complete(&self, ctx: &CompContext) -> ShResult<Vec<Candidate>>;
fn source(&self) -> &str; fn source(&self) -> &str;
fn get_flags(&self) -> CompOptFlags { fn get_flags(&self) -> CompOptFlags {
CompOptFlags::empty() CompOptFlags::empty()
@@ -527,17 +635,17 @@ impl CompContext {
pub enum CompResult { pub enum CompResult {
NoMatch, NoMatch,
Single { result: String }, Single { result: Candidate },
Many { candidates: Vec<String> }, Many { candidates: Vec<Candidate> },
} }
impl CompResult { impl CompResult {
pub fn from_candidates(candidates: Vec<String>) -> Self { pub fn from_candidates(mut candidates: Vec<Candidate>) -> Self {
if candidates.is_empty() { if candidates.is_empty() {
Self::NoMatch Self::NoMatch
} else if candidates.len() == 1 { } else if candidates.len() == 1 {
Self::Single { Self::Single {
result: candidates[0].clone(), result: candidates.remove(0)
} }
} else { } else {
Self::Many { candidates } Self::Many { candidates }
@@ -568,7 +676,7 @@ pub trait Completer {
fn reset(&mut self); fn reset(&mut self);
fn reset_stay_active(&mut self); fn reset_stay_active(&mut self);
fn is_active(&self) -> bool; fn is_active(&self) -> bool;
fn all_candidates(&self) -> Vec<String> { fn all_candidates(&self) -> Vec<Candidate> {
vec![] vec![]
} }
fn selected_candidate(&self) -> Option<String>; fn selected_candidate(&self) -> Option<String>;
@@ -671,6 +779,15 @@ impl From<String> for ScoredCandidate {
} }
} }
impl From<Candidate> for ScoredCandidate {
fn from(candidate: Candidate) -> Self {
Self {
content: candidate.0,
score: None,
}
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FuzzyLayout { pub struct FuzzyLayout {
rows: u16, rows: u16,
@@ -739,11 +856,11 @@ impl QueryEditor {
} }
} }
#[derive(Clone, Debug)] #[derive(Clone, Default, Debug)]
pub struct FuzzySelector { pub struct FuzzySelector {
query: QueryEditor, query: QueryEditor,
filtered: Vec<ScoredCandidate>, filtered: Vec<ScoredCandidate>,
candidates: Vec<String>, candidates: Vec<Candidate>,
cursor: ClampedUsize, cursor: ClampedUsize,
number_candidates: bool, number_candidates: bool,
old_layout: Option<FuzzyLayout>, old_layout: Option<FuzzyLayout>,
@@ -798,7 +915,7 @@ impl FuzzySelector {
} }
} }
pub fn candidates(&self) -> &[String] { pub fn candidates(&self) -> &[Candidate] {
&self.candidates &self.candidates
} }
@@ -814,7 +931,7 @@ impl FuzzySelector {
self.candidates.len() self.candidates.len()
} }
pub fn activate(&mut self, candidates: Vec<String>) { pub fn activate(&mut self, candidates: Vec<Candidate>) {
self.active = true; self.active = true;
self.candidates = candidates; self.candidates = candidates;
self.score_candidates(); self.score_candidates();
@@ -913,7 +1030,7 @@ impl FuzzySelector {
.clone() .clone()
.into_iter() .into_iter()
.filter_map(|c| { .filter_map(|c| {
let mut sc = ScoredCandidate::new(c); let mut sc = ScoredCandidate::new(c.to_string());
let score = sc.fuzzy_score(self.query.linebuf.as_str()); let score = sc.fuzzy_score(self.query.linebuf.as_str());
if score > i32::MIN { Some(sc) } else { None } if score > i32::MIN { Some(sc) } else { None }
}) })
@@ -1167,7 +1284,7 @@ impl Default for FuzzyCompleter {
} }
impl Completer for FuzzyCompleter { impl Completer for FuzzyCompleter {
fn all_candidates(&self) -> Vec<String> { fn all_candidates(&self) -> Vec<Candidate> {
self.selector.candidates.clone() self.selector.candidates.clone()
} }
fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) { fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) {
@@ -1188,12 +1305,34 @@ impl Completer for FuzzyCompleter {
.original_input .original_input
.get(start..end) .get(start..end)
.unwrap_or_default(); .unwrap_or_default();
start += slice.width(); let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
let completion = selected.strip_prefix(slice).unwrap_or(&selected); let (prefix, completion) = if ignore_case {
let escaped = escape_str(completion, false); // Replace the filename part (after last /) with the candidate's casing
// but preserve any unexpanded prefix like $VAR/
if let Some(last_sep) = slice.rfind('/') {
let prefix_end = start + last_sep + 1;
let trailing_slash = selected.ends_with('/');
let trimmed = selected.trim_end_matches('/');
let mut basename = trimmed.rsplit('/').next().unwrap_or(&selected).to_string();
if trailing_slash {
basename.push('/');
}
(
self.completer.original_input[..prefix_end].to_string(),
basename,
)
} else {
(self.completer.original_input[..start].to_string(), selected.clone())
}
} else {
start += slice.width();
let completion = selected.strip_prefix(slice).unwrap_or(&selected);
(self.completer.original_input[..start].to_string(), completion.to_string())
};
let escaped = escape_str(&completion, false);
let ret = format!( let ret = format!(
"{}{}{}", "{}{}{}",
&self.completer.original_input[..start], prefix,
escaped, escaped,
&self.completer.original_input[end..] &self.completer.original_input[end..]
); );
@@ -1256,7 +1395,7 @@ impl Completer for FuzzyCompleter {
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct SimpleCompleter { pub struct SimpleCompleter {
pub candidates: Vec<String>, pub candidates: Vec<Candidate>,
pub selected_idx: usize, pub selected_idx: usize,
pub original_input: String, pub original_input: String,
pub token_span: (usize, usize), pub token_span: (usize, usize),
@@ -1266,7 +1405,7 @@ pub struct SimpleCompleter {
} }
impl Completer for SimpleCompleter { impl Completer for SimpleCompleter {
fn all_candidates(&self) -> Vec<String> { fn all_candidates(&self) -> Vec<Candidate> {
self.candidates.clone() self.candidates.clone()
} }
fn reset_stay_active(&mut self) { fn reset_stay_active(&mut self) {
@@ -1299,7 +1438,7 @@ impl Completer for SimpleCompleter {
} }
fn selected_candidate(&self) -> Option<String> { fn selected_candidate(&self) -> Option<String> {
self.candidates.get(self.selected_idx).cloned() self.candidates.get(self.selected_idx).map(|c| c.to_string())
} }
fn token_span(&self) -> (usize, usize) { fn token_span(&self) -> (usize, usize) {
@@ -1407,7 +1546,7 @@ impl SimpleCompleter {
&& !ends_with_unescaped(&c, " ") && !ends_with_unescaped(&c, " ")
{ {
// already has a space // already has a space
format!("{} ", c) Candidate::from(format!("{} ", c))
} else { } else {
c c
} }
@@ -1449,12 +1588,32 @@ impl SimpleCompleter {
let selected = &self.candidates[self.selected_idx]; let selected = &self.candidates[self.selected_idx];
let (mut start, end) = self.token_span; let (mut start, end) = self.token_span;
let slice = self.original_input.get(start..end).unwrap_or(""); let slice = self.original_input.get(start..end).unwrap_or("");
start += slice.width(); let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
let completion = selected.strip_prefix(slice).unwrap_or(selected); let (prefix, completion) = if ignore_case {
let escaped = escape_str(completion, false); if let Some(last_sep) = slice.rfind('/') {
let prefix_end = start + last_sep + 1;
let trailing_slash = selected.ends_with('/');
let trimmed = selected.trim_end_matches('/');
let mut basename = trimmed.rsplit('/').next().unwrap_or(selected.as_str()).to_string();
if trailing_slash {
basename.push('/');
}
(
self.original_input[..prefix_end].to_string(),
basename,
)
} else {
(self.original_input[..start].to_string(), selected.to_string())
}
} else {
start += slice.width();
let completion = selected.strip_prefix(slice).unwrap_or(selected.to_string());
(self.original_input[..start].to_string(), completion)
};
let escaped = escape_str(&completion, false);
format!( format!(
"{}{}{}", "{}{}{}",
&self.original_input[..start], prefix,
escaped, escaped,
&self.original_input[end..] &self.original_input[end..]
) )
@@ -1649,11 +1808,12 @@ impl SimpleCompleter {
let is_var_completion = last_marker == Some(markers::VAR_SUB) let is_var_completion = last_marker == Some(markers::VAR_SUB)
&& !candidates.is_empty() && !candidates.is_empty()
&& candidates.iter().any(|c| c.starts_with('$')); && candidates.iter().any(|c| c.starts_with('$'));
if !is_var_completion { let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
if !is_var_completion && !ignore_case {
candidates = candidates candidates = candidates
.into_iter() .into_iter()
.map(|c| match c.strip_prefix(&expanded) { .map(|c| match c.strip_prefix(&expanded) {
Some(suffix) => format!("{raw_tk}{suffix}"), Some(suffix) => Candidate::from(format!("{raw_tk}{suffix}")),
None => c, None => c,
}) })
.collect(); .collect();
@@ -1781,7 +1941,7 @@ mod tests {
#[test] #[test]
fn complete_signals_int() { fn complete_signals_int() {
let results = complete_signals("INT"); let results = complete_signals("INT");
assert!(results.contains(&"INT".to_string())); assert!(results.contains(&Candidate::from("INT")));
} }
#[test] #[test]

View File

@@ -203,6 +203,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
.collect() .collect()
} }
#[derive(Default, Clone, Debug)]
pub struct History { pub struct History {
path: PathBuf, path: PathBuf,
pub pending: Option<LineBuf>, // command, cursor_pos pub pending: Option<LineBuf>, // command, cursor_pos
@@ -214,6 +215,7 @@ pub struct History {
//search_direction: Direction, //search_direction: Direction,
ignore_dups: bool, ignore_dups: bool,
max_size: Option<u32>, max_size: Option<u32>,
stateless: bool,
} }
impl History { impl History {
@@ -229,6 +231,7 @@ impl History {
//search_direction: Direction::Backward, //search_direction: Direction::Backward,
ignore_dups: false, ignore_dups: false,
max_size: None, max_size: None,
stateless: true,
} }
} }
pub fn new() -> ShResult<Self> { pub fn new() -> ShResult<Self> {
@@ -266,6 +269,7 @@ impl History {
//search_direction: Direction::Backward, //search_direction: Direction::Backward,
ignore_dups, ignore_dups,
max_size, max_size,
stateless: false,
}) })
} }
@@ -280,7 +284,7 @@ impl History {
.search_mask .search_mask
.clone() .clone()
.into_iter() .into_iter()
.map(|ent| ent.command().to_string()); .map(|ent| super::complete::Candidate::from(ent.command()));
self.fuzzy_finder.activate(raw_entries.collect()); self.fuzzy_finder.activate(raw_entries.collect());
None None
} }
@@ -450,6 +454,9 @@ impl History {
} }
pub fn save(&mut self) -> ShResult<()> { pub fn save(&mut self) -> ShResult<()> {
if self.stateless {
return Ok(());
}
let mut file = OpenOptions::new() let mut file = OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)

View File

@@ -12,17 +12,21 @@ use super::vicmd::{
ViCmd, Word, ViCmd, Word,
}; };
use crate::{ use crate::{
expand::expand_cmd_sub,
libsh::{error::ShResult, guards::var_ctx_guard}, libsh::{error::ShResult, guards::var_ctx_guard},
parse::{ parse::{
Redir, RedirType,
execute::exec_input, execute::exec_input,
lex::{LexFlags, LexStream, QuoteState, Tk}, lex::{LexFlags, LexStream, QuoteState, Tk, TkFlags, TkRule},
}, },
prelude::*, prelude::*,
procio::{IoFrame, IoMode, IoStack},
readline::{ readline::{
history::History, history::History,
markers, markers,
register::{RegisterContent, write_register}, register::{RegisterContent, write_register},
term::RawModeGuard, term::{RawModeGuard, get_win_size},
vicmd::{ReadSrc, WriteDest},
}, },
state::{VarFlags, VarKind, read_shopts, write_meta, write_vars}, state::{VarFlags, VarKind, read_shopts, write_meta, write_vars},
}; };
@@ -351,51 +355,77 @@ impl ClampedUsize {
} }
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
pub struct DepthCalc { pub struct IndentCtx {
depth: usize, depth: usize,
ctx: Vec<Tk>, ctx: Vec<Tk>,
in_escaped_line: bool,
} }
impl DepthCalc { impl IndentCtx {
pub fn new() -> Self { Self::default() } pub fn new() -> Self {
Self::default()
}
pub fn descend(&mut self, tk: Tk) { pub fn depth(&self) -> usize {
self.ctx.push(tk); self.depth
self.depth += 1; }
}
pub fn ascend(&mut self) { pub fn ctx(&self) -> &[Tk] {
self.depth = self.depth.saturating_sub(1); &self.ctx
self.ctx.pop(); }
}
pub fn check_tk(&mut self, tk: Tk) { pub fn descend(&mut self, tk: Tk) {
if tk.is_opener() { self.ctx.push(tk);
self.descend(tk); self.depth += 1;
} else if self.ctx.last().is_some_and(|t| tk.is_closer_for(t)) { }
self.ascend();
}
}
pub fn calculate(&mut self, input: &str) -> usize { pub fn ascend(&mut self) {
if input.ends_with("\\\n") { self.depth = self.depth.saturating_sub(1);
self.depth += 1; // Line continuation, so we need to add an extra level self.ctx.pop();
}
pub fn reset(&mut self) {
std::mem::take(self);
}
pub fn check_tk(&mut self, tk: Tk) {
if tk.is_opener() {
self.descend(tk);
} else if self.ctx.last().is_some_and(|t| tk.is_closer_for(t)) {
self.ascend();
} else if matches!(tk.class, TkRule::Sep) && self.in_escaped_line {
self.in_escaped_line = false;
self.depth = self.depth.saturating_sub(1);
} }
let input = Arc::new(input.to_string()); }
let Ok(tokens) = LexStream::new(input.clone(), LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<Tk>>>() else {
log::error!("Lexing failed during depth calculation: {:?}", input);
return 0;
};
for tk in tokens { pub fn calculate(&mut self, input: &str) -> usize {
self.check_tk(tk); self.depth = 0;
} self.ctx.clear();
self.in_escaped_line = false;
self.depth let input_arc = Arc::new(input.to_string());
} let Ok(tokens) =
LexStream::new(input_arc, LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<Tk>>>()
else {
log::error!("Lexing failed during depth calculation: {:?}", input);
return 0;
};
for tk in tokens {
self.check_tk(tk);
}
if input.ends_with("\\\n") {
self.in_escaped_line = true;
self.depth += 1;
}
self.depth
}
} }
#[derive(Default, Clone, Debug)] #[derive(Clone, Debug)]
pub struct LineBuf { pub struct LineBuf {
pub buffer: String, pub buffer: String,
pub hint: Option<String>, pub hint: Option<String>,
@@ -408,18 +438,37 @@ pub struct LineBuf {
pub insert_mode_start_pos: Option<usize>, pub insert_mode_start_pos: Option<usize>,
pub saved_col: Option<usize>, pub saved_col: Option<usize>,
pub auto_indent_level: usize, pub indent_ctx: IndentCtx,
pub undo_stack: Vec<Edit>, pub undo_stack: Vec<Edit>,
pub redo_stack: Vec<Edit>, pub redo_stack: Vec<Edit>,
} }
impl Default for LineBuf {
fn default() -> Self {
Self {
buffer: String::new(),
hint: None,
grapheme_indices: Some(vec![]),
cursor: ClampedUsize::new(0, 0, false),
select_mode: None,
select_range: None,
last_selection: None,
insert_mode_start_pos: None,
saved_col: None,
indent_ctx: IndentCtx::new(),
undo_stack: vec![],
redo_stack: vec![],
}
}
}
impl LineBuf { impl LineBuf {
pub fn new() -> Self { pub fn new() -> Self {
let mut new = Self { let mut new = Self::default();
grapheme_indices: Some(vec![]), // We know the buffer is empty, so this keeps us safe from unwrapping None
..Default::default()
};
new.update_graphemes(); new.update_graphemes();
new new
} }
@@ -662,6 +711,17 @@ impl LineBuf {
pub fn read_slice_to_cursor(&self) -> Option<&str> { pub fn read_slice_to_cursor(&self) -> Option<&str> {
self.read_slice_to(self.cursor.get()) self.read_slice_to(self.cursor.get())
} }
pub fn cursor_is_escaped(&mut self) -> bool {
let Some(to_cursor) = self.slice_to_cursor() else {
return false;
};
// count the number of backslashes
let delta = to_cursor.len() - to_cursor.trim_end_matches('\\').len();
// an even number of backslashes means each one is escaped
delta % 2 != 0
}
pub fn slice_to_cursor_inclusive(&mut self) -> Option<&str> { pub fn slice_to_cursor_inclusive(&mut self) -> Option<&str> {
self.slice_to(self.cursor.ret_add(1)) self.slice_to(self.cursor.ret_add(1))
} }
@@ -960,12 +1020,31 @@ impl LineBuf {
if self.end_of_line() == self.cursor.max { if self.end_of_line() == self.cursor.max {
return None; return None;
} }
let target_line = self.cursor_line_number() + n; let target_line = self.cursor_line_number() + n - 1;
let start = self.start_of_line(); let start = self.start_of_line();
let (_, end) = self.line_bounds(target_line); let (_, end) = self.line_bounds(target_line);
Some((start, end)) Some((start, end))
} }
pub fn lines_in_range(&mut self, range: Range<usize>) -> Vec<(usize, usize)> {
let mut ranges = vec![];
let mut first_line = self.pos_line_number(range.start);
let mut last_line = self.pos_line_number(range.end);
(first_line, last_line) = ordered(first_line, last_line);
if first_line == last_line {
return vec![self.line_bounds(first_line)];
}
for line_no in first_line..last_line {
let (s, e) = self.line_bounds(line_no);
ranges.push((s, e));
}
ranges
}
pub fn line_bounds(&self, n: usize) -> (usize, usize) { pub fn line_bounds(&self, n: usize) -> (usize, usize) {
if n > self.total_lines() { if n > self.total_lines() {
panic!( panic!(
@@ -2076,15 +2155,17 @@ impl LineBuf {
let end = start + (new.len().max(gr.len())); let end = start + (new.len().max(gr.len()));
self.buffer.replace_range(start..end, new); self.buffer.replace_range(start..end, new);
} }
pub fn calc_indent_level(&mut self) { pub fn calc_indent_level(&mut self) -> usize {
let to_cursor = self self.calc_indent_level_for_pos(self.cursor.get())
.slice_to_cursor() }
pub fn calc_indent_level_for_pos(&mut self, pos: usize) -> usize {
let slice = self
.slice_to(pos)
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or(self.buffer.clone()); .unwrap_or(self.buffer.clone());
let mut calc = DepthCalc::new(); self.indent_ctx.calculate(&slice)
self.auto_indent_level = calc.calculate(&to_cursor);
} }
pub fn eval_motion(&mut self, verb: Option<&Verb>, motion: MotionCmd) -> MotionKind { pub fn eval_motion(&mut self, verb: Option<&Verb>, motion: MotionCmd) -> MotionKind {
let buffer = self.buffer.clone(); let buffer = self.buffer.clone();
@@ -2288,7 +2369,7 @@ impl LineBuf {
let pos = if count == 1 { let pos = if count == 1 {
self.end_of_line_exclusive() self.end_of_line_exclusive()
} else if let Some((_, end)) = self.select_lines_down(count) { } else if let Some((_, end)) = self.select_lines_down(count) {
end end.saturating_sub(1)
} else { } else {
self.end_of_line_exclusive() self.end_of_line_exclusive()
}; };
@@ -2405,14 +2486,6 @@ impl LineBuf {
MotionKind::On(target_pos) MotionKind::On(target_pos)
} }
MotionCmd(_count, Motion::ScreenLineUp) => todo!(),
MotionCmd(_count, Motion::ScreenLineUpCharwise) => todo!(),
MotionCmd(_count, Motion::ScreenLineDown) => todo!(),
MotionCmd(_count, Motion::ScreenLineDownCharwise) => todo!(),
MotionCmd(_count, Motion::BeginningOfScreenLine) => todo!(),
MotionCmd(_count, Motion::FirstGraphicalOnScreenLine) => todo!(),
MotionCmd(_count, Motion::HalfOfScreen) => todo!(),
MotionCmd(_count, Motion::HalfOfScreenLineText) => todo!(),
MotionCmd(_count, Motion::WholeBuffer) => { MotionCmd(_count, Motion::WholeBuffer) => {
MotionKind::Exclusive((0, self.grapheme_indices().len())) MotionKind::Exclusive((0, self.grapheme_indices().len()))
} }
@@ -2453,8 +2526,9 @@ impl LineBuf {
final_end = final_end.min(self.cursor.max); final_end = final_end.min(self.cursor.max);
MotionKind::Exclusive((start, final_end)) MotionKind::Exclusive((start, final_end))
} }
MotionCmd(_count, Motion::RepeatMotion) => todo!(), MotionCmd(_count, Motion::RepeatMotion) | MotionCmd(_count, Motion::RepeatMotionRev) => {
MotionCmd(_count, Motion::RepeatMotionRev) => todo!(), unreachable!("already handled in readline/mod.rs")
}
MotionCmd(_count, Motion::Null) MotionCmd(_count, Motion::Null)
| MotionCmd(_count, Motion::Global(_)) | MotionCmd(_count, Motion::Global(_))
| MotionCmd(_count, Motion::NotGlobal(_)) => MotionKind::Null, | MotionCmd(_count, Motion::NotGlobal(_)) => MotionKind::Null,
@@ -2661,8 +2735,8 @@ impl LineBuf {
register.write_to_register(register_content); register.write_to_register(register_content);
self.cursor.set(start); self.cursor.set(start);
if do_indent { if do_indent {
self.calc_indent_level(); let depth = self.calc_indent_level();
let tabs = (0..self.auto_indent_level).map(|_| '\t'); let tabs = (0..depth).map(|_| '\t');
for tab in tabs { for tab in tabs {
self.insert_at_cursor(tab); self.insert_at_cursor(tab);
self.cursor.add(1); self.cursor.add(1);
@@ -2897,17 +2971,29 @@ impl LineBuf {
}; };
end = end.saturating_sub(1); end = end.saturating_sub(1);
let mut last_was_whitespace = false; let mut last_was_whitespace = false;
for i in start..end { let mut last_was_escape = false;
let mut i = start;
while i < end {
let Some(gr) = self.grapheme_at(i) else { let Some(gr) = self.grapheme_at(i) else {
i += 1;
continue; continue;
}; };
if gr == "\n" { if gr == "\n" {
if last_was_whitespace { if last_was_whitespace {
self.remove(i); self.remove(i);
end -= 1;
} else { } else {
self.force_replace_at(i, " "); self.force_replace_at(i, " ");
} }
if last_was_escape {
// if we are here, then we just joined an escaped newline
// semantically, echo foo\\nbar == echo foo bar
// so a joined line should remove the escape.
self.remove(i - 1);
end -= 1;
}
last_was_whitespace = false; last_was_whitespace = false;
last_was_escape = false;
let strip_pos = if self.grapheme_at(i) == Some(" ") { let strip_pos = if self.grapheme_at(i) == Some(" ") {
i + 1 i + 1
} else { } else {
@@ -2915,33 +3001,49 @@ impl LineBuf {
}; };
while self.grapheme_at(strip_pos) == Some("\t") { while self.grapheme_at(strip_pos) == Some("\t") {
self.remove(strip_pos); self.remove(strip_pos);
end -= 1;
} }
self.cursor.set(i); self.cursor.set(i);
i += 1;
continue; continue;
} else if gr == "\\" {
if last_was_whitespace && last_was_escape {
// if we are here, then the pattern of the last three chars was this:
// ' \\', a space and two backslashes.
// This means the "last" was an escaped backslash, not whitespace.
last_was_whitespace = false;
}
last_was_escape = !last_was_escape;
} else {
last_was_whitespace = is_whitespace(gr);
last_was_escape = false;
} }
last_was_whitespace = is_whitespace(gr); i += 1;
} }
Ok(()) Ok(())
} }
fn verb_insert_char(&mut self, ch: char) { fn verb_insert_char(&mut self, ch: char) {
self.insert_at_cursor(ch); self.insert_at_cursor(ch);
self.cursor.add(1); self.cursor.add(1);
let before = self.auto_indent_level; let before_escaped = self.indent_ctx.in_escaped_line;
if read_shopts(|o| o.prompt.auto_indent) { let before = self.indent_ctx.depth();
self.calc_indent_level(); if read_shopts(|o| o.prompt.auto_indent) {
if self.auto_indent_level < before { let after = self.calc_indent_level();
let delta = before - self.auto_indent_level; // Only dedent if the depth decrease came from a closer, not from
let line_start = self.start_of_line(); // a line continuation bonus going away
for _ in 0..delta { if after < before && !(before_escaped && !self.indent_ctx.in_escaped_line) {
if self.grapheme_at(line_start).is_some_and(|gr| gr == "\t") { let delta = before - after;
self.remove(line_start); let line_start = self.start_of_line();
if !self.cursor_at_max() { for _ in 0..delta {
self.cursor.sub(1); if self.grapheme_at(line_start).is_some_and(|gr| gr == "\t") {
} self.remove(line_start);
} if !self.cursor_at_max() {
} self.cursor.sub(1);
} }
} }
}
}
}
} }
fn verb_insert(&mut self, string: String) { fn verb_insert(&mut self, string: String) {
self.insert_str_at_cursor(&string); self.insert_str_at_cursor(&string);
@@ -2978,30 +3080,36 @@ impl LineBuf {
} }
Ok(()) Ok(())
} }
#[allow(clippy::unnecessary_to_owned)] #[allow(clippy::unnecessary_to_owned)]
fn verb_dedent(&mut self, motion: MotionKind) -> ShResult<()> { fn verb_dedent(&mut self, motion: MotionKind) -> ShResult<()> {
let Some((start, mut end)) = self.range_from_motion(&motion) else { let Some((start, end)) = self.range_from_motion(&motion) else {
return Ok(()); return Ok(());
}; };
let end = end.min(self.grapheme_indices().len().saturating_sub(1));
// Collect tab positions to remove, then remove in reverse so indices stay valid
let mut to_remove = Vec::new();
if self.grapheme_at(start) == Some("\t") { if self.grapheme_at(start) == Some("\t") {
self.remove(start); to_remove.push(start);
} }
end = end.min(self.grapheme_indices().len().saturating_sub(1)); let range_indices = self.grapheme_indices()[start..end].to_vec();
let mut range_indices = self.grapheme_indices()[start..end].to_vec().into_iter(); let mut i = 0;
while let Some(idx) = range_indices.next() { while i < range_indices.len() {
let gr = self.grapheme_at(idx).unwrap(); let idx = range_indices[i];
if gr == "\n" { if self.grapheme_at(idx) == Some("\n") && i + 1 < range_indices.len() {
let Some(idx) = range_indices.next() else { let next_idx = range_indices[i + 1];
if self.grapheme_at(self.grapheme_indices().len().saturating_sub(1)) == Some("\t") { if self.grapheme_at(next_idx) == Some("\t") {
self.remove(self.grapheme_indices().len().saturating_sub(1)); to_remove.push(next_idx);
} i += 1;
break;
};
if self.grapheme_at(idx) == Some("\t") {
self.remove(idx);
} }
} }
i += 1;
} }
for idx in to_remove.into_iter().rev() {
self.remove(idx);
}
match motion { match motion {
MotionKind::ExclusiveWithTargetCol((_, _), pos) MotionKind::ExclusiveWithTargetCol((_, _), pos)
| MotionKind::InclusiveWithTargetCol((_, _), pos) => { | MotionKind::InclusiveWithTargetCol((_, _), pos) => {
@@ -3013,6 +3121,29 @@ impl LineBuf {
} }
Ok(()) Ok(())
} }
fn verb_equalize(&mut self, motion: MotionKind) -> ShResult<()> {
let Some((s, e)) = self.range_from_motion(&motion) else {
return Ok(());
};
let lines = self.lines_in_range(s..e);
let target_col = self.cursor_col();
// reverse the list of line spans so that the spans stay valid
for (s, _) in lines.into_iter().rev() {
let indent_level = self.calc_indent_level_for_pos(s);
while self.grapheme_at(s).is_some_and(|c| c == "\t") {
self.remove(s)
}
for _ in 0..indent_level {
self.insert_at(s, '\t');
}
}
self.cursor.set(s);
self.cursor.add(target_col);
Ok(())
}
fn verb_insert_mode_line_break(&mut self, anchor: Anchor) -> ShResult<()> { fn verb_insert_mode_line_break(&mut self, anchor: Anchor) -> ShResult<()> {
let (mut start, end) = self.this_line_exclusive(); let (mut start, end) = self.this_line_exclusive();
let auto_indent = read_shopts(|o| o.prompt.auto_indent); let auto_indent = read_shopts(|o| o.prompt.auto_indent);
@@ -3021,8 +3152,8 @@ impl LineBuf {
Anchor::After => { Anchor::After => {
self.push('\n'); self.push('\n');
if auto_indent { if auto_indent {
self.calc_indent_level(); let depth = self.calc_indent_level();
for _ in 0..self.auto_indent_level { for _ in 0..depth {
self.push('\t'); self.push('\t');
} }
} }
@@ -3031,8 +3162,8 @@ impl LineBuf {
} }
Anchor::Before => { Anchor::Before => {
if auto_indent { if auto_indent {
self.calc_indent_level(); let depth = self.calc_indent_level();
for _ in 0..self.auto_indent_level { for _ in 0..depth {
self.insert_at(0, '\t'); self.insert_at(0, '\t');
} }
} }
@@ -3059,8 +3190,8 @@ impl LineBuf {
self.insert_at_cursor('\n'); self.insert_at_cursor('\n');
self.cursor.add(1); self.cursor.add(1);
if auto_indent { if auto_indent {
self.calc_indent_level(); let depth = self.calc_indent_level();
for _ in 0..self.auto_indent_level { for _ in 0..depth {
self.insert_at_cursor('\t'); self.insert_at_cursor('\t');
self.cursor.add(1); self.cursor.add(1);
} }
@@ -3210,27 +3341,27 @@ impl LineBuf {
) -> ShResult<()> { ) -> ShResult<()> {
match verb { match verb {
Verb::Delete | Verb::Yank | Verb::Change => self.verb_ydc(motion, register, verb)?, Verb::Delete | Verb::Yank | Verb::Change => self.verb_ydc(motion, register, verb)?,
Verb::Rot13 => self.verb_rot13(motion)?, Verb::Rot13 => self.verb_rot13(motion)?,
Verb::ReplaceChar(ch) => self.verb_replace_char(motion, ch)?, Verb::ReplaceChar(ch) => self.verb_replace_char(motion, ch)?,
Verb::ReplaceCharInplace(ch, count) => self.verb_replace_char_inplace(ch, count)?, Verb::ReplaceCharInplace(ch, count) => self.verb_replace_char_inplace(ch, count)?,
Verb::ToggleCaseInplace(count) => self.verb_toggle_case_inplace(count), Verb::ToggleCaseInplace(count) => self.verb_toggle_case_inplace(count),
Verb::ToggleCaseRange => self.verb_case_transform(motion, CaseTransform::Toggle)?, Verb::ToggleCaseRange => self.verb_case_transform(motion, CaseTransform::Toggle)?,
Verb::ToLower => self.verb_case_transform(motion, CaseTransform::Lower)?, Verb::ToLower => self.verb_case_transform(motion, CaseTransform::Lower)?,
Verb::ToUpper => self.verb_case_transform(motion, CaseTransform::Upper)?, Verb::ToUpper => self.verb_case_transform(motion, CaseTransform::Upper)?,
Verb::Redo | Verb::Undo => self.verb_undo_redo(verb)?, Verb::Redo | Verb::Undo => self.verb_undo_redo(verb)?,
Verb::RepeatLast => todo!(), Verb::RepeatLast => unreachable!("already handled in readline.rs"),
Verb::Put(anchor) => self.verb_put(anchor, register)?, Verb::Put(anchor) => self.verb_put(anchor, register)?,
Verb::SwapVisualAnchor => self.verb_swap_visual_anchor(), Verb::SwapVisualAnchor => self.verb_swap_visual_anchor(),
Verb::JoinLines => self.verb_join_lines()?, Verb::JoinLines => self.verb_join_lines()?,
Verb::InsertChar(ch) => self.verb_insert_char(ch), Verb::InsertChar(ch) => self.verb_insert_char(ch),
Verb::Insert(string) => self.verb_insert(string), Verb::Insert(string) => self.verb_insert(string),
Verb::Indent => self.verb_indent(motion)?, Verb::Indent => self.verb_indent(motion)?,
Verb::Dedent => self.verb_dedent(motion)?, Verb::Dedent => self.verb_dedent(motion)?,
Verb::Equalize => todo!(), Verb::Equalize => self.verb_equalize(motion)?,
Verb::InsertModeLineBreak(anchor) => self.verb_insert_mode_line_break(anchor)?, Verb::InsertModeLineBreak(anchor) => self.verb_insert_mode_line_break(anchor)?,
Verb::AcceptLineOrNewline => self.verb_accept_line_or_newline()?, Verb::AcceptLineOrNewline => self.verb_accept_line_or_newline()?,
Verb::IncrementNumber(n) => self.verb_adjust_number(n as i64)?, Verb::IncrementNumber(n) => self.verb_adjust_number(n as i64)?,
Verb::DecrementNumber(n) => self.verb_adjust_number(-(n as i64))?, Verb::DecrementNumber(n) => self.verb_adjust_number(-(n as i64))?,
Verb::Complete Verb::Complete
| Verb::ExMode | Verb::ExMode
| Verb::EndOfFile | Verb::EndOfFile
@@ -3244,12 +3375,82 @@ impl LineBuf {
| Verb::CompleteBackward | Verb::CompleteBackward
| Verb::VisualModeSelectLast => self.apply_motion(motion), | Verb::VisualModeSelectLast => self.apply_motion(motion),
Verb::ShellCmd(cmd) => self.verb_shell_cmd(cmd)?, Verb::ShellCmd(cmd) => self.verb_shell_cmd(cmd)?,
Verb::Normal(_) Verb::Read(src) => match src {
| Verb::Read(_) ReadSrc::File(path_buf) => {
| Verb::Write(_) if !path_buf.is_file() {
| Verb::Substitute(..) write_meta(|m| m.post_system_message(format!("{} is not a file", path_buf.display())));
| Verb::RepeatSubstitute return Ok(());
| Verb::RepeatGlobal => {} }
let Ok(contents) = std::fs::read_to_string(&path_buf) else {
write_meta(|m| {
m.post_system_message(format!("Failed to read file {}", path_buf.display()))
});
return Ok(());
};
let grapheme_count = contents.graphemes(true).count();
self.insert_str_at_cursor(&contents);
self.cursor.add(grapheme_count);
}
ReadSrc::Cmd(cmd) => {
let output = match expand_cmd_sub(&cmd) {
Ok(out) => out,
Err(e) => {
e.print_error();
return Ok(());
}
};
let grapheme_count = output.graphemes(true).count();
self.insert_str_at_cursor(&output);
self.cursor.add(grapheme_count);
}
},
Verb::Write(dest) => match dest {
WriteDest::FileAppend(ref path_buf) | WriteDest::File(ref path_buf) => {
let Ok(mut file) = (if matches!(dest, WriteDest::File(_)) {
OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path_buf)
} else {
OpenOptions::new().create(true).append(true).open(path_buf)
}) else {
write_meta(|m| {
m.post_system_message(format!("Failed to open file {}", path_buf.display()))
});
return Ok(());
};
if let Err(e) = file.write_all(self.as_str().as_bytes()) {
write_meta(|m| {
m.post_system_message(format!(
"Failed to write to file {}: {e}",
path_buf.display()
))
});
}
return Ok(());
}
WriteDest::Cmd(cmd) => {
let buf = self.as_str().to_string();
let io_mode = IoMode::Buffer {
tgt_fd: STDIN_FILENO,
buf,
flags: TkFlags::IS_HEREDOC | TkFlags::LIT_HEREDOC,
};
let redir = Redir::new(io_mode, RedirType::Input);
let mut frame = IoFrame::new();
frame.push(redir);
let mut stack = IoStack::new();
stack.push_frame(frame);
exec_input(cmd, Some(stack), false, Some("ex write".into()))?;
}
},
Verb::Edit(path) => {
let input = format!("$EDITOR {}", path.display());
exec_input(input, None, true, Some("ex edit".into()))?;
}
Verb::Normal(_) | Verb::Substitute(..) | Verb::RepeatSubstitute | Verb::RepeatGlobal => {}
} }
Ok(()) Ok(())
} }
@@ -3289,9 +3490,9 @@ impl LineBuf {
/* /*
* Let's evaluate the motion now * Let's evaluate the motion now
* If we got some weird command like 'dvw' we will * If we got some weird command like 'dvw' we will
* have to simulate a visual selection to get the range * have to simulate a visual selection to get the range
* If motion is None, we will try to use self.select_range * If motion is None, we will try to use self.select_range
* If self.select_range is None, we will use MotionKind::Null * If self.select_range is None, we will use MotionKind::Null
*/ */
let motion_eval = let motion_eval =
if flags.intersects(CmdFlags::VISUAL | CmdFlags::VISUAL_LINE | CmdFlags::VISUAL_BLOCK) { if flags.intersects(CmdFlags::VISUAL | CmdFlags::VISUAL_LINE | CmdFlags::VISUAL_BLOCK) {

View File

@@ -132,6 +132,18 @@ pub mod markers {
pub fn is_marker(c: Marker) -> bool { pub fn is_marker(c: Marker) -> bool {
('\u{e000}'..'\u{efff}').contains(&c) ('\u{e000}'..'\u{efff}').contains(&c)
} }
// Help command formatting markers
pub const TAG: Marker = '\u{e180}';
pub const REFERENCE: Marker = '\u{e181}';
pub const HEADER: Marker = '\u{e182}';
pub const CODE: Marker = '\u{e183}';
/// angle brackets
pub const KEYWORD_1: Marker = '\u{e184}';
/// curly brackets
pub const KEYWORD_2: Marker = '\u{e185}';
/// square brackets
pub const KEYWORD_3: Marker = '\u{e186}';
} }
type Marker = char; type Marker = char;
@@ -253,10 +265,10 @@ pub struct ShedVi {
pub repeat_action: Option<CmdReplay>, pub repeat_action: Option<CmdReplay>,
pub repeat_motion: Option<MotionCmd>, pub repeat_motion: Option<MotionCmd>,
pub editor: LineBuf, pub editor: LineBuf,
pub next_is_escaped: bool,
pub old_layout: Option<Layout>, pub old_layout: Option<Layout>,
pub history: History, pub history: History,
pub ex_history: History,
pub needs_redraw: bool, pub needs_redraw: bool,
} }
@@ -271,7 +283,6 @@ impl ShedVi {
completer: Box::new(FuzzyCompleter::default()), completer: Box::new(FuzzyCompleter::default()),
highlighter: Highlighter::new(), highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()), mode: Box::new(ViInsert::new()),
next_is_escaped: false,
saved_mode: None, saved_mode: None,
pending_keymap: Vec::new(), pending_keymap: Vec::new(),
old_layout: None, old_layout: None,
@@ -279,6 +290,7 @@ impl ShedVi {
repeat_motion: None, repeat_motion: None,
editor: LineBuf::new(), editor: LineBuf::new(),
history: History::new()?, history: History::new()?,
ex_history: History::empty(),
needs_redraw: true, needs_redraw: true,
}; };
write_vars(|v| { write_vars(|v| {
@@ -303,7 +315,6 @@ impl ShedVi {
completer: Box::new(FuzzyCompleter::default()), completer: Box::new(FuzzyCompleter::default()),
highlighter: Highlighter::new(), highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()), mode: Box::new(ViInsert::new()),
next_is_escaped: false,
saved_mode: None, saved_mode: None,
pending_keymap: Vec::new(), pending_keymap: Vec::new(),
old_layout: None, old_layout: None,
@@ -311,6 +322,7 @@ impl ShedVi {
repeat_motion: None, repeat_motion: None,
editor: LineBuf::new(), editor: LineBuf::new(),
history: History::empty(), history: History::empty(),
ex_history: History::empty(),
needs_redraw: true, needs_redraw: true,
}; };
write_vars(|v| { write_vars(|v| {
@@ -334,6 +346,18 @@ impl ShedVi {
self self
} }
/// A mutable reference to the currently focused editor
/// This includes the main LineBuf, and sub-editors for modes like Ex mode.
pub fn focused_editor(&mut self) -> &mut LineBuf {
self.mode.editor().unwrap_or(&mut self.editor)
}
/// A mutable reference to the currently focused history, if any.
/// This includes the main history struct, and history for sub-editors like Ex mode.
pub fn focused_history(&mut self) -> &mut History {
self.mode.history().unwrap_or(&mut self.history)
}
/// Feed raw bytes from stdin into the reader's buffer /// Feed raw bytes from stdin into the reader's buffer
pub fn feed_bytes(&mut self, bytes: &[u8]) { pub fn feed_bytes(&mut self, bytes: &[u8]) {
self.reader.feed_bytes(bytes); self.reader.feed_bytes(bytes);
@@ -355,8 +379,8 @@ impl ShedVi {
self.completer.reset_stay_active(); self.completer.reset_stay_active();
self.needs_redraw = true; self.needs_redraw = true;
Ok(()) Ok(())
} else if self.history.fuzzy_finder.is_active() { } else if self.focused_history().fuzzy_finder.is_active() {
self.history.fuzzy_finder.reset_stay_active(); self.focused_history().fuzzy_finder.reset_stay_active();
self.needs_redraw = true; self.needs_redraw = true;
Ok(()) Ok(())
} else { } else {
@@ -417,7 +441,7 @@ impl ShedVi {
LexStream::new(Arc::clone(&input), LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<_>>>(); LexStream::new(Arc::clone(&input), LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<_>>>();
let lex_result2 = let lex_result2 =
LexStream::new(Arc::clone(&input), LexFlags::empty()).collect::<ShResult<Vec<_>>>(); LexStream::new(Arc::clone(&input), LexFlags::empty()).collect::<ShResult<Vec<_>>>();
let is_top_level = self.editor.auto_indent_level == 0; let is_top_level = self.editor.indent_ctx.ctx().is_empty();
let is_complete = match (lex_result1.is_err(), lex_result2.is_err()) { let is_complete = match (lex_result1.is_err(), lex_result2.is_err()) {
(true, true) => { (true, true) => {
@@ -444,26 +468,29 @@ impl ShedVi {
// Process all available keys // Process all available keys
while let Some(key) = self.reader.read_key()? { while let Some(key) = self.reader.read_key()? {
log::debug!(
"Read key: {key:?} in mode {:?}, self.reader.verbatim = {}",
self.mode.report_mode(),
self.reader.verbatim
);
// If completer or history search are active, delegate input to it // If completer or history search are active, delegate input to it
if self.history.fuzzy_finder.is_active() { if self.focused_history().fuzzy_finder.is_active() {
self.print_line(false)?; self.print_line(false)?;
match self.history.fuzzy_finder.handle_key(key)? { match self.focused_history().fuzzy_finder.handle_key(key)? {
SelectorResponse::Accept(cmd) => { SelectorResponse::Accept(cmd) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
self.editor.set_buffer(cmd.to_string()); {
self.editor.move_cursor_to_end(); let editor = self.focused_editor();
editor.set_buffer(cmd.to_string());
editor.move_cursor_to_end();
}
self self
.history .history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
self.editor.set_hint(None); self.editor.set_hint(None);
self.history.fuzzy_finder.clear(&mut self.writer)?; {
self.history.fuzzy_finder.reset(); let mut writer = std::mem::take(&mut self.writer);
self.focused_history().fuzzy_finder.clear(&mut writer)?;
self.writer = writer;
}
self.focused_history().fuzzy_finder.reset();
with_vars([("_HIST_ENTRY".into(), cmd.clone())], || { with_vars([("_HIST_ENTRY".into(), cmd.clone())], || {
post_cmds.exec_with(&cmd); post_cmds.exec_with(&cmd);
@@ -486,7 +513,11 @@ impl ShedVi {
post_cmds.exec(); post_cmds.exec();
self.editor.set_hint(None); self.editor.set_hint(None);
self.history.fuzzy_finder.clear(&mut self.writer)?; {
let mut writer = std::mem::take(&mut self.writer);
self.focused_history().fuzzy_finder.clear(&mut writer)?;
self.writer = writer;
}
write_vars(|v| { write_vars(|v| {
v.set_var( v.set_var(
"SHED_VI_MODE", "SHED_VI_MODE",
@@ -513,8 +544,8 @@ impl ShedVi {
let span_start = self.completer.token_span().0; let span_start = self.completer.token_span().0;
let new_cursor = span_start + candidate.len(); let new_cursor = span_start + candidate.len();
let line = self.completer.get_completed_line(&candidate); let line = self.completer.get_completed_line(&candidate);
self.editor.set_buffer(line); self.focused_editor().set_buffer(line);
self.editor.cursor.set(new_cursor); self.focused_editor().cursor.set(new_cursor);
// Don't reset yet — clear() needs old_layout to erase the selector. // Don't reset yet — clear() needs old_layout to erase the selector.
if !self.history.at_pending() { if !self.history.at_pending() {
@@ -631,10 +662,6 @@ impl ShedVi {
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<Option<ReadlineEvent>> { pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<Option<ReadlineEvent>> {
if self.should_accept_hint(&key) { if self.should_accept_hint(&key) {
log::debug!(
"Accepting hint on key {key:?} in mode {:?}",
self.mode.report_mode()
);
self.editor.accept_hint(); self.editor.accept_hint();
if !self.history.at_pending() { if !self.history.at_pending() {
self.history.reset_to_pending(); self.history.reset_to_pending();
@@ -647,7 +674,8 @@ impl ShedVi {
} }
if let KeyEvent(KeyCode::Tab, mod_keys) = key { if let KeyEvent(KeyCode::Tab, mod_keys) = key {
if self.editor.attempt_history_expansion(&self.history) { if self.mode.report_mode() != ModeReport::Ex
&& self.editor.attempt_history_expansion(&self.history) {
// If history expansion occurred, don't attempt completion yet // If history expansion occurred, don't attempt completion yet
// allow the user to see the expanded command and accept or edit it before completing // allow the user to see the expanded command and accept or edit it before completing
return Ok(None); return Ok(None);
@@ -657,8 +685,8 @@ impl ShedVi {
ModKeys::SHIFT => -1, ModKeys::SHIFT => -1,
_ => 1, _ => 1,
}; };
let line = self.editor.as_str().to_string(); let line = self.focused_editor().as_str().to_string();
let cursor_pos = self.editor.cursor_byte_pos(); let cursor_pos = self.focused_editor().cursor_byte_pos();
match self.completer.complete(line, cursor_pos, direction) { match self.completer.complete(line, cursor_pos, direction) {
Err(e) => { Err(e) => {
@@ -682,8 +710,8 @@ impl ShedVi {
.map(|c| c.len()) .map(|c| c.len())
.unwrap_or_default(); .unwrap_or_default();
self.editor.set_buffer(line.clone()); self.focused_editor().set_buffer(line.clone());
self.editor.cursor.set(new_cursor); self.focused_editor().cursor.set(new_cursor);
if !self.history.at_pending() { if !self.history.at_pending() {
self.history.reset_to_pending(); self.history.reset_to_pending();
@@ -745,18 +773,18 @@ impl ShedVi {
self.needs_redraw = true; self.needs_redraw = true;
return Ok(None); return Ok(None);
} else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key } else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key
&& self.mode.report_mode() == ModeReport::Insert && matches!(self.mode.report_mode(), ModeReport::Insert | ModeReport::Ex)
{ {
let initial = self.editor.as_str(); let initial = self.focused_editor().as_str().to_string();
match self.history.start_search(initial) { match self.focused_history().start_search(&initial) {
Some(entry) => { Some(entry) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect));
with_vars([("_HIST_ENTRY".into(), entry.clone())], || { with_vars([("_HIST_ENTRY".into(), entry.clone())], || {
post_cmds.exec_with(&entry); post_cmds.exec_with(&entry);
}); });
self.editor.set_buffer(entry); self.focused_editor().set_buffer(entry);
self.editor.move_cursor_to_end(); self.focused_editor().move_cursor_to_end();
self self
.history .history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
@@ -764,9 +792,9 @@ impl ShedVi {
} }
None => { None => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen)); let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen));
let entries = self.history.fuzzy_finder.candidates(); let entries = self.focused_history().fuzzy_finder.candidates().to_vec();
let matches = self let matches = self
.history .focused_history()
.fuzzy_finder .fuzzy_finder
.filtered() .filtered()
.iter() .iter()
@@ -789,7 +817,7 @@ impl ShedVi {
}, },
); );
if self.history.fuzzy_finder.is_active() { if self.focused_history().fuzzy_finder.is_active() {
write_vars(|v| { write_vars(|v| {
v.set_var( v.set_var(
"SHED_VI_MODE", "SHED_VI_MODE",
@@ -808,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 { let Ok(cmd) = self.mode.handle_key_fallible(key) else {
// it's an ex mode error // it's an ex mode error
self.mode = Box::new(ViNormal::new()) as Box<dyn ViMode>; self.swap_mode(&mut (Box::new(ViNormal::new()) as Box<dyn ViMode>));
return Ok(None); return Ok(None);
}; };
@@ -834,8 +855,7 @@ impl ShedVi {
} }
if cmd.is_submit_action() if cmd.is_submit_action()
&& !self.next_is_escaped && !self.editor.cursor_is_escaped()
&& !self.editor.buffer.ends_with('\\')
&& (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete)) && (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete))
{ {
if self.editor.attempt_history_expansion(&self.history) { if self.editor.attempt_history_expansion(&self.history) {
@@ -854,10 +874,10 @@ impl ShedVi {
} }
if cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) { if cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) {
if self.editor.buffer.is_empty() { if self.focused_editor().buffer.is_empty() {
return Ok(Some(ReadlineEvent::Eof)); return Ok(Some(ReadlineEvent::Eof));
} else { } else {
self.editor = LineBuf::new(); *self.focused_editor() = LineBuf::new();
self.mode = Box::new(ViInsert::new()); self.mode = Box::new(ViInsert::new());
self.needs_redraw = true; self.needs_redraw = true;
return Ok(None); return Ok(None);
@@ -865,9 +885,22 @@ impl ShedVi {
} }
let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit()); let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit());
let is_shell_cmd = cmd.verb().is_some_and(|v| matches!(v.1, Verb::ShellCmd(_)));
let is_ex_cmd = cmd.flags.contains(CmdFlags::IS_EX_CMD);
log::debug!("is_ex_cmd: {is_ex_cmd}");
if is_shell_cmd {
self.old_layout = None;
}
if is_ex_cmd {
self.ex_history.push(cmd.raw_seq.clone());
self.ex_history.reset();
log::debug!("ex_history: {:?}", self.ex_history.entries());
}
let before = self.editor.buffer.clone(); let before = self.editor.buffer.clone();
self.exec_cmd(cmd, false)?; self.exec_cmd(cmd, false)?;
if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) { if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) {
for key in keys { for key in keys {
self.handle_key(key)?; self.handle_key(key)?;
@@ -999,7 +1032,11 @@ impl ShedVi {
let one_line = new_layout.end.row == 0; let one_line = new_layout.end.row == 0;
self.completer.clear(&mut self.writer)?; self.completer.clear(&mut self.writer)?;
self.history.fuzzy_finder.clear(&mut self.writer)?; {
let mut writer = std::mem::take(&mut self.writer);
self.focused_history().fuzzy_finder.clear(&mut writer)?;
self.writer = writer;
}
if let Some(layout) = self.old_layout.as_ref() { if let Some(layout) = self.old_layout.as_ref() {
self.writer.clear_rows(layout)?; self.writer.clear_rows(layout)?;
@@ -1092,10 +1129,15 @@ impl ShedVi {
self.completer.draw(&mut self.writer)?; self.completer.draw(&mut self.writer)?;
self self
.history .focused_history()
.fuzzy_finder .fuzzy_finder
.set_prompt_line_context(preceding_width, new_layout.cursor.col); .set_prompt_line_context(preceding_width, new_layout.cursor.col);
self.history.fuzzy_finder.draw(&mut self.writer)?;
{
let mut writer = std::mem::take(&mut self.writer);
self.focused_history().fuzzy_finder.draw(&mut writer)?;
self.writer = writer;
}
self.old_layout = Some(new_layout); self.old_layout = Some(new_layout);
self.needs_redraw = false; self.needs_redraw = false;
@@ -1152,7 +1194,7 @@ impl ShedVi {
) )
} }
Verb::ExMode => Box::new(ViEx::new()), Verb::ExMode => Box::new(ViEx::new(self.ex_history.clone())),
Verb::VerbatimMode => { Verb::VerbatimMode => {
self.reader.verbatim_single = true; self.reader.verbatim_single = true;
@@ -1242,7 +1284,7 @@ impl ShedVi {
ModeReport::Normal => Box::new(ViNormal::new()), ModeReport::Normal => Box::new(ViNormal::new()),
ModeReport::Insert => Box::new(ViInsert::new()), ModeReport::Insert => Box::new(ViInsert::new()),
ModeReport::Visual => Box::new(ViVisual::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::Replace => Box::new(ViReplace::new()),
ModeReport::Verbatim => Box::new(ViVerbatim::new()), ModeReport::Verbatim => Box::new(ViVerbatim::new()),
ModeReport::Unknown => unreachable!(), ModeReport::Unknown => unreachable!(),
@@ -1269,10 +1311,6 @@ impl ShedVi {
for _ in 0..repeat { for _ in 0..repeat {
let cmds = cmds.clone(); let cmds = cmds.clone();
for (i, cmd) in cmds.iter().enumerate() { 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)?; self.exec_cmd(cmd.clone(), true)?;
// After the first command, start merging so all subsequent // After the first command, start merging so all subsequent
// edits fold into one undo entry (e.g. cw + inserted chars) // edits fold into one undo entry (e.g. cw + inserted chars)
@@ -1291,7 +1329,7 @@ impl ShedVi {
ModeReport::Normal => Box::new(ViNormal::new()) as Box<dyn ViMode>, ModeReport::Normal => Box::new(ViNormal::new()) as Box<dyn ViMode>,
ModeReport::Insert => Box::new(ViInsert::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::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::Replace => Box::new(ViReplace::new()) as Box<dyn ViMode>,
ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box<dyn ViMode>, ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box<dyn ViMode>,
ModeReport::Unknown => unreachable!(), ModeReport::Unknown => unreachable!(),
@@ -1623,6 +1661,12 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
let mut insertions: Vec<(usize, Marker)> = vec![]; let mut insertions: Vec<(usize, Marker)> = vec![];
// Heredoc tokens have spans covering the body content far from the <<
// operator, which breaks position tracking after marker insertions
if token.flags.contains(TkFlags::IS_HEREDOC) {
return insertions;
}
if token.class != TkRule::Str if token.class != TkRule::Str
&& let Some(marker) = marker_for(&token.class) && let Some(marker) = marker_for(&token.class)
{ {

View File

@@ -294,12 +294,14 @@ impl Read for TermBuffer {
struct KeyCollector { struct KeyCollector {
events: VecDeque<KeyEvent>, events: VecDeque<KeyEvent>,
ss3_pending: bool,
} }
impl KeyCollector { impl KeyCollector {
fn new() -> Self { fn new() -> Self {
Self { Self {
events: VecDeque::new(), events: VecDeque::new(),
ss3_pending: false,
} }
} }
@@ -337,7 +339,55 @@ impl Default for KeyCollector {
impl Perform for KeyCollector { impl Perform for KeyCollector {
fn print(&mut self, c: char) { fn print(&mut self, c: char) {
log::trace!("print: {c:?}");
// vte routes 0x7f (DEL) to print instead of execute // vte routes 0x7f (DEL) to print instead of execute
if self.ss3_pending {
self.ss3_pending = false;
match c {
'A' => {
self.push(KeyEvent(KeyCode::Up, ModKeys::empty()));
return;
}
'B' => {
self.push(KeyEvent(KeyCode::Down, ModKeys::empty()));
return;
}
'C' => {
self.push(KeyEvent(KeyCode::Right, ModKeys::empty()));
return;
}
'D' => {
self.push(KeyEvent(KeyCode::Left, ModKeys::empty()));
return;
}
'H' => {
self.push(KeyEvent(KeyCode::Home, ModKeys::empty()));
return;
}
'F' => {
self.push(KeyEvent(KeyCode::End, ModKeys::empty()));
return;
}
'P' => {
self.push(KeyEvent(KeyCode::F(1), ModKeys::empty()));
return;
}
'Q' => {
self.push(KeyEvent(KeyCode::F(2), ModKeys::empty()));
return;
}
'R' => {
self.push(KeyEvent(KeyCode::F(3), ModKeys::empty()));
return;
}
'S' => {
self.push(KeyEvent(KeyCode::F(4), ModKeys::empty()));
return;
}
_ => {}
}
}
if c == '\x7f' { if c == '\x7f' {
self.push(KeyEvent(KeyCode::Backspace, ModKeys::empty())); self.push(KeyEvent(KeyCode::Backspace, ModKeys::empty()));
} else { } else {
@@ -346,6 +396,7 @@ impl Perform for KeyCollector {
} }
fn execute(&mut self, byte: u8) { fn execute(&mut self, byte: u8) {
log::trace!("execute: {byte:#04x}");
let event = match byte { let event = match byte {
0x00 => KeyEvent(KeyCode::Char(' '), ModKeys::CTRL), // Ctrl+Space / Ctrl+@ 0x00 => KeyEvent(KeyCode::Char(' '), ModKeys::CTRL), // Ctrl+Space / Ctrl+@
0x09 => KeyEvent(KeyCode::Tab, ModKeys::empty()), // Tab (Ctrl+I) 0x09 => KeyEvent(KeyCode::Tab, ModKeys::empty()), // Tab (Ctrl+I)
@@ -370,6 +421,9 @@ impl Perform for KeyCollector {
_ignore: bool, _ignore: bool,
action: char, action: char,
) { ) {
log::trace!(
"CSI dispatch: params={params:?}, intermediates={intermediates:?}, action={action:?}"
);
let params: Vec<u16> = params let params: Vec<u16> = params
.iter() .iter()
.map(|p| p.first().copied().unwrap_or(0)) .map(|p| p.first().copied().unwrap_or(0))
@@ -481,16 +535,11 @@ impl Perform for KeyCollector {
} }
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) { fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
// SS3 sequences (ESC O P/Q/R/S for F1-F4) log::trace!("ESC dispatch: intermediates={intermediates:?}, byte={byte:#04x}");
if intermediates == [b'O'] { // SS3 sequences
let key = match byte { if byte == b'O' {
b'P' => KeyCode::F(1), self.ss3_pending = true;
b'Q' => KeyCode::F(2), return;
b'R' => KeyCode::F(3),
b'S' => KeyCode::F(4),
_ => return,
};
self.push(KeyEvent(key, ModKeys::empty()));
} }
} }
} }
@@ -844,6 +893,7 @@ impl Default for Layout {
} }
} }
#[derive(Clone, Debug, Default)]
pub struct TermWriter { pub struct TermWriter {
last_bell: Option<Instant>, last_bell: Option<Instant>,
out: RawFd, out: RawFd,

View File

@@ -2,10 +2,15 @@
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use crate::{ use crate::{
readline::{Prompt, ShedVi}, readline::{Prompt, ShedVi, annotate_input},
testutil::TestGuard, 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. /// 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 { macro_rules! vi_test {
{ $($name:ident: $input:expr => $op:expr => $expected_text:expr,$expected_cursor:expr);* } => { { $($name:ident: $input:expr => $op:expr => $expected_text:expr,$expected_cursor:expr);* } => {
@@ -26,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) { fn test_vi(initial: &str) -> (ShedVi, TestGuard) {
let g = TestGuard::new(); let g = TestGuard::new();
let prompt = Prompt::default(); let prompt = Prompt::default();
@@ -38,198 +294,225 @@ fn test_vi(initial: &str) -> (ShedVi, TestGuard) {
// Why can't I marry a programming language // Why can't I marry a programming language
vi_test! { vi_test! {
vi_dw_basic : "hello world" => "dw" => "world", 0; vi_dw_basic : "hello world" => "dw" => "world", 0;
vi_dw_middle : "one two three" => "wdw" => "one three", 4; vi_dw_middle : "one two three" => "wdw" => "one three", 4;
vi_dd_whole_line : "hello world" => "dd" => "", 0; vi_dd_whole_line : "hello world" => "dd" => "", 0;
vi_x_single : "hello" => "x" => "ello", 0; vi_x_single : "hello" => "x" => "ello", 0;
vi_x_middle : "hello" => "llx" => "helo", 2; vi_x_middle : "hello" => "llx" => "helo", 2;
vi_X_backdelete : "hello" => "llX" => "hllo", 1; vi_X_backdelete : "hello" => "llX" => "hllo", 1;
vi_h_motion : "hello" => "$h" => "hello", 3; vi_h_motion : "hello" => "$h" => "hello", 3;
vi_l_motion : "hello" => "l" => "hello", 1; vi_l_motion : "hello" => "l" => "hello", 1;
vi_h_at_start : "hello" => "h" => "hello", 0; vi_h_at_start : "hello" => "h" => "hello", 0;
vi_l_at_end : "hello" => "$l" => "hello", 4; vi_l_at_end : "hello" => "$l" => "hello", 4;
vi_w_forward : "one two three" => "w" => "one two three", 4; vi_w_forward : "one two three" => "w" => "one two three", 4;
vi_b_backward : "one two three" => "$b" => "one two three", 8; vi_b_backward : "one two three" => "$b" => "one two three", 8;
vi_e_end : "one two three" => "e" => "one two three", 2; vi_e_end : "one two three" => "e" => "one two three", 2;
vi_ge_back_end : "one two three" => "$ge" => "one two three", 6; vi_ge_back_end : "one two three" => "$ge" => "one two three", 6;
vi_w_punctuation : "foo.bar baz" => "w" => "foo.bar baz", 3; vi_w_punctuation : "foo.bar baz" => "w" => "foo.bar baz", 3;
vi_e_punctuation : "foo.bar baz" => "e" => "foo.bar baz", 2; vi_e_punctuation : "foo.bar baz" => "e" => "foo.bar baz", 2;
vi_b_punctuation : "foo.bar baz" => "$b" => "foo.bar baz", 8; vi_b_punctuation : "foo.bar baz" => "$b" => "foo.bar baz", 8;
vi_w_at_eol : "hello" => "$w" => "hello", 4; vi_w_at_eol : "hello" => "$w" => "hello", 4;
vi_b_at_bol : "hello" => "b" => "hello", 0; vi_b_at_bol : "hello" => "b" => "hello", 0;
vi_W_forward : "foo.bar baz" => "W" => "foo.bar baz", 8; vi_W_forward : "foo.bar baz" => "W" => "foo.bar baz", 8;
vi_B_backward : "foo.bar baz" => "$B" => "foo.bar baz", 8; vi_B_backward : "foo.bar baz" => "$B" => "foo.bar baz", 8;
vi_E_end : "foo.bar baz" => "E" => "foo.bar baz", 6; vi_E_end : "foo.bar baz" => "E" => "foo.bar baz", 6;
vi_gE_back_end : "one two three" => "$gE" => "one two three", 6; vi_gE_back_end : "one two three" => "$gE" => "one two three", 6;
vi_W_skip_punct : "one-two three" => "W" => "one-two three", 8; vi_W_skip_punct : "one-two three" => "W" => "one-two three", 8;
vi_B_skip_punct : "one two-three" => "$B" => "one two-three", 4; vi_B_skip_punct : "one two-three" => "$B" => "one two-three", 4;
vi_E_skip_punct : "one-two three" => "E" => "one-two three", 6; vi_E_skip_punct : "one-two three" => "E" => "one-two three", 6;
vi_dW_big : "foo.bar baz" => "dW" => "baz", 0; vi_dW_big : "foo.bar baz" => "dW" => "baz", 0;
vi_cW_big : "foo.bar baz" => "cWx\x1b" => "x baz", 0; vi_cW_big : "foo.bar baz" => "cWx\x1b" => "x baz", 0;
vi_zero_bol : " hello" => "$0" => " hello", 0; vi_zero_bol : " hello" => "$0" => " hello", 0;
vi_caret_first_char : " hello" => "$^" => " hello", 2; vi_caret_first_char : " hello" => "$^" => " hello", 2;
vi_dollar_eol : "hello world" => "$" => "hello world", 10; vi_dollar_eol : "hello world" => "$" => "hello world", 10;
vi_g_last_nonws : "hello " => "g_" => "hello ", 4; vi_g_last_nonws : "hello " => "g_" => "hello ", 4;
vi_g_no_trailing : "hello" => "g_" => "hello", 4; vi_g_no_trailing : "hello" => "g_" => "hello", 4;
vi_pipe_column : "hello world" => "6|" => "hello world", 5; vi_pipe_column : "hello world" => "6|" => "hello world", 5;
vi_pipe_col1 : "hello world" => "1|" => "hello world", 0; vi_pipe_col1 : "hello world" => "1|" => "hello world", 0;
vi_I_insert_front : " hello" => "Iworld \x1b" => " world hello", 7; vi_I_insert_front : " hello" => "Iworld \x1b" => " world hello", 7;
vi_A_append_end : "hello" => "A world\x1b" => "hello world", 10; vi_A_append_end : "hello" => "A world\x1b" => "hello world", 10;
vi_f_find : "hello world" => "fo" => "hello world", 4; vi_f_find : "hello world" => "fo" => "hello world", 4;
vi_F_find_back : "hello world" => "$Fo" => "hello world", 7; vi_F_find_back : "hello world" => "$Fo" => "hello world", 7;
vi_t_till : "hello world" => "tw" => "hello world", 5; vi_t_till : "hello world" => "tw" => "hello world", 5;
vi_T_till_back : "hello world" => "$To" => "hello world", 8; vi_T_till_back : "hello world" => "$To" => "hello world", 8;
vi_f_no_match : "hello" => "fz" => "hello", 0; vi_f_no_match : "hello" => "fz" => "hello", 0;
vi_semicolon_repeat : "abcabc" => "fa;;" => "abcabc", 3; vi_semicolon_repeat : "abcabc" => "fa;;" => "abcabc", 3;
vi_comma_reverse : "abcabc" => "fa;;," => "abcabc", 0; vi_comma_reverse : "abcabc" => "fa;;," => "abcabc", 0;
vi_df_semicolon : "abcabc" => "fa;;dfa" => "abcabc", 3; vi_df_semicolon : "abcabc" => "fa;;dfa" => "abcabc", 3;
vi_t_at_target : "aab" => "lta" => "aab", 1; vi_t_at_target : "aab" => "lta" => "aab", 1;
vi_D_to_end : "hello world" => "wD" => "hello ", 5; vi_D_to_end : "hello world" => "wD" => "hello ", 5;
vi_d_dollar : "hello world" => "wd$" => "hello ", 5; vi_d_dollar : "hello world" => "wd$" => "hello ", 5;
vi_d0_to_start : "hello world" => "$d0" => "d", 0; vi_d0_to_start : "hello world" => "$d0" => "d", 0;
vi_dw_multiple : "one two three" => "d2w" => "three", 0; vi_dw_multiple : "one two three" => "d2w" => "three", 0;
vi_dt_char : "hello world" => "dtw" => "world", 0; vi_dt_char : "hello world" => "dtw" => "world", 0;
vi_df_char : "hello world" => "dfw" => "orld", 0; vi_df_char : "hello world" => "dfw" => "orld", 0;
vi_dh_back : "hello" => "lldh" => "hllo", 1; vi_dh_back : "hello" => "lldh" => "hllo", 1;
vi_dl_forward : "hello" => "dl" => "ello", 0; vi_dl_forward : "hello" => "dl" => "ello", 0;
vi_dge_back_end : "one two three" => "$dge" => "one tw", 5; vi_dge_back_end : "one two three" => "$dge" => "one tw", 5;
vi_dG_to_end : "hello world" => "dG" => "", 0; vi_dG_to_end : "hello world" => "dG" => "", 0;
vi_dgg_to_start : "hello world" => "$dgg" => "", 0; vi_dgg_to_start : "hello world" => "$dgg" => "", 0;
vi_d_semicolon : "abcabc" => "fad;" => "abcabc", 3; vi_d_semicolon : "abcabc" => "fad;" => "abcabc", 3;
vi_cw_basic : "hello world" => "cwfoo\x1b" => "foo world", 2; vi_cw_basic : "hello world" => "cwfoo\x1b" => "foo world", 2;
vi_C_to_end : "hello world" => "wCfoo\x1b" => "hello foo", 8; vi_C_to_end : "hello world" => "wCfoo\x1b" => "hello foo", 8;
vi_cc_whole : "hello world" => "ccfoo\x1b" => "foo", 2; vi_cc_whole : "hello world" => "ccfoo\x1b" => "foo", 2;
vi_ct_char : "hello world" => "ctwfoo\x1b" => "fooworld", 2; vi_ct_char : "hello world" => "ctwfoo\x1b" => "fooworld", 2;
vi_s_single : "hello" => "sfoo\x1b" => "fooello", 2; vi_s_single : "hello" => "sfoo\x1b" => "fooello", 2;
vi_S_whole_line : "hello world" => "Sfoo\x1b" => "foo", 2; vi_S_whole_line : "hello world" => "Sfoo\x1b" => "foo", 2;
vi_cl_forward : "hello" => "clX\x1b" => "Xello", 0; vi_cl_forward : "hello" => "clX\x1b" => "Xello", 0;
vi_ch_backward : "hello" => "llchX\x1b" => "hXllo", 1; vi_ch_backward : "hello" => "llchX\x1b" => "hXllo", 1;
vi_cb_word_back : "hello world" => "$cbfoo\x1b" => "hello food", 8; vi_cb_word_back : "hello world" => "$cbfoo\x1b" => "hello food", 8;
vi_ce_word_end : "hello world" => "cefoo\x1b" => "foo world", 2; vi_ce_word_end : "hello world" => "cefoo\x1b" => "foo world", 2;
vi_c0_to_start : "hello world" => "wc0foo\x1b" => "fooworld", 2; vi_c0_to_start : "hello world" => "wc0foo\x1b" => "fooworld", 2;
vi_yw_p_basic : "hello world" => "ywwP" => "hello hello world", 11; vi_yw_p_basic : "hello world" => "ywwP" => "hello hello world", 11;
vi_dw_p_paste : "hello world" => "dwP" => "hello world", 5; vi_dw_p_paste : "hello world" => "dwP" => "hello world", 5;
vi_dd_p_paste : "hello world" => "ddp" => "\nhello world", 1; vi_dd_p_paste : "hello world" => "ddp" => "\nhello world", 1;
vi_y_dollar_p : "hello world" => "wy$P" => "hello worldworld", 10; vi_y_dollar_p : "hello world" => "wy$P" => "hello worldworld", 10;
vi_ye_p : "hello world" => "yewP" => "hello helloworld", 10; vi_ye_p : "hello world" => "yewP" => "hello helloworld", 10;
vi_yy_p : "hello world" => "yyp" => "hello world\nhello world", 12; vi_yy_p : "hello world" => "yyp" => "hello world\nhello world", 12;
vi_Y_p : "hello world" => "Yp" => "hhello worldello world", 11; vi_Y_p : "hello world" => "Yp" => "hhello worldello world", 11;
vi_p_after_x : "hello" => "xp" => "ehllo", 1; vi_p_after_x : "hello" => "xp" => "ehllo", 1;
vi_P_before : "hello" => "llxP" => "hello", 2; vi_P_before : "hello" => "llxP" => "hello", 2;
vi_paste_empty : "hello" => "p" => "hello", 0; vi_paste_empty : "hello" => "p" => "hello", 0;
vi_r_replace : "hello" => "ra" => "aello", 0; vi_r_replace : "hello" => "ra" => "aello", 0;
vi_r_middle : "hello" => "llra" => "healo", 2; vi_r_middle : "hello" => "llra" => "healo", 2;
vi_r_at_end : "hello" => "$ra" => "hella", 4; vi_r_at_end : "hello" => "$ra" => "hella", 4;
vi_r_space : "hello" => "r " => " ello", 0; vi_r_space : "hello" => "r " => " ello", 0;
vi_r_with_count : "hello" => "3rx" => "xxxlo", 2; vi_r_with_count : "hello" => "3rx" => "xxxlo", 2;
vi_tilde_single : "hello" => "~" => "Hello", 1; vi_tilde_single : "hello" => "~" => "Hello", 1;
vi_tilde_count : "hello" => "3~" => "HELlo", 3; vi_tilde_count : "hello" => "3~" => "HELlo", 3;
vi_tilde_at_end : "HELLO" => "$~" => "HELLo", 4; vi_tilde_at_end : "HELLO" => "$~" => "HELLo", 4;
vi_tilde_mixed : "hElLo" => "5~" => "HeLlO", 4; vi_tilde_mixed : "hElLo" => "5~" => "HeLlO", 4;
vi_gu_word : "HELLO world" => "guw" => "hello world", 0; vi_gu_word : "HELLO world" => "guw" => "hello world", 0;
vi_gU_word : "hello WORLD" => "gUw" => "HELLO WORLD", 0; vi_gU_word : "hello WORLD" => "gUw" => "HELLO WORLD", 0;
vi_gu_dollar : "HELLO WORLD" => "gu$" => "hello world", 0; vi_gu_dollar : "HELLO WORLD" => "gu$" => "hello world", 0;
vi_gU_dollar : "hello world" => "gU$" => "HELLO WORLD", 0; vi_gU_dollar : "hello world" => "gU$" => "HELLO WORLD", 0;
vi_gu_0 : "HELLO WORLD" => "$gu0" => "hello worlD", 0; vi_gu_0 : "HELLO WORLD" => "$gu0" => "hello worlD", 0;
vi_gU_0 : "hello world" => "$gU0" => "HELLO WORLd", 0; vi_gU_0 : "hello world" => "$gU0" => "HELLO WORLd", 0;
vi_gtilde_word : "hello WORLD" => "g~w" => "HELLO WORLD", 0; vi_gtilde_word : "hello WORLD" => "g~w" => "HELLO WORLD", 0;
vi_gtilde_dollar : "hello WORLD" => "g~$" => "HELLO world", 0; vi_gtilde_dollar : "hello WORLD" => "g~$" => "HELLO world", 0;
vi_diw_inner : "one two three" => "wdiw" => "one three", 4; vi_diw_inner : "one two three" => "wdiw" => "one three", 4;
vi_ciw_replace : "hello world" => "ciwfoo\x1b" => "foo world", 2; vi_ciw_replace : "hello world" => "ciwfoo\x1b" => "foo world", 2;
vi_daw_around : "one two three" => "wdaw" => "one three", 4; vi_daw_around : "one two three" => "wdaw" => "one three", 4;
vi_yiw_p : "hello world" => "yiwAp \x1bp" => "hello worldp hello", 17; vi_yiw_p : "hello world" => "yiwAp \x1bp" => "hello worldp hello", 17;
vi_diW_big_inner : "one-two three" => "diW" => " three", 0; vi_diW_big_inner : "one-two three" => "diW" => " three", 0;
vi_daW_big_around : "one two-three end" => "wdaW" => "one end", 4; vi_daW_big_around : "one two-three end" => "wdaW" => "one end", 4;
vi_ciW_big : "one-two three" => "ciWx\x1b" => "x three", 0; vi_ciW_big : "one-two three" => "ciWx\x1b" => "x three", 0;
vi_di_dquote : "one \"two\" three" => "f\"di\"" => "one \"\" three", 5; vi_di_dquote : "one \"two\" three" => "f\"di\"" => "one \"\" three", 5;
vi_da_dquote : "one \"two\" three" => "f\"da\"" => "one three", 4; vi_da_dquote : "one \"two\" three" => "f\"da\"" => "one three", 4;
vi_ci_dquote : "one \"two\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5; vi_ci_dquote : "one \"two\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5;
vi_di_squote : "one 'two' three" => "f'di'" => "one '' three", 5; vi_di_squote : "one 'two' three" => "f'di'" => "one '' three", 5;
vi_da_squote : "one 'two' three" => "f'da'" => "one three", 4; vi_da_squote : "one 'two' three" => "f'da'" => "one three", 4;
vi_di_backtick : "one `two` three" => "f`di`" => "one `` three", 5; vi_di_backtick : "one `two` three" => "f`di`" => "one `` three", 5;
vi_da_backtick : "one `two` three" => "f`da`" => "one three", 4; vi_da_backtick : "one `two` three" => "f`da`" => "one three", 4;
vi_ci_dquote_empty : "one \"\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5; vi_ci_dquote_empty : "one \"\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5;
vi_di_paren : "one (two) three" => "f(di(" => "one () three", 5; vi_di_paren : "one (two) three" => "f(di(" => "one () three", 5;
vi_da_paren : "one (two) three" => "f(da(" => "one three", 4; vi_da_paren : "one (two) three" => "f(da(" => "one three", 4;
vi_ci_paren : "one (two) three" => "f(ci(x\x1b" => "one (x) three", 5; vi_ci_paren : "one (two) three" => "f(ci(x\x1b" => "one (x) three", 5;
vi_di_brace : "one {two} three" => "f{di{" => "one {} three", 5; vi_di_brace : "one {two} three" => "f{di{" => "one {} three", 5;
vi_da_brace : "one {two} three" => "f{da{" => "one three", 4; vi_da_brace : "one {two} three" => "f{da{" => "one three", 4;
vi_di_bracket : "one [two] three" => "f[di[" => "one [] three", 5; vi_di_bracket : "one [two] three" => "f[di[" => "one [] three", 5;
vi_da_bracket : "one [two] three" => "f[da[" => "one three", 4; vi_da_bracket : "one [two] three" => "f[da[" => "one three", 4;
vi_di_angle : "one <two> three" => "f<di<" => "one <> three", 5; vi_di_angle : "one <two> three" => "f<di<" => "one <> three", 5;
vi_da_angle : "one <two> three" => "f<da<" => "one three", 4; vi_da_angle : "one <two> three" => "f<da<" => "one three", 4;
vi_di_paren_nested : "fn(a, (b, c))" => "f(di(" => "fn()", 3; vi_di_paren_nested : "fn(a, (b, c))" => "f(di(" => "fn()", 3;
vi_di_paren_empty : "fn() end" => "f(di(" => "fn() end", 3; vi_di_paren_empty : "fn() end" => "f(di(" => "fn() end", 3;
vi_dib_alias : "one (two) three" => "f(dib" => "one () three", 5; vi_dib_alias : "one (two) three" => "f(dib" => "one () three", 5;
vi_diB_alias : "one {two} three" => "f{diB" => "one {} three", 5; vi_diB_alias : "one {two} three" => "f{diB" => "one {} three", 5;
vi_percent_paren : "(hello) world" => "%" => "(hello) world", 6; vi_percent_paren : "(hello) world" => "%" => "(hello) world", 6;
vi_percent_brace : "{hello} world" => "%" => "{hello} world", 6; vi_percent_brace : "{hello} world" => "%" => "{hello} world", 6;
vi_percent_bracket : "[hello] world" => "%" => "[hello] world", 6; vi_percent_bracket : "[hello] world" => "%" => "[hello] world", 6;
vi_percent_from_close: "(hello) world" => "f)%" => "(hello) world", 0; vi_percent_from_close: "(hello) world" => "f)%" => "(hello) world", 0;
vi_d_percent_paren : "(hello) world" => "d%" => " world", 0; vi_d_percent_paren : "(hello) world" => "d%" => " world", 0;
vi_i_insert : "hello" => "iX\x1b" => "Xhello", 0; vi_i_insert : "hello" => "iX\x1b" => "Xhello", 0;
vi_a_append : "hello" => "aX\x1b" => "hXello", 1; vi_a_append : "hello" => "aX\x1b" => "hXello", 1;
vi_I_front : " hello" => "IX\x1b" => " Xhello", 2; vi_I_front : " hello" => "IX\x1b" => " Xhello", 2;
vi_A_end : "hello" => "AX\x1b" => "helloX", 5; vi_A_end : "hello" => "AX\x1b" => "helloX", 5;
vi_o_open_below : "hello" => "oworld\x1b" => "hello\nworld", 10; vi_o_open_below : "hello" => "oworld\x1b" => "hello\nworld", 10;
vi_O_open_above : "hello" => "Oworld\x1b" => "world\nhello", 4; vi_O_open_above : "hello" => "Oworld\x1b" => "world\nhello", 4;
vi_empty_input : "" => "i hello\x1b" => " hello", 5; vi_empty_input : "" => "i hello\x1b" => " hello", 5;
vi_insert_escape : "hello" => "aX\x1b" => "hXello", 1; vi_insert_escape : "hello" => "aX\x1b" => "hXello", 1;
vi_ctrl_w_del_word : "hello world" => "A\x17\x1b" => "hello ", 5; vi_ctrl_w_del_word : "hello world" => "A\x17\x1b" => "hello ", 5;
vi_ctrl_h_backspace : "hello" => "A\x08\x1b" => "hell", 3; vi_ctrl_h_backspace : "hello" => "A\x08\x1b" => "hell", 3;
vi_u_undo_delete : "hello world" => "dwu" => "hello world", 0; vi_u_undo_delete : "hello world" => "dwu" => "hello world", 0;
vi_u_undo_change : "hello world" => "ciwfoo\x1bu" => "hello world", 0; vi_u_undo_change : "hello world" => "ciwfoo\x1bu" => "hello world", 0;
vi_u_undo_x : "hello" => "xu" => "hello", 0; vi_u_undo_x : "hello" => "xu" => "hello", 0;
vi_ctrl_r_redo : "hello" => "xu\x12" => "ello", 0; vi_ctrl_r_redo : "hello" => "xu\x12" => "ello", 0;
vi_u_multiple : "hello world" => "xdwu" => "ello world", 0; vi_u_multiple : "hello world" => "xdwu" => "ello world", 0;
vi_redo_after_undo : "hello world" => "dwu\x12" => "world", 0; vi_redo_after_undo : "hello world" => "dwu\x12" => "world", 0;
vi_dot_repeat_x : "hello" => "x." => "llo", 0; vi_dot_repeat_x : "hello" => "x." => "llo", 0;
vi_dot_repeat_dw : "one two three" => "dw." => "three", 0; vi_dot_repeat_dw : "one two three" => "dw." => "three", 0;
vi_dot_repeat_cw : "one two three" => "cwfoo\x1bw." => "foo foo three", 6; vi_dot_repeat_cw : "one two three" => "cwfoo\x1bw." => "foo foo three", 6;
vi_dot_repeat_r : "hello" => "ra.." => "aello", 0; vi_dot_repeat_r : "hello" => "ra.." => "aello", 0;
vi_dot_repeat_s : "hello" => "sX\x1bl." => "XXllo", 1; vi_dot_repeat_s : "hello" => "sX\x1bl." => "XXllo", 1;
vi_count_h : "hello world" => "$3h" => "hello world", 7; vi_count_h : "hello world" => "$3h" => "hello world", 7;
vi_count_l : "hello world" => "3l" => "hello world", 3; vi_count_l : "hello world" => "3l" => "hello world", 3;
vi_count_w : "one two three four" => "2w" => "one two three four", 8; vi_count_w : "one two three four" => "2w" => "one two three four", 8;
vi_count_b : "one two three four" => "$2b" => "one two three four", 8; vi_count_b : "one two three four" => "$2b" => "one two three four", 8;
vi_count_x : "hello" => "3x" => "lo", 0; vi_count_x : "hello" => "3x" => "lo", 0;
vi_count_dw : "one two three four" => "2dw" => "three four", 0; vi_count_dw : "one two three four" => "2dw" => "three four", 0;
vi_verb_count_motion : "one two three four" => "d2w" => "three four", 0; vi_verb_count_motion : "one two three four" => "d2w" => "three four", 0;
vi_count_s : "hello" => "3sX\x1b" => "Xlo", 0; vi_count_s : "hello" => "3sX\x1b" => "Xlo", 0;
vi_indent_line : "hello" => ">>" => "\thello", 1; vi_indent_line : "hello" => ">>" => "\thello", 1;
vi_dedent_line : "\thello" => "<<" => "hello", 0; vi_dedent_line : "\thello" => "<<" => "hello", 0;
vi_indent_double : "hello" => ">>>>" => "\t\thello", 2; vi_indent_double : "hello" => ">>>>" => "\t\thello", 2;
vi_J_join_lines : "hello\nworld" => "J" => "hello world", 5; vi_J_join_lines : "hello\nworld" => "J" => "hello world", 5;
vi_v_u_lower : "HELLO" => "vlllu" => "hellO", 0; vi_v_u_lower : "HELLO" => "vlllu" => "hellO", 0;
vi_v_U_upper : "hello" => "vlllU" => "HELLo", 0; vi_v_U_upper : "hello" => "vlllU" => "HELLo", 0;
vi_v_d_delete : "hello world" => "vwwd" => "", 0; vi_v_d_delete : "hello world" => "vwwd" => "", 0;
vi_v_x_delete : "hello world" => "vwwx" => "", 0; vi_v_x_delete : "hello world" => "vwwx" => "", 0;
vi_v_c_change : "hello world" => "vwcfoo\x1b" => "fooorld", 2; vi_v_c_change : "hello world" => "vwcfoo\x1b" => "fooorld", 2;
vi_v_y_p_yank : "hello world" => "vwyAp \x1bp" => "hello worldp hello w", 19; vi_v_y_p_yank : "hello world" => "vwyAp \x1bp" => "hello worldp hello w", 19;
vi_v_dollar_d : "hello world" => "wv$d" => "hello ", 5; vi_v_dollar_d : "hello world" => "wv$d" => "hello ", 5;
vi_v_0_d : "hello world" => "$v0d" => "", 0; vi_v_0_d : "hello world" => "$v0d" => "", 0;
vi_ve_d : "hello world" => "ved" => " world", 0; vi_ve_d : "hello world" => "ved" => " world", 0;
vi_v_o_swap : "hello world" => "vllod" => "lo world", 0; vi_v_o_swap : "hello world" => "vllod" => "lo world", 0;
vi_v_r_replace : "hello" => "vlllrx" => "xxxxo", 0; vi_v_r_replace : "hello" => "vlllrx" => "xxxxo", 0;
vi_v_tilde_case : "hello" => "vlll~" => "HELLo", 0; vi_v_tilde_case : "hello" => "vlll~" => "HELLo", 0;
vi_V_d_delete : "hello world" => "Vd" => "", 0; vi_V_d_delete : "hello world" => "Vd" => "", 0;
vi_V_y_p : "hello world" => "Vyp" => "hello world\nhello world", 12; vi_V_y_p : "hello world" => "Vyp" => "hello world\nhello world", 12;
vi_V_S_change : "hello world" => "VSfoo\x1b" => "foo", 2; vi_V_S_change : "hello world" => "VSfoo\x1b" => "foo", 2;
vi_ctrl_a_inc : "num 5 end" => "w\x01" => "num 6 end", 4; vi_ctrl_a_inc : "num 5 end" => "w\x01" => "num 6 end", 4;
vi_ctrl_x_dec : "num 5 end" => "w\x18" => "num 4 end", 4; vi_ctrl_x_dec : "num 5 end" => "w\x18" => "num 4 end", 4;
vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 4; vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 4;
vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 4; vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 4;
vi_ctrl_a_count : "num 5 end" => "w3\x01" => "num 8 end", 4; vi_ctrl_a_count : "num 5 end" => "w3\x01" => "num 8 end", 4;
vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 4; vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 4;
vi_delete_empty : "" => "x" => "", 0; vi_delete_empty : "" => "x" => "", 0;
vi_undo_on_empty : "" => "u" => "", 0; vi_undo_on_empty : "" => "u" => "", 0;
vi_w_single_char : "a b c" => "w" => "a b c", 2; vi_w_single_char : "a b c" => "w" => "a b c", 2;
vi_dw_last_word : "hello" => "dw" => "", 0; vi_dw_last_word : "hello" => "dw" => "", 0;
vi_dollar_single : "h" => "$" => "h", 0; vi_dollar_single : "h" => "$" => "h", 0;
vi_caret_no_ws : "hello" => "$^" => "hello", 0; vi_caret_no_ws : "hello" => "$^" => "hello", 0;
vi_f_last_char : "hello" => "fo" => "hello", 4; vi_f_last_char : "hello" => "fo" => "hello", 4;
vi_r_on_space : "hello world" => "5|r-" => "hell- world", 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_indent_cursor_pos : "echo foo" => ">>" => "\techo foo", 1;
vi_join_indent_lines : "echo foo\n\t\techo bar" => "J" => "echo foo echo bar", 8 vi_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}"
);
}

View File

@@ -1,5 +1,9 @@
use std::path::PathBuf;
use bitflags::bitflags; use bitflags::bitflags;
use crate::readline::vimode::ex::SubFlags;
use super::register::{RegisterContent, append_register, read_register, write_register}; use super::register::{RegisterContent, append_register, read_register, write_register};
//TODO: write tests that take edit results and cursor positions from actual //TODO: write tests that take edit results and cursor positions from actual
@@ -64,6 +68,7 @@ bitflags! {
const VISUAL_LINE = 1<<1; const VISUAL_LINE = 1<<1;
const VISUAL_BLOCK = 1<<2; const VISUAL_BLOCK = 1<<2;
const EXIT_CUR_MODE = 1<<3; const EXIT_CUR_MODE = 1<<3;
const IS_EX_CMD = 1<<4;
} }
} }
@@ -255,7 +260,8 @@ pub enum Verb {
Normal(String), Normal(String),
Read(ReadSrc), Read(ReadSrc),
Write(WriteDest), Write(WriteDest),
Substitute(String, String, super::vimode::ex::SubFlags), Edit(PathBuf),
Substitute(String, String, SubFlags),
RepeatSubstitute, RepeatSubstitute,
RepeatGlobal, RepeatGlobal,
} }
@@ -301,6 +307,9 @@ impl Verb {
| Self::JoinLines | Self::JoinLines
| Self::InsertChar(_) | Self::InsertChar(_)
| Self::Insert(_) | Self::Insert(_)
| Self::Dedent
| Self::Indent
| Self::Equalize
| Self::Rot13 | Self::Rot13
| Self::EndOfFile | Self::EndOfFile
| Self::IncrementNumber(_) | Self::IncrementNumber(_)
@@ -332,16 +341,8 @@ pub enum Motion {
ForwardCharForced, ForwardCharForced,
LineUp, LineUp,
LineUpCharwise, LineUpCharwise,
ScreenLineUp,
ScreenLineUpCharwise,
LineDown, LineDown,
LineDownCharwise, LineDownCharwise,
ScreenLineDown,
ScreenLineDownCharwise,
BeginningOfScreenLine,
FirstGraphicalOnScreenLine,
HalfOfScreen,
HalfOfScreenLineText,
WholeBuffer, WholeBuffer,
StartOfBuffer, StartOfBuffer,
EndOfBuffer, EndOfBuffer,
@@ -381,12 +382,8 @@ impl Motion {
&self, &self,
Self::BeginningOfLine Self::BeginningOfLine
| Self::BeginningOfFirstWord | Self::BeginningOfFirstWord
| Self::BeginningOfScreenLine
| Self::FirstGraphicalOnScreenLine
| Self::LineDownCharwise | Self::LineDownCharwise
| Self::LineUpCharwise | Self::LineUpCharwise
| Self::ScreenLineUpCharwise
| Self::ScreenLineDownCharwise
| Self::ToColumn | Self::ToColumn
| Self::TextObj(TextObj::Sentence(_)) | Self::TextObj(TextObj::Sentence(_))
| Self::TextObj(TextObj::Paragraph(_)) | Self::TextObj(TextObj::Paragraph(_))
@@ -395,20 +392,13 @@ impl Motion {
| Self::ToBrace(_) | Self::ToBrace(_)
| Self::ToBracket(_) | Self::ToBracket(_)
| Self::ToParen(_) | Self::ToParen(_)
| Self::ScreenLineDown
| Self::ScreenLineUp
| Self::Range(_, _) | Self::Range(_, _)
) )
} }
pub fn is_linewise(&self) -> bool { pub fn is_linewise(&self) -> bool {
matches!( matches!(
self, self,
Self::WholeLineInclusive Self::WholeLineInclusive | Self::WholeLineExclusive | Self::LineUp | Self::LineDown
| Self::WholeLineExclusive
| Self::LineUp
| Self::LineDown
| Self::ScreenLineDown
| Self::ScreenLineUp
) )
} }
} }

View File

@@ -5,7 +5,11 @@ use std::str::Chars;
use itertools::Itertools; use itertools::Itertools;
use crate::bitflags; use crate::bitflags;
use crate::expand::{Expander, expand_raw};
use crate::libsh::error::{ShErr, ShErrKind, ShResult}; use crate::libsh::error::{ShErr, ShErrKind, ShResult};
use crate::parse::lex::TkFlags;
use crate::readline::complete::SimpleCompleter;
use crate::readline::history::History;
use crate::readline::keys::KeyEvent; use crate::readline::keys::KeyEvent;
use crate::readline::linebuf::LineBuf; use crate::readline::linebuf::LineBuf;
use crate::readline::vicmd::{ use crate::readline::vicmd::{
@@ -13,7 +17,7 @@ use crate::readline::vicmd::{
WriteDest, WriteDest,
}; };
use crate::readline::vimode::{ModeReport, ViInsert, ViMode}; use crate::readline::vimode::{ModeReport, ViInsert, ViMode};
use crate::state::write_meta; use crate::state::{get_home, write_meta};
bitflags! { bitflags! {
#[derive(Debug,Clone,Copy,PartialEq,Eq)] #[derive(Debug,Clone,Copy,PartialEq,Eq)]
@@ -33,16 +37,64 @@ bitflags! {
struct ExEditor { struct ExEditor {
buf: LineBuf, buf: LineBuf,
mode: ViInsert, mode: ViInsert,
history: History,
} }
impl ExEditor { impl ExEditor {
pub fn new(history: History) -> Self {
let mut new = Self {
history,
..Default::default()
};
new.buf.update_graphemes();
new
}
pub fn clear(&mut self) { pub fn clear(&mut self) {
*self = Self::default() *self = Self::default()
} }
pub fn should_grab_history(&mut self, cmd: &ViCmd) -> bool {
cmd.verb().is_none()
&& (cmd
.motion()
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineUpCharwise)))
&& self.buf.start_of_line() == 0)
|| (cmd
.motion()
.is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineDownCharwise)))
&& self.buf.end_of_line() == self.buf.cursor_max())
}
pub fn scroll_history(&mut self, cmd: ViCmd) {
let count = &cmd.motion().unwrap().0;
let motion = &cmd.motion().unwrap().1;
let count = match motion {
Motion::LineUpCharwise => -(*count as isize),
Motion::LineDownCharwise => *count as isize,
_ => unreachable!(),
};
let entry = self.history.scroll(count);
if let Some(entry) = entry {
let buf = std::mem::take(&mut self.buf);
self.buf.set_buffer(entry.command().to_string());
if self.history.pending.is_none() {
self.history.pending = Some(buf);
}
self.buf.set_hint(None);
self.buf.move_cursor_to_end();
} else if let Some(pending) = self.history.pending.take() {
self.buf = pending;
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<()> { pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<()> {
let Some(cmd) = self.mode.handle_key(key) else { let Some(mut cmd) = self.mode.handle_key(key) else {
return Ok(()); return Ok(());
}; };
cmd.alter_line_motion_if_no_verb();
log::debug!("ExEditor got cmd: {:?}", cmd);
if self.should_grab_history(&cmd) {
log::debug!("Grabbing history for cmd: {:?}", cmd);
self.scroll_history(cmd);
return Ok(());
}
self.buf.exec_cmd(cmd) self.buf.exec_cmd(cmd)
} }
} }
@@ -53,8 +105,10 @@ pub struct ViEx {
} }
impl ViEx { impl ViEx {
pub fn new() -> Self { pub fn new(history: History) -> Self {
Self::default() Self {
pending_cmd: ExEditor::new(history),
}
} }
} }
@@ -62,18 +116,12 @@ impl ViMode for ViEx {
// Ex mode can return errors, so we use this fallible method instead of the normal one // Ex mode can return errors, so we use this fallible method instead of the normal one
fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult<Option<ViCmd>> { fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult<Option<ViCmd>> {
use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M}; use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M};
log::debug!("[ViEx] handle_key_fallible: key={:?}", key);
match key { match key {
E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => { E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => {
let input = self.pending_cmd.buf.as_str(); let input = self.pending_cmd.buf.as_str();
log::debug!("[ViEx] Enter pressed, pending_cmd={:?}", input);
match parse_ex_cmd(input) { match parse_ex_cmd(input) {
Ok(cmd) => { Ok(cmd) => Ok(cmd),
log::debug!("[ViEx] parse_ex_cmd Ok: {:?}", cmd);
Ok(cmd)
}
Err(e) => { Err(e) => {
log::debug!("[ViEx] parse_ex_cmd Err: {:?}", e);
let msg = e.unwrap_or(format!("Not an editor command: {}", input)); let msg = e.unwrap_or(format!("Not an editor command: {}", input));
write_meta(|m| m.post_system_message(msg.clone())); write_meta(|m| m.post_system_message(msg.clone()));
Err(ShErr::simple(ShErrKind::ParseErr, msg)) Err(ShErr::simple(ShErrKind::ParseErr, msg))
@@ -81,29 +129,21 @@ impl ViMode for ViEx {
} }
} }
E(C::Char('C'), M::CTRL) => { E(C::Char('C'), M::CTRL) => {
log::debug!("[ViEx] Ctrl-C, clearing");
self.pending_cmd.clear(); self.pending_cmd.clear();
Ok(None) Ok(None)
} }
E(C::Esc, M::NONE) => { E(C::Esc, M::NONE) => Ok(Some(ViCmd {
log::debug!("[ViEx] Esc, returning to normal mode"); register: RegisterName::default(),
Ok(Some(ViCmd { verb: Some(VerbCmd(1, Verb::NormalMode)),
register: RegisterName::default(), motion: None,
verb: Some(VerbCmd(1, Verb::NormalMode)), flags: CmdFlags::empty(),
motion: None, raw_seq: "".into(),
flags: CmdFlags::empty(), })),
raw_seq: "".into(), _ => self.pending_cmd.handle_key(key).map(|_| None),
}))
}
_ => {
log::debug!("[ViEx] forwarding key to ExEditor");
self.pending_cmd.handle_key(key).map(|_| None)
}
} }
} }
fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> { fn handle_key(&mut self, key: KeyEvent) -> Option<ViCmd> {
let result = self.handle_key_fallible(key); let result = self.handle_key_fallible(key);
log::debug!("[ViEx] handle_key result: {:?}", result);
result.ok().flatten() result.ok().flatten()
} }
fn is_repeatable(&self) -> bool { fn is_repeatable(&self) -> bool {
@@ -114,6 +154,14 @@ impl ViMode for ViEx {
None None
} }
fn editor(&mut self) -> Option<&mut LineBuf> {
Some(&mut self.pending_cmd.buf)
}
fn history(&mut self) -> Option<&mut History> {
Some(&mut self.pending_cmd.history)
}
fn cursor_style(&self) -> String { fn cursor_style(&self) -> String {
"\x1b[3 q".to_string() "\x1b[3 q".to_string()
} }
@@ -177,7 +225,7 @@ fn parse_ex_cmd(raw: &str) -> Result<Option<ViCmd>, Option<String>> {
verb, verb,
motion, motion,
raw_seq: raw.to_string(), raw_seq: raw.to_string(),
flags: CmdFlags::EXIT_CUR_MODE, flags: CmdFlags::EXIT_CUR_MODE | CmdFlags::IS_EX_CMD,
})) }))
} }
@@ -207,7 +255,7 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
let mut cmd_name = String::new(); let mut cmd_name = String::new();
while let Some(ch) = chars.peek() { while let Some(ch) = chars.peek() {
if ch == &'!' { if cmd_name.is_empty() && ch == &'!' {
cmd_name.push(*ch); cmd_name.push(*ch);
chars.next(); chars.next();
break; break;
@@ -224,12 +272,17 @@ fn parse_ex_command(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Opt
let cmd = unescape_shell_cmd(&cmd); let cmd = unescape_shell_cmd(&cmd);
Ok(Some(Verb::ShellCmd(cmd))) Ok(Some(Verb::ShellCmd(cmd)))
} }
_ if "help".starts_with(&cmd_name) => {
let cmd = "help ".to_string() + chars.collect::<String>().trim();
Ok(Some(Verb::ShellCmd(cmd)))
}
"normal!" => parse_normal(chars), "normal!" => parse_normal(chars),
_ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)), _ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)),
_ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)), _ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)),
_ if "put".starts_with(&cmd_name) => Ok(Some(Verb::Put(Anchor::After))), _ if "put".starts_with(&cmd_name) => Ok(Some(Verb::Put(Anchor::After))),
_ if "read".starts_with(&cmd_name) => parse_read(chars), _ if "read".starts_with(&cmd_name) => parse_read(chars),
_ if "write".starts_with(&cmd_name) => parse_write(chars), _ if "write".starts_with(&cmd_name) => parse_write(chars),
_ if "edit".starts_with(&cmd_name) => parse_edit(chars),
_ if "substitute".starts_with(&cmd_name) => parse_substitute(chars), _ if "substitute".starts_with(&cmd_name) => parse_substitute(chars),
_ => Err(None), _ => Err(None),
} }
@@ -244,6 +297,19 @@ fn parse_normal(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<
Ok(Some(Verb::Normal(seq))) Ok(Some(Verb::Normal(seq)))
} }
fn parse_edit(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
chars
.peeking_take_while(|c| c.is_whitespace())
.for_each(drop);
let arg: String = chars.collect();
if arg.trim().is_empty() {
return Err(Some("Expected file path after ':edit'".into()));
}
let arg_path = get_path(arg.trim())?;
Ok(Some(Verb::Edit(arg_path)))
}
fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> { fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
chars chars
.peeking_take_while(|c| c.is_whitespace()) .peeking_take_while(|c| c.is_whitespace())
@@ -266,23 +332,20 @@ fn parse_read(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<St
if is_shell_read { if is_shell_read {
Ok(Some(Verb::Read(ReadSrc::Cmd(arg)))) Ok(Some(Verb::Read(ReadSrc::Cmd(arg))))
} else { } else {
let arg_path = get_path(arg.trim()); let arg_path = get_path(arg.trim())?;
Ok(Some(Verb::Read(ReadSrc::File(arg_path)))) Ok(Some(Verb::Read(ReadSrc::File(arg_path))))
} }
} }
fn get_path(path: &str) -> PathBuf { fn get_path(path: &str) -> Result<PathBuf, Option<String>> {
if let Some(stripped) = path.strip_prefix("~/") log::debug!("Expanding path: {}", path);
&& let Some(home) = std::env::var_os("HOME") let expanded = Expander::from_raw(path, TkFlags::empty())
{ .map_err(|e| Some(format!("Error expanding path: {}", e)))?
return PathBuf::from(home).join(stripped); .expand()
} .map_err(|e| Some(format!("Error expanding path: {}", e)))?
if path == "~" .join(" ");
&& let Some(home) = std::env::var_os("HOME") log::debug!("Expanded path: {}", expanded);
{ Ok(PathBuf::from(&expanded))
return PathBuf::from(home);
}
PathBuf::from(path)
} }
fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> { fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<String>> {
@@ -305,7 +368,7 @@ fn parse_write(chars: &mut Peekable<Chars<'_>>) -> Result<Option<Verb>, Option<S
} }
let arg: String = chars.collect(); let arg: String = chars.collect();
let arg_path = get_path(arg.trim()); let arg_path = get_path(arg.trim())?;
let dest = if is_file_append { let dest = if is_file_append {
WriteDest::FileAppend(arg_path) WriteDest::FileAppend(arg_path)

View File

@@ -3,7 +3,9 @@ use std::fmt::Display;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use crate::libsh::error::ShResult; use crate::libsh::error::ShResult;
use crate::readline::history::History;
use crate::readline::keys::{KeyCode as K, KeyEvent as E, ModKeys as M}; use crate::readline::keys::{KeyCode as K, KeyEvent as E, ModKeys as M};
use crate::readline::linebuf::LineBuf;
use crate::readline::vicmd::{Motion, MotionCmd, To, Verb, VerbCmd, ViCmd}; use crate::readline::vicmd::{Motion, MotionCmd, To, Verb, VerbCmd, ViCmd};
pub mod ex; pub mod ex;
@@ -82,6 +84,12 @@ pub trait ViMode {
fn pending_cursor(&self) -> Option<usize> { fn pending_cursor(&self) -> Option<usize> {
None None
} }
fn editor(&mut self) -> Option<&mut LineBuf> {
None
}
fn history(&mut self) -> Option<&mut History> {
None
}
fn move_cursor_on_undo(&self) -> bool; fn move_cursor_on_undo(&self) -> bool;
fn clamp_cursor(&self) -> bool; fn clamp_cursor(&self) -> bool;
fn hist_scroll_start_pos(&self) -> Option<To>; fn hist_scroll_start_pos(&self) -> Option<To>;

View File

@@ -450,26 +450,10 @@ impl ViNormal {
Motion::WordMotion(To::End, Word::Big, Direction::Backward), Motion::WordMotion(To::End, Word::Big, Direction::Backward),
)); ));
} }
'k' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineUp));
}
'j' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineDown));
}
'_' => { '_' => {
chars = chars_clone; chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::EndOfLastWord)); break 'motion_parse Some(MotionCmd(count, Motion::EndOfLastWord));
} }
'0' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfScreenLine));
}
'^' => {
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::FirstGraphicalOnScreenLine));
}
_ => return self.quit_parse(), _ => return self.quit_parse(),
} }
} }

View File

@@ -376,16 +376,6 @@ impl ViVisual {
Motion::WordMotion(To::End, Word::Big, Direction::Backward), Motion::WordMotion(To::End, Word::Big, Direction::Backward),
)); ));
} }
'k' => {
chars_clone.next();
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineUp));
}
'j' => {
chars_clone.next();
chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::ScreenLineDown));
}
_ => return self.quit_parse(), _ => return self.quit_parse(),
} }
} else { } else {

View File

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

View File

@@ -1,31 +1,45 @@
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering}; use std::{
collections::VecDeque,
sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering},
};
use nix::sys::signal::{SaFlags, SigAction, sigaction}; use nix::{
sys::signal::{SaFlags, SigAction, sigaction},
unistd::getpid,
};
use crate::{ use crate::{
builtin::trap::TrapTarget, builtin::trap::TrapTarget,
jobs::{JobCmdFlags, JobID, take_term}, jobs::{Job, JobCmdFlags, JobID, take_term},
libsh::error::{ShErr, ShErrKind, ShResult}, libsh::error::{ShErr, ShErrKind, ShResult},
parse::execute::exec_input, parse::execute::exec_input,
prelude::*, prelude::*,
state::{AutoCmd, AutoCmdKind, read_jobs, read_logic, write_jobs, write_meta}, state::{
AutoCmd, AutoCmdKind, VarFlags, VarKind, read_jobs, read_logic, write_jobs, write_meta,
write_vars,
},
}; };
static SIGNALS: AtomicU64 = AtomicU64::new(0); static SIGNALS: AtomicU64 = AtomicU64::new(0);
pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true); pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true);
pub static SHOULD_QUIT: AtomicBool = AtomicBool::new(false); pub static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
pub static JOB_DONE: AtomicBool = AtomicBool::new(false); pub static JOB_DONE: AtomicBool = AtomicBool::new(false);
pub static QUIT_CODE: AtomicI32 = AtomicI32::new(0); pub static QUIT_CODE: AtomicI32 = AtomicI32::new(0);
const MISC_SIGNALS: [Signal; 22] = [ /// Window size change signal
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
/// SIGUSR1 tells the prompt that it needs to fully refresh.
/// Useful for dynamic prompt content and asynchronous refreshing
pub static GOT_SIGUSR1: AtomicBool = AtomicBool::new(false);
const MISC_SIGNALS: [Signal; 21] = [
Signal::SIGILL, Signal::SIGILL,
Signal::SIGTRAP, Signal::SIGTRAP,
Signal::SIGABRT, Signal::SIGABRT,
Signal::SIGBUS, Signal::SIGBUS,
Signal::SIGFPE, Signal::SIGFPE,
Signal::SIGUSR1,
Signal::SIGSEGV, Signal::SIGSEGV,
Signal::SIGUSR2, Signal::SIGUSR2,
Signal::SIGPIPE, Signal::SIGPIPE,
@@ -65,7 +79,7 @@ pub fn check_signals() -> ShResult<()> {
if got_signal(Signal::SIGINT) { if got_signal(Signal::SIGINT) {
interrupt()?; interrupt()?;
run_trap(Signal::SIGINT)?; run_trap(Signal::SIGINT)?;
return Err(ShErr::simple(ShErrKind::ClearReadline, "")); return Err(ShErr::simple(ShErrKind::Interrupt, ""));
} }
if got_signal(Signal::SIGHUP) { if got_signal(Signal::SIGHUP) {
run_trap(Signal::SIGHUP)?; run_trap(Signal::SIGHUP)?;
@@ -87,6 +101,10 @@ pub fn check_signals() -> ShResult<()> {
GOT_SIGWINCH.store(true, Ordering::SeqCst); GOT_SIGWINCH.store(true, Ordering::SeqCst);
run_trap(Signal::SIGWINCH)?; run_trap(Signal::SIGWINCH)?;
} }
if got_signal(Signal::SIGUSR1) {
GOT_SIGUSR1.store(true, Ordering::SeqCst);
run_trap(Signal::SIGUSR1)?;
}
for sig in MISC_SIGNALS { for sig in MISC_SIGNALS {
if got_signal(sig) { if got_signal(sig) {
@@ -316,6 +334,25 @@ pub fn child_exited(pid: Pid, status: WtStat) -> ShResult<()> {
let result = read_jobs(|j| j.query(JobID::Pgid(pgid)).cloned()); let result = read_jobs(|j| j.query(JobID::Pgid(pgid)).cloned());
if let Some(job) = result { if let Some(job) = result {
let job_complete_msg = job.display(&job_order, JobCmdFlags::PIDS).to_string(); let job_complete_msg = job.display(&job_order, JobCmdFlags::PIDS).to_string();
let statuses = job.get_stats();
for status in &statuses {
if let WtStat::Signaled(_, sig, _) = status
&& *sig == Signal::SIGINT
{
// Necessary to interrupt stuff like shell loops
kill(getpid(), Signal::SIGINT).ok();
}
}
if let Some(pipe_status) = Job::pipe_status(&statuses) {
let pipe_status = pipe_status
.into_iter()
.map(|s| s.to_string())
.collect::<VecDeque<String>>();
write_vars(|v| v.set_var("PIPESTATUS", VarKind::Arr(pipe_status), VarFlags::NONE))?;
}
let post_job_hooks = read_logic(|l| l.get_autocmds(AutoCmdKind::OnJobFinish)); let post_job_hooks = read_logic(|l| l.get_autocmds(AutoCmdKind::OnJobFinish));
for cmd in post_job_hooks { for cmd in post_job_hooks {

View File

@@ -8,7 +8,7 @@ use std::{
time::Duration, time::Duration,
}; };
use nix::unistd::{User, gethostname, getppid}; use nix::unistd::{User, gethostname, getppid, getuid};
use regex::Regex; use regex::Regex;
use crate::{ use crate::{
@@ -31,7 +31,7 @@ use crate::{
}, },
prelude::*, prelude::*,
readline::{ readline::{
complete::{BashCompSpec, CompSpec}, complete::{BashCompSpec, Candidate, CompSpec},
keys::KeyEvent, keys::KeyEvent,
markers, markers,
}, },
@@ -1001,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 { impl From<&[String]> for Var {
fn from(value: &[String]) -> Self { fn from(value: &[String]) -> Self {
let mut new = VecDeque::new(); let mut new = VecDeque::new();
@@ -1098,6 +1105,8 @@ impl VarTab {
.map(|hname| hname.to_string_lossy().to_string()) .map(|hname| hname.to_string_lossy().to_string())
.unwrap_or_default(); .unwrap_or_default();
let help_paths = format!("/usr/share/shed/doc:{home}/.local/share/shed/doc");
unsafe { unsafe {
env::set_var("IFS", " \t\n"); env::set_var("IFS", " \t\n");
env::set_var("HOST", hostname.clone()); env::set_var("HOST", hostname.clone());
@@ -1114,6 +1123,7 @@ impl VarTab {
env::set_var("SHELL", pathbuf_to_string(std::env::current_exe())); env::set_var("SHELL", pathbuf_to_string(std::env::current_exe()));
env::set_var("SHED_HIST", format!("{}/.shedhist", home)); env::set_var("SHED_HIST", format!("{}/.shedhist", home));
env::set_var("SHED_RC", format!("{}/.shedrc", home)); env::set_var("SHED_RC", format!("{}/.shedrc", home));
env::set_var("SHED_HPATH", help_paths);
} }
} }
pub fn init_sh_argv(&mut self) { pub fn init_sh_argv(&mut self) {
@@ -1330,6 +1340,15 @@ impl VarTab {
.get(&ShellParam::Status) .get(&ShellParam::Status)
.map(|s| s.to_string()) .map(|s| s.to_string())
.unwrap_or("0".into()), .unwrap_or("0".into()),
ShellParam::AllArgsStr => {
let ifs = get_separator();
self
.params
.get(&ShellParam::AllArgs)
.map(|s| s.replace(markers::ARG_SEP, &ifs).to_string())
.unwrap_or_default()
}
_ => self _ => self
.params .params
.get(&param) .get(&param)
@@ -1842,6 +1861,15 @@ pub fn change_dir<P: AsRef<Path>>(dir: P) -> ShResult<()> {
Ok(()) Ok(())
} }
pub fn get_separator() -> String {
env::var("IFS")
.unwrap_or(String::from(" "))
.chars()
.next()
.unwrap()
.to_string()
}
pub fn get_status() -> i32 { pub fn get_status() -> i32 {
read_vars(|v| v.get_param(ShellParam::Status)) read_vars(|v| v.get_param(ShellParam::Status))
.parse::<i32>() .parse::<i32>()
@@ -1851,19 +1879,44 @@ pub fn set_status(code: i32) {
write_vars(|v| v.set_param(ShellParam::Status, &code.to_string())) write_vars(|v| v.set_param(ShellParam::Status, &code.to_string()))
} }
pub fn source_rc() -> ShResult<()> { pub fn source_runtime_file(name: &str, env_var_name: Option<&str>) -> ShResult<()> {
let path = if let Ok(path) = env::var("SHED_RC") { let etc_path = PathBuf::from(format!("/etc/shed/{name}"));
if etc_path.is_file()
&& let Err(e) = source_file(etc_path)
{
e.print_error();
}
let path = if let Some(name) = env_var_name
&& let Ok(path) = env::var(name)
{
PathBuf::from(&path) PathBuf::from(&path)
} else if let Some(home) = get_home() {
home.join(format!(".{name}"))
} else { } else {
let home = env::var("HOME").unwrap(); return Err(ShErr::simple(
PathBuf::from(format!("{home}/.shedrc")) ShErrKind::InternalErr,
"could not determine home path",
));
}; };
if !path.exists() { if !path.is_file() {
return Err(ShErr::simple(ShErrKind::InternalErr, ".shedrc not found")); return Ok(());
} }
source_file(path) source_file(path)
} }
pub fn source_rc() -> ShResult<()> {
source_runtime_file("shedrc", Some("SHED_RC"))
}
pub fn source_login() -> ShResult<()> {
source_runtime_file("shed_profile", Some("SHED_PROFILE"))
}
pub fn source_env() -> ShResult<()> {
source_runtime_file("shedenv", Some("SHED_ENV"))
}
pub fn source_file(path: PathBuf) -> ShResult<()> { pub fn source_file(path: PathBuf) -> ShResult<()> {
let source_name = path.to_string_lossy().to_string(); let source_name = path.to_string_lossy().to_string();
let mut file = OpenOptions::new().read(true).open(path)?; let mut file = OpenOptions::new().read(true).open(path)?;
@@ -1873,3 +1926,42 @@ pub fn source_file(path: PathBuf) -> ShResult<()> {
exec_input(buf, None, false, Some(source_name))?; exec_input(buf, None, false, Some(source_name))?;
Ok(()) Ok(())
} }
#[track_caller]
pub fn get_home_unchecked() -> PathBuf {
if let Some(home) = get_home() {
home
} else {
let caller = std::panic::Location::caller();
panic!(
"get_home_unchecked: could not determine home directory (called from {}:{})",
caller.file(),
caller.line()
)
}
}
#[track_caller]
pub fn get_home_str_unchecked() -> String {
if let Some(home) = get_home() {
home.to_string_lossy().to_string()
} else {
let caller = std::panic::Location::caller();
panic!(
"get_home_str_unchecked: could not determine home directory (called from {}:{})",
caller.file(),
caller.line()
)
}
}
pub fn get_home() -> Option<PathBuf> {
env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(|| User::from_uid(getuid()).ok().flatten().map(|u| u.dir))
}
pub fn get_home_str() -> Option<String> {
get_home().map(|h| h.to_string_lossy().to_string())
}