diff --git a/Cargo.toml b/Cargo.toml index ea10038..b765058 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ rand = "0.10.0" regex = "1.11.1" scopeguard = "1.2.0" serde_json = "1.0.149" +tempfile = "3.24.0" unicode-segmentation = "1.12.0" unicode-width = "0.2.0" vte = "0.15" diff --git a/doc/arith.txt b/doc/arith.txt new file mode 100644 index 0000000..ea28191 --- /dev/null +++ b/doc/arith.txt @@ -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| diff --git a/doc/glob.txt b/doc/glob.txt new file mode 100644 index 0000000..857daeb --- /dev/null +++ b/doc/glob.txt @@ -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| diff --git a/doc/param.txt b/doc/param.txt new file mode 100644 index 0000000..d67cb39 --- /dev/null +++ b/doc/param.txt @@ -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 \}` + + `${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| diff --git a/doc/redirect.txt b/doc/redirect.txt new file mode 100644 index 0000000..f5d10c8 --- /dev/null +++ b/doc/redirect.txt @@ -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| diff --git a/src/builtin/help.rs b/src/builtin/help.rs new file mode 100644 index 0000000..9377c07 --- /dev/null +++ b/src/builtin/help.rs @@ -0,0 +1,291 @@ +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, file_name: Option) -> ShResult<()> { + let pager = 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 +} diff --git a/src/builtin/mod.rs b/src/builtin/mod.rs index db8f624..a17f930 100644 --- a/src/builtin/mod.rs +++ b/src/builtin/mod.rs @@ -25,13 +25,14 @@ pub mod source; pub mod test; // [[ ]] thing pub mod trap; pub mod varcmds; +pub mod help; -pub const BUILTINS: [&str; 50] = [ +pub const BUILTINS: [&str; 51] = [ "echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg", "disown", "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin", "command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly", "unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type", - "getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek", + "getopts", "keymap", "read_key", "autocmd", "ulimit", "umask", "seek", "help", ]; pub fn true_builtin() -> ShResult<()> { diff --git a/src/expand.rs b/src/expand.rs index 4f5f6ae..d576f32 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -479,21 +479,28 @@ pub fn expand_raw(chars: &mut Peekable>) -> ShResult { 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::() && let Ok(result) = User::from_uid(Uid::from_raw(id)) && let Some(user) = result { + // uid expansion like '~1000' + // shed only feature btw B) user.dir.to_string_lossy().to_string() } else { + // no match, use literal format!("~{username}") }; + result.push_str(&home); } markers::PROC_SUB_OUT => { @@ -1399,6 +1406,7 @@ pub fn unescape_str(raw: &str) -> String { /// 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(); @@ -1576,6 +1584,10 @@ pub fn unescape_math(raw: &str) -> String { #[derive(Debug)] pub enum ParamExp { Len, // #var_name + ToUpperFirst, // ^var_name + ToUpperAll, // ^^var_name + ToLowerFirst, // ,var_name + ToLowerAll, // ,,var_name DefaultUnsetOrNull(String), // :- DefaultUnset(String), // - SetDefaultUnsetOrNull(String), // := @@ -1611,6 +1623,11 @@ impl FromStr for ParamExp { )) }; + if s == "^^" { return Ok(ToUpperAll) } + if s == "^" { return Ok(ToUpperFirst) } + if s == ",," { return Ok(ToLowerAll) } + if s == "," { return Ok(ToLowerFirst) } + // Handle indirect var expansion: ${!var} if let Some(var) = s.strip_prefix('!') { if var.ends_with('*') || var.ends_with('@') { @@ -1716,7 +1733,7 @@ pub fn perform_param_expansion(raw: &str) -> ShResult { while let Some(ch) = chars.next() { match ch { - '!' | '#' | '%' | ':' | '-' | '+' | '=' | '/' | '?' => { + '!' | '#' | '%' | ':' | '-' | '+' | '^' | ',' | '=' | '/' | '?' => { rest.push(ch); rest.push_str(&chars.collect::()); break; @@ -1728,6 +1745,32 @@ pub fn perform_param_expansion(raw: &str) -> ShResult { if let Ok(expansion) = rest.parse::() { match expansion { ParamExp::Len => unreachable!(), + ParamExp::ToUpperAll => { + let value = vars.get_var(&var_name); + Ok(value.to_uppercase()) + } + ParamExp::ToUpperFirst => { + let value = vars.get_var(&var_name); + let mut chars = value.chars(); + let first = chars.next() + .map(|c| c.to_uppercase() + .to_string()) + .unwrap_or_default(); + Ok(first + chars.as_str()) + + } + ParamExp::ToLowerAll => { + let value = vars.get_var(&var_name); + Ok(value.to_lowercase()) + } + ParamExp::ToLowerFirst => { + let value = vars.get_var(&var_name); + let mut chars = value.chars(); + let first = chars.next() + .map(|c| c.to_lowercase().to_string()) + .unwrap_or_default(); + Ok(first + chars.as_str()) + } ParamExp::DefaultUnsetOrNull(default) => { match vars.try_get_var(&var_name).filter(|v| !v.is_empty()) { Some(val) => Ok(val), diff --git a/src/parse/execute.rs b/src/parse/execute.rs index 1a9d19f..db1d0a6 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -8,29 +8,7 @@ use ariadne::Fmt; use crate::{ builtin::{ - alias::{alias, unalias}, - arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, - autocmd::autocmd, - cd::cd, - complete::{compgen_builtin, complete_builtin}, - dirstack::{dirs, popd, pushd}, - echo::echo, - eval, exec, - flowctl::flowctl, - getopts::getopts, - intro, - jobctl::{self, JobBehavior, continue_job, disown, jobs}, - keymap, map, - pwd::pwd, - read::{self, read_builtin}, - resource::{ulimit, umask_builtin}, - seek::seek, - shift::shift, - shopt::shopt, - source::source, - test::double_bracket_test, - trap::{TrapTarget, trap}, - varcmds::{export, local, readonly, unset}, + alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, help::help, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, seek::seek, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset} }, expand::{expand_aliases, expand_case_pattern, glob_to_regex}, jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, @@ -1017,6 +995,7 @@ impl Dispatcher { "ulimit" => ulimit(cmd), "umask" => umask_builtin(cmd), "seek" => seek(cmd), + "help" => help(cmd), "true" | ":" => { state::set_status(0); Ok(()) diff --git a/src/parse/mod.rs b/src/parse/mod.rs index 0d920fd..46063b9 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -88,7 +88,6 @@ impl ParsedSrc { Err(error) => return Err(vec![error]), } } - log::trace!("Tokens: {:#?}", tokens); let mut errors = vec![]; let mut nodes = vec![]; @@ -1038,7 +1037,6 @@ impl ParseStream { Ok(Some(node)) } fn parse_brc_grp(&mut self, from_func_def: bool) -> ShResult> { - log::debug!("Trying to parse a brace group"); let mut node_tks: Vec = vec![]; let mut body: Vec = vec![]; let mut redirs: Vec = vec![]; @@ -1051,7 +1049,6 @@ impl ParseStream { self.catch_separator(&mut node_tks); loop { - log::debug!("Parsing a brace group body"); if *self.next_tk_class() == TkRule::BraceGrpEnd { node_tks.push(self.next_tk().unwrap()); break; @@ -1078,7 +1075,6 @@ impl ParseStream { } self.catch_separator(&mut node_tks); 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); return Err(parse_err_full( "Expected a closing brace for this brace group", @@ -1088,15 +1084,11 @@ impl ParseStream { } } - log::debug!( - "Finished parsing brace group body, now looking for redirections if it's not a function definition" - ); if !from_func_def { self.parse_redir(&mut redirs, &mut node_tks)?; } - log::debug!("Finished parsing brace group redirections, constructing node"); let node = Node { class: NdRule::BraceGrp { body }, diff --git a/src/procio.rs b/src/procio.rs index 0c5f2a6..b6b3fe4 100644 --- a/src/procio.rs +++ b/src/procio.rs @@ -115,6 +115,11 @@ impl IoMode { pub fn buffer(tgt_fd: RawFd, buf: String, flags: TkFlags) -> ShResult { Ok(Self::Buffer { tgt_fd, buf, flags }) } + pub fn loaded_pipe(tgt_fd: RawFd, buf: &[u8]) -> ShResult { + let (rpipe, wpipe) = nix::unistd::pipe()?; + write(wpipe, buf)?; + Ok(Self::Pipe { tgt_fd, pipe: rpipe.into() }) + } pub fn get_pipes() -> (Self, Self) { let (rpipe, wpipe) = nix::unistd::pipe2(OFlag::O_CLOEXEC).unwrap(); ( diff --git a/src/readline/complete.rs b/src/readline/complete.rs index b92f44d..af4b8aa 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -739,7 +739,7 @@ impl QueryEditor { } } -#[derive(Clone, Debug)] +#[derive(Clone, Default, Debug)] pub struct FuzzySelector { query: QueryEditor, filtered: Vec, diff --git a/src/readline/history.rs b/src/readline/history.rs index a1e5371..ba1fbe3 100644 --- a/src/readline/history.rs +++ b/src/readline/history.rs @@ -203,6 +203,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec { .collect() } +#[derive(Default,Clone,Debug)] pub struct History { path: PathBuf, pub pending: Option, // command, cursor_pos @@ -214,6 +215,7 @@ pub struct History { //search_direction: Direction, ignore_dups: bool, max_size: Option, + stateless: bool } impl History { @@ -229,6 +231,7 @@ impl History { //search_direction: Direction::Backward, ignore_dups: false, max_size: None, + stateless: true, } } pub fn new() -> ShResult { @@ -266,6 +269,7 @@ impl History { //search_direction: Direction::Backward, ignore_dups, max_size, + stateless: false, }) } @@ -450,6 +454,9 @@ impl History { } pub fn save(&mut self) -> ShResult<()> { + if self.stateless { + return Ok(()); + } let mut file = OpenOptions::new() .create(true) .append(true) diff --git a/src/readline/linebuf.rs b/src/readline/linebuf.rs index be10fee..d86d64c 100644 --- a/src/readline/linebuf.rs +++ b/src/readline/linebuf.rs @@ -421,7 +421,7 @@ impl IndentCtx { } } -#[derive(Default, Clone, Debug)] +#[derive(Clone, Debug)] pub struct LineBuf { pub buffer: String, pub hint: Option, @@ -440,6 +440,28 @@ pub struct LineBuf { pub redo_stack: Vec, } +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 { pub fn new() -> Self { let mut new = Self { diff --git a/src/readline/mod.rs b/src/readline/mod.rs index de6077e..9ce0816 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -132,6 +132,18 @@ pub mod markers { pub fn is_marker(c: Marker) -> bool { ('\u{e000}'..'\u{efff}').contains(&c) } + + // Help command formatting markers + pub const TAG: Marker = '\u{e180}'; + pub const REFERENCE: Marker = '\u{e181}'; + pub const HEADER: Marker = '\u{e182}'; + pub const CODE: Marker = '\u{e183}'; + /// angle brackets + pub const KEYWORD_1: Marker = '\u{e184}'; + /// curly brackets + pub const KEYWORD_2: Marker = '\u{e185}'; + /// square brackets + pub const KEYWORD_3: Marker = '\u{e186}'; } type Marker = char; @@ -256,6 +268,7 @@ pub struct ShedVi { pub old_layout: Option, pub history: History, + pub ex_history: History, pub needs_redraw: bool, } @@ -277,6 +290,7 @@ impl ShedVi { repeat_motion: None, editor: LineBuf::new(), history: History::new()?, + ex_history: History::empty(), needs_redraw: true, }; write_vars(|v| { @@ -308,6 +322,7 @@ impl ShedVi { repeat_motion: None, editor: LineBuf::new(), history: History::empty(), + ex_history: History::empty(), needs_redraw: true, }; write_vars(|v| { @@ -798,7 +813,8 @@ impl ShedVi { let Ok(cmd) = self.mode.handle_key_fallible(key) else { // it's an ex mode error - self.mode = Box::new(ViNormal::new()) as Box; + self.swap_mode(&mut (Box::new(ViNormal::new()) as Box)); + return Ok(None); }; @@ -844,9 +860,22 @@ impl ShedVi { } let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit()); + let is_shell_cmd = cmd.verb().is_some_and(|v| matches!(v.1, Verb::ShellCmd(_))); + let is_ex_cmd = cmd.flags.contains(CmdFlags::IS_EX_CMD); + log::debug!("is_ex_cmd: {is_ex_cmd}"); + if is_shell_cmd { + self.old_layout = None; + } + if is_ex_cmd { + self.ex_history.push(cmd.raw_seq.clone()); + self.ex_history.reset(); + log::debug!("ex_history: {:?}", self.ex_history.entries()); + } let before = self.editor.buffer.clone(); + self.exec_cmd(cmd, false)?; + if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) { for key in keys { self.handle_key(key)?; @@ -1131,7 +1160,7 @@ impl ShedVi { ) } - Verb::ExMode => Box::new(ViEx::new()), + Verb::ExMode => Box::new(ViEx::new(self.ex_history.clone())), Verb::VerbatimMode => { self.reader.verbatim_single = true; @@ -1221,7 +1250,7 @@ impl ShedVi { ModeReport::Normal => Box::new(ViNormal::new()), ModeReport::Insert => Box::new(ViInsert::new()), ModeReport::Visual => Box::new(ViVisual::new()), - ModeReport::Ex => Box::new(ViEx::new()), + ModeReport::Ex => Box::new(ViEx::new(self.ex_history.clone())), ModeReport::Replace => Box::new(ViReplace::new()), ModeReport::Verbatim => Box::new(ViVerbatim::new()), ModeReport::Unknown => unreachable!(), @@ -1266,7 +1295,7 @@ impl ShedVi { ModeReport::Normal => Box::new(ViNormal::new()) as Box, ModeReport::Insert => Box::new(ViInsert::new()) as Box, ModeReport::Visual => Box::new(ViVisual::new()) as Box, - ModeReport::Ex => Box::new(ViEx::new()) as Box, + ModeReport::Ex => Box::new(ViEx::new(self.ex_history.clone())) as Box, ModeReport::Replace => Box::new(ViReplace::new()) as Box, ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box, ModeReport::Unknown => unreachable!(), diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index 47f4e54..3deb968 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -64,6 +64,7 @@ bitflags! { const VISUAL_LINE = 1<<1; const VISUAL_BLOCK = 1<<2; const EXIT_CUR_MODE = 1<<3; + const IS_EX_CMD = 1<<4; } } diff --git a/src/readline/vimode/ex.rs b/src/readline/vimode/ex.rs index 03843e7..bb2f883 100644 --- a/src/readline/vimode/ex.rs +++ b/src/readline/vimode/ex.rs @@ -6,6 +6,7 @@ use itertools::Itertools; use crate::bitflags; use crate::libsh::error::{ShErr, ShErrKind, ShResult}; +use crate::readline::history::History; use crate::readline::keys::KeyEvent; use crate::readline::linebuf::LineBuf; use crate::readline::vicmd::{ @@ -33,16 +34,64 @@ bitflags! { struct ExEditor { buf: LineBuf, mode: ViInsert, + history: History } impl ExEditor { + pub fn new(history: History) -> Self { + let mut new = Self { + history, + ..Default::default() + }; + new.buf.update_graphemes(); + new + } pub fn clear(&mut self) { *self = Self::default() } + pub fn should_grab_history(&mut self, cmd: &ViCmd) -> bool { + cmd.verb().is_none() + && (cmd + .motion() + .is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineUpCharwise))) + && self.buf.start_of_line() == 0) + || (cmd + .motion() + .is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineDownCharwise))) + && self.buf.end_of_line() == self.buf.cursor_max()) + } + pub fn scroll_history(&mut self, cmd: ViCmd) { + let count = &cmd.motion().unwrap().0; + let motion = &cmd.motion().unwrap().1; + let count = match motion { + Motion::LineUpCharwise => -(*count as isize), + Motion::LineDownCharwise => *count as isize, + _ => unreachable!(), + }; + let entry = self.history.scroll(count); + if let Some(entry) = entry { + let buf = std::mem::take(&mut self.buf); + self.buf.set_buffer(entry.command().to_string()); + if self.history.pending.is_none() { + self.history.pending = Some(buf); + } + self.buf.set_hint(None); + self.buf.move_cursor_to_end(); + } else if let Some(pending) = self.history.pending.take() { + self.buf = pending; + } + } pub fn handle_key(&mut self, key: KeyEvent) -> ShResult<()> { - let Some(cmd) = self.mode.handle_key(key) else { + let Some(mut cmd) = self.mode.handle_key(key) else { return Ok(()); }; + cmd.alter_line_motion_if_no_verb(); + log::debug!("ExEditor got cmd: {:?}", cmd); + if self.should_grab_history(&cmd) { + log::debug!("Grabbing history for cmd: {:?}", cmd); + self.scroll_history(cmd); + return Ok(()) + } self.buf.exec_cmd(cmd) } } @@ -53,8 +102,8 @@ pub struct ViEx { } impl ViEx { - pub fn new() -> Self { - Self::default() + pub fn new(history: History) -> Self { + Self { pending_cmd: ExEditor::new(history) } } } @@ -62,18 +111,14 @@ impl ViMode for ViEx { // Ex mode can return errors, so we use this fallible method instead of the normal one fn handle_key_fallible(&mut self, key: KeyEvent) -> ShResult> { use crate::readline::keys::{KeyCode as C, KeyEvent as E, ModKeys as M}; - log::debug!("[ViEx] handle_key_fallible: key={:?}", key); match key { E(C::Char('\r'), M::NONE) | E(C::Enter, M::NONE) => { let input = self.pending_cmd.buf.as_str(); - log::debug!("[ViEx] Enter pressed, pending_cmd={:?}", input); match parse_ex_cmd(input) { Ok(cmd) => { - log::debug!("[ViEx] parse_ex_cmd Ok: {:?}", cmd); Ok(cmd) } Err(e) => { - log::debug!("[ViEx] parse_ex_cmd Err: {:?}", e); let msg = e.unwrap_or(format!("Not an editor command: {}", input)); write_meta(|m| m.post_system_message(msg.clone())); Err(ShErr::simple(ShErrKind::ParseErr, msg)) @@ -81,12 +126,10 @@ impl ViMode for ViEx { } } E(C::Char('C'), M::CTRL) => { - log::debug!("[ViEx] Ctrl-C, clearing"); self.pending_cmd.clear(); Ok(None) } E(C::Esc, M::NONE) => { - log::debug!("[ViEx] Esc, returning to normal mode"); Ok(Some(ViCmd { register: RegisterName::default(), verb: Some(VerbCmd(1, Verb::NormalMode)), @@ -96,14 +139,12 @@ impl ViMode for ViEx { })) } _ => { - log::debug!("[ViEx] forwarding key to ExEditor"); self.pending_cmd.handle_key(key).map(|_| None) } } } fn handle_key(&mut self, key: KeyEvent) -> Option { let result = self.handle_key_fallible(key); - log::debug!("[ViEx] handle_key result: {:?}", result); result.ok().flatten() } fn is_repeatable(&self) -> bool { @@ -177,7 +218,7 @@ fn parse_ex_cmd(raw: &str) -> Result, Option> { verb, motion, raw_seq: raw.to_string(), - flags: CmdFlags::EXIT_CUR_MODE, + flags: CmdFlags::EXIT_CUR_MODE | CmdFlags::IS_EX_CMD, })) } @@ -224,6 +265,10 @@ fn parse_ex_command(chars: &mut Peekable>) -> Result, Opt let cmd = unescape_shell_cmd(&cmd); Ok(Some(Verb::ShellCmd(cmd))) } + _ if "help".starts_with(&cmd_name) => { + let cmd = "help ".to_string() + chars.collect::().trim(); + Ok(Some(Verb::ShellCmd(cmd))) + } "normal!" => parse_normal(chars), _ if "delete".starts_with(&cmd_name) => Ok(Some(Verb::Delete)), _ if "yank".starts_with(&cmd_name) => Ok(Some(Verb::Yank)), diff --git a/src/state.rs b/src/state.rs index 9d513a1..690a5d9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1098,6 +1098,8 @@ impl VarTab { .map(|hname| hname.to_string_lossy().to_string()) .unwrap_or_default(); + let help_paths = format!("/usr/share/shed/doc:{home}/.local/share/shed/doc"); + unsafe { env::set_var("IFS", " \t\n"); env::set_var("HOST", hostname.clone()); @@ -1114,6 +1116,7 @@ impl VarTab { env::set_var("SHELL", pathbuf_to_string(std::env::current_exe())); env::set_var("SHED_HIST", format!("{}/.shedhist", home)); env::set_var("SHED_RC", format!("{}/.shedrc", home)); + env::set_var("SHED_HPATH", help_paths); } } pub fn init_sh_argv(&mut self) {