Compare commits

...

16 Commits

Author SHA1 Message Date
8c6de4f4ec 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   ()  { }' not parse correctly

fixed conjunctions short circuiting instead of skipping
2026-03-15 00:01:33 -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
09024728f6 Add token-aware depth calculator for indentation, improve brace group error handling, and clean up warnings 2026-03-13 20:57:04 -04:00
307386ffc6 tightened up some logic with indenting and joining lines
added more linebuf tests

extracted all verb match arms into private methods on LineBuf
2026-03-13 19:24:30 -04:00
13227943c6 Add unit and integration tests for tab completion, fuzzy scoring, escaping, and wordbreak handling 2026-03-13 18:40:29 -04:00
a46ebe6868 Use COMP_WORDBREAKS for completion word breaking, fix cursor row in vi command mode, and append completion suffix instead of replacing full token 2026-03-13 11:18:57 -04:00
5500b081fe Strip escape markers from expanded patterns in parameter expansion operations 2026-03-12 09:20:07 -04:00
f279159873 tab completion and glob results are now properly escaped before being parsed 2026-03-11 18:48:07 -04:00
bb3db444db Add screensaver idle command support, autocd directory completion, and unused import cleanup 2026-03-10 12:20:40 -04:00
85e5fc2875 Fork non-command nodes for background jobs, fix interactive flag in child processes, and add empty variable test for [ builtin 2026-03-09 21:55:03 -04:00
ac429cbdf4 Fix crash when using vi visual selection on empty buffer 2026-03-08 00:36:46 -05:00
a464540fbe Implement SHLVL tracking, LINENO variable, and magic shell variables (SECONDS, EPOCHREALTIME, EPOCHSECONDS, RANDOM) 2026-03-08 00:30:22 -05:00
07d7015dd4 Add ! negation support, fix POSIX exit statuses, and improve vi emulation with comprehensive tests 2026-03-07 22:04:33 -05:00
490ce4571d added tests for the parser 2026-03-07 14:38:07 -05:00
51 changed files with 6857 additions and 2574 deletions

View File

@@ -8,7 +8,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

View File

@@ -38,28 +38,30 @@ pub fn alias(node: Node) -> ShResult<()> {
write(stdout, alias_output.as_bytes())?; // Write it write(stdout, alias_output.as_bytes())?; // Write it
} else { } else {
for (arg, span) in argv { for (arg, span) in argv {
let Some((name, body)) = arg.split_once('=') else { let Some((name, body)) = arg.split_once('=') else {
let Some(alias) = read_logic(|l| l.get_alias(&arg)) else { let Some(alias) = read_logic(|l| l.get_alias(&arg)) else {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
span, span,
"alias: Expected an assignment in alias args", "alias: Expected an assignment in alias args",
)); ));
}; };
let alias_output = format!("{arg}='{alias}'"); let alias_output = format!("{arg}='{alias}'");
let stdout = borrow_fd(STDOUT_FILENO); let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, alias_output.as_bytes())?; // Write it write(stdout, alias_output.as_bytes())?; // Write it
state::set_status(0); state::set_status(0);
return Ok(()); return Ok(());
}; };
if name == "command" || name == "builtin" { if name == "command" || name == "builtin" {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::ExecFail, ShErrKind::ExecFail,
span, span,
format!("alias: Cannot assign alias to reserved name '{}'", name.fg(next_color())), format!(
"alias: Cannot assign alias to reserved name '{}'",
name.fg(next_color())
),
)); ));
} }
write_logic(|l| l.insert_alias(name, body, span.clone())); write_logic(|l| l.insert_alias(name, body, span.clone()));
@@ -118,7 +120,7 @@ pub fn unalias(node: Node) -> ShResult<()> {
mod tests { mod tests {
use crate::state::{self, read_logic}; use crate::state::{self, read_logic};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
fn alias_set_and_expand() { fn alias_set_and_expand() {

View File

@@ -229,9 +229,9 @@ pub fn get_arr_op_opts(opts: Vec<Opt>) -> ShResult<ArrOpOpts> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::VecDeque; use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
use crate::state::{self, read_vars, write_vars, VarFlags, VarKind};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
use std::collections::VecDeque;
fn set_arr(name: &str, elems: &[&str]) { fn set_arr(name: &str, elems: &[&str]) {
let arr = VecDeque::from_iter(elems.iter().map(|s| s.to_string())); let arr = VecDeque::from_iter(elems.iter().map(|s| s.to_string()));

View File

@@ -159,7 +159,10 @@ mod tests {
test_input("autocmd post-cmd 'echo post'").unwrap(); test_input("autocmd post-cmd 'echo post'").unwrap();
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 1); assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 1);
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(), 1); assert_eq!(
read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(),
1
);
} }
// ===================== Pattern ===================== // ===================== Pattern =====================
@@ -205,7 +208,10 @@ mod tests {
test_input("autocmd -c pre-cmd").unwrap(); test_input("autocmd -c pre-cmd").unwrap();
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 0); assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 0);
assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(), 1); assert_eq!(
read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(),
1
);
} }
#[test] #[test]
@@ -245,11 +251,21 @@ mod tests {
fn all_kinds_parse() { fn all_kinds_parse() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let kinds = [ let kinds = [
"pre-cmd", "post-cmd", "pre-change-dir", "post-change-dir", "pre-cmd",
"on-job-finish", "pre-prompt", "post-prompt", "post-cmd",
"pre-mode-change", "post-mode-change", "pre-change-dir",
"on-history-open", "on-history-close", "on-history-select", "post-change-dir",
"on-completion-start", "on-completion-cancel", "on-completion-select", "on-job-finish",
"pre-prompt",
"post-prompt",
"pre-mode-change",
"post-mode-change",
"on-history-open",
"on-history-close",
"on-history-select",
"on-completion-start",
"on-completion-cancel",
"on-completion-select",
"on-exit", "on-exit",
]; ];
for kind in kinds { for kind in kinds {

View File

@@ -78,159 +78,165 @@ pub fn cd(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use std::env; use std::env;
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
use crate::state; use crate::state;
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
// ===================== Basic Navigation ===================== // ===================== Basic Navigation =====================
#[test] #[test]
fn cd_simple() { fn cd_simple() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let old_dir = env::current_dir().unwrap(); let old_dir = env::current_dir().unwrap();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap(); test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let new_dir = env::current_dir().unwrap(); let new_dir = env::current_dir().unwrap();
assert_ne!(old_dir, new_dir); assert_ne!(old_dir, new_dir);
assert_eq!(new_dir.display().to_string(), temp_dir.path().display().to_string()); assert_eq!(
} new_dir.display().to_string(),
temp_dir.path().display().to_string()
);
}
#[test] #[test]
fn cd_no_args_goes_home() { fn cd_no_args_goes_home() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
unsafe { env::set_var("HOME", temp_dir.path()) }; unsafe { env::set_var("HOME", temp_dir.path()) };
test_input("cd").unwrap(); test_input("cd").unwrap();
let cwd = env::current_dir().unwrap(); let cwd = env::current_dir().unwrap();
assert_eq!(cwd.display().to_string(), temp_dir.path().display().to_string()); assert_eq!(
} cwd.display().to_string(),
temp_dir.path().display().to_string()
);
}
#[test] #[test]
fn cd_relative_path() { fn cd_relative_path() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let sub = temp_dir.path().join("child"); let sub = temp_dir.path().join("child");
fs::create_dir(&sub).unwrap(); fs::create_dir(&sub).unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap(); test_input(format!("cd {}", temp_dir.path().display())).unwrap();
test_input("cd child").unwrap(); test_input("cd child").unwrap();
let cwd = env::current_dir().unwrap(); let cwd = env::current_dir().unwrap();
assert_eq!(cwd.display().to_string(), sub.display().to_string()); assert_eq!(cwd.display().to_string(), sub.display().to_string());
} }
// ===================== Environment ===================== // ===================== Environment =====================
#[test] #[test]
fn cd_sets_pwd_env() { fn cd_sets_pwd_env() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap(); test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let pwd = env::var("PWD").unwrap(); let pwd = env::var("PWD").unwrap();
assert_eq!(pwd, env::current_dir().unwrap().display().to_string()); assert_eq!(pwd, env::current_dir().unwrap().display().to_string());
} }
#[test] #[test]
fn cd_status_zero_on_success() { fn cd_status_zero_on_success() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
test_input(format!("cd {}", temp_dir.path().display())).unwrap(); test_input(format!("cd {}", temp_dir.path().display())).unwrap();
assert_eq!(state::get_status(), 0); assert_eq!(state::get_status(), 0);
} }
// ===================== Error Cases ===================== // ===================== Error Cases =====================
#[test] #[test]
fn cd_nonexistent_dir_fails() { fn cd_nonexistent_dir_fails() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let result = test_input("cd /nonexistent_path_that_does_not_exist_xyz"); let result = test_input("cd /nonexistent_path_that_does_not_exist_xyz");
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn cd_file_not_directory_fails() { fn cd_file_not_directory_fails() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("afile.txt"); let file_path = temp_dir.path().join("afile.txt");
fs::write(&file_path, "hello").unwrap(); fs::write(&file_path, "hello").unwrap();
let result = test_input(format!("cd {}", file_path.display())); let result = test_input(format!("cd {}", file_path.display()));
assert!(result.is_err()); assert!(result.is_err());
} }
// ===================== Multiple cd ===================== // ===================== Multiple cd =====================
#[test] #[test]
fn cd_multiple_times() { fn cd_multiple_times() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let dir_a = TempDir::new().unwrap(); let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap(); let dir_b = TempDir::new().unwrap();
test_input(format!("cd {}", dir_a.path().display())).unwrap(); test_input(format!("cd {}", dir_a.path().display())).unwrap();
assert_eq!( assert_eq!(
env::current_dir().unwrap().display().to_string(), env::current_dir().unwrap().display().to_string(),
dir_a.path().display().to_string() dir_a.path().display().to_string()
); );
test_input(format!("cd {}", dir_b.path().display())).unwrap(); test_input(format!("cd {}", dir_b.path().display())).unwrap();
assert_eq!( assert_eq!(
env::current_dir().unwrap().display().to_string(), env::current_dir().unwrap().display().to_string(),
dir_b.path().display().to_string() dir_b.path().display().to_string()
); );
} }
#[test] #[test]
fn cd_nested_subdirectories() { fn cd_nested_subdirectories() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let deep = temp_dir.path().join("a").join("b").join("c"); let deep = temp_dir.path().join("a").join("b").join("c");
fs::create_dir_all(&deep).unwrap(); fs::create_dir_all(&deep).unwrap();
test_input(format!("cd {}", deep.display())).unwrap(); test_input(format!("cd {}", deep.display())).unwrap();
assert_eq!( assert_eq!(
env::current_dir().unwrap().display().to_string(), env::current_dir().unwrap().display().to_string(),
deep.display().to_string() deep.display().to_string()
); );
} }
// ===================== Autocmd Integration ===================== // ===================== Autocmd Integration =====================
#[test] #[test]
fn cd_fires_post_change_dir_autocmd() { fn cd_fires_post_change_dir_autocmd() {
let guard = TestGuard::new(); let guard = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
test_input("autocmd post-change-dir 'echo cd-hook-fired'").unwrap(); test_input("autocmd post-change-dir 'echo cd-hook-fired'").unwrap();
guard.read_output(); guard.read_output();
test_input(format!("cd {}", temp_dir.path().display())).unwrap(); test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let out = guard.read_output(); let out = guard.read_output();
assert!(out.contains("cd-hook-fired")); assert!(out.contains("cd-hook-fired"));
} }
#[test] #[test]
fn cd_fires_pre_change_dir_autocmd() { fn cd_fires_pre_change_dir_autocmd() {
let guard = TestGuard::new(); let guard = TestGuard::new();
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
test_input("autocmd pre-change-dir 'echo pre-cd'").unwrap(); test_input("autocmd pre-change-dir 'echo pre-cd'").unwrap();
guard.read_output(); guard.read_output();
test_input(format!("cd {}", temp_dir.path().display())).unwrap(); test_input(format!("cd {}", temp_dir.path().display())).unwrap();
let out = guard.read_output(); let out = guard.read_output();
assert!(out.contains("pre-cd")); assert!(out.contains("pre-cd"));
} }
} }

View File

@@ -176,20 +176,20 @@ pub fn complete_builtin(node: Node) -> ShResult<()> {
read_meta(|m| -> ShResult<()> { read_meta(|m| -> ShResult<()> {
let specs = m.comp_specs().values(); let specs = m.comp_specs().values();
for spec in specs { for spec in specs {
let stdout = borrow_fd(STDOUT_FILENO); let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, spec.source().as_bytes())?; write(stdout, spec.source().as_bytes())?;
} }
Ok(()) Ok(())
})?; })?;
} else { } else {
read_meta(|m| -> ShResult<()> { read_meta(|m| -> ShResult<()> {
for (cmd, _) in &argv { for (cmd, _) in &argv {
if let Some(spec) = m.comp_specs().get(cmd) { if let Some(spec) = m.comp_specs().get(cmd) {
let stdout = borrow_fd(STDOUT_FILENO); let stdout = borrow_fd(STDOUT_FILENO);
write(stdout, spec.source().as_bytes())?; write(stdout, spec.source().as_bytes())?;
} }
} }
Ok(()) Ok(())
})?; })?;
} }
@@ -316,10 +316,10 @@ pub fn get_comp_opts(opts: Vec<Opt>) -> ShResult<CompOpts> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::state::{self, VarFlags, VarKind, read_meta, write_vars};
use crate::testutil::{TestGuard, test_input};
use std::fs; use std::fs;
use tempfile::TempDir; use tempfile::TempDir;
use crate::state::{self, read_meta, write_vars, VarFlags, VarKind};
use crate::testutil::{TestGuard, test_input};
// ===================== complete: Registration ===================== // ===================== complete: Registration =====================

View File

@@ -12,12 +12,13 @@ use crate::{
}; };
pub fn truncate_home_path(path: String) -> String { pub fn truncate_home_path(path: String) -> String {
if let Ok(home) = env::var("HOME") if let Ok(home) = env::var("HOME")
&& path.starts_with(&home) { && path.starts_with(&home)
let new = path.strip_prefix(&home).unwrap(); {
return format!("~{new}"); let new = path.strip_prefix(&home).unwrap();
} return format!("~{new}");
path.to_string() }
path.to_string()
} }
enum StackIdx { enum StackIdx {
@@ -376,8 +377,7 @@ pub fn dirs(node: Node) -> ShResult<()> {
.map(|d| d.to_string_lossy().to_string()); .map(|d| d.to_string_lossy().to_string());
if abbreviate_home { if abbreviate_home {
stack.map(truncate_home_path) stack.map(truncate_home_path).collect()
.collect()
} else { } else {
stack.collect() stack.collect()
} }
@@ -428,189 +428,198 @@ pub fn dirs(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use std::{env, path::PathBuf}; use crate::{
use crate::{state::{self, read_meta}, testutil::{TestGuard, test_input}}; state::{self, read_meta},
use pretty_assertions::{assert_ne,assert_eq}; testutil::{TestGuard, test_input},
use tempfile::TempDir; };
use pretty_assertions::{assert_eq, assert_ne};
use std::{env, path::PathBuf};
use tempfile::TempDir;
#[test] #[test]
fn test_pushd_interactive() { fn test_pushd_interactive() {
let g = TestGuard::new(); let g = TestGuard::new();
let current_dir = env::current_dir().unwrap(); let current_dir = env::current_dir().unwrap();
test_input("pushd /tmp").unwrap(); test_input("pushd /tmp").unwrap();
let new_dir = env::current_dir().unwrap(); let new_dir = env::current_dir().unwrap();
assert_ne!(new_dir, current_dir); assert_ne!(new_dir, current_dir);
assert_eq!(new_dir, PathBuf::from("/tmp")); assert_eq!(new_dir, PathBuf::from("/tmp"));
let dir_stack = read_meta(|m| m.dirs().clone()); let dir_stack = read_meta(|m| m.dirs().clone());
assert_eq!(dir_stack.len(), 1); assert_eq!(dir_stack.len(), 1);
assert_eq!(dir_stack[0], current_dir); assert_eq!(dir_stack[0], current_dir);
let out = g.read_output(); let out = g.read_output();
let path = super::truncate_home_path(current_dir.to_string_lossy().to_string()); let path = super::truncate_home_path(current_dir.to_string_lossy().to_string());
assert_eq!(out, format!("/tmp {path}\n")); assert_eq!(out, format!("/tmp {path}\n"));
} }
#[test] #[test]
fn test_popd_interactive() { fn test_popd_interactive() {
let g = TestGuard::new(); let g = TestGuard::new();
let current_dir = env::current_dir().unwrap(); let current_dir = env::current_dir().unwrap();
let tempdir = TempDir::new().unwrap(); let tempdir = TempDir::new().unwrap();
let tempdir_raw = tempdir.path().to_path_buf().to_string_lossy().to_string(); let tempdir_raw = tempdir.path().to_path_buf().to_string_lossy().to_string();
test_input(format!("pushd {tempdir_raw}")).unwrap(); test_input(format!("pushd {tempdir_raw}")).unwrap();
let dir_stack = read_meta(|m| m.dirs().clone()); let dir_stack = read_meta(|m| m.dirs().clone());
assert_eq!(dir_stack.len(), 1); assert_eq!(dir_stack.len(), 1);
assert_eq!(dir_stack[0], current_dir); assert_eq!(dir_stack[0], current_dir);
assert_eq!(env::current_dir().unwrap(), tempdir.path()); assert_eq!(env::current_dir().unwrap(), tempdir.path());
g.read_output(); // consume output of pushd g.read_output(); // consume output of pushd
test_input("popd").unwrap(); test_input("popd").unwrap();
assert_eq!(env::current_dir().unwrap(), current_dir); assert_eq!(env::current_dir().unwrap(), current_dir);
let out = g.read_output(); let out = g.read_output();
let path = super::truncate_home_path(current_dir.to_string_lossy().to_string()); let path = super::truncate_home_path(current_dir.to_string_lossy().to_string());
assert_eq!(out, format!("{path}\n")); assert_eq!(out, format!("{path}\n"));
} }
#[test] #[test]
fn test_popd_empty_stack() { fn test_popd_empty_stack() {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("popd").unwrap_err(); test_input("popd").unwrap_err();
assert_ne!(state::get_status(), 0); assert_ne!(state::get_status(), 0);
} }
#[test] #[test]
fn test_pushd_multiple_then_popd() { fn test_pushd_multiple_then_popd() {
let g = TestGuard::new(); let g = TestGuard::new();
let original = env::current_dir().unwrap(); let original = env::current_dir().unwrap();
let tmp1 = TempDir::new().unwrap(); let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().to_path_buf(); let path1 = tmp1.path().to_path_buf();
let path2 = tmp2.path().to_path_buf(); let path2 = tmp2.path().to_path_buf();
test_input(format!("pushd {}", path1.display())).unwrap(); test_input(format!("pushd {}", path1.display())).unwrap();
test_input(format!("pushd {}", path2.display())).unwrap(); test_input(format!("pushd {}", path2.display())).unwrap();
g.read_output(); g.read_output();
assert_eq!(env::current_dir().unwrap(), path2); assert_eq!(env::current_dir().unwrap(), path2);
let stack = read_meta(|m| m.dirs().clone()); let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 2); assert_eq!(stack.len(), 2);
assert_eq!(stack[0], path1); assert_eq!(stack[0], path1);
assert_eq!(stack[1], original); assert_eq!(stack[1], original);
test_input("popd").unwrap(); test_input("popd").unwrap();
assert_eq!(env::current_dir().unwrap(), path1); assert_eq!(env::current_dir().unwrap(), path1);
test_input("popd").unwrap(); test_input("popd").unwrap();
assert_eq!(env::current_dir().unwrap(), original); assert_eq!(env::current_dir().unwrap(), original);
let stack = read_meta(|m| m.dirs().clone()); let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 0); assert_eq!(stack.len(), 0);
} }
#[test] #[test]
fn test_pushd_rotate_plus() { fn test_pushd_rotate_plus() {
let g = TestGuard::new(); let g = TestGuard::new();
let original = env::current_dir().unwrap(); let original = env::current_dir().unwrap();
let tmp1 = TempDir::new().unwrap(); let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().to_path_buf(); let path1 = tmp1.path().to_path_buf();
let path2 = tmp2.path().to_path_buf(); let path2 = tmp2.path().to_path_buf();
// Build stack: cwd=original, then pushd path1, pushd path2 // Build stack: cwd=original, then pushd path1, pushd path2
// Stack after: cwd=path2, [path1, original] // Stack after: cwd=path2, [path1, original]
test_input(format!("pushd {}", path1.display())).unwrap(); test_input(format!("pushd {}", path1.display())).unwrap();
test_input(format!("pushd {}", path2.display())).unwrap(); test_input(format!("pushd {}", path2.display())).unwrap();
g.read_output(); g.read_output();
// pushd +1 rotates: [path2, path1, original] -> rotate_left(1) -> [path1, original, path2] // pushd +1 rotates: [path2, path1, original] -> rotate_left(1) -> [path1, original, path2]
// pop front -> cwd=path1, stack=[original, path2] // pop front -> cwd=path1, stack=[original, path2]
test_input("pushd +1").unwrap(); test_input("pushd +1").unwrap();
assert_eq!(env::current_dir().unwrap(), path1); assert_eq!(env::current_dir().unwrap(), path1);
let stack = read_meta(|m| m.dirs().clone()); let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 2); assert_eq!(stack.len(), 2);
assert_eq!(stack[0], original); assert_eq!(stack[0], original);
assert_eq!(stack[1], path2); assert_eq!(stack[1], path2);
} }
#[test] #[test]
fn test_pushd_no_cd_flag() { fn test_pushd_no_cd_flag() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let original = env::current_dir().unwrap(); let original = env::current_dir().unwrap();
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf(); let path = tmp.path().to_path_buf();
test_input(format!("pushd -n {}", path.display())).unwrap(); test_input(format!("pushd -n {}", path.display())).unwrap();
// -n means don't cd, but the dir should still be on the stack // -n means don't cd, but the dir should still be on the stack
assert_eq!(env::current_dir().unwrap(), original); assert_eq!(env::current_dir().unwrap(), original);
} }
#[test] #[test]
fn test_dirs_clear() { fn test_dirs_clear() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
test_input(format!("pushd {}", tmp.path().display())).unwrap(); test_input(format!("pushd {}", tmp.path().display())).unwrap();
assert_eq!(read_meta(|m| m.dirs().len()), 1); assert_eq!(read_meta(|m| m.dirs().len()), 1);
test_input("dirs -c").unwrap(); test_input("dirs -c").unwrap();
assert_eq!(read_meta(|m| m.dirs().len()), 0); assert_eq!(read_meta(|m| m.dirs().len()), 0);
} }
#[test] #[test]
fn test_dirs_one_per_line() { fn test_dirs_one_per_line() {
let g = TestGuard::new(); let g = TestGuard::new();
let original = env::current_dir().unwrap(); let original = env::current_dir().unwrap();
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf(); let path = tmp.path().to_path_buf();
test_input(format!("pushd {}", path.display())).unwrap(); test_input(format!("pushd {}", path.display())).unwrap();
g.read_output(); g.read_output();
test_input("dirs -p").unwrap(); test_input("dirs -p").unwrap();
let out = g.read_output(); let out = g.read_output();
let lines: Vec<&str> = out.split('\n').filter(|l| !l.is_empty()).collect(); let lines: Vec<&str> = out.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
assert_eq!(lines[0], super::truncate_home_path(path.to_string_lossy().to_string())); assert_eq!(
assert_eq!(lines[1], super::truncate_home_path(original.to_string_lossy().to_string())); lines[0],
} super::truncate_home_path(path.to_string_lossy().to_string())
);
assert_eq!(
lines[1],
super::truncate_home_path(original.to_string_lossy().to_string())
);
}
#[test] #[test]
fn test_popd_indexed_from_top() { fn test_popd_indexed_from_top() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let original = env::current_dir().unwrap(); let original = env::current_dir().unwrap();
let tmp1 = TempDir::new().unwrap(); let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap(); let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().to_path_buf(); let path1 = tmp1.path().to_path_buf();
let path2 = tmp2.path().to_path_buf(); let path2 = tmp2.path().to_path_buf();
// Stack: cwd=path2, [path1, original] // Stack: cwd=path2, [path1, original]
test_input(format!("pushd {}", path1.display())).unwrap(); test_input(format!("pushd {}", path1.display())).unwrap();
test_input(format!("pushd {}", path2.display())).unwrap(); test_input(format!("pushd {}", path2.display())).unwrap();
// popd +1 removes index (1-1)=0 from stored dirs, i.e. path1 // popd +1 removes index (1-1)=0 from stored dirs, i.e. path1
test_input("popd +1").unwrap(); test_input("popd +1").unwrap();
assert_eq!(env::current_dir().unwrap(), path2); // no cd assert_eq!(env::current_dir().unwrap(), path2); // no cd
let stack = read_meta(|m| m.dirs().clone()); let stack = read_meta(|m| m.dirs().clone());
assert_eq!(stack.len(), 1); assert_eq!(stack.len(), 1);
assert_eq!(stack[0], original); assert_eq!(stack[0], original);
} }
#[test] #[test]
fn test_pushd_nonexistent_dir() { fn test_pushd_nonexistent_dir() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let result = test_input("pushd /nonexistent_dir_12345"); let result = test_input("pushd /nonexistent_dir_12345");
assert!(result.is_err()); assert!(result.is_err());
} }
} }

View File

@@ -31,7 +31,7 @@ bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EchoFlags: u32 { pub struct EchoFlags: u32 {
const NO_NEWLINE = 0b000001; const NO_NEWLINE = 0b000001;
const NO_ESCAPE = 0b000010; const NO_ESCAPE = 0b000010;
const USE_ESCAPE = 0b000100; const USE_ESCAPE = 0b000100;
const USE_PROMPT = 0b001000; const USE_PROMPT = 0b001000;
} }
@@ -55,16 +55,17 @@ pub fn echo(node: Node) -> ShResult<()> {
} }
let output_channel = borrow_fd(STDOUT_FILENO); let output_channel = borrow_fd(STDOUT_FILENO);
let xpg_echo = read_shopts(|o| o.core.xpg_echo); // If true, echo expands escape sequences by default, and -E opts out let xpg_echo = read_shopts(|o| o.core.xpg_echo); // If true, echo expands escape sequences by default, and -E opts out
let use_escape = (xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE); let use_escape =
(xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE);
let mut echo_output = prepare_echo_args( let mut echo_output = prepare_echo_args(
argv argv
.into_iter() .into_iter()
.map(|a| a.0) // Extract the String from the tuple of (String,Span) .map(|a| a.0) // Extract the String from the tuple of (String,Span)
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
use_escape, use_escape,
flags.contains(EchoFlags::USE_PROMPT), flags.contains(EchoFlags::USE_PROMPT),
)? )?
.join(" "); .join(" ");
@@ -207,7 +208,7 @@ pub fn get_echo_flags(opts: Vec<Opt>) -> ShResult<EchoFlags> {
Opt::Short('n') => flags |= EchoFlags::NO_NEWLINE, Opt::Short('n') => flags |= EchoFlags::NO_NEWLINE,
Opt::Short('e') => flags |= EchoFlags::USE_ESCAPE, Opt::Short('e') => flags |= EchoFlags::USE_ESCAPE,
Opt::Short('p') => flags |= EchoFlags::USE_PROMPT, Opt::Short('p') => flags |= EchoFlags::USE_PROMPT,
Opt::Short('E') => flags |= EchoFlags::NO_ESCAPE, Opt::Short('E') => flags |= EchoFlags::NO_ESCAPE,
_ => { _ => {
return Err(ShErr::simple( return Err(ShErr::simple(
ShErrKind::ExecFail, ShErrKind::ExecFail,
@@ -308,11 +309,7 @@ mod tests {
#[test] #[test]
fn prepare_multiple_args() { fn prepare_multiple_args() {
let result = prepare_echo_args( let result = prepare_echo_args(vec!["hello".into(), "world".into()], false, false).unwrap();
vec!["hello".into(), "world".into()],
false,
false,
).unwrap();
assert_eq!(result, vec!["hello", "world"]); assert_eq!(result, vec!["hello", "world"]);
} }

View File

@@ -37,7 +37,7 @@ pub fn eval(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::state::{self, read_vars, write_vars, VarFlags, VarKind}; use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
// ===================== Basic ===================== // ===================== Basic =====================
@@ -80,7 +80,8 @@ mod tests {
#[test] #[test]
fn eval_expands_variable() { fn eval_expands_variable() {
let guard = TestGuard::new(); let guard = TestGuard::new();
write_vars(|v| v.set_var("CMD", VarKind::Str("echo evaluated".into()), VarFlags::NONE)).unwrap(); write_vars(|v| v.set_var("CMD", VarKind::Str("echo evaluated".into()), VarFlags::NONE))
.unwrap();
test_input("eval $CMD").unwrap(); test_input("eval $CMD").unwrap();
let out = guard.read_output(); let out = guard.read_output();

View File

@@ -50,7 +50,7 @@ pub fn exec_builtin(node: Node) -> ShResult<()> {
mod tests { mod tests {
use crate::state; use crate::state;
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
// Testing exec is a bit tricky since it replaces the current process, so we just test that it correctly handles the case of no arguments and the case of a nonexistent command. We can't really test that it successfully executes a command since that would replace the test process itself. // Testing exec is a bit tricky since it replaces the current process, so we just test that it correctly handles the case of no arguments and the case of a nonexistent command. We can't really test that it successfully executes a command since that would replace the test process itself.
#[test] #[test]
fn exec_no_args_succeeds() { fn exec_no_args_succeeds() {
@@ -62,7 +62,9 @@ mod tests {
#[test] #[test]
fn exec_nonexistent_command_fails() { fn exec_nonexistent_command_fails() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let result = test_input("exec _____________no_such_______command_xyz_____________hopefully______this_doesnt______exist_____somewhere_in___your______PATH__________________"); let result = test_input(
"exec _____________no_such_______command_xyz_____________hopefully______this_doesnt______exist_____somewhere_in___your______PATH__________________",
);
assert!(result.is_err()); assert!(result.is_err());
} }
} }

View File

@@ -185,7 +185,7 @@ mod tests {
let out = guard.read_output(); let out = guard.read_output();
assert!(out.contains("cat")); assert!(out.contains("cat"));
assert!(out.contains("is")); assert!(out.contains("is"));
assert!(out.contains("/")); // Should show a path assert!(out.contains("/")); // Should show a path
} }
// ===================== Not found ===================== // ===================== Not found =====================

View File

@@ -81,10 +81,10 @@ impl KeyMapOpts {
opt: Opt::Short('o'), // operator-pending mode opt: Opt::Short('o'), // operator-pending mode
takes_arg: false, takes_arg: false,
}, },
OptSpec { OptSpec {
opt: Opt::Long("remove".into()), opt: Opt::Long("remove".into()),
takes_arg: true, takes_arg: true,
}, },
OptSpec { OptSpec {
opt: Opt::Short('r'), // replace mode opt: Opt::Short('r'), // replace mode
takes_arg: false, takes_arg: false,
@@ -180,8 +180,8 @@ pub fn keymap(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::getopt::Opt;
use crate::expand::expand_keymap; use crate::expand::expand_keymap;
use crate::getopt::Opt;
use crate::state::{self, read_logic}; use crate::state::{self, read_logic};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
@@ -217,7 +217,8 @@ mod tests {
let opts = KeyMapOpts::from_opts(&[ let opts = KeyMapOpts::from_opts(&[
Opt::Short('n'), Opt::Short('n'),
Opt::LongWithArg("remove".into(), "jk".into()), Opt::LongWithArg("remove".into(), "jk".into()),
]).unwrap(); ])
.unwrap();
assert_eq!(opts.remove, Some("jk".into())); assert_eq!(opts.remove, Some("jk".into()));
} }
@@ -273,10 +274,7 @@ mod tests {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("keymap -n jk '<ESC>'").unwrap(); test_input("keymap -n jk '<ESC>'").unwrap();
let maps = read_logic(|l| l.keymaps_filtered( let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
KeyMapFlags::NORMAL,
&expand_keymap("jk"),
));
assert!(!maps.is_empty()); assert!(!maps.is_empty());
} }
@@ -285,10 +283,7 @@ mod tests {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("keymap -i jk '<ESC>'").unwrap(); test_input("keymap -i jk '<ESC>'").unwrap();
let maps = read_logic(|l| l.keymaps_filtered( let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::INSERT, &expand_keymap("jk")));
KeyMapFlags::INSERT,
&expand_keymap("jk"),
));
assert!(!maps.is_empty()); assert!(!maps.is_empty());
} }
@@ -298,10 +293,7 @@ mod tests {
test_input("keymap -n jk '<ESC>'").unwrap(); test_input("keymap -n jk '<ESC>'").unwrap();
test_input("keymap -n jk 'dd'").unwrap(); test_input("keymap -n jk 'dd'").unwrap();
let maps = read_logic(|l| l.keymaps_filtered( let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
KeyMapFlags::NORMAL,
&expand_keymap("jk"),
));
assert_eq!(maps.len(), 1); assert_eq!(maps.len(), 1);
assert_eq!(maps[0].action, "dd"); assert_eq!(maps[0].action, "dd");
} }
@@ -312,10 +304,7 @@ mod tests {
test_input("keymap -n jk '<ESC>'").unwrap(); test_input("keymap -n jk '<ESC>'").unwrap();
test_input("keymap -n --remove jk").unwrap(); test_input("keymap -n --remove jk").unwrap();
let maps = read_logic(|l| l.keymaps_filtered( let maps = read_logic(|l| l.keymaps_filtered(KeyMapFlags::NORMAL, &expand_keymap("jk")));
KeyMapFlags::NORMAL,
&expand_keymap("jk"),
));
assert!(maps.is_empty()); assert!(maps.is_empty());
} }

View File

@@ -389,7 +389,7 @@ pub fn get_map_opts(opts: Vec<Opt>) -> MapOpts {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{MapNode, MapFlags, get_map_opts}; use super::{MapFlags, MapNode, get_map_opts};
use crate::getopt::Opt; use crate::getopt::Opt;
use crate::state::{self, read_vars}; use crate::state::{self, read_vars};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
@@ -433,10 +433,7 @@ mod tests {
#[test] #[test]
fn mapnode_remove_nested() { fn mapnode_remove_nested() {
let mut root = MapNode::default(); let mut root = MapNode::default();
root.set( root.set(&["a".into(), "b".into()], MapNode::StaticLeaf("val".into()));
&["a".into(), "b".into()],
MapNode::StaticLeaf("val".into()),
);
root.remove(&["a".into(), "b".into()]); root.remove(&["a".into(), "b".into()]);
assert!(root.get(&["a".into(), "b".into()]).is_none()); assert!(root.get(&["a".into(), "b".into()]).is_none());
// Parent branch should still exist // Parent branch should still exist

View File

@@ -17,20 +17,21 @@ pub mod keymap;
pub mod map; pub mod map;
pub mod pwd; pub mod pwd;
pub mod read; pub mod read;
pub mod resource;
pub mod shift; pub mod shift;
pub mod shopt; pub mod shopt;
pub mod source; pub mod source;
pub mod test; // [[ ]] thing pub mod test; // [[ ]] thing
pub mod trap; pub mod trap;
pub mod varcmds; pub mod varcmds;
pub mod resource; pub mod seek;
pub const BUILTINS: [&str; 49] = [ pub const BUILTINS: [&str; 50] = [
"echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg", "disown", "echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg",
"alias", "unalias", "return", "break", "continue", "exit", "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"
]; ];
pub fn true_builtin() -> ShResult<()> { pub fn true_builtin() -> ShResult<()> {
@@ -50,31 +51,34 @@ pub fn noop_builtin() -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use crate::{state, testutil::{TestGuard, test_input}}; use crate::{
state,
testutil::{TestGuard, test_input},
};
// You can never be too sure!!!!!! // You can never be too sure!!!!!!
#[test] #[test]
fn test_true() { fn test_true() {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("true").unwrap(); test_input("true").unwrap();
assert_eq!(state::get_status(), 0); assert_eq!(state::get_status(), 0);
} }
#[test] #[test]
fn test_false() { fn test_false() {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("false").unwrap(); test_input("false").unwrap();
assert_eq!(state::get_status(), 1); assert_eq!(state::get_status(), 1);
} }
#[test] #[test]
fn test_noop() { fn test_noop() {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input(":").unwrap(); test_input(":").unwrap();
assert_eq!(state::get_status(), 0); assert_eq!(state::get_status(), 0);
} }
} }

View File

@@ -27,10 +27,10 @@ pub fn pwd(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::env;
use tempfile::TempDir;
use crate::state; use crate::state;
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
use std::env;
use tempfile::TempDir;
#[test] #[test]
fn pwd_prints_cwd() { fn pwd_prints_cwd() {

View File

@@ -367,7 +367,7 @@ pub fn get_read_key_opts(opts: Vec<Opt>) -> ShResult<ReadKeyOpts> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::state::{self, read_vars, write_vars, VarFlags, VarKind}; use crate::state::{self, VarFlags, VarKind, read_vars, write_vars};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
// ===================== Basic read into REPLY ===================== // ===================== Basic read into REPLY =====================

View File

@@ -1,92 +1,115 @@
use ariadne::Fmt; use ariadne::Fmt;
use nix::{libc::STDOUT_FILENO, sys::{resource::{Resource, getrlimit, setrlimit}, stat::{Mode, umask}}, unistd::write}; use nix::{
libc::STDOUT_FILENO,
use crate::{ sys::{
getopt::{Opt, OptSpec, get_opts_from_tokens_strict}, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}, parse::{NdRule, Node}, procio::borrow_fd, state::{self} resource::{Resource, getrlimit, setrlimit},
stat::{Mode, umask},
},
unistd::write,
}; };
fn ulimit_opt_spec() -> [OptSpec;5] { use crate::{
[ getopt::{Opt, OptSpec, get_opts_from_tokens_strict},
OptSpec { libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color},
opt: Opt::Short('n'), // file descriptors parse::{NdRule, Node},
takes_arg: true, procio::borrow_fd,
}, state::{self},
OptSpec { };
opt: Opt::Short('u'), // max user processes
takes_arg: true, fn ulimit_opt_spec() -> [OptSpec; 5] {
}, [
OptSpec { OptSpec {
opt: Opt::Short('s'), // stack size opt: Opt::Short('n'), // file descriptors
takes_arg: true, takes_arg: true,
}, },
OptSpec { OptSpec {
opt: Opt::Short('c'), // core dump file size opt: Opt::Short('u'), // max user processes
takes_arg: true, takes_arg: true,
}, },
OptSpec { OptSpec {
opt: Opt::Short('v'), // virtual memory opt: Opt::Short('s'), // stack size
takes_arg: true, takes_arg: true,
} },
] OptSpec {
opt: Opt::Short('c'), // core dump file size
takes_arg: true,
},
OptSpec {
opt: Opt::Short('v'), // virtual memory
takes_arg: true,
},
]
} }
struct UlimitOpts { struct UlimitOpts {
fds: Option<u64>, fds: Option<u64>,
procs: Option<u64>, procs: Option<u64>,
stack: Option<u64>, stack: Option<u64>,
core: Option<u64>, core: Option<u64>,
vmem: Option<u64>, vmem: Option<u64>,
} }
fn get_ulimit_opts(opt: &[Opt]) -> ShResult<UlimitOpts> { fn get_ulimit_opts(opt: &[Opt]) -> ShResult<UlimitOpts> {
let mut opts = UlimitOpts { let mut opts = UlimitOpts {
fds: None, fds: None,
procs: None, procs: None,
stack: None, stack: None,
core: None, core: None,
vmem: None, vmem: None,
}; };
for o in opt { for o in opt {
match o { match o {
Opt::ShortWithArg('n', arg) => { Opt::ShortWithArg('n', arg) => {
opts.fds = Some(arg.parse().map_err(|_| ShErr::simple( opts.fds = Some(arg.parse().map_err(|_| {
ShErrKind::ParseErr, ShErr::simple(
format!("invalid argument for -n: {}", arg.fg(next_color())), ShErrKind::ParseErr,
))?); format!("invalid argument for -n: {}", arg.fg(next_color())),
}, )
Opt::ShortWithArg('u', arg) => { })?);
opts.procs = Some(arg.parse().map_err(|_| ShErr::simple( }
ShErrKind::ParseErr, Opt::ShortWithArg('u', arg) => {
format!("invalid argument for -u: {}", arg.fg(next_color())), opts.procs = Some(arg.parse().map_err(|_| {
))?); ShErr::simple(
}, ShErrKind::ParseErr,
Opt::ShortWithArg('s', arg) => { format!("invalid argument for -u: {}", arg.fg(next_color())),
opts.stack = Some(arg.parse().map_err(|_| ShErr::simple( )
ShErrKind::ParseErr, })?);
format!("invalid argument for -s: {}", arg.fg(next_color())), }
))?); Opt::ShortWithArg('s', arg) => {
}, opts.stack = Some(arg.parse().map_err(|_| {
Opt::ShortWithArg('c', arg) => { ShErr::simple(
opts.core = Some(arg.parse().map_err(|_| ShErr::simple( ShErrKind::ParseErr,
ShErrKind::ParseErr, format!("invalid argument for -s: {}", arg.fg(next_color())),
format!("invalid argument for -c: {}", arg.fg(next_color())), )
))?); })?);
}, }
Opt::ShortWithArg('v', arg) => { Opt::ShortWithArg('c', arg) => {
opts.vmem = Some(arg.parse().map_err(|_| ShErr::simple( opts.core = Some(arg.parse().map_err(|_| {
ShErrKind::ParseErr, ShErr::simple(
format!("invalid argument for -v: {}", arg.fg(next_color())), ShErrKind::ParseErr,
))?); format!("invalid argument for -c: {}", arg.fg(next_color())),
}, )
o => return Err(ShErr::simple( })?);
ShErrKind::ParseErr, }
format!("invalid option: {}", o.fg(next_color())), Opt::ShortWithArg('v', arg) => {
)), opts.vmem = Some(arg.parse().map_err(|_| {
} ShErr::simple(
} ShErrKind::ParseErr,
format!("invalid argument for -v: {}", arg.fg(next_color())),
)
})?);
}
o => {
return Err(ShErr::simple(
ShErrKind::ParseErr,
format!("invalid option: {}", o.fg(next_color())),
));
}
}
}
Ok(opts) Ok(opts)
} }
pub fn ulimit(node: Node) -> ShResult<()> { pub fn ulimit(node: Node) -> ShResult<()> {
@@ -99,282 +122,308 @@ pub fn ulimit(node: Node) -> ShResult<()> {
unreachable!() unreachable!()
}; };
let (_, opts) = get_opts_from_tokens_strict(argv, &ulimit_opt_spec()).promote_err(span.clone())?; let (_, opts) =
let ulimit_opts = get_ulimit_opts(&opts).promote_err(span.clone())?; get_opts_from_tokens_strict(argv, &ulimit_opt_spec()).promote_err(span.clone())?;
let ulimit_opts = get_ulimit_opts(&opts).promote_err(span.clone())?;
if let Some(fds) = ulimit_opts.fds { if let Some(fds) = ulimit_opts.fds {
let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE).map_err(|e| ShErr::at( let (_, hard) = getrlimit(Resource::RLIMIT_NOFILE).map_err(|e| {
ShErrKind::ExecFail, ShErr::at(
span.clone(), ShErrKind::ExecFail,
format!("failed to get file descriptor limit: {}", e), span.clone(),
))?; format!("failed to get file descriptor limit: {}", e),
setrlimit(Resource::RLIMIT_NOFILE, fds, hard).map_err(|e| ShErr::at( )
ShErrKind::ExecFail, })?;
span.clone(), setrlimit(Resource::RLIMIT_NOFILE, fds, hard).map_err(|e| {
format!("failed to set file descriptor limit: {}", e), ShErr::at(
))?; ShErrKind::ExecFail,
} span.clone(),
if let Some(procs) = ulimit_opts.procs { format!("failed to set file descriptor limit: {}", e),
let (_, hard) = getrlimit(Resource::RLIMIT_NPROC).map_err(|e| ShErr::at( )
ShErrKind::ExecFail, })?;
span.clone(), }
format!("failed to get process limit: {}", e), if let Some(procs) = ulimit_opts.procs {
))?; let (_, hard) = getrlimit(Resource::RLIMIT_NPROC).map_err(|e| {
setrlimit(Resource::RLIMIT_NPROC, procs, hard).map_err(|e| ShErr::at( ShErr::at(
ShErrKind::ExecFail, ShErrKind::ExecFail,
span.clone(), span.clone(),
format!("failed to set process limit: {}", e), format!("failed to get process limit: {}", e),
))?; )
} })?;
if let Some(stack) = ulimit_opts.stack { setrlimit(Resource::RLIMIT_NPROC, procs, hard).map_err(|e| {
let (_, hard) = getrlimit(Resource::RLIMIT_STACK).map_err(|e| ShErr::at( ShErr::at(
ShErrKind::ExecFail, ShErrKind::ExecFail,
span.clone(), span.clone(),
format!("failed to get stack size limit: {}", e), format!("failed to set process limit: {}", e),
))?; )
setrlimit(Resource::RLIMIT_STACK, stack, hard).map_err(|e| ShErr::at( })?;
ShErrKind::ExecFail, }
span.clone(), if let Some(stack) = ulimit_opts.stack {
format!("failed to set stack size limit: {}", e), let (_, hard) = getrlimit(Resource::RLIMIT_STACK).map_err(|e| {
))?; ShErr::at(
} ShErrKind::ExecFail,
if let Some(core) = ulimit_opts.core { span.clone(),
let (_, hard) = getrlimit(Resource::RLIMIT_CORE).map_err(|e| ShErr::at( format!("failed to get stack size limit: {}", e),
ShErrKind::ExecFail, )
span.clone(), })?;
format!("failed to get core dump size limit: {}", e), setrlimit(Resource::RLIMIT_STACK, stack, hard).map_err(|e| {
))?; ShErr::at(
setrlimit(Resource::RLIMIT_CORE, core, hard).map_err(|e| ShErr::at( ShErrKind::ExecFail,
ShErrKind::ExecFail, span.clone(),
span.clone(), format!("failed to set stack size limit: {}", e),
format!("failed to set core dump size limit: {}", e), )
))?; })?;
} }
if let Some(vmem) = ulimit_opts.vmem { if let Some(core) = ulimit_opts.core {
let (_, hard) = getrlimit(Resource::RLIMIT_AS).map_err(|e| ShErr::at( let (_, hard) = getrlimit(Resource::RLIMIT_CORE).map_err(|e| {
ShErrKind::ExecFail, ShErr::at(
span.clone(), ShErrKind::ExecFail,
format!("failed to get virtual memory limit: {}", e), span.clone(),
))?; format!("failed to get core dump size limit: {}", e),
setrlimit(Resource::RLIMIT_AS, vmem, hard).map_err(|e| ShErr::at( )
ShErrKind::ExecFail, })?;
span.clone(), setrlimit(Resource::RLIMIT_CORE, core, hard).map_err(|e| {
format!("failed to set virtual memory limit: {}", e), ShErr::at(
))?; ShErrKind::ExecFail,
} span.clone(),
format!("failed to set core dump size limit: {}", e),
)
})?;
}
if let Some(vmem) = ulimit_opts.vmem {
let (_, hard) = getrlimit(Resource::RLIMIT_AS).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to get virtual memory limit: {}", e),
)
})?;
setrlimit(Resource::RLIMIT_AS, vmem, hard).map_err(|e| {
ShErr::at(
ShErrKind::ExecFail,
span.clone(),
format!("failed to set virtual memory limit: {}", e),
)
})?;
}
state::set_status(0); state::set_status(0);
Ok(()) Ok(())
} }
pub fn umask_builtin(node: Node) -> ShResult<()> { pub fn umask_builtin(node: Node) -> ShResult<()> {
let span = node.get_span(); let span = node.get_span();
let NdRule::Command { let NdRule::Command {
assignments: _, assignments: _,
argv, argv,
} = node.class else { unreachable!() }; } = node.class
else {
unreachable!()
};
let (argv, opts) = get_opts_from_tokens_strict( let (argv, opts) = get_opts_from_tokens_strict(
argv, argv,
&[OptSpec { opt: Opt::Short('S'), takes_arg: false }], &[OptSpec {
)?; opt: Opt::Short('S'),
let argv = &argv[1..]; // skip command name takes_arg: false,
}],
)?;
let argv = &argv[1..]; // skip command name
let old = umask(Mode::empty()); let old = umask(Mode::empty());
umask(old); umask(old);
let mut old_bits = old.bits(); let mut old_bits = old.bits();
if !argv.is_empty() { if !argv.is_empty() {
if argv.len() > 1 { if argv.len() > 1 {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::ParseErr, ShErrKind::ParseErr,
span.clone(), span.clone(),
format!("umask takes at most one argument, got {}", argv.len()), format!("umask takes at most one argument, got {}", argv.len()),
)); ));
} }
let arg = argv[0].clone(); let arg = argv[0].clone();
let raw = arg.as_str(); let raw = arg.as_str();
if raw.chars().any(|c| c.is_ascii_digit()) { if raw.chars().any(|c| c.is_ascii_digit()) {
let mode_raw = u32::from_str_radix(raw, 8).map_err(|_| ShErr::at( let mode_raw = u32::from_str_radix(raw, 8).map_err(|_| {
ShErrKind::ParseErr, ShErr::at(
span.clone(), ShErrKind::ParseErr,
format!("invalid numeric umask: {}", raw.fg(next_color())), span.clone(),
))?; format!("invalid numeric umask: {}", raw.fg(next_color())),
)
})?;
let mode = Mode::from_bits(mode_raw).ok_or_else(|| ShErr::at( let mode = Mode::from_bits(mode_raw).ok_or_else(|| {
ShErrKind::ParseErr, ShErr::at(
span.clone(), ShErrKind::ParseErr,
format!("invalid umask value: {}", raw.fg(next_color())), span.clone(),
))?; format!("invalid umask value: {}", raw.fg(next_color())),
)
})?;
umask(mode); umask(mode);
} else { } else {
let parts = raw.split(','); let parts = raw.split(',');
for part in parts { for part in parts {
if let Some((who,bits)) = part.split_once('=') { if let Some((who, bits)) = part.split_once('=') {
let mut new_bits = 0; let mut new_bits = 0;
if bits.contains('r') { if bits.contains('r') {
new_bits |= 4; new_bits |= 4;
} }
if bits.contains('w') { if bits.contains('w') {
new_bits |= 2; new_bits |= 2;
} }
if bits.contains('x') { if bits.contains('x') {
new_bits |= 1; new_bits |= 1;
} }
for ch in who.chars() { for ch in who.chars() {
match ch { match ch {
'o' => { 'o' => {
old_bits &= !0o7; old_bits &= !0o7;
old_bits |= !new_bits & 0o7; old_bits |= !new_bits & 0o7;
} }
'g' => { 'g' => {
old_bits &= !(0o7 << 3); old_bits &= !(0o7 << 3);
old_bits |= (!new_bits & 0o7) << 3; old_bits |= (!new_bits & 0o7) << 3;
} }
'u' => { 'u' => {
old_bits &= !(0o7 << 6); old_bits &= !(0o7 << 6);
old_bits |= (!new_bits & 0o7) << 6; old_bits |= (!new_bits & 0o7) << 6;
} }
'a' => { 'a' => {
let denied = !new_bits & 0o7; let denied = !new_bits & 0o7;
old_bits = denied | (denied << 3) | (denied << 6); old_bits = denied | (denied << 3) | (denied << 6);
} }
_ => { _ => {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::ParseErr, ShErrKind::ParseErr,
span.clone(), span.clone(),
format!("invalid umask 'who' character: {}", ch.fg(next_color())), format!("invalid umask 'who' character: {}", ch.fg(next_color())),
)); ));
} }
} }
} }
umask(Mode::from_bits_truncate(old_bits)); umask(Mode::from_bits_truncate(old_bits));
} else if let Some((who,bits)) = part.split_once('+') { } else if let Some((who, bits)) = part.split_once('+') {
let mut new_bits = 0; let mut new_bits = 0;
if bits.contains('r') { if bits.contains('r') {
new_bits |= 4; new_bits |= 4;
} }
if bits.contains('w') { if bits.contains('w') {
new_bits |= 2; new_bits |= 2;
} }
if bits.contains('x') { if bits.contains('x') {
new_bits |= 1; new_bits |= 1;
} }
for ch in who.chars() { for ch in who.chars() {
match ch { match ch {
'o' => { 'o' => {
old_bits &= !(new_bits & 0o7); old_bits &= !(new_bits & 0o7);
} }
'g' => { 'g' => {
old_bits &= !((new_bits & 0o7) << 3); old_bits &= !((new_bits & 0o7) << 3);
} }
'u' => { 'u' => {
old_bits &= !((new_bits & 0o7) << 6); old_bits &= !((new_bits & 0o7) << 6);
} }
'a' => { 'a' => {
let mask = new_bits & 0o7; let mask = new_bits & 0o7;
old_bits &= !(mask | (mask << 3) | (mask << 6)); old_bits &= !(mask | (mask << 3) | (mask << 6));
} }
_ => { _ => {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::ParseErr, ShErrKind::ParseErr,
span.clone(), span.clone(),
format!("invalid umask 'who' character: {}", ch.fg(next_color())), format!("invalid umask 'who' character: {}", ch.fg(next_color())),
)); ));
} }
} }
} }
umask(Mode::from_bits_truncate(old_bits)); umask(Mode::from_bits_truncate(old_bits));
} else if let Some((who,bits)) = part.split_once('-') { } else if let Some((who, bits)) = part.split_once('-') {
let mut new_bits = 0; let mut new_bits = 0;
if bits.contains('r') { if bits.contains('r') {
new_bits |= 4; new_bits |= 4;
} }
if bits.contains('w') { if bits.contains('w') {
new_bits |= 2; new_bits |= 2;
} }
if bits.contains('x') { if bits.contains('x') {
new_bits |= 1; new_bits |= 1;
} }
for ch in who.chars() { for ch in who.chars() {
match ch { match ch {
'o' => { 'o' => {
old_bits |= new_bits & 0o7; old_bits |= new_bits & 0o7;
} }
'g' => { 'g' => {
old_bits |= (new_bits << 3) & (0o7 << 3); old_bits |= (new_bits << 3) & (0o7 << 3);
} }
'u' => { 'u' => {
old_bits |= (new_bits << 6) & (0o7 << 6); old_bits |= (new_bits << 6) & (0o7 << 6);
} }
'a' => { 'a' => {
old_bits |= (new_bits | (new_bits << 3) | (new_bits << 6)) & 0o777; old_bits |= (new_bits | (new_bits << 3) | (new_bits << 6)) & 0o777;
} }
_ => { _ => {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::ParseErr, ShErrKind::ParseErr,
span.clone(), span.clone(),
format!("invalid umask 'who' character: {}", ch.fg(next_color())), format!("invalid umask 'who' character: {}", ch.fg(next_color())),
)); ));
} }
} }
} }
umask(Mode::from_bits_truncate(old_bits)); umask(Mode::from_bits_truncate(old_bits));
} else { } else {
return Err(ShErr::at( return Err(ShErr::at(
ShErrKind::ParseErr, ShErrKind::ParseErr,
span.clone(), span.clone(),
format!("invalid symbolic umask part: {}", part.fg(next_color())), format!("invalid symbolic umask part: {}", part.fg(next_color())),
)); ));
} }
} }
} }
} else if !opts.is_empty() {
let u = (old_bits >> 6) & 0o7;
let g = (old_bits >> 3) & 0o7;
let o = old_bits & 0o7;
let mut u_str = String::from("u=");
let mut g_str = String::from("g=");
let mut o_str = String::from("o=");
let stuff = [(u, &mut u_str), (g, &mut g_str), (o, &mut o_str)];
for (bits, out) in stuff.into_iter() {
if bits & 4 == 0 {
out.push('r');
}
if bits & 2 == 0 {
out.push('w');
}
if bits & 1 == 0 {
out.push('x');
}
}
} else if !opts.is_empty() { let msg = [u_str, g_str, o_str].join(",");
let u = (old_bits >> 6) & 0o7; let stdout = borrow_fd(STDOUT_FILENO);
let g = (old_bits >> 3) & 0o7; write(stdout, msg.as_bytes())?;
let o = old_bits & 0o7; write(stdout, b"\n")?;
let mut u_str = String::from("u="); } else {
let mut g_str = String::from("g="); let raw = format!("{:04o}\n", old_bits);
let mut o_str = String::from("o=");
let stuff = [
(u, &mut u_str),
(g, &mut g_str),
(o, &mut o_str),
];
for (bits, out) in stuff.into_iter() {
if bits & 4 == 0 {
out.push('r');
}
if bits & 2 == 0 {
out.push('w');
}
if bits & 1 == 0 {
out.push('x');
}
}
let msg = [u_str,g_str,o_str].join(","); let stdout = borrow_fd(STDOUT_FILENO);
let stdout = borrow_fd(STDOUT_FILENO); write(stdout, raw.as_bytes())?;
write(stdout, msg.as_bytes())?; }
write(stdout, b"\n")?;
} else {
let raw = format!("{:04o}\n", old_bits);
let stdout = borrow_fd(STDOUT_FILENO); state::set_status(0);
write(stdout, raw.as_bytes())?; Ok(())
}
state::set_status(0);
Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -423,7 +472,8 @@ mod tests {
let opts = get_ulimit_opts(&[ let opts = get_ulimit_opts(&[
Opt::ShortWithArg('n', "256".into()), Opt::ShortWithArg('n', "256".into()),
Opt::ShortWithArg('c', "0".into()), Opt::ShortWithArg('c', "0".into()),
]).unwrap(); ])
.unwrap();
assert_eq!(opts.fds, Some(256)); assert_eq!(opts.fds, Some(256));
assert_eq!(opts.core, Some(0)); assert_eq!(opts.core, Some(0));
assert!(opts.procs.is_none()); assert!(opts.procs.is_none());

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

@@ -0,0 +1,253 @@
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

@@ -44,128 +44,128 @@ pub fn source(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use std::io::Write; use std::io::Write;
use tempfile::{NamedTempFile, TempDir}; use crate::state::{self, read_logic, read_vars};
use crate::state::{self, read_logic, read_vars}; use crate::testutil::{TestGuard, test_input};
use crate::testutil::{TestGuard, test_input}; use tempfile::{NamedTempFile, TempDir};
#[test] #[test]
fn source_simple() { fn source_simple() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"some_var=some_val").unwrap(); file.write_all(b"some_var=some_val").unwrap();
test_input(format!("source {path}")).unwrap(); test_input(format!("source {path}")).unwrap();
let var = read_vars(|v| v.get_var("some_var")); let var = read_vars(|v| v.get_var("some_var"));
assert_eq!(var, "some_val".to_string()); assert_eq!(var, "some_val".to_string());
} }
#[test] #[test]
fn source_multiple_commands() { fn source_multiple_commands() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"x=1\ny=2\nz=3").unwrap(); file.write_all(b"x=1\ny=2\nz=3").unwrap();
test_input(format!("source {path}")).unwrap(); test_input(format!("source {path}")).unwrap();
assert_eq!(read_vars(|v| v.get_var("x")), "1"); assert_eq!(read_vars(|v| v.get_var("x")), "1");
assert_eq!(read_vars(|v| v.get_var("y")), "2"); assert_eq!(read_vars(|v| v.get_var("y")), "2");
assert_eq!(read_vars(|v| v.get_var("z")), "3"); assert_eq!(read_vars(|v| v.get_var("z")), "3");
} }
#[test] #[test]
fn source_defines_function() { fn source_defines_function() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"greet() { echo hi; }").unwrap(); file.write_all(b"greet() { echo hi; }").unwrap();
test_input(format!("source {path}")).unwrap(); test_input(format!("source {path}")).unwrap();
let func = read_logic(|l| l.get_func("greet")); let func = read_logic(|l| l.get_func("greet"));
assert!(func.is_some()); assert!(func.is_some());
} }
#[test] #[test]
fn source_defines_alias() { fn source_defines_alias() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"alias ll='ls -la'").unwrap(); file.write_all(b"alias ll='ls -la'").unwrap();
test_input(format!("source {path}")).unwrap(); test_input(format!("source {path}")).unwrap();
let alias = read_logic(|l| l.get_alias("ll")); let alias = read_logic(|l| l.get_alias("ll"));
assert!(alias.is_some()); assert!(alias.is_some());
} }
#[test] #[test]
fn source_output_captured() { fn source_output_captured() {
let guard = TestGuard::new(); let guard = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"echo sourced").unwrap(); file.write_all(b"echo sourced").unwrap();
test_input(format!("source {path}")).unwrap(); test_input(format!("source {path}")).unwrap();
let out = guard.read_output(); let out = guard.read_output();
assert!(out.contains("sourced")); assert!(out.contains("sourced"));
} }
#[test] #[test]
fn source_multiple_files() { fn source_multiple_files() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file1 = NamedTempFile::new().unwrap(); let mut file1 = NamedTempFile::new().unwrap();
let mut file2 = NamedTempFile::new().unwrap(); let mut file2 = NamedTempFile::new().unwrap();
let path1 = file1.path().display().to_string(); let path1 = file1.path().display().to_string();
let path2 = file2.path().display().to_string(); let path2 = file2.path().display().to_string();
file1.write_all(b"a=from_file1").unwrap(); file1.write_all(b"a=from_file1").unwrap();
file2.write_all(b"b=from_file2").unwrap(); file2.write_all(b"b=from_file2").unwrap();
test_input(format!("source {path1} {path2}")).unwrap(); test_input(format!("source {path1} {path2}")).unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "from_file1"); assert_eq!(read_vars(|v| v.get_var("a")), "from_file1");
assert_eq!(read_vars(|v| v.get_var("b")), "from_file2"); assert_eq!(read_vars(|v| v.get_var("b")), "from_file2");
} }
// ===================== Dot syntax ===================== // ===================== Dot syntax =====================
#[test] #[test]
fn source_dot_syntax() { fn source_dot_syntax() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"dot_var=dot_val").unwrap(); file.write_all(b"dot_var=dot_val").unwrap();
test_input(format!(". {path}")).unwrap(); test_input(format!(". {path}")).unwrap();
assert_eq!(read_vars(|v| v.get_var("dot_var")), "dot_val"); assert_eq!(read_vars(|v| v.get_var("dot_var")), "dot_val");
} }
// ===================== Error cases ===================== // ===================== Error cases =====================
#[test] #[test]
fn source_nonexistent_file() { fn source_nonexistent_file() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let result = test_input("source /tmp/__no_such_file_xyz__"); let result = test_input("source /tmp/__no_such_file_xyz__");
assert!(result.is_err()); assert!(result.is_err());
} }
#[test] #[test]
fn source_directory_fails() { fn source_directory_fails() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let result = test_input(format!("source {}", dir.path().display())); let result = test_input(format!("source {}", dir.path().display()));
assert!(result.is_err()); assert!(result.is_err());
} }
// ===================== Status ===================== // ===================== Status =====================
#[test] #[test]
fn source_status_zero() { fn source_status_zero() {
let _g = TestGuard::new(); let _g = TestGuard::new();
let mut file = NamedTempFile::new().unwrap(); let mut file = NamedTempFile::new().unwrap();
let path = file.path().display().to_string(); let path = file.path().display().to_string();
file.write_all(b"true").unwrap(); file.write_all(b"true").unwrap();
test_input(format!("source {path}")).unwrap(); test_input(format!("source {path}")).unwrap();
assert_eq!(state::get_status(), 0); assert_eq!(state::get_status(), 0);
} }
} }

View File

@@ -94,7 +94,10 @@ impl FromStr for TestOp {
"-ge" => Ok(Self::IntGe), "-ge" => Ok(Self::IntGe),
"-le" => Ok(Self::IntLe), "-le" => Ok(Self::IntLe),
_ if TEST_UNARY_OPS.contains(&s) => Ok(Self::Unary(s.parse::<UnaryOp>()?)), _ if TEST_UNARY_OPS.contains(&s) => Ok(Self::Unary(s.parse::<UnaryOp>()?)),
_ => Err(ShErr::simple(ShErrKind::SyntaxErr, format!("Invalid test operator '{}'", s))), _ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("Invalid test operator '{}'", s),
)),
} }
} }
} }
@@ -121,7 +124,7 @@ pub fn double_bracket_test(node: Node) -> ShResult<bool> {
}; };
let mut last_result = false; let mut last_result = false;
let mut conjunct_op: Option<ConjunctOp>; let mut conjunct_op: Option<ConjunctOp>;
log::trace!("test cases: {:#?}", cases); log::trace!("test cases: {:#?}", cases);
for case in cases { for case in cases {
let result = match case { let result = match case {
@@ -305,10 +308,10 @@ pub fn double_bracket_test(node: Node) -> ShResult<bool> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::fs;
use tempfile::{TempDir, NamedTempFile};
use crate::state; use crate::state;
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
use std::fs;
use tempfile::{NamedTempFile, TempDir};
// ===================== Unary: file tests ===================== // ===================== Unary: file tests =====================
@@ -590,9 +593,10 @@ mod tests {
fn parse_unary_ops() { fn parse_unary_ops() {
use super::UnaryOp; use super::UnaryOp;
use std::str::FromStr; use std::str::FromStr;
for op in ["-e", "-d", "-f", "-h", "-L", "-r", "-w", "-x", "-s", for op in [
"-p", "-S", "-b", "-c", "-k", "-O", "-G", "-N", "-u", "-e", "-d", "-f", "-h", "-L", "-r", "-w", "-x", "-s", "-p", "-S", "-b", "-c", "-k", "-O",
"-g", "-t", "-n", "-z"] { "-G", "-N", "-u", "-g", "-t", "-n", "-z",
] {
assert!(UnaryOp::from_str(op).is_ok(), "failed to parse {op}"); assert!(UnaryOp::from_str(op).is_ok(), "failed to parse {op}");
} }
} }

View File

@@ -171,10 +171,10 @@ pub fn trap(node: Node) -> ShResult<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::TrapTarget; use super::TrapTarget;
use std::str::FromStr;
use nix::sys::signal::Signal;
use crate::state::{self, read_logic}; use crate::state::{self, read_logic};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
use nix::sys::signal::Signal;
use std::str::FromStr;
// ===================== Pure: TrapTarget parsing ===================== // ===================== Pure: TrapTarget parsing =====================
@@ -231,7 +231,9 @@ mod tests {
#[test] #[test]
fn display_signal_roundtrip() { fn display_signal_roundtrip() {
for name in &["INT", "QUIT", "TERM", "USR1", "USR2", "ALRM", "CHLD", "WINCH"] { for name in &[
"INT", "QUIT", "TERM", "USR1", "USR2", "ALRM", "CHLD", "WINCH",
] {
let target = TrapTarget::from_str(name).unwrap(); let target = TrapTarget::from_str(name).unwrap();
assert_eq!(target.to_string(), *name); assert_eq!(target.to_string(), *name);
} }

View File

@@ -245,8 +245,16 @@ mod tests {
test_input("readonly a=1 b=2").unwrap(); test_input("readonly a=1 b=2").unwrap();
assert_eq!(read_vars(|v| v.get_var("a")), "1"); assert_eq!(read_vars(|v| v.get_var("a")), "1");
assert_eq!(read_vars(|v| v.get_var("b")), "2"); assert_eq!(read_vars(|v| v.get_var("b")), "2");
assert!(read_vars(|v| v.get_var_flags("a")).unwrap().contains(VarFlags::READONLY)); assert!(
assert!(read_vars(|v| v.get_var_flags("b")).unwrap().contains(VarFlags::READONLY)); read_vars(|v| v.get_var_flags("a"))
.unwrap()
.contains(VarFlags::READONLY)
);
assert!(
read_vars(|v| v.get_var_flags("b"))
.unwrap()
.contains(VarFlags::READONLY)
);
} }
#[test] #[test]
@@ -385,7 +393,11 @@ mod tests {
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input("local mylocal").unwrap(); test_input("local mylocal").unwrap();
assert_eq!(read_vars(|v| v.get_var("mylocal")), ""); assert_eq!(read_vars(|v| v.get_var("mylocal")), "");
assert!(read_vars(|v| v.get_var_flags("mylocal")).unwrap().contains(VarFlags::LOCAL)); assert!(
read_vars(|v| v.get_var_flags("mylocal"))
.unwrap()
.contains(VarFlags::LOCAL)
);
} }
#[test] #[test]

View File

@@ -40,18 +40,23 @@ 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 +80,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![];
@@ -86,6 +95,11 @@ impl Expander {
'outer: while let Some(ch) = chars.next() { 'outer: while let Some(ch) = chars.next() {
match ch { match ch {
markers::ESCAPE => {
if let Some(next_ch) = chars.next() {
cur_word.push(next_ch);
}
}
markers::DUB_QUOTE | markers::SNG_QUOTE | markers::SUBSH => { markers::DUB_QUOTE | markers::SNG_QUOTE | markers::SUBSH => {
while let Some(q_ch) = chars.next() { while let Some(q_ch) = chars.next() {
match q_ch { match q_ch {
@@ -634,8 +648,12 @@ pub fn expand_glob(raw: &str) -> ShResult<String> {
{ {
let entry = let entry =
entry.map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid filename found in glob"))?; entry.map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid filename found in glob"))?;
let entry_raw = entry
.to_str()
.ok_or_else(|| ShErr::simple(ShErrKind::SyntaxErr, "Non-UTF8 filename found in glob"))?;
let escaped = escape_str(entry_raw, true);
words.push(entry.to_str().unwrap().to_string()) words.push(escaped)
} }
Ok(words.join(" ")) Ok(words.join(" "))
} }
@@ -973,6 +991,11 @@ pub fn expand_cmd_sub(raw: &str) -> ShResult<String> {
} }
} }
/// Strip ESCAPE markers from a string, leaving the characters they protect intact.
fn strip_escape_markers(s: &str) -> String {
s.replace(markers::ESCAPE, "")
}
/// Processes strings into intermediate representations that are more readable /// Processes strings into intermediate representations that are more readable
/// by the program /// by the program
/// ///
@@ -989,6 +1012,7 @@ pub fn unescape_str(raw: &str) -> String {
'~' if first_char => result.push(markers::TILDE_SUB), '~' if first_char => result.push(markers::TILDE_SUB),
'\\' => { '\\' => {
if let Some(next_ch) = chars.next() { if let Some(next_ch) = chars.next() {
result.push(markers::ESCAPE);
result.push(next_ch) result.push(next_ch)
} }
} }
@@ -1139,6 +1163,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;
@@ -1303,6 +1346,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;
@@ -1311,6 +1373,133 @@ 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
/// Used for completion results, and glob filename matches.
pub fn escape_str(raw: &str, use_marker: bool) -> String {
let mut result = String::new();
let mut chars = raw.chars();
while let Some(ch) = chars.next() {
match ch {
'\'' | '"' | '\\' | '|' | '&' | ';' | '(' | ')' | '<' | '>' | '$' | '*' | '!' | '`' | '{'
| '?' | '[' | '#' | ' ' | '\t' | '\n' => {
if use_marker {
result.push(markers::ESCAPE);
} else {
result.push('\\');
}
result.push(ch);
continue;
}
'~' if result.is_empty() => {
if use_marker {
result.push(markers::ESCAPE);
} else {
result.push('\\');
}
result.push(ch);
continue;
}
_ => {
result.push(ch);
continue;
}
}
}
result
}
pub fn unescape_math(raw: &str) -> String { pub fn unescape_math(raw: &str) -> String {
let mut chars = raw.chars().peekable(); let mut chars = raw.chars().peekable();
let mut result = String::new(); let mut result = String::new();
@@ -1588,7 +1777,8 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
ParamExp::RemShortestPrefix(prefix) => { ParamExp::RemShortestPrefix(prefix) => {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let unescaped = unescape_str(&prefix); let unescaped = unescape_str(&prefix);
let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix); let expanded =
strip_escape_markers(&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix));
let pattern = Pattern::new(&expanded).unwrap(); let pattern = Pattern::new(&expanded).unwrap();
for i in 0..=value.len() { for i in 0..=value.len() {
let sliced = &value[..i]; let sliced = &value[..i];
@@ -1601,7 +1791,8 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
ParamExp::RemLongestPrefix(prefix) => { ParamExp::RemLongestPrefix(prefix) => {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let unescaped = unescape_str(&prefix); let unescaped = unescape_str(&prefix);
let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix); let expanded =
strip_escape_markers(&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(prefix));
let pattern = Pattern::new(&expanded).unwrap(); let pattern = Pattern::new(&expanded).unwrap();
for i in (0..=value.len()).rev() { for i in (0..=value.len()).rev() {
let sliced = &value[..i]; let sliced = &value[..i];
@@ -1614,7 +1805,8 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
ParamExp::RemShortestSuffix(suffix) => { ParamExp::RemShortestSuffix(suffix) => {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let unescaped = unescape_str(&suffix); let unescaped = unescape_str(&suffix);
let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix); let expanded =
strip_escape_markers(&expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix));
let pattern = Pattern::new(&expanded).unwrap(); let pattern = Pattern::new(&expanded).unwrap();
for i in (0..=value.len()).rev() { for i in (0..=value.len()).rev() {
let sliced = &value[i..]; let sliced = &value[i..];
@@ -1627,8 +1819,9 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
ParamExp::RemLongestSuffix(suffix) => { ParamExp::RemLongestSuffix(suffix) => {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let unescaped = unescape_str(&suffix); let unescaped = unescape_str(&suffix);
let expanded_suffix = let expanded_suffix = strip_escape_markers(
expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix.clone()); &expand_raw(&mut unescaped.chars().peekable()).unwrap_or(suffix.clone()),
);
let pattern = Pattern::new(&expanded_suffix).unwrap(); let pattern = Pattern::new(&expanded_suffix).unwrap();
for i in 0..=value.len() { for i in 0..=value.len() {
let sliced = &value[i..]; let sliced = &value[i..];
@@ -1642,8 +1835,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let search = unescape_str(&search); let search = unescape_str(&search);
let replace = unescape_str(&replace); let replace = unescape_str(&replace);
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search); let expanded_search =
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace); strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
let expanded_replace =
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
let regex = glob_to_regex(&expanded_search, false); // unanchored pattern let regex = glob_to_regex(&expanded_search, false); // unanchored pattern
if let Some(mat) = regex.find(&value) { if let Some(mat) = regex.find(&value) {
@@ -1659,8 +1854,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let search = unescape_str(&search); let search = unescape_str(&search);
let replace = unescape_str(&replace); let replace = unescape_str(&replace);
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search); let expanded_search =
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace); strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
let expanded_replace =
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
let regex = glob_to_regex(&expanded_search, false); let regex = glob_to_regex(&expanded_search, false);
let mut result = String::new(); let mut result = String::new();
let mut last_match_end = 0; let mut last_match_end = 0;
@@ -1679,8 +1876,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let search = unescape_str(&search); let search = unescape_str(&search);
let replace = unescape_str(&replace); let replace = unescape_str(&replace);
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search); let expanded_search =
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace); strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
let expanded_replace =
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
let pattern = Pattern::new(&expanded_search).unwrap(); let pattern = Pattern::new(&expanded_search).unwrap();
for i in (0..=value.len()).rev() { for i in (0..=value.len()).rev() {
let sliced = &value[..i]; let sliced = &value[..i];
@@ -1694,8 +1893,10 @@ pub fn perform_param_expansion(raw: &str) -> ShResult<String> {
let value = vars.get_var(&var_name); let value = vars.get_var(&var_name);
let search = unescape_str(&search); let search = unescape_str(&search);
let replace = unescape_str(&replace); let replace = unescape_str(&replace);
let expanded_search = expand_raw(&mut search.chars().peekable()).unwrap_or(search); let expanded_search =
let expanded_replace = expand_raw(&mut replace.chars().peekable()).unwrap_or(replace); strip_escape_markers(&expand_raw(&mut search.chars().peekable()).unwrap_or(search));
let expanded_replace =
strip_escape_markers(&expand_raw(&mut replace.chars().peekable()).unwrap_or(replace));
let pattern = Pattern::new(&expanded_search).unwrap(); let pattern = Pattern::new(&expanded_search).unwrap();
for i in (0..=value.len()).rev() { for i in (0..=value.len()).rev() {
let sliced = &value[i..]; let sliced = &value[i..];
@@ -1740,6 +1941,11 @@ pub fn expand_case_pattern(raw: &str) -> ShResult<String> {
markers::DUB_QUOTE | markers::SNG_QUOTE => { markers::DUB_QUOTE | markers::SNG_QUOTE => {
in_quote = !in_quote; in_quote = !in_quote;
} }
markers::ESCAPE => {
if let Some(next_ch) = chars.next() {
result.push(next_ch);
}
}
'*' | '?' | '[' | ']' if in_quote => { '*' | '?' | '[' | ']' if in_quote => {
result.push('\\'); result.push('\\');
result.push(ch); result.push(ch);
@@ -2381,11 +2587,11 @@ pub fn parse_key_alias(alias: &str) -> Option<KeyEvent> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::time::Duration;
use crate::readline::keys::{KeyCode, KeyEvent, ModKeys};
use crate::state::{write_vars, read_vars, ArrIndex, VarKind, VarFlags};
use crate::parse::lex::Span; use crate::parse::lex::Span;
use crate::readline::keys::{KeyCode, KeyEvent, ModKeys};
use crate::state::{ArrIndex, VarFlags, VarKind, read_vars, write_vars};
use crate::testutil::{TestGuard, test_input}; use crate::testutil::{TestGuard, test_input};
use std::time::Duration;
// ===================== has_braces ===================== // ===================== has_braces =====================
@@ -2525,10 +2731,7 @@ mod tests {
#[test] #[test]
fn braces_simple_list() { fn braces_simple_list() {
assert_eq!( assert_eq!(expand_braces_full("{a,b,c}").unwrap(), vec!["a", "b", "c"]);
expand_braces_full("{a,b,c}").unwrap(),
vec!["a", "b", "c"]
);
} }
#[test] #[test]
@@ -2614,11 +2817,23 @@ mod tests {
assert_eq!(result, vec!["prepost", "preapost"]); assert_eq!(result, vec!["prepost", "preapost"]);
} }
#[test] #[test]
fn braces_cursed() { fn braces_cursed() {
let result = expand_braces_full("foo{a,{1,2,3,{1..4},5},c}{5..1}bar").unwrap(); let result = expand_braces_full("foo{a,{1,2,3,{1..4},5},c}{5..1}bar").unwrap();
assert_eq!(result, vec![ "fooa5bar", "fooa4bar", "fooa3bar", "fooa2bar", "fooa1bar", "foo15bar", "foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo15bar", "foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo45bar", "foo44bar", "foo43bar", "foo42bar", "foo41bar", "foo55bar", "foo54bar", "foo53bar", "foo52bar", "foo51bar", "fooc5bar", "fooc4bar", "fooc3bar", "fooc2bar", "fooc1bar", ]) assert_eq!(
} result,
vec![
"fooa5bar", "fooa4bar", "fooa3bar", "fooa2bar", "fooa1bar", "foo15bar", "foo14bar",
"foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar",
"foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo15bar",
"foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar",
"foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar",
"foo45bar", "foo44bar", "foo43bar", "foo42bar", "foo41bar", "foo55bar", "foo54bar",
"foo53bar", "foo52bar", "foo51bar", "fooc5bar", "fooc4bar", "fooc3bar", "fooc2bar",
"fooc1bar",
]
)
}
// ===================== Arithmetic ===================== // ===================== Arithmetic =====================
@@ -2858,7 +3073,8 @@ mod tests {
#[test] #[test]
fn unescape_backslash() { fn unescape_backslash() {
let result = unescape_str("hello\\nworld"); let result = unescape_str("hello\\nworld");
assert_eq!(result, "hellonworld"); let expected = format!("hello{}nworld", markers::ESCAPE);
assert_eq!(result, expected);
} }
#[test] #[test]
@@ -3089,10 +3305,22 @@ mod tests {
#[test] #[test]
fn key_alias_arrows() { fn key_alias_arrows() {
assert_eq!(parse_key_alias("UP").unwrap(), KeyEvent(KeyCode::Up, ModKeys::NONE)); assert_eq!(
assert_eq!(parse_key_alias("DOWN").unwrap(), KeyEvent(KeyCode::Down, ModKeys::NONE)); parse_key_alias("UP").unwrap(),
assert_eq!(parse_key_alias("LEFT").unwrap(), KeyEvent(KeyCode::Left, ModKeys::NONE)); KeyEvent(KeyCode::Up, ModKeys::NONE)
assert_eq!(parse_key_alias("RIGHT").unwrap(), KeyEvent(KeyCode::Right, ModKeys::NONE)); );
assert_eq!(
parse_key_alias("DOWN").unwrap(),
KeyEvent(KeyCode::Down, ModKeys::NONE)
);
assert_eq!(
parse_key_alias("LEFT").unwrap(),
KeyEvent(KeyCode::Left, ModKeys::NONE)
);
assert_eq!(
parse_key_alias("RIGHT").unwrap(),
KeyEvent(KeyCode::Right, ModKeys::NONE)
);
} }
#[test] #[test]
@@ -3104,7 +3332,13 @@ mod tests {
#[test] #[test]
fn key_alias_ctrl_shift_alt_modifier() { fn key_alias_ctrl_shift_alt_modifier() {
let key = parse_key_alias("C-S-A-b").unwrap(); let key = parse_key_alias("C-S-A-b").unwrap();
assert_eq!(key, KeyEvent(KeyCode::Char('B'), ModKeys::CTRL | ModKeys::SHIFT | ModKeys::ALT)); assert_eq!(
key,
KeyEvent(
KeyCode::Char('B'),
ModKeys::CTRL | ModKeys::SHIFT | ModKeys::ALT
)
);
} }
#[test] #[test]
@@ -3296,7 +3530,14 @@ mod tests {
#[test] #[test]
fn param_remove_shortest_prefix() { fn param_remove_shortest_prefix() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
write_vars(|v| v.set_var("PATH", VarKind::Str("/usr/local/bin".into()), VarFlags::NONE)).unwrap(); write_vars(|v| {
v.set_var(
"PATH",
VarKind::Str("/usr/local/bin".into()),
VarFlags::NONE,
)
})
.unwrap();
let result = perform_param_expansion("PATH#*/").unwrap(); let result = perform_param_expansion("PATH#*/").unwrap();
assert_eq!(result, "usr/local/bin"); assert_eq!(result, "usr/local/bin");
@@ -3305,7 +3546,14 @@ mod tests {
#[test] #[test]
fn param_remove_longest_prefix() { fn param_remove_longest_prefix() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
write_vars(|v| v.set_var("PATH", VarKind::Str("/usr/local/bin".into()), VarFlags::NONE)).unwrap(); write_vars(|v| {
v.set_var(
"PATH",
VarKind::Str("/usr/local/bin".into()),
VarFlags::NONE,
)
})
.unwrap();
let result = perform_param_expansion("PATH##*/").unwrap(); let result = perform_param_expansion("PATH##*/").unwrap();
assert_eq!(result, "bin"); assert_eq!(result, "bin");
@@ -3419,7 +3667,10 @@ mod tests {
fn word_split_default_ifs() { fn word_split_default_ifs() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let mut exp = Expander { raw: "hello world\tfoo".to_string() }; let mut exp = Expander {
raw: "hello world\tfoo".to_string(),
flags: TkFlags::empty()
};
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["hello", "world", "foo"]); assert_eq!(words, vec!["hello", "world", "foo"]);
} }
@@ -3427,9 +3678,14 @@ mod tests {
#[test] #[test]
fn word_split_custom_ifs() { fn word_split_custom_ifs() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
unsafe { std::env::set_var("IFS", ":"); } unsafe {
std::env::set_var("IFS", ":");
}
let mut exp = Expander { raw: "a:b:c".to_string() }; let mut exp = Expander {
raw: "a:b:c".to_string(),
flags: TkFlags::empty()
};
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["a", "b", "c"]); assert_eq!(words, vec!["a", "b", "c"]);
} }
@@ -3437,9 +3693,14 @@ mod tests {
#[test] #[test]
fn word_split_empty_ifs() { fn word_split_empty_ifs() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
unsafe { std::env::set_var("IFS", ""); } unsafe {
std::env::set_var("IFS", "");
}
let mut exp = Expander { raw: "hello world".to_string() }; let mut exp = Expander {
raw: "hello world".to_string(),
flags: TkFlags::empty()
};
let words = exp.split_words(); let words = exp.split_words();
assert_eq!(words, vec!["hello world"]); assert_eq!(words, vec!["hello world"]);
} }
@@ -3449,11 +3710,82 @@ 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"]);
} }
// ===================== Escaped Word Splitting =====================
#[test]
fn word_split_escaped_space() {
let _guard = TestGuard::new();
let raw = format!("hello{}world", unescape_str("\\ "));
let mut exp = Expander {
raw,
flags: TkFlags::empty()
};
let words = exp.split_words();
assert_eq!(words, vec!["hello world"]);
}
#[test]
fn word_split_escaped_tab() {
let _guard = TestGuard::new();
let raw = format!("hello{}world", unescape_str("\\\t"));
let mut exp = Expander {
raw,
flags: TkFlags::empty()
};
let words = exp.split_words();
assert_eq!(words, vec!["hello\tworld"]);
}
#[test]
fn word_split_escaped_custom_ifs() {
let _guard = TestGuard::new();
unsafe {
std::env::set_var("IFS", ":");
}
let raw = format!("a{}b:c", unescape_str("\\:"));
let mut exp = Expander {
raw,
flags: TkFlags::empty()
};
let words = exp.split_words();
assert_eq!(words, vec!["a:b", "c"]);
}
// ===================== Parameter Expansion with Escapes (TestGuard) =====================
#[test]
fn param_exp_prefix_removal_escaped() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("branch", VarKind::Str("## main".into()), VarFlags::NONE)).unwrap();
test_input("echo \"${branch#\\#\\# }\"").unwrap();
let out = guard.read_output();
assert_eq!(out, "main\n");
}
#[test]
fn param_exp_suffix_removal_escaped() {
let guard = TestGuard::new();
write_vars(|v| v.set_var("val", VarKind::Str("hello world!!".into()), VarFlags::NONE)).unwrap();
test_input("echo \"${val%\\!\\!}\"").unwrap();
let out = guard.read_output();
assert_eq!(out, "hello world\n");
}
// ===================== Arithmetic with Variables (TestGuard) ===================== // ===================== Arithmetic with Variables (TestGuard) =====================
#[test] #[test]
@@ -3478,8 +3810,13 @@ mod tests {
fn array_index_first() { fn array_index_first() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
write_vars(|v| { write_vars(|v| {
v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE) v.set_var(
}).unwrap(); "arr",
VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]),
VarFlags::NONE,
)
})
.unwrap();
let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(0))).unwrap(); let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(0))).unwrap();
assert_eq!(val, "a"); assert_eq!(val, "a");
@@ -3489,8 +3826,13 @@ mod tests {
fn array_index_second() { fn array_index_second() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
write_vars(|v| { write_vars(|v| {
v.set_var("arr", VarKind::arr_from_vec(vec!["x".into(), "y".into(), "z".into()]), VarFlags::NONE) v.set_var(
}).unwrap(); "arr",
VarKind::arr_from_vec(vec!["x".into(), "y".into(), "z".into()]),
VarFlags::NONE,
)
})
.unwrap();
let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(1))).unwrap(); let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(1))).unwrap();
assert_eq!(val, "y"); assert_eq!(val, "y");
@@ -3500,8 +3842,13 @@ mod tests {
fn array_all_elems() { fn array_all_elems() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
write_vars(|v| { write_vars(|v| {
v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE) v.set_var(
}).unwrap(); "arr",
VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]),
VarFlags::NONE,
)
})
.unwrap();
let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap(); let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap();
assert_eq!(elems, vec!["a", "b", "c"]); assert_eq!(elems, vec!["a", "b", "c"]);
@@ -3511,8 +3858,13 @@ mod tests {
fn array_elem_count() { fn array_elem_count() {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
write_vars(|v| { write_vars(|v| {
v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE) v.set_var(
}).unwrap(); "arr",
VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]),
VarFlags::NONE,
)
})
.unwrap();
let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap(); let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap();
assert_eq!(elems.len(), 3); assert_eq!(elems.len(), 3);
@@ -3525,7 +3877,9 @@ mod tests {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let dummy_span = Span::default(); let dummy_span = Span::default();
crate::state::SHED.with(|s| { crate::state::SHED.with(|s| {
s.logic.borrow_mut().insert_alias("ll", "ls -la", dummy_span.clone()); s.logic
.borrow_mut()
.insert_alias("ll", "ls -la", dummy_span.clone());
}); });
let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone()); let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone());
@@ -3538,7 +3892,9 @@ mod tests {
let _guard = TestGuard::new(); let _guard = TestGuard::new();
let dummy_span = Span::default(); let dummy_span = Span::default();
crate::state::SHED.with(|s| { crate::state::SHED.with(|s| {
s.logic.borrow_mut().insert_alias("foo", "foo --verbose", dummy_span.clone()); s.logic
.borrow_mut()
.insert_alias("foo", "foo --verbose", dummy_span.clone());
}); });
let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone()); let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone());
@@ -3550,26 +3906,47 @@ mod tests {
// ===================== Direct Input Tests (TestGuard) ===================== // ===================== Direct Input Tests (TestGuard) =====================
#[test] #[test]
fn index_simple() { fn index_simple() {
let guard = TestGuard::new(); let guard = TestGuard::new();
write_vars(|v| v.set_var("arr", VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])), VarFlags::NONE)).unwrap(); write_vars(|v| {
v.set_var(
"arr",
VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])),
VarFlags::NONE,
)
})
.unwrap();
test_input("echo $arr").unwrap(); test_input("echo $arr").unwrap();
let out = guard.read_output(); let out = guard.read_output();
assert_eq!(out, "foo bar biz\n"); assert_eq!(out, "foo bar biz\n");
} }
#[test] #[test]
fn index_cursed() { fn index_cursed() {
let guard = TestGuard::new(); let guard = TestGuard::new();
write_vars(|v| v.set_var("arr", VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])), VarFlags::NONE)).unwrap(); write_vars(|v| {
write_vars(|v| v.set_var("i", VarKind::Arr(VecDeque::from(["0".into(), "1".into(), "2".into()])), VarFlags::NONE)).unwrap(); v.set_var(
"arr",
VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])),
VarFlags::NONE,
)
})
.unwrap();
write_vars(|v| {
v.set_var(
"i",
VarKind::Arr(VecDeque::from(["0".into(), "1".into(), "2".into()])),
VarFlags::NONE,
)
})
.unwrap();
test_input("echo $echo ${var:-${arr[$(($(echo ${i[0]}) + 1))]}}").unwrap(); test_input("echo $echo ${var:-${arr[$(($(echo ${i[0]}) + 1))]}}").unwrap();
let out = guard.read_output(); let out = guard.read_output();
assert_eq!(out, "bar\n"); assert_eq!(out, "bar\n");
} }
} }

View File

@@ -3,7 +3,11 @@ use std::sync::Arc;
use ariadne::Fmt; use ariadne::Fmt;
use fmt::Display; use fmt::Display;
use crate::{libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::lex::Tk, prelude::*}; use crate::{
libsh::error::{ShErr, ShErrKind, ShResult, next_color},
parse::lex::Tk,
prelude::*,
};
pub type OptSet = Arc<[Opt]>; pub type OptSet = Arc<[Opt]>;
@@ -69,32 +73,38 @@ pub fn get_opts(words: Vec<String>) -> (Vec<String>, Vec<Opt>) {
} }
pub fn get_opts_from_tokens_strict( pub fn get_opts_from_tokens_strict(
tokens: Vec<Tk>, tokens: Vec<Tk>,
opt_specs: &[OptSpec], opt_specs: &[OptSpec],
) -> ShResult<(Vec<Tk>, Vec<Opt>)> { ) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
sort_tks(tokens, opt_specs, true) sort_tks(tokens, opt_specs, true)
} }
pub fn get_opts_from_tokens( pub fn get_opts_from_tokens(
tokens: Vec<Tk>, tokens: Vec<Tk>,
opt_specs: &[OptSpec], opt_specs: &[OptSpec],
) -> ShResult<(Vec<Tk>, Vec<Opt>)> { ) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
sort_tks(tokens, opt_specs, false) sort_tks(tokens, opt_specs, false)
} }
pub fn sort_tks(tokens: Vec<Tk>, opt_specs: &[OptSpec], strict: bool) -> ShResult<(Vec<Tk>, Vec<Opt>)> { pub fn sort_tks(
tokens: Vec<Tk>,
opt_specs: &[OptSpec],
strict: bool,
) -> ShResult<(Vec<Tk>, Vec<Opt>)> {
let mut tokens_iter = tokens let mut tokens_iter = tokens
.into_iter() .into_iter()
.map(|t| t.expand()) .map(|t| t.expand())
.collect::<ShResult<Vec<_>>>()? .collect::<ShResult<Vec<_>>>()?
.into_iter(); .into_iter()
.peekable();
let mut opts = vec![]; let mut opts = vec![];
let mut non_opts = vec![]; let mut non_opts = vec![];
while let Some(token) = tokens_iter.next() { while let Some(token) = tokens_iter.next() {
if &token.to_string() == "--" { if &token.to_string() == "--" {
non_opts.extend(tokens_iter); non_opts.push(token);
break; non_opts.extend(tokens_iter);
break;
} }
let parsed_opts = Opt::parse(&token.to_string()); let parsed_opts = Opt::parse(&token.to_string());
@@ -125,14 +135,14 @@ pub fn sort_tks(tokens: Vec<Tk>, opt_specs: &[OptSpec], strict: bool) -> ShResul
} }
} }
if !pushed { if !pushed {
if strict { if strict {
return Err(ShErr::simple( return Err(ShErr::simple(
ShErrKind::ParseErr, ShErrKind::ParseErr,
format!("Unknown option: {}", opt.to_string().fg(next_color())), format!("Unknown option: {}", opt.to_string().fg(next_color())),
)); ));
} else { } else {
non_opts.push(token.clone()); non_opts.push(token.clone());
} }
} }
} }
} }
@@ -140,12 +150,11 @@ pub fn sort_tks(tokens: Vec<Tk>, opt_specs: &[OptSpec], strict: bool) -> ShResul
Ok((non_opts, opts)) Ok((non_opts, opts))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::parse::lex::{LexFlags, LexStream}; use crate::parse::lex::{LexFlags, LexStream};
use super::*; use super::*;
#[test] #[test]
fn parse_short_single() { fn parse_short_single() {
@@ -156,7 +165,10 @@ use super::*;
#[test] #[test]
fn parse_short_combined() { fn parse_short_combined() {
let opts = Opt::parse("-abc"); let opts = Opt::parse("-abc");
assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]); assert_eq!(
opts,
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
);
} }
#[test] #[test]
@@ -173,7 +185,12 @@ use super::*;
#[test] #[test]
fn get_opts_basic() { fn get_opts_basic() {
let words = vec!["file.txt".into(), "-v".into(), "--help".into(), "arg".into()]; let words = vec![
"file.txt".into(),
"-v".into(),
"--help".into(),
"arg".into(),
];
let (non_opts, opts) = get_opts(words); let (non_opts, opts) = get_opts(words);
assert_eq!(non_opts, vec!["file.txt", "arg"]); assert_eq!(non_opts, vec!["file.txt", "arg"]);
assert_eq!(opts, vec![Opt::Short('v'), Opt::Long("help".into())]); assert_eq!(opts, vec![Opt::Short('v'), Opt::Long("help".into())]);
@@ -191,7 +208,10 @@ use super::*;
fn get_opts_combined_short() { fn get_opts_combined_short() {
let words = vec!["-abc".into(), "file".into()]; let words = vec!["-abc".into(), "file".into()];
let (non_opts, opts) = get_opts(words); let (non_opts, opts) = get_opts(words);
assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]); assert_eq!(
opts,
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
);
assert_eq!(non_opts, vec!["file"]); assert_eq!(non_opts, vec!["file"]);
} }
@@ -215,128 +235,175 @@ use super::*;
assert_eq!(Opt::Short('v').to_string(), "-v"); assert_eq!(Opt::Short('v').to_string(), "-v");
assert_eq!(Opt::Long("help".into()).to_string(), "--help"); assert_eq!(Opt::Long("help".into()).to_string(), "--help");
assert_eq!(Opt::ShortWithArg('o', "file".into()).to_string(), "-o file"); assert_eq!(Opt::ShortWithArg('o', "file".into()).to_string(), "-o file");
assert_eq!(Opt::LongWithArg("output".into(), "file".into()).to_string(), "--output file"); assert_eq!(
Opt::LongWithArg("output".into(), "file".into()).to_string(),
"--output file"
);
} }
fn lex(input: &str) -> Vec<Tk> { fn lex(input: &str) -> Vec<Tk> {
LexStream::new(Arc::new(input.to_string()), LexFlags::empty()) LexStream::new(Arc::new(input.to_string()), LexFlags::empty())
.collect::<ShResult<Vec<Tk>>>() .collect::<ShResult<Vec<Tk>>>()
.unwrap() .unwrap()
} }
#[test] #[test]
fn get_opts_from_tks() { fn get_opts_from_tks() {
let tokens = lex("file.txt --help -v arg"); let tokens = lex("file.txt --help -v arg");
let opt_spec = vec![ let opt_spec = vec![
OptSpec { opt: Opt::Short('v'), takes_arg: false }, OptSpec {
OptSpec { opt: Opt::Long("help".into()), takes_arg: false }, opt: Opt::Short('v'),
]; takes_arg: false,
},
OptSpec {
opt: Opt::Long("help".into()),
takes_arg: false,
},
];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
let mut opts = opts.into_iter(); let mut opts = opts.into_iter();
assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into()))); assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into())));
assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into()))); assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into())));
let mut non_opts = non_opts.into_iter().map(|s| s.to_string()); let mut non_opts = non_opts.into_iter().map(|s| s.to_string());
assert!(non_opts.any(|s| s == "file.txt" || s == "arg")); assert!(non_opts.any(|s| s == "file.txt" || s == "arg"));
assert!(non_opts.any(|s| s == "file.txt" || s == "arg")); assert!(non_opts.any(|s| s == "file.txt" || s == "arg"));
} }
#[test] #[test]
fn tks_short_with_arg() { fn tks_short_with_arg() {
let tokens = lex("-o output.txt file.txt"); let tokens = lex("-o output.txt file.txt");
let opt_spec = vec![ let opt_spec = vec![OptSpec {
OptSpec { opt: Opt::Short('o'), takes_arg: true }, opt: Opt::Short('o'),
]; takes_arg: true,
}];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::ShortWithArg('o', "output.txt".into())]); assert_eq!(opts, vec![Opt::ShortWithArg('o', "output.txt".into())]);
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect(); let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"file.txt".to_string())); assert!(non_opts.contains(&"file.txt".to_string()));
} }
#[test] #[test]
fn tks_long_with_arg() { fn tks_long_with_arg() {
let tokens = lex("--output result.txt input.txt"); let tokens = lex("--output result.txt input.txt");
let opt_spec = vec![ let opt_spec = vec![OptSpec {
OptSpec { opt: Opt::Long("output".into()), takes_arg: true }, opt: Opt::Long("output".into()),
]; takes_arg: true,
}];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::LongWithArg("output".into(), "result.txt".into())]); assert_eq!(
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect(); opts,
assert!(non_opts.contains(&"input.txt".to_string())); vec![Opt::LongWithArg("output".into(), "result.txt".into())]
} );
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"input.txt".to_string()));
}
#[test] #[test]
fn tks_double_dash_stops() { fn tks_double_dash_stops() {
let tokens = lex("-v -- -a --foo"); let tokens = lex("-v -- -a --foo");
let opt_spec = vec![ let opt_spec = vec![
OptSpec { opt: Opt::Short('v'), takes_arg: false }, OptSpec {
OptSpec { opt: Opt::Short('a'), takes_arg: false }, opt: Opt::Short('v'),
]; takes_arg: false,
},
OptSpec {
opt: Opt::Short('a'),
takes_arg: false,
},
];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::Short('v')]); assert_eq!(opts, vec![Opt::Short('v')]);
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect(); let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"-a".to_string())); assert!(non_opts.contains(&"-a".to_string()));
assert!(non_opts.contains(&"--foo".to_string())); assert!(non_opts.contains(&"--foo".to_string()));
} }
#[test] #[test]
fn tks_combined_short_with_spec() { fn tks_combined_short_with_spec() {
let tokens = lex("-abc"); let tokens = lex("-abc");
let opt_spec = vec![ let opt_spec = vec![
OptSpec { opt: Opt::Short('a'), takes_arg: false }, OptSpec {
OptSpec { opt: Opt::Short('b'), takes_arg: false }, opt: Opt::Short('a'),
OptSpec { opt: Opt::Short('c'), takes_arg: false }, takes_arg: false,
]; },
OptSpec {
opt: Opt::Short('b'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('c'),
takes_arg: false,
},
];
let (_non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (_non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]); assert_eq!(
} opts,
vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]
);
}
#[test] #[test]
fn tks_unknown_opt_becomes_non_opt() { fn tks_unknown_opt_becomes_non_opt() {
let tokens = lex("-v -x file"); let tokens = lex("-v -x file");
let opt_spec = vec![ let opt_spec = vec![OptSpec {
OptSpec { opt: Opt::Short('v'), takes_arg: false }, opt: Opt::Short('v'),
]; takes_arg: false,
}];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![Opt::Short('v')]); assert_eq!(opts, vec![Opt::Short('v')]);
// -x is not in spec, so its token goes to non_opts // -x is not in spec, so its token goes to non_opts
assert!(non_opts.into_iter().map(|s| s.to_string()).any(|s| s == "-x" || s == "file")); assert!(
} non_opts
.into_iter()
.map(|s| s.to_string())
.any(|s| s == "-x" || s == "file")
);
}
#[test] #[test]
fn tks_mixed_short_and_long_with_args() { fn tks_mixed_short_and_long_with_args() {
let tokens = lex("-n 5 --output file.txt input"); let tokens = lex("-n 5 --output file.txt input");
let opt_spec = vec![ let opt_spec = vec![
OptSpec { opt: Opt::Short('n'), takes_arg: true }, OptSpec {
OptSpec { opt: Opt::Long("output".into()), takes_arg: true }, opt: Opt::Short('n'),
]; takes_arg: true,
},
OptSpec {
opt: Opt::Long("output".into()),
takes_arg: true,
},
];
let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap();
assert_eq!(opts, vec![ assert_eq!(
Opt::ShortWithArg('n', "5".into()), opts,
Opt::LongWithArg("output".into(), "file.txt".into()), vec![
]); Opt::ShortWithArg('n', "5".into()),
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect(); Opt::LongWithArg("output".into(), "file.txt".into()),
assert!(non_opts.contains(&"input".to_string())); ]
} );
let non_opts: Vec<String> = non_opts.into_iter().map(|s| s.to_string()).collect();
assert!(non_opts.contains(&"input".to_string()));
}
} }

View File

@@ -1,10 +1,10 @@
use ariadne::{Color, Fmt}; use ariadne::{Color, Fmt};
use ariadne::{Report, ReportKind}; use ariadne::{Report, ReportKind};
use rand::TryRng; use rand::TryRng;
use yansi::Paint;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use std::fmt::Display; use std::fmt::Display;
use yansi::Paint;
use crate::procio::RedirGuard; use crate::procio::RedirGuard;
use crate::{ use crate::{
@@ -150,7 +150,7 @@ impl Display for Note {
writeln!(f, "{note}: {main}")?; writeln!(f, "{note}: {main}")?;
} else { } else {
let bar_break = Fmt::fg("-", Color::Cyan); let bar_break = Fmt::fg("-", Color::Cyan);
let bar_break = bar_break.bold(); let bar_break = bar_break.bold();
let indent = " ".repeat(self.depth); let indent = " ".repeat(self.depth);
writeln!(f, " {indent}{bar_break} {main}")?; writeln!(f, " {indent}{bar_break} {main}")?;
} }
@@ -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,7 +209,9 @@ 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)
} }

View File

@@ -147,11 +147,9 @@ 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");
res res
} }
} }

View File

@@ -2,6 +2,14 @@ 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

@@ -39,9 +39,11 @@ 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_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending};
use crate::state::{AutoCmdKind, read_logic, read_shopts, source_rc, write_jobs, write_meta}; use crate::state::{
AutoCmdKind, read_logic, read_shopts, source_rc, write_jobs, write_meta, write_shopts,
};
use clap::Parser; use clap::Parser;
use state::{read_vars, write_vars}; use state::write_vars;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct ShedArgs { struct ShedArgs {
@@ -63,20 +65,6 @@ struct ShedArgs {
login_shell: bool, login_shell: bool,
} }
/// Force evaluation of lazily-initialized values early in shell startup.
///
/// In particular, this ensures that the variable table is initialized, which
/// populates environment variables from the system. If this initialization is
/// deferred too long, features like prompt expansion may fail due to missing
/// environment variables.
///
/// This function triggers initialization by calling `read_vars` with a no-op
/// closure, which forces access to the variable table and causes its `LazyLock`
/// constructor to run.
fn kickstart_lazy_evals() {
read_vars(|_| {});
}
/// We need to make sure that even if we panic, our child processes get sighup /// We need to make sure that even if we panic, our child processes get sighup
fn setup_panic_handler() { fn setup_panic_handler() {
let default_panic_hook = std::panic::take_hook(); let default_panic_hook = std::panic::take_hook();
@@ -111,7 +99,6 @@ fn setup_panic_handler() {
fn main() -> ExitCode { fn main() -> ExitCode {
yansi::enable(); yansi::enable();
env_logger::init(); env_logger::init();
kickstart_lazy_evals();
setup_panic_handler(); setup_panic_handler();
let mut args = ShedArgs::parse(); let mut args = ShedArgs::parse();
@@ -130,14 +117,24 @@ fn main() -> ExitCode {
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
} }
// Increment SHLVL, or set to 1 if not present or invalid.
// This var represents how many nested shell instances we're in
if let Ok(var) = env::var("SHLVL")
&& let Ok(lvl) = var.parse::<u32>()
{
unsafe { env::set_var("SHLVL", (lvl + 1).to_string()) };
} else {
unsafe { env::set_var("SHLVL", "1") };
}
if let Err(e) = if let Some(path) = args.script { if let Err(e) = if let Some(path) = args.script {
run_script(path, args.script_args) run_script(path, args.script_args)
} else if let Some(cmd) = args.command { } else if let Some(cmd) = args.command {
exec_dash_c(cmd) exec_dash_c(cmd)
} else { } else {
let res = shed_interactive(args); let res = shed_interactive(args);
write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit write(borrow_fd(*TTY_FILENO), b"\x1b[?2004l").ok(); // disable bracketed paste mode on exit
res res
} { } {
e.print_error(); e.print_error();
}; };
@@ -207,7 +204,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
} }
}; };
readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode readline.writer.flush_write("\x1b[?2004h")?; // enable bracketed paste mode
// Main poll loop // Main poll loop
loop { loop {
@@ -226,9 +223,9 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
readline.reset_active_widget(false)?; readline.reset_active_widget(false)?;
} }
ShErrKind::CleanExit(code) => { ShErrKind::CleanExit(code) => {
QUIT_CODE.store(*code, Ordering::SeqCst); QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(()); return Ok(());
} }
_ => e.print_error(), _ => e.print_error(),
} }
} }
@@ -255,14 +252,38 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
PollFlags::POLLIN, PollFlags::POLLIN,
)]; )];
let mut exec_if_timeout = None;
let timeout = if readline.pending_keymap.is_empty() { let timeout = if readline.pending_keymap.is_empty() {
PollTimeout::MAX let screensaver_cmd = read_shopts(|o| o.prompt.screensaver_cmd.clone());
let screensaver_idle_time = read_shopts(|o| o.prompt.screensaver_idle_time);
if screensaver_idle_time > 0 && !screensaver_cmd.is_empty() {
exec_if_timeout = Some(screensaver_cmd);
PollTimeout::from((screensaver_idle_time * 1000) as u16)
} else {
PollTimeout::MAX
}
} else { } else {
PollTimeout::from(1000u16) PollTimeout::from(1000u16)
}; };
match poll(&mut fds, timeout) { match poll(&mut fds, timeout) {
Ok(_) => {} Ok(0) => {
// We timed out.
if let Some(cmd) = exec_if_timeout {
let prepared = ReadlineEvent::Line(cmd);
let saved_hist_opt = read_shopts(|o| o.core.auto_hist);
let _guard = scopeguard::guard(saved_hist_opt, |opt| {
write_shopts(|o| o.core.auto_hist = opt);
});
write_shopts(|o| o.core.auto_hist = false); // don't save screensaver command to history
match handle_readline_event(&mut readline, Ok(prepared))? {
true => return Ok(()),
false => continue,
}
}
}
Err(Errno::EINTR) => { Err(Errno::EINTR) => {
// Interrupted by signal, loop back to handle it // Interrupted by signal, loop back to handle it
continue; continue;
@@ -271,6 +292,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
eprintln!("poll error: {e}"); eprintln!("poll error: {e}");
break; break;
} }
Ok(_) => {}
} }
// Timeout — resolve pending keymap ambiguity // Timeout — resolve pending keymap ambiguity

View File

@@ -8,7 +8,7 @@ use ariadne::Fmt;
use crate::{ use crate::{
builtin::{ builtin::{
alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset} alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, seek::seek, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}
}, },
expand::{expand_aliases, expand_case_pattern, glob_to_regex}, expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
@@ -136,13 +136,18 @@ pub fn exec_dash_c(input: String) -> ShResult<()> {
if nodes.len() == 1 { if nodes.len() == 1 {
let is_single_cmd = match &nodes[0].class { let is_single_cmd = match &nodes[0].class {
NdRule::Command { .. } => true, NdRule::Command { .. } => true,
NdRule::Pipeline { cmds } => cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. }), NdRule::Pipeline { cmds } => {
cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. })
}
NdRule::Conjunction { elements } => { NdRule::Conjunction { elements } => {
elements.len() == 1 && match &elements[0].cmd.class { elements.len() == 1
NdRule::Pipeline { cmds } => cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. }), && match &elements[0].cmd.class {
NdRule::Command { .. } => true, NdRule::Pipeline { cmds } => {
_ => false, cmds.len() == 1 && matches!(cmds[0].class, NdRule::Command { .. })
} }
NdRule::Command { .. } => true,
_ => false,
}
} }
_ => false, _ => false,
}; };
@@ -151,8 +156,12 @@ pub fn exec_dash_c(input: String) -> ShResult<()> {
let mut node = nodes.remove(0); let mut node = nodes.remove(0);
loop { loop {
match node.class { match node.class {
NdRule::Conjunction { mut elements } => { node = *elements.remove(0).cmd; } NdRule::Conjunction { mut elements } => {
NdRule::Pipeline { mut cmds } => { node = cmds.remove(0); } node = *elements.remove(0).cmd;
}
NdRule::Pipeline { mut cmds } => {
node = cmds.remove(0);
}
NdRule::Command { .. } => break, NdRule::Command { .. } => break,
_ => break, _ => break,
} }
@@ -250,6 +259,7 @@ impl Dispatcher {
NdRule::CaseNode { .. } => self.exec_case(node)?, NdRule::CaseNode { .. } => self.exec_case(node)?,
NdRule::BraceGrp { .. } => self.exec_brc_grp(node)?, NdRule::BraceGrp { .. } => self.exec_brc_grp(node)?,
NdRule::FuncDef { .. } => self.exec_func_def(node)?, NdRule::FuncDef { .. } => self.exec_func_def(node)?,
NdRule::Negate { .. } => self.exec_negated(node)?,
NdRule::Command { .. } => self.dispatch_cmd(node)?, NdRule::Command { .. } => self.dispatch_cmd(node)?,
NdRule::Test { .. } => self.exec_test(node)?, NdRule::Test { .. } => self.exec_test(node)?,
_ => unreachable!(), _ => unreachable!(),
@@ -257,6 +267,15 @@ impl Dispatcher {
Ok(()) Ok(())
} }
pub fn dispatch_cmd(&mut self, node: Node) -> ShResult<()> { pub fn dispatch_cmd(&mut self, node: Node) -> ShResult<()> {
let (line, _) = node.get_span().clone().line_and_col();
write_vars(|v| {
v.set_var(
"LINENO",
VarKind::Str((line + 1).to_string()),
VarFlags::NONE,
)
})?;
let Some(cmd) = node.get_command() else { let Some(cmd) = node.get_command() else {
return self.exec_cmd(node); // Argv is empty, probably an assignment return self.exec_cmd(node); // Argv is empty, probably an assignment
}; };
@@ -284,30 +303,35 @@ impl Dispatcher {
self.exec_cmd(node) self.exec_cmd(node)
} }
} }
pub fn exec_negated(&mut self, node: Node) -> ShResult<()> {
let NdRule::Negate { cmd } = node.class else {
unreachable!()
};
self.dispatch_node(*cmd)?;
let status = state::get_status();
state::set_status(if status == 0 { 1 } else { 0 });
Ok(())
}
pub fn exec_conjunction(&mut self, conjunction: Node) -> ShResult<()> { pub fn exec_conjunction(&mut self, conjunction: Node) -> ShResult<()> {
let NdRule::Conjunction { elements } = conjunction.class else { let NdRule::Conjunction { elements } = conjunction.class else {
unreachable!() unreachable!()
}; };
let mut elem_iter = elements.into_iter(); let mut elem_iter = elements.into_iter();
let mut skip = false;
while let Some(element) = elem_iter.next() { while let Some(element) = elem_iter.next() {
let ConjunctNode { cmd, operator } = element; let ConjunctNode { cmd, operator } = element;
self.dispatch_node(*cmd)?; if !skip {
self.dispatch_node(*cmd)?;
}
let status = state::get_status(); let status = state::get_status();
match operator { skip = match operator {
ConjunctOp::And => { ConjunctOp::And => status != 0,
if status != 0 { ConjunctOp::Or => status == 0,
break;
}
}
ConjunctOp::Or => {
if status == 0 {
break;
}
}
ConjunctOp::Null => break, ConjunctOp::Null => break,
} };
} }
Ok(()) Ok(())
} }
@@ -327,7 +351,7 @@ 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(
@@ -350,7 +374,7 @@ impl Dispatcher {
Ok(()) Ok(())
} }
fn exec_subsh(&mut self, subsh: Node) -> ShResult<()> { fn exec_subsh(&mut self, subsh: Node) -> ShResult<()> {
let _blame = subsh.get_span().clone(); let _blame = subsh.get_span().clone();
let NdRule::Command { assignments, argv } = subsh.class else { let NdRule::Command { assignments, argv } = subsh.class else {
unreachable!() unreachable!()
}; };
@@ -578,6 +602,7 @@ impl Dispatcher {
} }
} }
} else { } else {
state::set_status(0);
break; break;
} }
} }
@@ -714,9 +739,13 @@ impl Dispatcher {
} }
} }
if !matched && !else_block.is_empty() { if !matched {
for node in else_block { if !else_block.is_empty() {
s.dispatch_node(node)?; for node in else_block {
s.dispatch_node(node)?;
}
} else {
state::set_status(0);
} }
} }
@@ -749,7 +778,18 @@ impl Dispatcher {
if cmds.len() == 1 { if cmds.len() == 1 {
self.fg_job = !is_bg && self.interactive; self.fg_job = !is_bg && self.interactive;
let cmd = cmds.into_iter().next().unwrap(); let cmd = cmds.into_iter().next().unwrap();
self.dispatch_node(cmd)?; if is_bg && !matches!(cmd.class, NdRule::Command { .. }) {
self.run_fork(
&cmd.get_command().map(|t| t.to_string()).unwrap_or_default(),
|s| {
if let Err(e) = s.dispatch_node(cmd) {
e.print_error();
}
},
)?;
} else {
self.dispatch_node(cmd)?;
}
// Give the pipeline terminal control as soon as the first child // Give the pipeline terminal control as soon as the first child
// establishes the PGID, so later children (e.g. nvim) don't get // establishes the PGID, so later children (e.g. nvim) don't get
@@ -822,7 +862,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();
@@ -945,8 +988,9 @@ impl Dispatcher {
"keymap" => keymap::keymap(cmd), "keymap" => keymap::keymap(cmd),
"read_key" => read::read_key(cmd), "read_key" => read::read_key(cmd),
"autocmd" => autocmd(cmd), "autocmd" => autocmd(cmd),
"ulimit" => ulimit(cmd), "ulimit" => ulimit(cmd),
"umask" => umask_builtin(cmd), "umask" => umask_builtin(cmd),
"seek" => seek(cmd),
"true" | ":" => { "true" | ":" => {
state::set_status(0); state::set_status(0);
Ok(()) Ok(())
@@ -1084,6 +1128,7 @@ impl Dispatcher {
match unsafe { fork()? } { match unsafe { fork()? } {
ForkResult::Child => { ForkResult::Child => {
let _ = setpgid(Pid::from_raw(0), existing_pgid.unwrap_or(Pid::from_raw(0))); let _ = setpgid(Pid::from_raw(0), existing_pgid.unwrap_or(Pid::from_raw(0)));
self.interactive = false;
f(self); f(self);
exit(state::get_status()) exit(state::get_status())
} }
@@ -1195,3 +1240,202 @@ pub fn is_func(tk: Option<Tk>) -> bool {
pub fn is_subsh(tk: Option<Tk>) -> bool { pub fn is_subsh(tk: Option<Tk>) -> bool {
tk.is_some_and(|tk| tk.flags.contains(TkFlags::IS_SUBSH)) tk.is_some_and(|tk| tk.flags.contains(TkFlags::IS_SUBSH))
} }
#[cfg(test)]
mod tests {
use crate::state;
use crate::testutil::{TestGuard, test_input};
// ===================== while/until status =====================
#[test]
fn while_loop_status_zero_after_completion() {
let _g = TestGuard::new();
test_input("while false; do :; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn while_loop_status_zero_after_iterations() {
let _g = TestGuard::new();
test_input("X=0; while [[ $X -lt 3 ]]; do X=$((X+1)); done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn until_loop_status_zero_after_completion() {
let _g = TestGuard::new();
test_input("until true; do :; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn until_loop_status_zero_after_iterations() {
let _g = TestGuard::new();
test_input("X=3; until [[ $X -le 0 ]]; do X=$((X-1)); done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn while_break_preserves_status() {
let _g = TestGuard::new();
test_input("while true; do break; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn while_body_status_propagates() {
let _g = TestGuard::new();
test_input("X=0; while [[ $X -lt 1 ]]; do X=$((X+1)); false; done").unwrap();
// Loop body ended with `false` (status 1), but the loop itself
// completed normally when the condition failed, so status should be 0
assert_eq!(state::get_status(), 0);
}
// ===================== if/elif/else status =====================
#[test]
fn if_true_body_status() {
let _g = TestGuard::new();
test_input("if true; then echo ok; fi").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn if_false_no_else_status() {
let _g = TestGuard::new();
test_input("if false; then echo ok; fi").unwrap();
// No branch taken, POSIX says status is 0
assert_eq!(state::get_status(), 0);
}
#[test]
fn if_else_branch_status() {
let _g = TestGuard::new();
test_input("if false; then true; else false; fi").unwrap();
assert_eq!(state::get_status(), 1);
}
// ===================== for loop status =====================
#[test]
fn for_loop_empty_list_status() {
let _g = TestGuard::new();
test_input("for x in; do echo $x; done").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn for_loop_body_status() {
let _g = TestGuard::new();
test_input("for x in a b c; do true; done").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== case status =====================
#[test]
fn case_match_status() {
let _g = TestGuard::new();
test_input("case foo in foo) true;; esac").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn case_no_match_status() {
let _g = TestGuard::new();
test_input("case foo in bar) true;; esac").unwrap();
assert_eq!(state::get_status(), 0);
}
// ===================== other stuff =====================
#[test]
fn for_loop_var_zip() {
let g = TestGuard::new();
test_input("for a b in 1 2 3 4 5 6; do echo $a $b; done").unwrap();
let out = g.read_output();
assert_eq!(out, "1 2\n3 4\n5 6\n");
}
#[test]
fn for_loop_unsets_zipped() {
let g = TestGuard::new();
test_input("for a b c d in 1 2 3 4 5 6; do echo $a $b $c $d; done").unwrap();
let out = g.read_output();
assert_eq!(out, "1 2 3 4\n5 6\n");
}
// ===================== negation (!) status =====================
#[test]
fn negate_true() {
let _g = TestGuard::new();
test_input("! true").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn negate_false() {
let _g = TestGuard::new();
test_input("! false").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn double_negate_true() {
let _g = TestGuard::new();
test_input("! ! true").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn double_negate_false() {
let _g = TestGuard::new();
test_input("! ! false").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn negate_pipeline_last_cmd() {
let _g = TestGuard::new();
// pipeline status = last cmd (false) = 1, negated → 0
test_input("! true | false").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn negate_pipeline_last_cmd_true() {
let _g = TestGuard::new();
// pipeline status = last cmd (true) = 0, negated → 1
test_input("! false | true").unwrap();
assert_eq!(state::get_status(), 1);
}
#[test]
fn negate_in_conjunction() {
let _g = TestGuard::new();
// ! binds to pipeline, not conjunction: (! (true && false)) && true
test_input("! (true && false) && true").unwrap();
assert_eq!(state::get_status(), 0);
}
#[test]
fn negate_in_if_condition() {
let g = TestGuard::new();
test_input("if ! false; then echo yes; fi").unwrap();
assert_eq!(state::get_status(), 0);
assert_eq!(g.read_output(), "yes\n");
}
#[test]
fn empty_var_in_test() {
let _g = TestGuard::new();
// POSIX specifies that a quoted unset variable expands to an empty string, so the shell actually sees `[ -n "" ]`, which returns false
test_input("[ -n \"$EMPTYVAR_PROBABLY_NOT_SET_TO_ANYTHING\" ]").unwrap();
assert_eq!(state::get_status(), 1);
// Without quotes, word splitting causes an empty var to be removed entirely, so the shell actually sees `[ -n ]`, testing the value of ']', which returns true
test_input("[ -n $EMPTYVAR_PROBABLY_NOT_SET_TO_ANYTHING ]").unwrap();
assert_eq!(state::get_status(), 0);
}
}

View File

@@ -17,9 +17,9 @@ use crate::{
}, },
}; };
pub const KEYWORDS: [&str; 16] = [ pub const KEYWORDS: [&str; 17] = [
"if", "then", "elif", "else", "fi", "while", "until", "select", "for", "in", "do", "done", "if", "then", "elif", "else", "fi", "while", "until", "select", "for", "in", "do", "done",
"case", "esac", "[[", "]]", "case", "esac", "[[", "]]", "!",
]; ];
pub const OPENERS: [&str; 6] = ["if", "while", "until", "for", "select", "case"]; pub const OPENERS: [&str; 6] = ["if", "while", "until", "for", "select", "case"];
@@ -166,6 +166,7 @@ pub enum TkRule {
ErrPipe, ErrPipe,
And, And,
Or, Or,
Bang,
Bg, Bg,
Sep, Sep,
Redir, Redir,
@@ -216,6 +217,32 @@ impl Tk {
}; };
self.span.as_str().trim() == ";;" self.span.as_str().trim() == ";;"
} }
pub fn is_opener(&self) -> bool {
OPENERS.contains(&self.as_str()) ||
matches!(self.class, TkRule::BraceGrpStart) ||
matches!(self.class, TkRule::CasePattern)
}
pub fn is_closer(&self) -> bool {
matches!(self.as_str(), "fi" | "done" | "esac") ||
self.has_double_semi() ||
matches!(self.class, TkRule::BraceGrpEnd)
}
pub fn is_closer_for(&self, other: &Tk) -> bool {
if (matches!(other.class, TkRule::BraceGrpStart) && matches!(self.class, TkRule::BraceGrpEnd))
|| (matches!(other.class, TkRule::CasePattern) && self.has_double_semi()) {
return true;
}
match other.as_str() {
"for" |
"while" |
"until" => matches!(self.as_str(), "done"),
"if" => matches!(self.as_str(), "fi"),
"case" => matches!(self.as_str(), "esac"),
_ => false
}
}
} }
impl Display for Tk { impl Display for Tk {
@@ -240,19 +267,12 @@ bitflags! {
const ASSIGN = 0b0000000001000000; const ASSIGN = 0b0000000001000000;
const BUILTIN = 0b0000000010000000; const BUILTIN = 0b0000000010000000;
const IS_PROCSUB = 0b0000000100000000; const IS_PROCSUB = 0b0000000100000000;
const IS_HEREDOC = 0b0000001000000000;
const LIT_HEREDOC = 0b0000010000000000;
const TAB_HEREDOC = 0b0000100000000000;
} }
} }
pub struct LexStream {
source: Arc<String>,
pub cursor: usize,
pub name: String,
quote_state: QuoteState,
brc_grp_depth: usize,
brc_grp_start: Option<usize>,
flags: LexFlags,
}
bitflags! { bitflags! {
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct LexFlags: u32 { pub struct LexFlags: u32 {
@@ -271,7 +291,6 @@ bitflags! {
/// The lexer has no more tokens to produce /// The lexer has no more tokens to produce
const STALE = 0b0001000000; const STALE = 0b0001000000;
const EXPECTING_IN = 0b0010000000; const EXPECTING_IN = 0b0010000000;
const IN_CASE = 0b0100000000;
} }
} }
@@ -295,6 +314,19 @@ pub fn clean_input(input: &str) -> String {
output output
} }
pub struct LexStream {
source: Arc<String>,
pub cursor: usize,
pub name: String,
quote_state: QuoteState,
brc_grp_depth: usize,
brc_grp_start: Option<usize>,
case_depth: usize,
heredoc_skip: Option<usize>,
flags: LexFlags,
}
impl LexStream { impl LexStream {
pub fn new(source: Arc<String>, flags: LexFlags) -> Self { pub fn new(source: Arc<String>, flags: LexFlags) -> Self {
let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD; let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD;
@@ -306,6 +338,8 @@ impl LexStream {
quote_state: QuoteState::default(), quote_state: QuoteState::default(),
brc_grp_depth: 0, brc_grp_depth: 0,
brc_grp_start: None, brc_grp_start: None,
heredoc_skip: None,
case_depth: 0,
} }
} }
/// Returns a slice of the source input using the given range /// Returns a slice of the source input using the given range
@@ -365,7 +399,7 @@ impl LexStream {
} }
pub fn read_redir(&mut self) -> Option<ShResult<Tk>> { pub fn read_redir(&mut self) -> Option<ShResult<Tk>> {
assert!(self.cursor <= self.source.len()); assert!(self.cursor <= self.source.len());
let slice = self.slice(self.cursor..)?; let slice = self.slice(self.cursor..)?.to_string();
let mut pos = self.cursor; let mut pos = self.cursor;
let mut chars = slice.chars().peekable(); let mut chars = slice.chars().peekable();
let mut tk = Tk::default(); let mut tk = Tk::default();
@@ -377,37 +411,51 @@ 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();
pos += 1;
let mut found_fd = false;
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;
}
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir); tk = self.get_token(self.cursor..pos, TkRule::Redir);
break; break;
} };
chars.next();
pos += 1;
let mut found_fd = false;
if chars.peek().is_some_and(|ch| *ch == '-') {
chars.next();
found_fd = true;
pos += 1;
} else {
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
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;
}
} }
'<' => { '<' => {
if chars.peek() == Some(&'(') { if chars.peek() == Some(&'(') {
@@ -415,14 +463,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 {
break; match chars.peek() {
} Some('<') => {
} chars.next();
pos += 1;
}
Some(ch) => {
let mut ch = *ch;
while is_field_sep(ch) {
let Some(next_ch) = chars.next() else {
// Incomplete input — fall through to emit << as Redir
break;
};
pos += next_ch.len_utf8();
ch = next_ch;
}
if is_field_sep(ch) {
// Ran out of input while skipping whitespace — fall through
} else {
let saved_cursor = self.cursor;
match self.read_heredoc(pos) {
Ok(Some(heredoc_tk)) => {
// cursor is set to after the delimiter word;
// heredoc_skip is set to after the body
pos = self.cursor;
self.cursor = saved_cursor;
tk = heredoc_tk;
break;
}
Ok(None) => {
// Incomplete heredoc — restore cursor and fall through
self.cursor = saved_cursor;
}
Err(e) => return Some(Err(e)),
}
}
}
_ => {
// No delimiter yet — input is incomplete
// Fall through to emit the << as a Redir token
}
}
}
Some('>') => {
chars.next();
pos += 1;
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
Some('&') => {
chars.next();
pos += 1;
let mut found_fd = false;
if chars.peek().is_some_and(|ch| *ch == '-') {
chars.next();
found_fd = true;
pos += 1;
} else {
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
chars.next();
found_fd = true;
pos += 1;
}
}
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
let span_start = self.cursor;
self.cursor = pos;
return Some(Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(span_start..pos, self.source.clone()),
"Invalid redirection",
)));
} else {
tk = self.get_token(self.cursor..pos, TkRule::Redir);
break;
}
}
_ => {}
}
tk = self.get_token(self.cursor..pos, TkRule::Redir); tk = self.get_token(self.cursor..pos, TkRule::Redir);
break; break;
} }
@@ -446,6 +574,130 @@ impl LexStream {
self.cursor = pos; self.cursor = pos;
Some(Ok(tk)) Some(Ok(tk))
} }
pub fn read_heredoc(&mut self, mut pos: usize) -> ShResult<Option<Tk>> {
let slice = self.slice(pos..).unwrap_or_default().to_string();
let mut chars = slice.chars();
let mut delim = String::new();
let mut flags = TkFlags::empty();
let mut first_char = true;
// Parse the delimiter word, stripping quotes
while let Some(ch) = chars.next() {
match ch {
'-' if first_char => {
pos += 1;
flags |= TkFlags::TAB_HEREDOC;
}
'\"' => {
pos += 1;
self.quote_state.toggle_double();
flags |= TkFlags::LIT_HEREDOC;
}
'\'' => {
pos += 1;
self.quote_state.toggle_single();
flags |= TkFlags::LIT_HEREDOC;
}
_ if self.quote_state.in_quote() => {
pos += ch.len_utf8();
delim.push(ch);
}
ch if is_hard_sep(ch) => {
break;
}
ch => {
pos += ch.len_utf8();
delim.push(ch);
}
}
first_char = false;
}
// pos is now right after the delimiter word — this is where
// the cursor should return so the rest of the line gets lexed
let cursor_after_delim = pos;
// Re-slice from cursor_after_delim so iterator and pos are in sync
// (the old chars iterator consumed the hard_sep without advancing pos)
let rest = self.slice(cursor_after_delim..).unwrap_or_default().to_string();
let mut chars = rest.chars();
// Scan forward to the newline (or use heredoc_skip from a previous heredoc)
let body_start = if let Some(skip) = self.heredoc_skip {
// A previous heredoc on this line already read its body;
// our body starts where that one ended
let skip_offset = skip - cursor_after_delim;
for _ in 0..skip_offset {
chars.next();
}
skip
} else {
// Skip the rest of the current line to find where the body begins
let mut scan = pos;
let mut found_newline = false;
while let Some(ch) = chars.next() {
scan += ch.len_utf8();
if ch == '\n' {
found_newline = true;
break;
}
}
if !found_newline {
if self.flags.contains(LexFlags::LEX_UNFINISHED) {
return Ok(None);
} else {
return Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(pos..pos, self.source.clone()),
"Heredoc delimiter not found",
));
}
}
scan
};
pos = body_start;
let start = pos;
// Read lines until we find one that matches the delimiter exactly
let mut line = String::new();
let mut line_start = pos;
while let Some(ch) = chars.next() {
pos += ch.len_utf8();
if ch == '\n' {
let trimmed = line.trim_end_matches('\r');
if trimmed == delim {
let mut tk = self.get_token(start..line_start, TkRule::Redir);
tk.flags |= TkFlags::IS_HEREDOC | flags;
self.heredoc_skip = Some(pos);
self.cursor = cursor_after_delim;
return Ok(Some(tk));
}
line.clear();
line_start = pos;
} else {
line.push(ch);
}
}
// Check the last line (no trailing newline)
let trimmed = line.trim_end_matches('\r');
if trimmed == delim {
let mut tk = self.get_token(start..line_start, TkRule::Redir);
tk.flags |= TkFlags::IS_HEREDOC | flags;
self.heredoc_skip = Some(pos);
self.cursor = cursor_after_delim;
return Ok(Some(tk));
}
if !self.flags.contains(LexFlags::LEX_UNFINISHED) {
Err(ShErr::at(
ShErrKind::ParseErr,
Span::new(start..pos, self.source.clone()),
format!("Heredoc delimiter '{}' not found", delim),
))
} else {
Ok(None)
}
}
pub fn read_string(&mut self) -> ShResult<Tk> { pub fn read_string(&mut self) -> ShResult<Tk> {
assert!(self.cursor <= self.source.len()); assert!(self.cursor <= self.source.len());
let slice = self.slice_from_cursor().unwrap().to_string(); let slice = self.slice_from_cursor().unwrap().to_string();
@@ -453,7 +705,7 @@ impl LexStream {
let mut chars = slice.chars().peekable(); let mut chars = slice.chars().peekable();
let can_be_subshell = chars.peek() == Some(&'('); let can_be_subshell = chars.peek() == Some(&'(');
if self.flags.contains(LexFlags::IN_CASE) if self.case_depth > 0
&& let Some(count) = case_pat_lookahead(chars.clone()) && let Some(count) = case_pat_lookahead(chars.clone())
{ {
pos += count; pos += count;
@@ -623,6 +875,16 @@ impl LexStream {
)); ));
} }
} }
'(' if can_be_subshell && chars.peek() == Some(&')') => {
// standalone "()" — function definition marker
pos += 2;
chars.next();
let mut tk = self.get_token(self.cursor..pos, TkRule::Str);
tk.mark(TkFlags::KEYWORD);
self.cursor = pos;
self.set_next_is_cmd(true);
return Ok(tk);
}
'(' if self.next_is_cmd() && can_be_subshell => { '(' if self.next_is_cmd() && can_be_subshell => {
pos += 1; pos += 1;
let mut paren_count = 1; let mut paren_count = 1;
@@ -731,7 +993,7 @@ impl LexStream {
"case" | "select" | "for" => { "case" | "select" | "for" => {
new_tk.mark(TkFlags::KEYWORD); new_tk.mark(TkFlags::KEYWORD);
self.flags |= LexFlags::EXPECTING_IN; self.flags |= LexFlags::EXPECTING_IN;
self.flags |= LexFlags::IN_CASE; self.case_depth += 1;
self.set_next_is_cmd(false); self.set_next_is_cmd(false);
} }
"in" if self.flags.contains(LexFlags::EXPECTING_IN) => { "in" if self.flags.contains(LexFlags::EXPECTING_IN) => {
@@ -739,8 +1001,8 @@ impl LexStream {
self.flags &= !LexFlags::EXPECTING_IN; self.flags &= !LexFlags::EXPECTING_IN;
} }
_ if is_keyword(text) => { _ if is_keyword(text) => {
if text == "esac" && self.flags.contains(LexFlags::IN_CASE) { if text == "esac" && self.case_depth > 0 {
self.flags &= !LexFlags::IN_CASE; self.case_depth -= 1;
} }
new_tk.mark(TkFlags::KEYWORD); new_tk.mark(TkFlags::KEYWORD);
} }
@@ -843,10 +1105,18 @@ impl Iterator for LexStream {
let token = match get_char(&self.source, self.cursor).unwrap() { let token = match get_char(&self.source, self.cursor).unwrap() {
'\r' | '\n' | ';' => { '\r' | '\n' | ';' => {
let ch = get_char(&self.source, self.cursor).unwrap();
let ch_idx = self.cursor; let ch_idx = self.cursor;
self.cursor += 1; self.cursor += 1;
self.set_next_is_cmd(true); self.set_next_is_cmd(true);
// If a heredoc was parsed on this line, skip past the body
// Only on newline — ';' is a command separator within the same line
if (ch == '\n' || ch == '\r')
&& let Some(skip) = self.heredoc_skip.take() {
self.cursor = skip;
}
while let Some(ch) = get_char(&self.source, self.cursor) { while let Some(ch) = get_char(&self.source, self.cursor) {
match ch { match ch {
'\\' if get_char(&self.source, self.cursor + 1) == Some('\n') => { '\\' if get_char(&self.source, self.cursor + 1) == Some('\n') => {
@@ -881,6 +1151,14 @@ impl Iterator for LexStream {
return self.next(); return self.next();
} }
} }
'!' if self.next_is_cmd() => {
self.cursor += 1;
let tk_type = TkRule::Bang;
let mut tk = self.get_token((self.cursor - 1)..self.cursor, tk_type);
tk.flags |= TkFlags::KEYWORD;
tk
}
'|' => { '|' => {
let ch_idx = self.cursor; let ch_idx = self.cursor;
self.cursor += 1; self.cursor += 1;

File diff suppressed because one or more lines are too long

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},
@@ -33,5 +33,4 @@ pub use nix::{
}, },
}; };
// Additional utilities, if needed, can be added here // Additional utilities, if needed, can be added here

View File

@@ -8,15 +8,26 @@ 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 +48,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 +91,28 @@ 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())?.expand()?.join(" "); // should just be one string, will have to find some way to handle a return of multiple paths
// multiple
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 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 +227,103 @@ 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 +434,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,
@@ -389,154 +491,166 @@ impl Iterator for PipeGenerator {
#[cfg(test)] #[cfg(test)]
pub mod tests { pub mod tests {
use crate::testutil::{TestGuard, has_cmd, has_cmds, test_input}; use crate::testutil::{TestGuard, has_cmd, has_cmds, test_input};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
fn pipeline_simple() { fn pipeline_simple() {
if !has_cmd("sed") { return }; if !has_cmd("sed") {
let g = TestGuard::new(); return;
};
let g = TestGuard::new();
test_input("echo foo | sed 's/foo/bar/'").unwrap(); test_input("echo foo | sed 's/foo/bar/'").unwrap();
let out = g.read_output(); let out = g.read_output();
assert_eq!(out, "bar\n"); assert_eq!(out, "bar\n");
} }
#[test] #[test]
fn pipeline_multi() { fn pipeline_multi() {
if !has_cmds(&[ if !has_cmds(&["cut", "sed"]) {
"cut", return;
"sed" }
]) { return; } let g = TestGuard::new();
let g = TestGuard::new();
test_input("echo foo bar baz | cut -d ' ' -f 2 | sed 's/a/A/'").unwrap(); test_input("echo foo bar baz | cut -d ' ' -f 2 | sed 's/a/A/'").unwrap();
let out = g.read_output(); let out = g.read_output();
assert_eq!(out, "bAr\n"); assert_eq!(out, "bAr\n");
} }
#[test] #[test]
fn rube_goldberg_pipeline() { fn rube_goldberg_pipeline() {
if !has_cmds(&[ if !has_cmds(&["sed", "cat"]) {
"sed", return;
"cat", }
]) { return } let g = TestGuard::new();
let g = TestGuard::new();
test_input("{ echo foo; echo bar } | if cat; then :; else echo failed; fi | (read line && echo $line | sed 's/foo/baz/'; sed 's/bar/buzz/')").unwrap(); test_input("{ echo foo; echo bar } | if cat; then :; else echo failed; fi | (read line && echo $line | sed 's/foo/baz/'; sed 's/bar/buzz/')").unwrap();
let out = g.read_output(); let out = g.read_output();
assert_eq!(out, "baz\nbuzz\n"); assert_eq!(out, "baz\nbuzz\n");
} }
#[test] #[test]
fn simple_file_redir() { fn simple_file_redir() {
let mut g = TestGuard::new(); let mut g = TestGuard::new();
test_input("echo this is in a file > /tmp/simple_file_redir.txt").unwrap(); test_input("echo this is in a file > /tmp/simple_file_redir.txt").unwrap();
g.add_cleanup(|| { std::fs::remove_file("/tmp/simple_file_redir.txt").ok(); }); g.add_cleanup(|| {
let contents = std::fs::read_to_string("/tmp/simple_file_redir.txt").unwrap(); std::fs::remove_file("/tmp/simple_file_redir.txt").ok();
});
let contents = std::fs::read_to_string("/tmp/simple_file_redir.txt").unwrap();
assert_eq!(contents, "this is in a file\n"); assert_eq!(contents, "this is in a file\n");
} }
#[test] #[test]
fn append_file_redir() { fn append_file_redir() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("append.txt"); let path = dir.path().join("append.txt");
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input(format!("echo first > {}", path.display())).unwrap(); test_input(format!("echo first > {}", path.display())).unwrap();
test_input(format!("echo second >> {}", path.display())).unwrap(); test_input(format!("echo second >> {}", path.display())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap(); let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "first\nsecond\n"); assert_eq!(contents, "first\nsecond\n");
} }
#[test] #[test]
fn input_redir() { fn input_redir() {
if !has_cmd("cat") { return; } if !has_cmd("cat") {
let dir = tempfile::TempDir::new().unwrap(); return;
let path = dir.path().join("input.txt"); }
std::fs::write(&path, "hello from file\n").unwrap(); let dir = tempfile::TempDir::new().unwrap();
let g = TestGuard::new(); let path = dir.path().join("input.txt");
std::fs::write(&path, "hello from file\n").unwrap();
let g = TestGuard::new();
test_input(format!("cat < {}", path.display())).unwrap(); test_input(format!("cat < {}", path.display())).unwrap();
let out = g.read_output(); let out = g.read_output();
assert_eq!(out, "hello from file\n"); assert_eq!(out, "hello from file\n");
} }
#[test] #[test]
fn stderr_redir_to_file() { fn stderr_redir_to_file() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("err.txt"); let path = dir.path().join("err.txt");
let g = TestGuard::new(); let g = TestGuard::new();
test_input(format!("echo error msg 2> {} >&2", path.display())).unwrap(); test_input(format!("echo error msg 2> {} >&2", path.display())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap(); let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "error msg\n"); assert_eq!(contents, "error msg\n");
// stdout should be empty since we redirected to stderr // stdout should be empty since we redirected to stderr
let out = g.read_output(); let out = g.read_output();
assert_eq!(out, ""); assert_eq!(out, "");
} }
#[test] #[test]
fn pipe_and_stderr() { fn pipe_and_stderr() {
if !has_cmd("cat") { return; } if !has_cmd("cat") {
let g = TestGuard::new(); return;
}
let g = TestGuard::new();
test_input("echo on stderr >&2 |& cat").unwrap(); test_input("echo on stderr >&2 |& cat").unwrap();
let out = g.read_output(); let out = g.read_output();
assert_eq!(out, "on stderr\n"); assert_eq!(out, "on stderr\n");
} }
#[test] #[test]
fn output_redir_clobber() { fn output_redir_clobber() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("clobber.txt"); let path = dir.path().join("clobber.txt");
let _g = TestGuard::new(); let _g = TestGuard::new();
test_input(format!("echo first > {}", path.display())).unwrap(); test_input(format!("echo first > {}", path.display())).unwrap();
test_input(format!("echo second > {}", path.display())).unwrap(); test_input(format!("echo second > {}", path.display())).unwrap();
let contents = std::fs::read_to_string(&path).unwrap(); let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "second\n"); assert_eq!(contents, "second\n");
} }
#[test] #[test]
fn pipeline_preserves_exit_status() { fn pipeline_preserves_exit_status() {
if !has_cmd("cat") { return; } if !has_cmd("cat") {
let _g = TestGuard::new(); return;
}
let _g = TestGuard::new();
test_input("false | cat").unwrap(); test_input("false | cat").unwrap();
// Pipeline exit status is the last command // Pipeline exit status is the last command
let status = crate::state::get_status(); let status = crate::state::get_status();
assert_eq!(status, 0); assert_eq!(status, 0);
test_input("cat < /dev/null | false").unwrap(); test_input("cat < /dev/null | false").unwrap();
let status = crate::state::get_status(); let status = crate::state::get_status();
assert_ne!(status, 0); assert_ne!(status, 0);
} }
#[test] #[test]
fn fd_duplication() { fn fd_duplication() {
let dir = tempfile::TempDir::new().unwrap(); let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("dup.txt"); let path = dir.path().join("dup.txt");
let _g = TestGuard::new(); let _g = TestGuard::new();
// Redirect stdout to file, then dup stderr to stdout — both should go to file // Redirect stdout to file, then dup stderr to stdout — both should go to file
test_input(format!("{{ echo out; echo err >&2 }} > {} 2>&1", path.display())).unwrap(); test_input(format!(
"{{ echo out; echo err >&2 }} > {} 2>&1",
path.display()
))
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap(); let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("out")); assert!(contents.contains("out"));
assert!(contents.contains("err")); assert!(contents.contains("err"));
} }
} }

View File

@@ -6,9 +6,11 @@ use std::{
}; };
use nix::sys::signal::Signal; use nix::sys::signal::Signal;
use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
builtin::complete::{CompFlags, CompOptFlags, CompOpts}, builtin::complete::{CompFlags, CompOptFlags, CompOpts},
expand::escape_str,
libsh::{error::ShResult, guards::var_ctx_guard, sys::TTY_FILENO, utils::TkVecUtils}, libsh::{error::ShResult, guards::var_ctx_guard, sys::TTY_FILENO, utils::TkVecUtils},
parse::{ parse::{
execute::exec_input, execute::exec_input,
@@ -22,7 +24,9 @@ use crate::{
term::{LineWriter, TermWriter, calc_str_width, get_win_size}, term::{LineWriter, TermWriter, calc_str_width, get_win_size},
vimode::{ViInsert, ViMode}, vimode::{ViInsert, ViMode},
}, },
state::{VarFlags, VarKind, read_jobs, read_logic, read_meta, read_vars, write_vars}, state::{
VarFlags, VarKind, read_jobs, read_logic, read_meta, read_shopts, read_vars, write_vars,
},
}; };
pub fn complete_signals(start: &str) -> Vec<String> { pub fn complete_signals(start: &str) -> Vec<String> {
@@ -173,6 +177,11 @@ fn complete_commands(start: &str) -> Vec<String> {
.collect() .collect()
}); });
if read_shopts(|o| o.core.autocd) {
let dirs = complete_dirs(start);
candidates.extend(dirs);
}
candidates.sort(); candidates.sort();
candidates candidates
} }
@@ -559,15 +568,17 @@ 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> { vec![] } fn all_candidates(&self) -> Vec<String> {
vec![]
}
fn selected_candidate(&self) -> Option<String>; fn selected_candidate(&self) -> Option<String>;
fn token_span(&self) -> (usize, usize); fn token_span(&self) -> (usize, usize);
fn original_input(&self) -> &str; fn original_input(&self) -> &str;
fn token(&self) -> &str { fn token(&self) -> &str {
let orig = self.original_input(); let orig = self.original_input();
let (s,e) = self.token_span(); let (s, e) = self.token_span();
orig.get(s..e).unwrap_or(orig) orig.get(s..e).unwrap_or(orig)
} }
fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>; fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>;
fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> { fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> {
Ok(()) Ok(())
@@ -787,21 +798,21 @@ impl FuzzySelector {
} }
} }
pub fn candidates(&self) -> &[String] { pub fn candidates(&self) -> &[String] {
&self.candidates &self.candidates
} }
pub fn filtered(&self) -> &[ScoredCandidate] { pub fn filtered(&self) -> &[ScoredCandidate] {
&self.filtered &self.filtered
} }
pub fn filtered_len(&self) -> usize { pub fn filtered_len(&self) -> usize {
self.filtered.len() self.filtered.len()
} }
pub fn candidates_len(&self) -> usize { pub fn candidates_len(&self) -> usize {
self.candidates.len() self.candidates.len()
} }
pub fn activate(&mut self, candidates: Vec<String>) { pub fn activate(&mut self, candidates: Vec<String>) {
self.active = true; self.active = true;
@@ -1156,9 +1167,9 @@ impl Default for FuzzyCompleter {
} }
impl Completer for FuzzyCompleter { impl Completer for FuzzyCompleter {
fn all_candidates(&self) -> Vec<String> { fn all_candidates(&self) -> Vec<String> {
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) {
self self
.selector .selector
@@ -1171,13 +1182,19 @@ impl Completer for FuzzyCompleter {
log::debug!("Getting completed line for candidate: {}", _candidate); log::debug!("Getting completed line for candidate: {}", _candidate);
let selected = self.selector.selected_candidate().unwrap_or_default(); let selected = self.selector.selected_candidate().unwrap_or_default();
log::debug!("Selected candidate: {}", selected); let (mut start, end) = self.completer.token_span;
let (start, end) = self.completer.token_span; let slice = self
log::debug!("Token span: ({}, {})", start, end); .completer
.original_input
.get(start..end)
.unwrap_or_default();
start += slice.width();
let completion = selected.strip_prefix(slice).unwrap_or(&selected);
let escaped = escape_str(completion, false);
let ret = format!( let ret = format!(
"{}{}{}", "{}{}{}",
&self.completer.original_input[..start], &self.completer.original_input[..start],
selected, escaped,
&self.completer.original_input[end..] &self.completer.original_input[end..]
); );
log::debug!("Completed line: {}", ret); log::debug!("Completed line: {}", ret);
@@ -1249,9 +1266,9 @@ pub struct SimpleCompleter {
} }
impl Completer for SimpleCompleter { impl Completer for SimpleCompleter {
fn all_candidates(&self) -> Vec<String> { fn all_candidates(&self) -> Vec<String> {
self.candidates.clone() self.candidates.clone()
} }
fn reset_stay_active(&mut self) { fn reset_stay_active(&mut self) {
let active = self.is_active(); let active = self.is_active();
self.reset(); self.reset();
@@ -1430,11 +1447,15 @@ impl SimpleCompleter {
} }
let selected = &self.candidates[self.selected_idx]; let selected = &self.candidates[self.selected_idx];
let (start, end) = self.token_span; let (mut start, end) = self.token_span;
let slice = self.original_input.get(start..end).unwrap_or("");
start += slice.width();
let completion = selected.strip_prefix(slice).unwrap_or(selected);
let escaped = escape_str(completion, false);
format!( format!(
"{}{}{}", "{}{}{}",
&self.original_input[..start], &self.original_input[..start],
selected, escaped,
&self.original_input[end..] &self.original_input[end..]
) )
} }
@@ -1593,10 +1614,12 @@ impl SimpleCompleter {
.set_range(self.token_span.0..self.token_span.1); .set_range(self.token_span.0..self.token_span.1);
} }
// If token contains '=', only complete after the '=' // If token contains any COMP_WORDBREAKS, break the word
let token_str = cur_token.span.as_str(); let token_str = cur_token.span.as_str();
if let Some(eq_pos) = token_str.rfind('=') {
self.token_span.0 = cur_token.span.range().start + eq_pos + 1; let word_breaks = read_vars(|v| v.try_get_var("COMP_WORDBREAKS")).unwrap_or("=".into());
if let Some(break_pos) = token_str.rfind(|c: char| word_breaks.contains(c)) {
self.token_span.0 = cur_token.span.range().start + break_pos + 1;
cur_token cur_token
.span .span
.set_range(self.token_span.0..self.token_span.1); .set_range(self.token_span.0..self.token_span.1);
@@ -1642,3 +1665,462 @@ impl SimpleCompleter {
Ok(CompResult::from_candidates(candidates)) Ok(CompResult::from_candidates(candidates))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::{
readline::{Prompt, ShedVi},
state::{VarFlags, VarKind, write_vars},
testutil::TestGuard,
};
use std::os::fd::AsRawFd;
fn test_vi(initial: &str) -> (ShedVi, TestGuard) {
let g = TestGuard::new();
let prompt = Prompt::default();
let vi = ShedVi::new_no_hist(prompt, g.pty_slave().as_raw_fd())
.unwrap()
.with_initial(initial);
(vi, g)
}
// ===================== extract_var_name =====================
#[test]
fn extract_var_simple() {
let (name, start, end) = extract_var_name("$HOME").unwrap();
assert_eq!(name, "HOME");
assert_eq!(start, 1);
assert_eq!(end, 5);
}
#[test]
fn extract_var_braced() {
let (name, start, end) = extract_var_name("${PATH}").unwrap();
assert_eq!(name, "PATH");
// '$' hits continue (no pos++), '{' is at pos=0, so name_start = 1
assert_eq!(start, 1);
assert_eq!(end, 5);
}
#[test]
fn extract_var_partial() {
let (name, start, _end) = extract_var_name("$HO").unwrap();
assert_eq!(name, "HO");
assert_eq!(start, 1);
}
#[test]
fn extract_var_none() {
assert!(extract_var_name("hello").is_none());
}
// ===================== ScoredCandidate::fuzzy_score =====================
#[test]
fn fuzzy_exact_match() {
let mut c = ScoredCandidate::new("hello".into());
let score = c.fuzzy_score("hello");
assert!(score > 0);
}
#[test]
fn fuzzy_prefix_match() {
let mut c = ScoredCandidate::new("hello_world".into());
let score = c.fuzzy_score("hello");
assert!(score > 0);
}
#[test]
fn fuzzy_no_match() {
let mut c = ScoredCandidate::new("abc".into());
let score = c.fuzzy_score("xyz");
assert_eq!(score, i32::MIN);
}
#[test]
fn fuzzy_empty_query() {
let mut c = ScoredCandidate::new("anything".into());
let score = c.fuzzy_score("");
assert_eq!(score, 0);
}
#[test]
fn fuzzy_boundary_bonus() {
let mut a = ScoredCandidate::new("foo_bar".into());
let mut b = ScoredCandidate::new("fxxxbxr".into());
let score_a = a.fuzzy_score("fbr");
let score_b = b.fuzzy_score("fbr");
// word-boundary match should score higher
assert!(score_a > score_b);
}
// ===================== CompResult::from_candidates =====================
#[test]
fn comp_result_no_match() {
let result = CompResult::from_candidates(vec![]);
assert!(matches!(result, CompResult::NoMatch));
}
#[test]
fn comp_result_single() {
let result = CompResult::from_candidates(vec!["foo".into()]);
assert!(matches!(result, CompResult::Single { .. }));
}
#[test]
fn comp_result_many() {
let result = CompResult::from_candidates(vec!["foo".into(), "bar".into()]);
assert!(matches!(result, CompResult::Many { .. }));
}
// ===================== complete_signals =====================
#[test]
fn complete_signals_int() {
let results = complete_signals("INT");
assert!(results.contains(&"INT".to_string()));
}
#[test]
fn complete_signals_empty() {
let results = complete_signals("");
assert!(!results.is_empty());
}
#[test]
fn complete_signals_no_match() {
let results = complete_signals("ZZZZZZZ");
assert!(results.is_empty());
}
// ===================== COMP_WORDBREAKS =====================
#[test]
fn wordbreak_equals_default() {
let _g = TestGuard::new();
let mut comp = SimpleCompleter::new();
let line = "cmd --foo=bar".to_string();
let cursor = line.len();
let _ = comp.get_candidates(line.clone(), cursor);
let eq_idx = line.find('=').unwrap();
assert_eq!(
comp.token_span.0,
eq_idx + 1,
"token_span.0 ({}) should be right after '=' ({})",
comp.token_span.0,
eq_idx
);
}
#[test]
fn wordbreak_colon_when_set() {
let _g = TestGuard::new();
write_vars(|v| v.set_var("COMP_WORDBREAKS", VarKind::Str("=:".into()), VarFlags::NONE))
.unwrap();
let mut comp = SimpleCompleter::new();
let line = "scp host:foo".to_string();
let cursor = line.len();
let _ = comp.get_candidates(line.clone(), cursor);
let colon_idx = line.find(':').unwrap();
assert_eq!(
comp.token_span.0,
colon_idx + 1,
"token_span.0 ({}) should be right after ':' ({})",
comp.token_span.0,
colon_idx
);
}
#[test]
fn wordbreak_rightmost_wins() {
let _g = TestGuard::new();
write_vars(|v| v.set_var("COMP_WORDBREAKS", VarKind::Str("=:".into()), VarFlags::NONE))
.unwrap();
let mut comp = SimpleCompleter::new();
let line = "cmd --opt=host:val".to_string();
let cursor = line.len();
let _ = comp.get_candidates(line.clone(), cursor);
let colon_idx = line.rfind(':').unwrap();
assert_eq!(
comp.token_span.0,
colon_idx + 1,
"should break at rightmost wordbreak char"
);
}
// ===================== SimpleCompleter cycling =====================
#[test]
fn cycle_wraps_forward() {
let _g = TestGuard::new();
let mut comp = SimpleCompleter {
candidates: vec!["aaa".into(), "bbb".into(), "ccc".into()],
selected_idx: 2,
original_input: "".into(),
token_span: (0, 0),
active: true,
dirs_only: false,
add_space: false,
};
comp.cycle_completion(1);
assert_eq!(comp.selected_idx, 0);
}
#[test]
fn cycle_wraps_backward() {
let _g = TestGuard::new();
let mut comp = SimpleCompleter {
candidates: vec!["aaa".into(), "bbb".into(), "ccc".into()],
selected_idx: 0,
original_input: "".into(),
token_span: (0, 0),
active: true,
dirs_only: false,
add_space: false,
};
comp.cycle_completion(-1);
assert_eq!(comp.selected_idx, 2);
}
// ===================== Completion escaping =====================
#[test]
fn escape_str_special_chars() {
use crate::expand::escape_str;
let escaped = escape_str("hello world", false);
assert_eq!(escaped, "hello\\ world");
}
#[test]
fn escape_str_multiple_specials() {
use crate::expand::escape_str;
let escaped = escape_str("a&b|c", false);
assert_eq!(escaped, "a\\&b\\|c");
}
#[test]
fn escape_str_no_specials() {
use crate::expand::escape_str;
let escaped = escape_str("hello", false);
assert_eq!(escaped, "hello");
}
#[test]
fn escape_str_all_shell_metacharacters() {
use crate::expand::escape_str;
for ch in [
'\'', '"', '\\', '|', '&', ';', '(', ')', '<', '>', '$', '*', '!', '`', '{', '?', '[', '#',
' ', '\t', '\n',
] {
let input = format!("a{ch}b");
let escaped = escape_str(&input, false);
let expected = format!("a\\{ch}b");
assert_eq!(escaped, expected, "failed to escape {:?}", ch);
}
}
#[test]
fn escape_str_kitchen_sink() {
use crate::expand::escape_str;
let input = "f$le (with) 'spaces' & {braces} | pipes; #hash ~tilde `backtick` !bang";
let escaped = escape_str(input, false);
assert_eq!(
escaped,
"f\\$le\\ \\(with\\)\\ \\'spaces\\'\\ \\&\\ \\{braces}\\ \\|\\ pipes\\;\\ \\#hash\\ ~tilde\\ \\`backtick\\`\\ \\!bang"
);
}
#[test]
fn completed_line_only_escapes_new_text() {
let _g = TestGuard::new();
// Simulate: user typed "echo hel", completion candidate is "hello world"
let comp = SimpleCompleter {
candidates: vec!["hello world".into()],
selected_idx: 0,
original_input: "echo hel".into(),
token_span: (5, 8), // "hel" spans bytes 5..8
active: true,
dirs_only: false,
add_space: false,
};
let result = comp.get_completed_line();
// "hel" is the user's text (not escaped), "lo world" is new (escaped)
assert_eq!(result, "echo hello\\ world");
}
#[test]
fn completed_line_no_new_text() {
let _g = TestGuard::new();
// User typed the full token, nothing new to escape
let comp = SimpleCompleter {
candidates: vec!["hello".into()],
selected_idx: 0,
original_input: "echo hello".into(),
token_span: (5, 10),
active: true,
dirs_only: false,
add_space: false,
};
let result = comp.get_completed_line();
assert_eq!(result, "echo hello");
}
#[test]
fn completed_line_appends_suffix_with_escape() {
let _g = TestGuard::new();
// User typed "echo hel", candidate is "hello world" (from filesystem)
// strip_prefix("hel") => "lo world", which gets escaped
let comp = SimpleCompleter {
candidates: vec!["hello world".into()],
selected_idx: 0,
original_input: "echo hel".into(),
token_span: (5, 8),
active: true,
dirs_only: false,
add_space: false,
};
let result = comp.get_completed_line();
assert_eq!(result, "echo hello\\ world");
}
#[test]
fn completed_line_suffix_only_escapes_new_part() {
let _g = TestGuard::new();
// User typed "echo hello", candidate is "hello world&done"
// strip_prefix("hello") => " world&done", only that gets escaped
let comp = SimpleCompleter {
candidates: vec!["hello world&done".into()],
selected_idx: 0,
original_input: "echo hello".into(),
token_span: (5, 10),
active: true,
dirs_only: false,
add_space: false,
};
let result = comp.get_completed_line();
// "hello" is preserved as-is, " world&done" gets escaped
assert_eq!(result, "echo hello\\ world\\&done");
}
#[test]
fn tab_escapes_special_in_filename() {
let tmp = std::env::temp_dir().join("shed_test_tab_esc");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("hello world.txt"), "").unwrap();
let (mut vi, _g) = test_vi("");
std::env::set_current_dir(&tmp).unwrap();
vi.feed_bytes(b"echo hello\t");
let _ = vi.process_input();
let line = vi.editor.as_str().to_string();
assert!(
line.contains("hello\\ world.txt"),
"expected escaped space in completion: {line:?}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn tab_does_not_escape_user_text() {
let tmp = std::env::temp_dir().join("shed_test_tab_noesc");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("my file.txt"), "").unwrap();
let (mut vi, _g) = test_vi("");
std::env::set_current_dir(&tmp).unwrap();
// User types "echo my\ " with the space already escaped
vi.feed_bytes(b"echo my\\ \t");
let _ = vi.process_input();
let line = vi.editor.as_str().to_string();
// The user's "my\ " should be preserved, not double-escaped to "my\\\ "
assert!(
!line.contains("my\\\\ "),
"user text should not be double-escaped: {line:?}"
);
assert!(
line.contains("my\\ file.txt"),
"expected completion with preserved user escape: {line:?}"
);
std::fs::remove_dir_all(&tmp).ok();
}
// ===================== Integration tests (pty) =====================
#[test]
fn tab_completes_filename() {
let tmp = std::env::temp_dir().join("shed_test_tab_fn");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("unique_shed_test_file.txt"), "").unwrap();
let (mut vi, _g) = test_vi("");
std::env::set_current_dir(&tmp).unwrap();
// Type "echo unique_shed_test" then press Tab
vi.feed_bytes(b"echo unique_shed_test\t");
let _ = vi.process_input();
let line = vi.editor.as_str().to_string();
assert!(
line.contains("unique_shed_test_file.txt"),
"expected completion in line: {line:?}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn tab_completes_directory_with_slash() {
let tmp = std::env::temp_dir().join("shed_test_tab_dir");
let _ = std::fs::create_dir_all(tmp.join("mysubdir"));
let (mut vi, _g) = test_vi("");
std::env::set_current_dir(&tmp).unwrap();
vi.feed_bytes(b"cd mysub\t");
let _ = vi.process_input();
let line = vi.editor.as_str().to_string();
assert!(
line.contains("mysubdir/"),
"expected dir completion with trailing slash: {line:?}"
);
std::fs::remove_dir_all(&tmp).ok();
}
#[test]
fn tab_after_equals() {
let tmp = std::env::temp_dir().join("shed_test_tab_eq");
let _ = std::fs::create_dir_all(&tmp);
std::fs::write(tmp.join("eqfile.txt"), "").unwrap();
let (mut vi, _g) = test_vi("");
std::env::set_current_dir(&tmp).unwrap();
vi.feed_bytes(b"cmd --opt=eqf\t");
let _ = vi.process_input();
let line = vi.editor.as_str().to_string();
assert!(
line.contains("--opt=eqfile.txt"),
"expected completion after '=': {line:?}"
);
std::fs::remove_dir_all(&tmp).ok();
}
}

View File

@@ -88,7 +88,9 @@ impl Highlighter {
while prefix_chars.peek().is_some() { while prefix_chars.peek().is_some() {
match chars.next() { match chars.next() {
Some(c) if c == markers::VISUAL_MODE_START || c == markers::VISUAL_MODE_END => continue, Some(c) if c == markers::VISUAL_MODE_START || c == markers::VISUAL_MODE_END => continue,
Some(c) if Some(&c) == prefix_chars.peek() => { prefix_chars.next(); } Some(c) if Some(&c) == prefix_chars.peek() => {
prefix_chars.next();
}
_ => return text.to_string(), // mismatch, return original _ => return text.to_string(), // mismatch, return original
} }
} }
@@ -104,7 +106,9 @@ impl Highlighter {
let mut si = suffix_chars.len(); let mut si = suffix_chars.len();
while si > 0 { while si > 0 {
if ti == 0 { return text.to_string(); } if ti == 0 {
return text.to_string();
}
ti -= 1; ti -= 1;
if chars[ti] == markers::VISUAL_MODE_START || chars[ti] == markers::VISUAL_MODE_END { if chars[ti] == markers::VISUAL_MODE_START || chars[ti] == markers::VISUAL_MODE_END {
continue; // skip visual markers continue; // skip visual markers
@@ -346,7 +350,9 @@ impl Highlighter {
recursive_highlighter.highlight(); recursive_highlighter.highlight();
// Read back visual state — selection may have started/ended inside // Read back visual state — selection may have started/ended inside
self.in_selection = recursive_highlighter.in_selection; self.in_selection = recursive_highlighter.in_selection;
self.style_stack.append(&mut recursive_highlighter.style_stack); self
.style_stack
.append(&mut recursive_highlighter.style_stack);
if selection_at_entry { if selection_at_entry {
self.emit_style(Style::BgWhite | Style::Black); self.emit_style(Style::BgWhite | Style::Black);
self.output.push_str(prefix); self.output.push_str(prefix);

View File

@@ -217,18 +217,36 @@ pub struct History {
} }
impl History { impl History {
pub fn empty() -> Self {
Self {
path: PathBuf::new(),
pending: None,
entries: Vec::new(),
search_mask: Vec::new(),
fuzzy_finder: FuzzySelector::new("History").number_candidates(true),
no_matches: false,
cursor: 0,
//search_direction: Direction::Backward,
ignore_dups: false,
max_size: None,
}
}
pub fn new() -> ShResult<Self> { pub fn new() -> ShResult<Self> {
let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes); let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes);
let max_hist = crate::state::read_shopts(|s| s.core.max_hist); let max_hist = crate::state::read_shopts(|s| s.core.max_hist);
let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({ let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({
let home = env::var("HOME").unwrap(); let home = env::var("HOME").unwrap();
format!("{home}/.shed_history") format!("{home}/.shed_history")
})); }));
let mut entries = read_hist_file(&path)?; let mut entries = read_hist_file(&path)?;
// Enforce max_hist limit on loaded entries (negative = unlimited) // Enforce max_hist limit on loaded entries (negative = unlimited)
if max_hist >= 0 && entries.len() > max_hist as usize { if max_hist >= 0 && entries.len() > max_hist as usize {
entries = entries.split_off(entries.len() - max_hist as usize); entries = entries.split_off(entries.len() - max_hist as usize);
} }
let search_mask = dedupe_entries(&entries); let search_mask = dedupe_entries(&entries);
let cursor = search_mask.len(); let cursor = search_mask.len();
let max_size = if max_hist < 0 { let max_size = if max_hist < 0 {
@@ -236,6 +254,7 @@ impl History {
} else { } else {
Some(max_hist as u32) Some(max_hist as u32)
}; };
Ok(Self { Ok(Self {
path, path,
entries, entries,
@@ -481,12 +500,8 @@ mod tests {
env::set_var(key, val); env::set_var(key, val);
} }
guard(prev, move |p| match p { guard(prev, move |p| match p {
Some(v) => unsafe { Some(v) => unsafe { env::set_var(key, v) },
env::set_var(key, v) None => unsafe { env::remove_var(key) },
},
None => unsafe {
env::remove_var(key)
},
}) })
} }
@@ -503,12 +518,7 @@ mod tests {
fn write_history_file(path: &Path) { fn write_history_file(path: &Path) {
fs::write( fs::write(
path, path,
[ [": 1;1;first\n", ": 2;1;second\n", ": 3;1;third\n"].concat(),
": 1;1;first\n",
": 2;1;second\n",
": 3;1;third\n",
]
.concat(),
) )
.unwrap(); .unwrap();
} }
@@ -567,12 +577,7 @@ mod tests {
let hist_path = tmp.path().join("history"); let hist_path = tmp.path().join("history");
fs::write( fs::write(
&hist_path, &hist_path,
[ [": 1;1;repeat\n", ": 2;1;unique\n", ": 3;1;repeat\n"].concat(),
": 1;1;repeat\n",
": 2;1;unique\n",
": 3;1;repeat\n",
]
.concat(),
) )
.unwrap(); .unwrap();

File diff suppressed because it is too large Load Diff

View File

@@ -9,14 +9,14 @@ use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVis
use crate::builtin::keymap::{KeyMapFlags, KeyMapMatch}; use crate::builtin::keymap::{KeyMapFlags, KeyMapMatch};
use crate::expand::expand_prompt; use crate::expand::expand_prompt;
use crate::libsh::sys::TTY_FILENO;
use crate::libsh::utils::AutoCmdVecUtils; use crate::libsh::utils::AutoCmdVecUtils;
use crate::parse::lex::{LexStream, QuoteState}; use crate::parse::lex::{LexStream, QuoteState};
use crate::readline::complete::{FuzzyCompleter, SelectorResponse}; use crate::readline::complete::{FuzzyCompleter, SelectorResponse};
use crate::readline::term::{Pos, TermReader, calc_str_width}; use crate::readline::term::{Pos, TermReader, calc_str_width};
use crate::readline::vimode::{ViEx, ViVerbatim}; use crate::readline::vimode::{ViEx, ViVerbatim};
use crate::state::{ use crate::state::{
AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta, write_vars AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta,
write_vars,
}; };
use crate::{ use crate::{
libsh::error::ShResult, libsh::error::ShResult,
@@ -39,6 +39,9 @@ pub mod term;
pub mod vicmd; pub mod vicmd;
pub mod vimode; pub mod vimode;
#[cfg(test)]
pub mod tests;
pub mod markers { pub mod markers {
use super::Marker; use super::Marker;
@@ -238,6 +241,7 @@ impl Default for Prompt {
pub struct ShedVi { pub struct ShedVi {
pub reader: PollReader, pub reader: PollReader,
pub writer: TermWriter, pub writer: TermWriter,
pub tty: RawFd,
pub prompt: Prompt, pub prompt: Prompt,
pub highlighter: Highlighter, pub highlighter: Highlighter,
@@ -249,7 +253,6 @@ 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,
@@ -263,10 +266,10 @@ impl ShedVi {
reader: PollReader::new(), reader: PollReader::new(),
writer: TermWriter::new(tty), writer: TermWriter::new(tty),
prompt, prompt,
tty,
completer: Box::new(FuzzyCompleter::default()), completer: Box::new(FuzzyCompleter::default()),
highlighter: Highlighter::new(), highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()), mode: Box::new(ViInsert::new()),
next_is_escaped: false,
saved_mode: None, saved_mode: None,
pending_keymap: Vec::new(), pending_keymap: Vec::new(),
old_layout: None, old_layout: None,
@@ -289,6 +292,37 @@ impl ShedVi {
Ok(new) Ok(new)
} }
pub fn new_no_hist(prompt: Prompt, tty: RawFd) -> ShResult<Self> {
let mut new = Self {
reader: PollReader::new(),
writer: TermWriter::new(tty),
tty,
prompt,
completer: Box::new(FuzzyCompleter::default()),
highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()),
saved_mode: None,
pending_keymap: Vec::new(),
old_layout: None,
repeat_action: None,
repeat_motion: None,
editor: LineBuf::new(),
history: History::empty(),
needs_redraw: true,
};
write_vars(|v| {
v.set_var(
"SHED_VI_MODE",
VarKind::Str(new.mode.report_mode().to_string()),
VarFlags::NONE,
)
})?;
new.prompt.refresh();
new.writer.flush_write("\n")?; // ensure we start on a new line, in case the previous command didn't end with a newline
new.print_line(false)?;
Ok(new)
}
pub fn with_initial(mut self, initial: &str) -> Self { pub fn with_initial(mut self, initial: &str) -> Self {
self.editor = LineBuf::new().with_initial(initial, 0); self.editor = LineBuf::new().with_initial(initial, 0);
self self
@@ -299,7 +333,7 @@ impl ShedVi {
/// Feed raw bytes from stdin into the reader's buffer /// Feed raw bytes from stdin into the reader's buffer
pub fn feed_bytes(&mut self, bytes: &[u8]) { pub fn feed_bytes(&mut self, bytes: &[u8]) {
self.reader.feed_bytes(bytes); self.reader.feed_bytes(bytes);
} }
/// Mark that the display needs to be redrawn (e.g., after SIGWINCH) /// Mark that the display needs to be redrawn (e.g., after SIGWINCH)
@@ -310,7 +344,7 @@ impl ShedVi {
pub fn fix_column(&mut self) -> ShResult<()> { pub fn fix_column(&mut self) -> ShResult<()> {
self self
.writer .writer
.fix_cursor_column(&mut TermReader::new(*TTY_FILENO)) .fix_cursor_column(&mut TermReader::new(self.tty))
} }
pub fn reset_active_widget(&mut self, full_redraw: bool) -> ShResult<()> { pub fn reset_active_widget(&mut self, full_redraw: bool) -> ShResult<()> {
@@ -318,10 +352,10 @@ impl ShedVi {
self.completer.reset_stay_active(); self.completer.reset_stay_active();
self.needs_redraw = true; self.needs_redraw = true;
Ok(()) Ok(())
} else if self.history.fuzzy_finder.is_active() { } else if self.history.fuzzy_finder.is_active() {
self.history.fuzzy_finder.reset_stay_active(); self.history.fuzzy_finder.reset_stay_active();
self.needs_redraw = true; self.needs_redraw = true;
Ok(()) Ok(())
} else { } else {
self.reset(full_redraw) self.reset(full_redraw)
} }
@@ -380,7 +414,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) => {
@@ -407,7 +441,11 @@ 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); 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.history.fuzzy_finder.is_active() {
self.print_line(false)?; self.print_line(false)?;
@@ -652,13 +690,14 @@ impl ShedVi {
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
let hint = self.history.get_hint(); let hint = self.history.get_hint();
self.editor.set_hint(hint); self.editor.set_hint(hint);
write_vars(|v| { write_vars(|v| {
v.set_var( v.set_var(
"SHED_VI_MODE", "SHED_VI_MODE",
VarKind::Str(self.mode.report_mode().to_string()), VarKind::Str(self.mode.report_mode().to_string()),
VarFlags::NONE, VarFlags::NONE,
) )
}).ok(); })
.ok();
// If we are here, we hit a case where pressing tab returned a single candidate // If we are here, we hit a case where pressing tab returned a single candidate
// So we can just go ahead and reset the completer after this // So we can just go ahead and reset the completer after this
@@ -666,15 +705,21 @@ impl ShedVi {
} }
Ok(None) => { Ok(None) => {
let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart)); let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart));
let candidates = self.completer.all_candidates(); let candidates = self.completer.all_candidates();
let num_candidates = candidates.len(); let num_candidates = candidates.len();
with_vars([ with_vars(
("_NUM_MATCHES".into(), Into::<Var>::into(num_candidates)), [
("_MATCHES".into(), Into::<Var>::into(candidates)), ("_NUM_MATCHES".into(), Into::<Var>::into(num_candidates)),
("_SEARCH_STR".into(), Into::<Var>::into(self.completer.token())), ("_MATCHES".into(), Into::<Var>::into(candidates)),
], || { (
post_cmds.exec(); "_SEARCH_STR".into(),
}); Into::<Var>::into(self.completer.token()),
),
],
|| {
post_cmds.exec();
},
);
if self.completer.is_active() { if self.completer.is_active() {
write_vars(|v| { write_vars(|v| {
@@ -689,21 +734,21 @@ impl ShedVi {
self.needs_redraw = true; self.needs_redraw = true;
self.editor.set_hint(None); self.editor.set_hint(None);
} else { } else {
self.writer.send_bell().ok(); self.writer.send_bell().ok();
} }
} }
} }
self.needs_redraw = true; self.needs_redraw = true;
return Ok(None); return Ok(None);
} else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key { } else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key
&& self.mode.report_mode() == ModeReport::Insert
{
let initial = self.editor.as_str(); let initial = self.editor.as_str();
match self.history.start_search(initial) { match self.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([ with_vars([("_HIST_ENTRY".into(), entry.clone())], || {
("_HIST_ENTRY".into(), entry.clone()),
], || {
post_cmds.exec_with(&entry); post_cmds.exec_with(&entry);
}); });
@@ -716,25 +761,30 @@ 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.history.fuzzy_finder.candidates();
let matches = self.history.fuzzy_finder let matches = self
.filtered() .history
.iter() .fuzzy_finder
.cloned() .filtered()
.map(|sc| sc.content) .iter()
.collect::<Vec<_>>(); .cloned()
.map(|sc| sc.content)
.collect::<Vec<_>>();
let num_entries = entries.len(); let num_entries = entries.len();
let num_matches = matches.len(); let num_matches = matches.len();
with_vars([ with_vars(
("_ENTRIES".into(),Into::<Var>::into(entries)), [
("_NUM_ENTRIES".into(),Into::<Var>::into(num_entries)), ("_ENTRIES".into(), Into::<Var>::into(entries)),
("_MATCHES".into(),Into::<Var>::into(matches)), ("_NUM_ENTRIES".into(), Into::<Var>::into(num_entries)),
("_NUM_MATCHES".into(),Into::<Var>::into(num_matches)), ("_MATCHES".into(), Into::<Var>::into(matches)),
("_SEARCH_STR".into(), Into::<Var>::into(initial)), ("_NUM_MATCHES".into(), Into::<Var>::into(num_matches)),
], || { ("_SEARCH_STR".into(), Into::<Var>::into(initial)),
post_cmds.exec(); ],
}); || {
post_cmds.exec();
},
);
if self.history.fuzzy_finder.is_active() { if self.history.fuzzy_finder.is_active() {
write_vars(|v| { write_vars(|v| {
@@ -749,20 +799,12 @@ impl ShedVi {
self.needs_redraw = true; self.needs_redraw = true;
self.editor.set_hint(None); self.editor.set_hint(None);
} else { } else {
self.writer.send_bell().ok(); self.writer.send_bell().ok();
} }
} }
} }
} }
if let KeyEvent(KeyCode::Char('\\'), ModKeys::NONE) = key
&& !self.next_is_escaped
{
self.next_is_escaped = true;
} else {
self.next_is_escaped = false;
}
let Ok(cmd) = self.mode.handle_key_fallible(key) else { let Ok(cmd) = self.mode.handle_key_fallible(key) else {
// it's an ex mode error // it's an ex mode error
self.mode = Box::new(ViNormal::new()) as Box<dyn ViMode>; self.mode = Box::new(ViNormal::new()) as Box<dyn ViMode>;
@@ -781,8 +823,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) {
@@ -814,7 +855,7 @@ 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 before = self.editor.buffer.clone(); let before = self.editor.buffer.clone();
self.exec_cmd(cmd)?; self.exec_cmd(cmd, false)?;
if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) { if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) {
for key in keys { for key in keys {
self.handle_key(key)?; self.handle_key(key)?;
@@ -839,7 +880,7 @@ impl ShedVi {
pub fn get_layout(&mut self, line: &str) -> Layout { pub fn get_layout(&mut self, line: &str) -> Layout {
let to_cursor = self.editor.slice_to_cursor().unwrap_or_default(); let to_cursor = self.editor.slice_to_cursor().unwrap_or_default();
let (cols, _) = get_win_size(*TTY_FILENO); let (cols, _) = get_win_size(self.tty);
Layout::from_parts(cols, self.prompt.get_ps1(), to_cursor, line) Layout::from_parts(cols, self.prompt.get_ps1(), to_cursor, line)
} }
pub fn scroll_history(&mut self, cmd: ViCmd) { pub fn scroll_history(&mut self, cmd: ViCmd) {
@@ -1018,6 +1059,7 @@ impl ShedVi {
let pending_seq = self.mode.pending_seq().unwrap_or_default(); let pending_seq = self.mode.pending_seq().unwrap_or_default();
write!(buf, "\n: {pending_seq}").unwrap(); write!(buf, "\n: {pending_seq}").unwrap();
new_layout.end.row += 1; new_layout.end.row += 1;
new_layout.cursor.row += 1;
} }
write!(buf, "{}", &self.mode.cursor_style()).unwrap(); write!(buf, "{}", &self.mode.cursor_style()).unwrap();
@@ -1072,99 +1114,74 @@ impl ShedVi {
post_mode_change.exec(); post_mode_change.exec();
} }
pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> { fn exec_mode_transition(&mut self, cmd: ViCmd, from_replay: bool) -> ShResult<()> {
let mut select_mode = None; let mut select_mode = None;
let mut is_insert_mode = false; let mut is_insert_mode = false;
if cmd.is_mode_transition() { let count = cmd.verb_count();
let count = cmd.verb_count();
let mut mode: Box<dyn ViMode> = if matches!( let mut mode: Box<dyn ViMode> = if matches!(
self.mode.report_mode(), self.mode.report_mode(),
ModeReport::Ex | ModeReport::Verbatim ModeReport::Ex | ModeReport::Verbatim
) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE) ) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE)
{ {
if let Some(saved) = self.saved_mode.take() { if let Some(saved) = self.saved_mode.take() {
saved saved
} else {
Box::new(ViNormal::new())
}
} else { } else {
match cmd.verb().unwrap().1 { Box::new(ViNormal::new())
Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => { }
is_insert_mode = true; } else {
Box::new(ViInsert::new().with_count(count as u16)) match cmd.verb().unwrap().1 {
} Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => {
is_insert_mode = true;
Verb::ExMode => Box::new(ViEx::new()), Box::new(
ViInsert::new()
Verb::VerbatimMode => Box::new(ViVerbatim::read_one().with_count(count as u16)), .with_count(count as u16)
.record_cmd(cmd.clone()),
Verb::NormalMode => Box::new(ViNormal::new()),
Verb::ReplaceMode => Box::new(ViReplace::new()),
Verb::VisualModeSelectLast => {
if self.mode.report_mode() != ModeReport::Visual {
self
.editor
.start_selecting(SelectMode::Char(SelectAnchor::End));
}
let mut mode: Box<dyn ViMode> = Box::new(ViVisual::new());
self.swap_mode(&mut mode);
return self.editor.exec_cmd(cmd);
}
Verb::VisualMode => {
select_mode = Some(SelectMode::Char(SelectAnchor::End));
Box::new(ViVisual::new())
}
Verb::VisualModeLine => {
select_mode = Some(SelectMode::Line(SelectAnchor::End));
Box::new(ViVisual::new())
}
_ => unreachable!(),
}
};
self.swap_mode(&mut mode);
if matches!(
self.mode.report_mode(),
ModeReport::Ex | ModeReport::Verbatim
) {
self.saved_mode = Some(mode);
write_vars(|v| {
v.set_var(
"SHED_VI_MODE",
VarKind::Str(self.mode.report_mode().to_string()),
VarFlags::NONE,
) )
})?; }
self.prompt.refresh();
return Ok(());
}
if mode.is_repeatable() { Verb::ExMode => Box::new(ViEx::new()),
self.repeat_action = mode.as_replay();
}
// Set cursor clamp BEFORE executing the command so that motions Verb::VerbatimMode => {
// (like EndOfLine for 'A') can reach positions valid in the new mode self.reader.verbatim_single = true;
self.editor.set_cursor_clamp(self.mode.clamp_cursor()); Box::new(ViVerbatim::new().with_count(count as u16))
self.editor.exec_cmd(cmd)?; }
if let Some(sel_mode) = select_mode { Verb::NormalMode => Box::new(ViNormal::new()),
self.editor.start_selecting(sel_mode);
} else {
self.editor.stop_selecting();
}
if is_insert_mode {
self.editor.mark_insert_mode_start_pos();
} else {
self.editor.clear_insert_mode_start_pos();
}
Verb::ReplaceMode => Box::new(ViReplace::new()),
Verb::VisualModeSelectLast => {
if self.mode.report_mode() != ModeReport::Visual {
self
.editor
.start_selecting(SelectMode::Char(SelectAnchor::End));
}
let mut mode: Box<dyn ViMode> = Box::new(ViVisual::new());
self.swap_mode(&mut mode);
return self.editor.exec_cmd(cmd);
}
Verb::VisualMode => {
select_mode = Some(SelectMode::Char(SelectAnchor::End));
Box::new(ViVisual::new())
}
Verb::VisualModeLine => {
select_mode = Some(SelectMode::Line(SelectAnchor::End));
Box::new(ViVisual::new())
}
_ => unreachable!(),
}
};
self.swap_mode(&mut mode);
if matches!(
self.mode.report_mode(),
ModeReport::Ex | ModeReport::Verbatim
) {
self.saved_mode = Some(mode);
write_vars(|v| { write_vars(|v| {
v.set_var( v.set_var(
"SHED_VI_MODE", "SHED_VI_MODE",
@@ -1173,8 +1190,56 @@ impl ShedVi {
) )
})?; })?;
self.prompt.refresh(); self.prompt.refresh();
return Ok(()); return Ok(());
}
if mode.is_repeatable() && !from_replay {
self.repeat_action = mode.as_replay();
}
// Set cursor clamp BEFORE executing the command so that motions
// (like EndOfLine for 'A') can reach positions valid in the new mode
self.editor.set_cursor_clamp(self.mode.clamp_cursor());
self.editor.exec_cmd(cmd)?;
if let Some(sel_mode) = select_mode {
self.editor.start_selecting(sel_mode);
} else {
self.editor.stop_selecting();
}
if is_insert_mode {
self.editor.mark_insert_mode_start_pos();
} else {
self.editor.clear_insert_mode_start_pos();
}
write_vars(|v| {
v.set_var(
"SHED_VI_MODE",
VarKind::Str(self.mode.report_mode().to_string()),
VarFlags::NONE,
)
})?;
self.prompt.refresh();
Ok(())
}
pub fn clone_mode(&self) -> Box<dyn ViMode> {
match self.mode.report_mode() {
ModeReport::Normal => Box::new(ViNormal::new()),
ModeReport::Insert => Box::new(ViInsert::new()),
ModeReport::Visual => Box::new(ViVisual::new()),
ModeReport::Ex => Box::new(ViEx::new()),
ModeReport::Replace => Box::new(ViReplace::new()),
ModeReport::Verbatim => Box::new(ViVerbatim::new()),
ModeReport::Unknown => unreachable!(),
}
}
pub fn exec_cmd(&mut self, mut cmd: ViCmd, from_replay: bool) -> ShResult<()> {
if cmd.is_mode_transition() {
return self.exec_mode_transition(cmd, from_replay);
} else if cmd.is_cmd_repeat() { } else if cmd.is_cmd_repeat() {
let Some(replay) = self.repeat_action.clone() else { let Some(replay) = self.repeat_action.clone() else {
return Ok(()); return Ok(());
@@ -1186,11 +1251,40 @@ impl ShedVi {
if count > 1 { if count > 1 {
repeat = count as u16; repeat = count as u16;
} }
let old_mode = self.mode.report_mode();
for _ in 0..repeat { for _ in 0..repeat {
let cmds = cmds.clone(); let cmds = cmds.clone();
for cmd in cmds { for (i, cmd) in cmds.iter().enumerate() {
self.editor.exec_cmd(cmd)? log::debug!(
"Replaying command {cmd:?} in mode {:?}, replay {i}/{repeat}",
self.mode.report_mode()
);
self.exec_cmd(cmd.clone(), true)?;
// After the first command, start merging so all subsequent
// edits fold into one undo entry (e.g. cw + inserted chars)
if i == 0
&& let Some(edit) = self.editor.undo_stack.last_mut()
{
edit.start_merge();
}
} }
// Stop merging at the end of the replay
if let Some(edit) = self.editor.undo_stack.last_mut() {
edit.stop_merge();
}
let old_mode_clone = match old_mode {
ModeReport::Normal => Box::new(ViNormal::new()) as Box<dyn ViMode>,
ModeReport::Insert => Box::new(ViInsert::new()) as Box<dyn ViMode>,
ModeReport::Visual => Box::new(ViVisual::new()) as Box<dyn ViMode>,
ModeReport::Ex => Box::new(ViEx::new()) as Box<dyn ViMode>,
ModeReport::Replace => Box::new(ViReplace::new()) as Box<dyn ViMode>,
ModeReport::Verbatim => Box::new(ViVerbatim::new()) as Box<dyn ViMode>,
ModeReport::Unknown => unreachable!(),
};
self.mode = old_mode_clone;
} }
} }
CmdReplay::Single(mut cmd) => { CmdReplay::Single(mut cmd) => {
@@ -1253,7 +1347,7 @@ impl ShedVi {
self.swap_mode(&mut mode); self.swap_mode(&mut mode);
} }
if cmd.is_repeatable() { if cmd.is_repeatable() && !from_replay {
if self.mode.report_mode() == ModeReport::Visual { if self.mode.report_mode() == ModeReport::Visual {
// The motion is assigned in the line buffer execution, so we also have to // The motion is assigned in the line buffer execution, so we also have to
// assign it here in order to be able to repeat it // assign it here in order to be able to repeat it
@@ -1272,7 +1366,11 @@ impl ShedVi {
self.editor.exec_cmd(cmd.clone())?; self.editor.exec_cmd(cmd.clone())?;
if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some_and(|v| v.1.is_edit()) { if self.mode.report_mode() == ModeReport::Visual
&& cmd
.verb()
.is_some_and(|v| v.1.is_edit() || v.1 == Verb::Yank)
{
self.editor.stop_selecting(); self.editor.stop_selecting();
let mut mode: Box<dyn ViMode> = Box::new(ViNormal::new()); let mut mode: Box<dyn ViMode> = Box::new(ViNormal::new());
self.swap_mode(&mut mode); self.swap_mode(&mut mode);
@@ -1332,6 +1430,8 @@ pub fn annotate_input(input: &str) -> String {
for tk in tokens.into_iter().rev() { for tk in tokens.into_iter().rev() {
let insertions = annotate_token(tk); let insertions = annotate_token(tk);
for (pos, marker) in insertions { for (pos, marker) in insertions {
log::info!("pos: {pos}, marker: {marker:?}");
log::info!("before: {annotated:?}");
let pos = pos.max(0).min(annotated.len()); let pos = pos.max(0).min(annotated.len());
annotated.insert(pos, marker); annotated.insert(pos, marker);
} }
@@ -1421,6 +1521,7 @@ pub fn get_insertions(input: &str) -> Vec<(usize, Marker)> {
pub fn marker_for(class: &TkRule) -> Option<Marker> { pub fn marker_for(class: &TkRule) -> Option<Marker> {
match class { match class {
TkRule::Pipe TkRule::Pipe
| TkRule::Bang
| TkRule::ErrPipe | TkRule::ErrPipe
| TkRule::And | TkRule::And
| TkRule::Or | TkRule::Or
@@ -1512,6 +1613,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

@@ -2,6 +2,24 @@ use std::{fmt::Display, sync::Mutex};
pub static REGISTERS: Mutex<Registers> = Mutex::new(Registers::new()); pub static REGISTERS: Mutex<Registers> = Mutex::new(Registers::new());
#[cfg(test)]
pub static SAVED_REGISTERS: Mutex<Option<Registers>> = Mutex::new(None);
#[cfg(test)]
pub fn save_registers() {
let mut saved = SAVED_REGISTERS.lock().unwrap();
*saved = Some(REGISTERS.lock().unwrap().clone());
}
#[cfg(test)]
pub fn restore_registers() {
let mut saved = SAVED_REGISTERS.lock().unwrap();
if let Some(ref registers) = *saved {
*REGISTERS.lock().unwrap() = registers.clone();
}
*saved = None;
}
pub fn read_register(ch: Option<char>) -> Option<RegisterContent> { pub fn read_register(ch: Option<char>) -> Option<RegisterContent> {
let lock = REGISTERS.lock().unwrap(); let lock = REGISTERS.lock().unwrap();
lock.get_reg(ch).map(|r| r.content().clone()) lock.get_reg(ch).map(|r| r.content().clone())
@@ -79,7 +97,7 @@ impl RegisterContent {
} }
} }
#[derive(Default, Debug)] #[derive(Default, Clone, Debug)]
pub struct Registers { pub struct Registers {
default: Register, default: Register,
a: Register, a: Register,

View File

@@ -444,8 +444,8 @@ impl Perform for KeyCollector {
21 => KeyCode::F(10), 21 => KeyCode::F(10),
23 => KeyCode::F(11), 23 => KeyCode::F(11),
24 => KeyCode::F(12), 24 => KeyCode::F(12),
200 => KeyCode::BracketedPasteStart, 200 => KeyCode::BracketedPasteStart,
201 => KeyCode::BracketedPasteEnd, 201 => KeyCode::BracketedPasteEnd,
_ => return, _ => return,
}; };
KeyEvent(key, mods) KeyEvent(key, mods)
@@ -498,8 +498,9 @@ impl Perform for KeyCollector {
pub struct PollReader { pub struct PollReader {
parser: Parser, parser: Parser,
collector: KeyCollector, collector: KeyCollector,
byte_buf: VecDeque<u8>, byte_buf: VecDeque<u8>,
pub verbatim: bool, pub verbatim_single: bool,
pub verbatim: bool,
} }
impl PollReader { impl PollReader {
@@ -507,32 +508,45 @@ impl PollReader {
Self { Self {
parser: Parser::new(), parser: Parser::new(),
collector: KeyCollector::new(), collector: KeyCollector::new(),
byte_buf: VecDeque::new(), byte_buf: VecDeque::new(),
verbatim: false, verbatim_single: false,
verbatim: false,
} }
} }
pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> { pub fn handle_bracket_paste(&mut self) -> Option<KeyEvent> {
let end_marker = b"\x1b[201~"; let end_marker = b"\x1b[201~";
let mut raw = vec![]; let mut raw = vec![];
while let Some(byte) = self.byte_buf.pop_front() { while let Some(byte) = self.byte_buf.pop_front() {
raw.push(byte); raw.push(byte);
if raw.ends_with(end_marker) { if raw.ends_with(end_marker) {
// Strip the end marker from the raw sequence // Strip the end marker from the raw sequence
raw.truncate(raw.len() - end_marker.len()); raw.truncate(raw.len() - end_marker.len());
let paste = String::from_utf8_lossy(&raw).to_string(); let paste = String::from_utf8_lossy(&raw).to_string();
self.verbatim = false; self.verbatim = false;
return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty())); return Some(KeyEvent(KeyCode::Verbatim(paste.into()), ModKeys::empty()));
} }
} }
self.verbatim = true; self.verbatim = true;
self.byte_buf.extend(raw); self.byte_buf.extend(raw);
None None
} }
pub fn read_one_verbatim(&mut self) -> Option<KeyEvent> {
if self.byte_buf.is_empty() {
return None;
}
let bytes: Vec<u8> = self.byte_buf.drain(..).collect();
let verbatim_str = String::from_utf8_lossy(&bytes).to_string();
Some(KeyEvent(
KeyCode::Verbatim(verbatim_str.into()),
ModKeys::empty(),
))
}
pub fn feed_bytes(&mut self, bytes: &[u8]) { pub fn feed_bytes(&mut self, bytes: &[u8]) {
self.byte_buf.extend(bytes); self.byte_buf.extend(bytes);
} }
} }
@@ -544,33 +558,42 @@ impl Default for PollReader {
impl KeyReader for PollReader { impl KeyReader for PollReader {
fn read_key(&mut self) -> Result<Option<KeyEvent>, ShErr> { fn read_key(&mut self) -> Result<Option<KeyEvent>, ShErr> {
if self.verbatim { if self.verbatim_single {
if let Some(paste) = self.handle_bracket_paste() { if let Some(key) = self.read_one_verbatim() {
return Ok(Some(paste)); self.verbatim_single = false;
} return Ok(Some(key));
// If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys }
return Ok(None); return Ok(None);
} else if self.byte_buf.len() == 1 }
&& self.byte_buf.front() == Some(&b'\x1b') { if self.verbatim {
// User pressed escape if let Some(paste) = self.handle_bracket_paste() {
self.byte_buf.pop_front(); // Consume the escape byte return Ok(Some(paste));
return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty()))); }
} // If we're in verbatim mode but haven't seen the end marker yet, don't attempt to parse keys
while let Some(byte) = self.byte_buf.pop_front() { return Ok(None);
self.parser.advance(&mut self.collector, &[byte]); } else if self.byte_buf.front() == Some(&b'\x1b') {
if let Some(key) = self.collector.pop() { // Escape: if it's the only byte, or the next byte isn't a valid
match key { // escape sequence prefix ([ or O), emit a standalone Escape
KeyEvent(KeyCode::BracketedPasteStart, _) => { if self.byte_buf.len() == 1 || !matches!(self.byte_buf.get(1), Some(b'[') | Some(b'O')) {
if let Some(paste) = self.handle_bracket_paste() { self.byte_buf.pop_front();
return Ok(Some(paste)); return Ok(Some(KeyEvent(KeyCode::Esc, ModKeys::empty())));
} else { }
continue; }
} while let Some(byte) = self.byte_buf.pop_front() {
} self.parser.advance(&mut self.collector, &[byte]);
_ => return Ok(Some(key)) if let Some(key) = self.collector.pop() {
} match key {
} KeyEvent(KeyCode::BracketedPasteStart, _) => {
} if let Some(paste) = self.handle_bracket_paste() {
return Ok(Some(paste));
} else {
continue;
}
}
_ => return Ok(Some(key)),
}
}
}
Ok(None) Ok(None)
} }
} }
@@ -822,7 +845,7 @@ impl Default for Layout {
} }
pub struct TermWriter { pub struct TermWriter {
last_bell: Option<Instant>, last_bell: Option<Instant>,
out: RawFd, out: RawFd,
pub t_cols: Col, // terminal width pub t_cols: Col, // terminal width
buffer: String, buffer: String,
@@ -832,7 +855,7 @@ impl TermWriter {
pub fn new(out: RawFd) -> Self { pub fn new(out: RawFd) -> Self {
let (t_cols, _) = get_win_size(out); let (t_cols, _) = get_win_size(out);
Self { Self {
last_bell: None, last_bell: None,
out, out,
t_cols, t_cols,
buffer: String::new(), buffer: String::new(),
@@ -1069,24 +1092,24 @@ impl LineWriter for TermWriter {
Ok(()) Ok(())
} }
fn send_bell(&mut self) -> ShResult<()> { fn send_bell(&mut self) -> ShResult<()> {
if read_shopts(|o| o.core.bell_enabled) { if read_shopts(|o| o.core.bell_enabled) {
// we use a cooldown because I don't like having my ears assaulted by 1 million bells // we use a cooldown because I don't like having my ears assaulted by 1 million bells
// whenever i finish clearing the line using backspace. // whenever i finish clearing the line using backspace.
let now = Instant::now(); let now = Instant::now();
// surprisingly, a fixed cooldown like '100' is actually more annoying than 1 million bells. // surprisingly, a fixed cooldown like '100' is actually more annoying than 1 million bells.
// I've found this range of 50-150 to be the best balance // I've found this range of 50-150 to be the best balance
let cooldown = rand::random_range(50..150); let cooldown = rand::random_range(50..150);
let should_send = match self.last_bell { let should_send = match self.last_bell {
None => true, None => true,
Some(time) => now.duration_since(time).as_millis() > cooldown, Some(time) => now.duration_since(time).as_millis() > cooldown,
}; };
if should_send { if should_send {
self.flush_write("\x07")?; self.flush_write("\x07")?;
self.last_bell = Some(now); self.last_bell = Some(now);
} }
} }
Ok(()) Ok(())
} }
} }

463
src/readline/tests.rs Normal file
View File

@@ -0,0 +1,463 @@
#![allow(non_snake_case)]
use std::os::fd::AsRawFd;
use crate::{
readline::{Prompt, ShedVi, annotate_input},
testutil::TestGuard,
};
fn assert_annotated(input: &str, expected: &str) {
let result = annotate_input(input);
assert_eq!(result, expected, "\nInput: {input:?}");
}
/// Tests for our vim logic emulation. Each test consists of an initial text, a sequence of keys to feed, and the expected final text and cursor position.
macro_rules! vi_test {
{ $($name:ident: $input:expr => $op:expr => $expected_text:expr,$expected_cursor:expr);* } => {
$(
#[test]
fn $name() {
let (mut vi, _g) = test_vi($input);
vi.feed_bytes(b"\x1b"); // Start in normal mode
vi.process_input().unwrap();
vi.feed_bytes($op.as_bytes());
vi.process_input().unwrap();
assert_eq!(vi.editor.as_str(), $expected_text);
assert_eq!(vi.editor.cursor.get(), $expected_cursor);
}
)*
};
}
// ===================== Annotation Tests =====================
#[test]
fn annotate_simple_command() {
assert_annotated("echo hello",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a}");
}
#[test]
fn annotate_pipeline() {
assert_annotated("ls | grep foo",
"\u{e100}ls\u{e11a} \u{e104}|\u{e11a} \u{e100}grep\u{e11a} \u{e102}foo\u{e11a}");
}
#[test]
fn annotate_conjunction() {
assert_annotated("echo foo && echo bar",
"\u{e101}echo\u{e11a} \u{e102}foo\u{e11a} \u{e104}&&\u{e11a} \u{e101}echo\u{e11a} \u{e102}bar\u{e11a}");
}
#[test]
fn annotate_redirect_output() {
assert_annotated("echo hello > file.txt",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a} \u{e105}>\u{e11a} \u{e102}file.txt\u{e11a}");
}
#[test]
fn annotate_redirect_append() {
assert_annotated("echo hello >> file.txt",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a} \u{e105}>>\u{e11a} \u{e102}file.txt\u{e11a}");
}
#[test]
fn annotate_redirect_input() {
assert_annotated("cat < file.txt",
"\u{e100}cat\u{e11a} \u{e105}<\u{e11a} \u{e102}file.txt\u{e11a}");
}
#[test]
fn annotate_fd_redirect() {
assert_annotated("cmd 2>&1",
"\u{e100}cmd\u{e11a} \u{e105}2>&1\u{e11a}");
}
#[test]
fn annotate_variable_sub() {
assert_annotated("echo $HOME",
"\u{e101}echo\u{e11a} \u{e102}\u{e10c}$HOME\u{e10d}\u{e11a}");
}
#[test]
fn annotate_variable_brace_sub() {
assert_annotated("echo ${HOME}",
"\u{e101}echo\u{e11a} \u{e102}\u{e10c}${HOME}\u{e10d}\u{e11a}");
}
#[test]
fn annotate_command_sub() {
assert_annotated("echo $(ls)",
"\u{e101}echo\u{e11a} \u{e102}\u{e10e}$(ls)\u{e10f}\u{e11a}");
}
#[test]
fn annotate_single_quoted_string() {
assert_annotated("echo 'hello world'",
"\u{e101}echo\u{e11a} \u{e102}\u{e114}'hello world'\u{e115}\u{e11a}");
}
#[test]
fn annotate_double_quoted_string() {
assert_annotated("echo \"hello world\"",
"\u{e101}echo\u{e11a} \u{e102}\u{e112}\"hello world\"\u{e113}\u{e11a}");
}
#[test]
fn annotate_assignment() {
assert_annotated("FOO=bar",
"\u{e107}FOO=bar\u{e11a}");
}
#[test]
fn annotate_assignment_with_command() {
assert_annotated("FOO=bar echo hello",
"\u{e107}FOO=bar\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}");
}
#[test]
fn annotate_if_statement() {
assert_annotated("if true; then echo yes; fi",
"\u{e103}if\u{e11a} \u{e101}true\u{e11a}\u{e108}; \u{e11a}\u{e103}then\u{e11a} \u{e101}echo\u{e11a} \u{e102}yes\u{e11a}\u{e108}; \u{e11a}\u{e103}fi\u{e11a}");
}
#[test]
fn annotate_for_loop() {
assert_annotated("for i in a b c; do echo $i; done",
"\u{e103}for\u{e11a} \u{e102}i\u{e11a} \u{e103}in\u{e11a} \u{e102}a\u{e11a} \u{e102}b\u{e11a} \u{e102}c\u{e11a}\u{e108}; \u{e11a}\u{e103}do\u{e11a} \u{e101}echo\u{e11a} \u{e102}\u{e10c}$i\u{e10d}\u{e11a}\u{e108}; \u{e11a}\u{e103}done\u{e11a}");
}
#[test]
fn annotate_while_loop() {
assert_annotated("while true; do echo hello; done",
"\u{e103}while\u{e11a} \u{e101}true\u{e11a}\u{e108}; \u{e11a}\u{e103}do\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}\u{e108}; \u{e11a}\u{e103}done\u{e11a}");
}
#[test]
fn annotate_case_statement() {
assert_annotated("case foo in bar) echo bar;; esac",
"\u{e103}case\u{e11a} \u{e102}foo\u{e11a} \u{e103}in\u{e11a} \u{e104}bar\u{e109})\u{e11a} \u{e101}echo\u{e11a} \u{e102}bar\u{e11a}\u{e108};; \u{e11a}\u{e103}esac\u{e11a}");
}
#[test]
fn annotate_brace_group() {
assert_annotated("{ echo hello; }",
"\u{e104}{\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}\u{e108}; \u{e11a}\u{e104}}\u{e11a}");
}
#[test]
fn annotate_comment() {
assert_annotated("echo hello # this is a comment",
"\u{e101}echo\u{e11a} \u{e102}hello\u{e11a} \u{e106}# this is a comment\u{e11a}");
}
#[test]
fn annotate_semicolon_sep() {
assert_annotated("echo foo; echo bar",
"\u{e101}echo\u{e11a} \u{e102}foo\u{e11a}\u{e108}; \u{e11a}\u{e101}echo\u{e11a} \u{e102}bar\u{e11a}");
}
#[test]
fn annotate_escaped_char() {
assert_annotated("echo hello\\ world",
"\u{e101}echo\u{e11a} \u{e102}hello\\ world\u{e11a}");
}
#[test]
fn annotate_glob() {
assert_annotated("ls *.txt",
"\u{e100}ls\u{e11a} \u{e102}\u{e117}*\u{e11a}.txt\u{e11a}");
}
#[test]
fn annotate_heredoc_operator() {
assert_annotated("cat <<EOF",
"\u{e100}cat\u{e11a} \u{e105}<<\u{e11a}\u{e102}EOF\u{e11a}");
}
#[test]
fn annotate_herestring_operator() {
assert_annotated("cat <<< hello",
"\u{e100}cat\u{e11a} \u{e105}<<<\u{e11a} \u{e102}hello\u{e11a}");
}
#[test]
fn annotate_nested_command_sub() {
assert_annotated("echo $(echo $(ls))",
"\u{e101}echo\u{e11a} \u{e102}\u{e10e}$(echo $(ls))\u{e10f}\u{e11a}");
}
#[test]
fn annotate_var_in_double_quotes() {
assert_annotated("echo \"hello $USER\"",
"\u{e101}echo\u{e11a} \u{e102}\u{e112}\"hello \u{e10c}$USER\u{e10d}\"\u{e113}\u{e11a}");
}
#[test]
fn annotate_func_def() {
assert_annotated("foo() { echo hello; }",
"\u{e103}foo()\u{e11a} \u{e104}{\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}\u{e108}; \u{e11a}\u{e104}}\u{e11a}");
}
#[test]
fn annotate_negate() {
assert_annotated("! echo hello",
"\u{e104}!\u{e11a} \u{e101}echo\u{e11a} \u{e102}hello\u{e11a}");
}
#[test]
fn annotate_or_conjunction() {
assert_annotated("false || echo fallback",
"\u{e101}false\u{e11a} \u{e104}||\u{e11a} \u{e101}echo\u{e11a} \u{e102}fallback\u{e11a}");
}
#[test]
fn annotate_complex_pipeline() {
assert_annotated("cat file.txt | grep pattern | wc -l",
"\u{e100}cat\u{e11a} \u{e102}file.txt\u{e11a} \u{e104}|\u{e11a} \u{e100}grep\u{e11a} \u{e102}pattern\u{e11a} \u{e104}|\u{e11a} \u{e100}wc\u{e11a} \u{e102}-l\u{e11a}");
}
#[test]
fn annotate_multiple_redirects() {
assert_annotated("cmd > out.txt 2> err.txt",
"\u{e100}cmd\u{e11a} \u{e105}>\u{e11a} \u{e102}out.txt\u{e11a} \u{e105}2>\u{e11a} \u{e102}err.txt\u{e11a}");
}
// ===================== Vi Tests =====================
fn test_vi(initial: &str) -> (ShedVi, TestGuard) {
let g = TestGuard::new();
let prompt = Prompt::default();
let vi = ShedVi::new_no_hist(prompt, g.pty_slave().as_raw_fd())
.unwrap()
.with_initial(initial);
(vi, g)
}
// Why can't I marry a programming language
vi_test! {
vi_dw_basic : "hello world" => "dw" => "world", 0;
vi_dw_middle : "one two three" => "wdw" => "one three", 4;
vi_dd_whole_line : "hello world" => "dd" => "", 0;
vi_x_single : "hello" => "x" => "ello", 0;
vi_x_middle : "hello" => "llx" => "helo", 2;
vi_X_backdelete : "hello" => "llX" => "hllo", 1;
vi_h_motion : "hello" => "$h" => "hello", 3;
vi_l_motion : "hello" => "l" => "hello", 1;
vi_h_at_start : "hello" => "h" => "hello", 0;
vi_l_at_end : "hello" => "$l" => "hello", 4;
vi_w_forward : "one two three" => "w" => "one two three", 4;
vi_b_backward : "one two three" => "$b" => "one two three", 8;
vi_e_end : "one two three" => "e" => "one two three", 2;
vi_ge_back_end : "one two three" => "$ge" => "one two three", 6;
vi_w_punctuation : "foo.bar baz" => "w" => "foo.bar baz", 3;
vi_e_punctuation : "foo.bar baz" => "e" => "foo.bar baz", 2;
vi_b_punctuation : "foo.bar baz" => "$b" => "foo.bar baz", 8;
vi_w_at_eol : "hello" => "$w" => "hello", 4;
vi_b_at_bol : "hello" => "b" => "hello", 0;
vi_W_forward : "foo.bar baz" => "W" => "foo.bar baz", 8;
vi_B_backward : "foo.bar baz" => "$B" => "foo.bar baz", 8;
vi_E_end : "foo.bar baz" => "E" => "foo.bar baz", 6;
vi_gE_back_end : "one two three" => "$gE" => "one two three", 6;
vi_W_skip_punct : "one-two three" => "W" => "one-two three", 8;
vi_B_skip_punct : "one two-three" => "$B" => "one two-three", 4;
vi_E_skip_punct : "one-two three" => "E" => "one-two three", 6;
vi_dW_big : "foo.bar baz" => "dW" => "baz", 0;
vi_cW_big : "foo.bar baz" => "cWx\x1b" => "x baz", 0;
vi_zero_bol : " hello" => "$0" => " hello", 0;
vi_caret_first_char : " hello" => "$^" => " hello", 2;
vi_dollar_eol : "hello world" => "$" => "hello world", 10;
vi_g_last_nonws : "hello " => "g_" => "hello ", 4;
vi_g_no_trailing : "hello" => "g_" => "hello", 4;
vi_pipe_column : "hello world" => "6|" => "hello world", 5;
vi_pipe_col1 : "hello world" => "1|" => "hello world", 0;
vi_I_insert_front : " hello" => "Iworld \x1b" => " world hello", 7;
vi_A_append_end : "hello" => "A world\x1b" => "hello world", 10;
vi_f_find : "hello world" => "fo" => "hello world", 4;
vi_F_find_back : "hello world" => "$Fo" => "hello world", 7;
vi_t_till : "hello world" => "tw" => "hello world", 5;
vi_T_till_back : "hello world" => "$To" => "hello world", 8;
vi_f_no_match : "hello" => "fz" => "hello", 0;
vi_semicolon_repeat : "abcabc" => "fa;;" => "abcabc", 3;
vi_comma_reverse : "abcabc" => "fa;;," => "abcabc", 0;
vi_df_semicolon : "abcabc" => "fa;;dfa" => "abcabc", 3;
vi_t_at_target : "aab" => "lta" => "aab", 1;
vi_D_to_end : "hello world" => "wD" => "hello ", 5;
vi_d_dollar : "hello world" => "wd$" => "hello ", 5;
vi_d0_to_start : "hello world" => "$d0" => "d", 0;
vi_dw_multiple : "one two three" => "d2w" => "three", 0;
vi_dt_char : "hello world" => "dtw" => "world", 0;
vi_df_char : "hello world" => "dfw" => "orld", 0;
vi_dh_back : "hello" => "lldh" => "hllo", 1;
vi_dl_forward : "hello" => "dl" => "ello", 0;
vi_dge_back_end : "one two three" => "$dge" => "one tw", 5;
vi_dG_to_end : "hello world" => "dG" => "", 0;
vi_dgg_to_start : "hello world" => "$dgg" => "", 0;
vi_d_semicolon : "abcabc" => "fad;" => "abcabc", 3;
vi_cw_basic : "hello world" => "cwfoo\x1b" => "foo world", 2;
vi_C_to_end : "hello world" => "wCfoo\x1b" => "hello foo", 8;
vi_cc_whole : "hello world" => "ccfoo\x1b" => "foo", 2;
vi_ct_char : "hello world" => "ctwfoo\x1b" => "fooworld", 2;
vi_s_single : "hello" => "sfoo\x1b" => "fooello", 2;
vi_S_whole_line : "hello world" => "Sfoo\x1b" => "foo", 2;
vi_cl_forward : "hello" => "clX\x1b" => "Xello", 0;
vi_ch_backward : "hello" => "llchX\x1b" => "hXllo", 1;
vi_cb_word_back : "hello world" => "$cbfoo\x1b" => "hello food", 8;
vi_ce_word_end : "hello world" => "cefoo\x1b" => "foo world", 2;
vi_c0_to_start : "hello world" => "wc0foo\x1b" => "fooworld", 2;
vi_yw_p_basic : "hello world" => "ywwP" => "hello hello world", 11;
vi_dw_p_paste : "hello world" => "dwP" => "hello world", 5;
vi_dd_p_paste : "hello world" => "ddp" => "\nhello world", 1;
vi_y_dollar_p : "hello world" => "wy$P" => "hello worldworld", 10;
vi_ye_p : "hello world" => "yewP" => "hello helloworld", 10;
vi_yy_p : "hello world" => "yyp" => "hello world\nhello world", 12;
vi_Y_p : "hello world" => "Yp" => "hhello worldello world", 11;
vi_p_after_x : "hello" => "xp" => "ehllo", 1;
vi_P_before : "hello" => "llxP" => "hello", 2;
vi_paste_empty : "hello" => "p" => "hello", 0;
vi_r_replace : "hello" => "ra" => "aello", 0;
vi_r_middle : "hello" => "llra" => "healo", 2;
vi_r_at_end : "hello" => "$ra" => "hella", 4;
vi_r_space : "hello" => "r " => " ello", 0;
vi_r_with_count : "hello" => "3rx" => "xxxlo", 2;
vi_tilde_single : "hello" => "~" => "Hello", 1;
vi_tilde_count : "hello" => "3~" => "HELlo", 3;
vi_tilde_at_end : "HELLO" => "$~" => "HELLo", 4;
vi_tilde_mixed : "hElLo" => "5~" => "HeLlO", 4;
vi_gu_word : "HELLO world" => "guw" => "hello world", 0;
vi_gU_word : "hello WORLD" => "gUw" => "HELLO WORLD", 0;
vi_gu_dollar : "HELLO WORLD" => "gu$" => "hello world", 0;
vi_gU_dollar : "hello world" => "gU$" => "HELLO WORLD", 0;
vi_gu_0 : "HELLO WORLD" => "$gu0" => "hello worlD", 0;
vi_gU_0 : "hello world" => "$gU0" => "HELLO WORLd", 0;
vi_gtilde_word : "hello WORLD" => "g~w" => "HELLO WORLD", 0;
vi_gtilde_dollar : "hello WORLD" => "g~$" => "HELLO world", 0;
vi_diw_inner : "one two three" => "wdiw" => "one three", 4;
vi_ciw_replace : "hello world" => "ciwfoo\x1b" => "foo world", 2;
vi_daw_around : "one two three" => "wdaw" => "one three", 4;
vi_yiw_p : "hello world" => "yiwAp \x1bp" => "hello worldp hello", 17;
vi_diW_big_inner : "one-two three" => "diW" => " three", 0;
vi_daW_big_around : "one two-three end" => "wdaW" => "one end", 4;
vi_ciW_big : "one-two three" => "ciWx\x1b" => "x three", 0;
vi_di_dquote : "one \"two\" three" => "f\"di\"" => "one \"\" three", 5;
vi_da_dquote : "one \"two\" three" => "f\"da\"" => "one three", 4;
vi_ci_dquote : "one \"two\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5;
vi_di_squote : "one 'two' three" => "f'di'" => "one '' three", 5;
vi_da_squote : "one 'two' three" => "f'da'" => "one three", 4;
vi_di_backtick : "one `two` three" => "f`di`" => "one `` three", 5;
vi_da_backtick : "one `two` three" => "f`da`" => "one three", 4;
vi_ci_dquote_empty : "one \"\" three" => "f\"ci\"x\x1b" => "one \"x\" three", 5;
vi_di_paren : "one (two) three" => "f(di(" => "one () three", 5;
vi_da_paren : "one (two) three" => "f(da(" => "one three", 4;
vi_ci_paren : "one (two) three" => "f(ci(x\x1b" => "one (x) three", 5;
vi_di_brace : "one {two} three" => "f{di{" => "one {} three", 5;
vi_da_brace : "one {two} three" => "f{da{" => "one three", 4;
vi_di_bracket : "one [two] three" => "f[di[" => "one [] three", 5;
vi_da_bracket : "one [two] three" => "f[da[" => "one three", 4;
vi_di_angle : "one <two> three" => "f<di<" => "one <> three", 5;
vi_da_angle : "one <two> three" => "f<da<" => "one three", 4;
vi_di_paren_nested : "fn(a, (b, c))" => "f(di(" => "fn()", 3;
vi_di_paren_empty : "fn() end" => "f(di(" => "fn() end", 3;
vi_dib_alias : "one (two) three" => "f(dib" => "one () three", 5;
vi_diB_alias : "one {two} three" => "f{diB" => "one {} three", 5;
vi_percent_paren : "(hello) world" => "%" => "(hello) world", 6;
vi_percent_brace : "{hello} world" => "%" => "{hello} world", 6;
vi_percent_bracket : "[hello] world" => "%" => "[hello] world", 6;
vi_percent_from_close: "(hello) world" => "f)%" => "(hello) world", 0;
vi_d_percent_paren : "(hello) world" => "d%" => " world", 0;
vi_i_insert : "hello" => "iX\x1b" => "Xhello", 0;
vi_a_append : "hello" => "aX\x1b" => "hXello", 1;
vi_I_front : " hello" => "IX\x1b" => " Xhello", 2;
vi_A_end : "hello" => "AX\x1b" => "helloX", 5;
vi_o_open_below : "hello" => "oworld\x1b" => "hello\nworld", 10;
vi_O_open_above : "hello" => "Oworld\x1b" => "world\nhello", 4;
vi_empty_input : "" => "i hello\x1b" => " hello", 5;
vi_insert_escape : "hello" => "aX\x1b" => "hXello", 1;
vi_ctrl_w_del_word : "hello world" => "A\x17\x1b" => "hello ", 5;
vi_ctrl_h_backspace : "hello" => "A\x08\x1b" => "hell", 3;
vi_u_undo_delete : "hello world" => "dwu" => "hello world", 0;
vi_u_undo_change : "hello world" => "ciwfoo\x1bu" => "hello world", 0;
vi_u_undo_x : "hello" => "xu" => "hello", 0;
vi_ctrl_r_redo : "hello" => "xu\x12" => "ello", 0;
vi_u_multiple : "hello world" => "xdwu" => "ello world", 0;
vi_redo_after_undo : "hello world" => "dwu\x12" => "world", 0;
vi_dot_repeat_x : "hello" => "x." => "llo", 0;
vi_dot_repeat_dw : "one two three" => "dw." => "three", 0;
vi_dot_repeat_cw : "one two three" => "cwfoo\x1bw." => "foo foo three", 6;
vi_dot_repeat_r : "hello" => "ra.." => "aello", 0;
vi_dot_repeat_s : "hello" => "sX\x1bl." => "XXllo", 1;
vi_count_h : "hello world" => "$3h" => "hello world", 7;
vi_count_l : "hello world" => "3l" => "hello world", 3;
vi_count_w : "one two three four" => "2w" => "one two three four", 8;
vi_count_b : "one two three four" => "$2b" => "one two three four", 8;
vi_count_x : "hello" => "3x" => "lo", 0;
vi_count_dw : "one two three four" => "2dw" => "three four", 0;
vi_verb_count_motion : "one two three four" => "d2w" => "three four", 0;
vi_count_s : "hello" => "3sX\x1b" => "Xlo", 0;
vi_indent_line : "hello" => ">>" => "\thello", 1;
vi_dedent_line : "\thello" => "<<" => "hello", 0;
vi_indent_double : "hello" => ">>>>" => "\t\thello", 2;
vi_J_join_lines : "hello\nworld" => "J" => "hello world", 5;
vi_v_u_lower : "HELLO" => "vlllu" => "hellO", 0;
vi_v_U_upper : "hello" => "vlllU" => "HELLo", 0;
vi_v_d_delete : "hello world" => "vwwd" => "", 0;
vi_v_x_delete : "hello world" => "vwwx" => "", 0;
vi_v_c_change : "hello world" => "vwcfoo\x1b" => "fooorld", 2;
vi_v_y_p_yank : "hello world" => "vwyAp \x1bp" => "hello worldp hello w", 19;
vi_v_dollar_d : "hello world" => "wv$d" => "hello ", 5;
vi_v_0_d : "hello world" => "$v0d" => "", 0;
vi_ve_d : "hello world" => "ved" => " world", 0;
vi_v_o_swap : "hello world" => "vllod" => "lo world", 0;
vi_v_r_replace : "hello" => "vlllrx" => "xxxxo", 0;
vi_v_tilde_case : "hello" => "vlll~" => "HELLo", 0;
vi_V_d_delete : "hello world" => "Vd" => "", 0;
vi_V_y_p : "hello world" => "Vyp" => "hello world\nhello world", 12;
vi_V_S_change : "hello world" => "VSfoo\x1b" => "foo", 2;
vi_ctrl_a_inc : "num 5 end" => "w\x01" => "num 6 end", 4;
vi_ctrl_x_dec : "num 5 end" => "w\x18" => "num 4 end", 4;
vi_ctrl_a_negative : "num -3 end" => "w\x01" => "num -2 end", 4;
vi_ctrl_x_to_neg : "num 0 end" => "w\x18" => "num -1 end", 4;
vi_ctrl_a_count : "num 5 end" => "w3\x01" => "num 8 end", 4;
vi_ctrl_a_width : "num -00001 end" => "w\x01" => "num 00000 end", 4;
vi_delete_empty : "" => "x" => "", 0;
vi_undo_on_empty : "" => "u" => "", 0;
vi_w_single_char : "a b c" => "w" => "a b c", 2;
vi_dw_last_word : "hello" => "dw" => "", 0;
vi_dollar_single : "h" => "$" => "h", 0;
vi_caret_no_ws : "hello" => "$^" => "hello", 0;
vi_f_last_char : "hello" => "fo" => "hello", 4;
vi_r_on_space : "hello world" => "5|r-" => "hell- world", 4;
vi_vw_doesnt_crash : "" => "vw" => "", 0;
vi_indent_cursor_pos : "echo foo" => ">>" => "\techo foo", 1;
vi_join_indent_lines : "echo foo\n\t\techo bar" => "J" => "echo foo echo bar", 8
}
#[test]
fn vi_auto_indent() {
let (mut vi, _g) = test_vi("");
// Type each line and press Enter separately so auto-indent triggers
let lines = [
"func() {",
"case foo in",
"bar)",
"while true; do",
"echo foo \\\rbar \\\rbiz \\\rbazz\rbreak\rdone\r;;\resac\r}"
];
for (i,line) in lines.iter().enumerate() {
vi.feed_bytes(line.as_bytes());
if i != lines.len() - 1 {
vi.feed_bytes(b"\r");
}
vi.process_input().unwrap();
}
assert_eq!(
vi.editor.as_str(),
"func() {\n\tcase foo in\n\t\tbar)\n\t\t\twhile true; do\n\t\t\t\techo foo \\\n\t\t\t\t\tbar \\\n\t\t\t\t\tbiz \\\n\t\t\t\t\tbazz\n\t\t\t\tbreak\n\t\t\tdone\n\t\t;;\n\tesac\n}"
);
}

View File

@@ -343,7 +343,7 @@ pub enum Motion {
HalfOfScreen, HalfOfScreen,
HalfOfScreenLineText, HalfOfScreenLineText,
WholeBuffer, WholeBuffer,
BeginningOfBuffer, StartOfBuffer,
EndOfBuffer, EndOfBuffer,
ToColumn, ToColumn,
ToDelimMatch, ToDelimMatch,

View File

@@ -13,6 +13,10 @@ impl ViInsert {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
pub fn record_cmd(mut self, cmd: ViCmd) -> Self {
self.cmds.push(cmd);
self
}
pub fn with_count(mut self, repeat_count: u16) -> Self { pub fn with_count(mut self, repeat_count: u16) -> Self {
self.repeat_count = repeat_count; self.repeat_count = repeat_count;
self self
@@ -61,10 +65,12 @@ impl ViMode for ViInsert {
raw_seq: String::new(), raw_seq: String::new(),
flags: Default::default(), flags: Default::default(),
}), }),
E(K::Verbatim(seq), _) => { E(K::Verbatim(seq), _) => {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Insert(seq.to_string()))); self
self.register_and_return() .pending_cmd
} .set_verb(VerbCmd(1, Verb::Insert(seq.to_string())));
self.register_and_return()
}
E(K::Char('W'), M::CTRL) => { E(K::Char('W'), M::CTRL) => {
self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete)); self.pending_cmd.set_verb(VerbCmd(1, Verb::Delete));
self.pending_cmd.set_motion(MotionCmd( self.pending_cmd.set_motion(MotionCmd(

View File

@@ -434,7 +434,7 @@ impl ViNormal {
'g' => { 'g' => {
chars_clone.next(); chars_clone.next();
chars = chars_clone; chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer)); break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer));
} }
'e' => { 'e' => {
chars = chars_clone; chars = chars_clone;

View File

@@ -4,19 +4,11 @@ use crate::readline::vicmd::{CmdFlags, RegisterName, To, Verb, VerbCmd, ViCmd};
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
pub struct ViVerbatim { pub struct ViVerbatim {
pending_seq: String,
sent_cmd: Vec<ViCmd>, sent_cmd: Vec<ViCmd>,
repeat_count: u16, repeat_count: u16,
read_one: bool
} }
impl ViVerbatim { impl ViVerbatim {
pub fn read_one() -> Self {
Self {
read_one: true,
..Self::default()
}
}
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
} }
@@ -31,7 +23,7 @@ impl ViVerbatim {
impl ViMode for ViVerbatim { impl ViMode for ViVerbatim {
fn handle_key(&mut self, key: E) -> Option<ViCmd> { fn handle_key(&mut self, key: E) -> Option<ViCmd> {
match key { match key {
E(K::Verbatim(seq), _mods) if self.read_one => { E(K::Verbatim(seq), _mods) => {
log::debug!("Received verbatim key sequence: {:?}", seq); log::debug!("Received verbatim key sequence: {:?}", seq);
let cmd = ViCmd { let cmd = ViCmd {
register: RegisterName::default(), register: RegisterName::default(),
@@ -43,22 +35,6 @@ impl ViMode for ViVerbatim {
self.sent_cmd.push(cmd.clone()); self.sent_cmd.push(cmd.clone());
Some(cmd) Some(cmd)
} }
E(K::Verbatim(seq), _mods) => {
self.pending_seq.push_str(&seq);
None
}
E(K::BracketedPasteEnd, _mods) => {
log::debug!("Received verbatim paste: {:?}", self.pending_seq);
let cmd = ViCmd {
register: RegisterName::default(),
verb: Some(VerbCmd(1, Verb::Insert(self.pending_seq.clone()))),
motion: None,
raw_seq: std::mem::take(&mut self.pending_seq),
flags: CmdFlags::EXIT_CUR_MODE,
};
self.sent_cmd.push(cmd.clone());
Some(cmd)
}
_ => common_cmds(key), _ => common_cmds(key),
} }
} }

View File

@@ -213,7 +213,7 @@ impl ViVisual {
let ch = chars_clone.next()?; let ch = chars_clone.next()?;
return Some(ViCmd { return Some(ViCmd {
register, register,
verb: Some(VerbCmd(1, Verb::ReplaceChar(ch))), verb: Some(VerbCmd(1, Verb::ReplaceCharInplace(ch, 1))),
motion: None, motion: None,
raw_seq: self.take_cmd(), raw_seq: self.take_cmd(),
flags: CmdFlags::empty(), flags: CmdFlags::empty(),
@@ -237,6 +237,24 @@ impl ViVisual {
flags: CmdFlags::empty(), flags: CmdFlags::empty(),
}); });
} }
's' => {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Delete)),
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
}
'S' => {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Change)),
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
}
'U' => { 'U' => {
return Some(ViCmd { return Some(ViCmd {
register, register,
@@ -283,8 +301,13 @@ impl ViVisual {
}); });
} }
'y' => { 'y' => {
chars = chars_clone; return Some(ViCmd {
break 'verb_parse Some(VerbCmd(count, Verb::Yank)); register,
verb: Some(VerbCmd(count, Verb::Yank)),
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
} }
'd' => { 'd' => {
chars = chars_clone; chars = chars_clone;
@@ -335,7 +358,7 @@ impl ViVisual {
'g' => { 'g' => {
chars_clone.next(); chars_clone.next();
chars = chars_clone; chars = chars_clone;
break 'motion_parse Some(MotionCmd(count, Motion::BeginningOfBuffer)); break 'motion_parse Some(MotionCmd(count, Motion::StartOfBuffer));
} }
'e' => { 'e' => {
chars_clone.next(); chars_clone.next();

View File

@@ -146,6 +146,7 @@ pub struct ShOptCore {
pub bell_enabled: bool, pub bell_enabled: bool,
pub max_recurse_depth: usize, pub max_recurse_depth: usize,
pub xpg_echo: bool, pub xpg_echo: bool,
pub noclobber: bool,
} }
impl ShOptCore { impl ShOptCore {
@@ -185,12 +186,12 @@ impl ShOptCore {
"shopt: expected an integer for max_hist value (-1 for unlimited)", "shopt: expected an integer for max_hist value (-1 for unlimited)",
)); ));
}; };
if val < -1 { if val < -1 {
return Err(ShErr::simple( return Err(ShErr::simple(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
"shopt: expected a non-negative integer or -1 for max_hist value", "shopt: expected a non-negative integer or -1 for max_hist value",
)); ));
} }
self.max_hist = val; self.max_hist = val;
} }
"interactive_comments" => { "interactive_comments" => {
@@ -238,6 +239,15 @@ impl ShOptCore {
}; };
self.xpg_echo = val; self.xpg_echo = val;
} }
"noclobber" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for noclobber value",
));
};
self.noclobber = val;
}
_ => { _ => {
return Err(ShErr::simple( return Err(ShErr::simple(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
@@ -304,6 +314,11 @@ impl ShOptCore {
output.push_str(&format!("{}", self.xpg_echo)); output.push_str(&format!("{}", self.xpg_echo));
Ok(Some(output)) Ok(Some(output))
} }
"noclobber" => {
let mut output = String::from("Prevent > from overwriting existing files (use >| to override)\n");
output.push_str(&format!("{}", self.noclobber));
Ok(Some(output))
}
_ => Err(ShErr::simple( _ => Err(ShErr::simple(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'core' option '{query}'"), format!("shopt: Unexpected 'core' option '{query}'"),
@@ -327,6 +342,7 @@ impl Display for ShOptCore {
output.push(format!("bell_enabled = {}", self.bell_enabled)); output.push(format!("bell_enabled = {}", self.bell_enabled));
output.push(format!("max_recurse_depth = {}", self.max_recurse_depth)); output.push(format!("max_recurse_depth = {}", self.max_recurse_depth));
output.push(format!("xpg_echo = {}", self.xpg_echo)); output.push(format!("xpg_echo = {}", self.xpg_echo));
output.push(format!("noclobber = {}", self.noclobber));
let final_output = output.join("\n"); let final_output = output.join("\n");
@@ -346,6 +362,7 @@ impl Default for ShOptCore {
bell_enabled: true, bell_enabled: true,
max_recurse_depth: 1000, max_recurse_depth: 1000,
xpg_echo: false, xpg_echo: false,
noclobber: false,
} }
} }
} }
@@ -360,6 +377,8 @@ pub struct ShOptPrompt {
pub linebreak_on_incomplete: bool, pub linebreak_on_incomplete: bool,
pub leader: String, pub leader: String,
pub line_numbers: bool, pub line_numbers: bool,
pub screensaver_cmd: String,
pub screensaver_idle_time: usize,
} }
impl ShOptPrompt { impl ShOptPrompt {
@@ -431,6 +450,18 @@ impl ShOptPrompt {
}; };
self.line_numbers = val; 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" => { "custom" => {
todo!() todo!()
} }
@@ -496,6 +527,17 @@ impl ShOptPrompt {
output.push_str(&format!("{}", self.line_numbers)); output.push_str(&format!("{}", self.line_numbers));
Ok(Some(output)) 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( _ => Err(ShErr::simple(
ShErrKind::SyntaxErr, ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'prompt' option '{query}'"), format!("shopt: Unexpected 'prompt' option '{query}'"),
@@ -519,6 +561,11 @@ impl Display for ShOptPrompt {
)); ));
output.push(format!("leader = {}", self.leader)); output.push(format!("leader = {}", self.leader));
output.push(format!("line_numbers = {}", self.line_numbers)); 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"); let final_output = output.join("\n");
@@ -537,6 +584,8 @@ impl Default for ShOptPrompt {
linebreak_on_incomplete: true, linebreak_on_incomplete: true,
leader: "\\".to_string(), leader: "\\".to_string(),
line_numbers: true, line_numbers: true,
screensaver_cmd: String::new(),
screensaver_idle_time: 0,
} }
} }
} }
@@ -547,23 +596,31 @@ mod tests {
#[test] #[test]
fn all_core_fields_covered() { fn all_core_fields_covered() {
let ShOptCore { let ShOptCore {
dotglob, autocd, hist_ignore_dupes, max_hist, dotglob,
interactive_comments, auto_hist, bell_enabled, max_recurse_depth, autocd,
xpg_echo, hist_ignore_dupes,
} = ShOptCore::default(); max_hist,
// If a field is added to the struct, this destructure fails to compile. interactive_comments,
let _ = ( auto_hist,
dotglob, bell_enabled,
autocd, max_recurse_depth,
hist_ignore_dupes, xpg_echo,
max_hist, noclobber,
interactive_comments, } = ShOptCore::default();
auto_hist, // If a field is added to the struct, this destructure fails to compile.
bell_enabled, let _ = (
max_recurse_depth, dotglob,
xpg_echo, autocd,
); hist_ignore_dupes,
max_hist,
interactive_comments,
auto_hist,
bell_enabled,
max_recurse_depth,
xpg_echo,
noclobber,
);
} }
#[test] #[test]
@@ -589,7 +646,7 @@ mod tests {
opts.set("core.max_hist", "-1").unwrap(); opts.set("core.max_hist", "-1").unwrap();
assert_eq!(opts.core.max_hist, -1); assert_eq!(opts.core.max_hist, -1);
assert!(opts.set("core.max_hist", "-500").is_err()); assert!(opts.set("core.max_hist", "-500").is_err());
} }
#[test] #[test]

View File

@@ -165,10 +165,10 @@ pub fn reset_signals(is_fg: bool) {
if sig == Signal::SIGKILL || sig == Signal::SIGSTOP { if sig == Signal::SIGKILL || sig == Signal::SIGSTOP {
continue; continue;
} }
if is_fg && (sig == Signal::SIGTTIN || sig == Signal::SIGTTOU) { if is_fg && (sig == Signal::SIGTTIN || sig == Signal::SIGTTOU) {
log::debug!("Not resetting SIGTTIN/SIGTTOU in foreground child"); log::debug!("Not resetting SIGTTIN/SIGTTOU in foreground child");
continue; continue;
} }
let _ = sigaction(sig, &default); let _ = sigaction(sig, &default);
} }
} }

View File

@@ -1,5 +1,11 @@
use std::{ use std::{
cell::RefCell, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, fmt::Display, ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign}, os::unix::fs::PermissionsExt, str::FromStr, time::Duration cell::RefCell,
collections::{HashMap, HashSet, VecDeque, hash_map::Entry},
fmt::Display,
ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign},
os::unix::fs::PermissionsExt,
str::FromStr,
time::Duration,
}; };
use nix::unistd::{User, gethostname, getppid}; use nix::unistd::{User, gethostname, getppid};
@@ -36,7 +42,7 @@ thread_local! {
pub static SHED: Shed = Shed::new(); pub static SHED: Shed = Shed::new();
} }
#[derive(Clone,Debug)] #[derive(Clone, Debug)]
pub struct Shed { pub struct Shed {
pub jobs: RefCell<JobTab>, pub jobs: RefCell<JobTab>,
pub var_scopes: RefCell<ScopeStack>, pub var_scopes: RefCell<ScopeStack>,
@@ -44,8 +50,8 @@ pub struct Shed {
pub logic: RefCell<LogTab>, pub logic: RefCell<LogTab>,
pub shopts: RefCell<ShOpts>, pub shopts: RefCell<ShOpts>,
#[cfg(test)] #[cfg(test)]
saved: RefCell<Option<Box<Self>>>, saved: RefCell<Option<Box<Self>>>,
} }
impl Shed { impl Shed {
@@ -57,8 +63,8 @@ impl Shed {
logic: RefCell::new(LogTab::new()), logic: RefCell::new(LogTab::new()),
shopts: RefCell::new(ShOpts::default()), shopts: RefCell::new(ShOpts::default()),
#[cfg(test)] #[cfg(test)]
saved: RefCell::new(None), saved: RefCell::new(None),
} }
} }
} }
@@ -71,27 +77,27 @@ impl Default for Shed {
#[cfg(test)] #[cfg(test)]
impl Shed { impl Shed {
pub fn save(&self) { pub fn save(&self) {
let saved = Self { let saved = Self {
jobs: RefCell::new(self.jobs.borrow().clone()), jobs: RefCell::new(self.jobs.borrow().clone()),
var_scopes: RefCell::new(self.var_scopes.borrow().clone()), var_scopes: RefCell::new(self.var_scopes.borrow().clone()),
meta: RefCell::new(self.meta.borrow().clone()), meta: RefCell::new(self.meta.borrow().clone()),
logic: RefCell::new(self.logic.borrow().clone()), logic: RefCell::new(self.logic.borrow().clone()),
shopts: RefCell::new(self.shopts.borrow().clone()), shopts: RefCell::new(self.shopts.borrow().clone()),
saved: RefCell::new(None), saved: RefCell::new(None),
}; };
*self.saved.borrow_mut() = Some(Box::new(saved)); *self.saved.borrow_mut() = Some(Box::new(saved));
} }
pub fn restore(&self) { pub fn restore(&self) {
if let Some(saved) = self.saved.take() { if let Some(saved) = self.saved.take() {
*self.jobs.borrow_mut() = saved.jobs.into_inner(); *self.jobs.borrow_mut() = saved.jobs.into_inner();
*self.var_scopes.borrow_mut() = saved.var_scopes.into_inner(); *self.var_scopes.borrow_mut() = saved.var_scopes.into_inner();
*self.meta.borrow_mut() = saved.meta.into_inner(); *self.meta.borrow_mut() = saved.meta.into_inner();
*self.logic.borrow_mut() = saved.logic.into_inner(); *self.logic.borrow_mut() = saved.logic.into_inner();
*self.shopts.borrow_mut() = saved.shopts.into_inner(); *self.shopts.borrow_mut() = saved.shopts.into_inner();
} }
} }
} }
#[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)] #[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)]
@@ -315,6 +321,34 @@ impl ScopeStack {
}; };
scope.set_var(var_name, val, flags) scope.set_var(var_name, val, flags)
} }
pub fn get_magic_var(&self, var_name: &str) -> Option<String> {
match var_name {
"SECONDS" => {
let shell_time = read_meta(|m| m.shell_time());
let secs = Instant::now().duration_since(shell_time).as_secs();
Some(secs.to_string())
}
"EPOCHREALTIME" => {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs_f64();
Some(epoch.to_string())
}
"EPOCHSECONDS" => {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::from_secs(0))
.as_secs();
Some(epoch.to_string())
}
"RANDOM" => {
let random = rand::random_range(0..32768);
Some(random.to_string())
}
_ => None,
}
}
pub fn get_arr_elems(&self, var_name: &str) -> ShResult<Vec<String>> { pub fn get_arr_elems(&self, var_name: &str) -> ShResult<Vec<String>> {
for scope in self.scopes.iter().rev() { for scope in self.scopes.iter().rev() {
if scope.var_exists(var_name) if scope.var_exists(var_name)
@@ -440,7 +474,9 @@ impl ScopeStack {
pub fn try_get_var(&self, var_name: &str) -> Option<String> { pub fn try_get_var(&self, var_name: &str) -> Option<String> {
// This version of get_var() is mainly used internally // This version of get_var() is mainly used internally
// so that we have access to Option methods // so that we have access to Option methods
if let Ok(param) = var_name.parse::<ShellParam>() { if let Some(magic) = self.get_magic_var(var_name) {
return Some(magic);
} else if let Ok(param) = var_name.parse::<ShellParam>() {
let val = self.get_param(param); let val = self.get_param(param);
if !val.is_empty() { if !val.is_empty() {
return Some(val); return Some(val);
@@ -463,6 +499,9 @@ impl ScopeStack {
var var
} }
pub fn get_var(&self, var_name: &str) -> String { pub fn get_var(&self, var_name: &str) -> String {
if let Some(magic) = self.get_magic_var(var_name) {
return magic;
}
if let Ok(param) = var_name.parse::<ShellParam>() { if let Ok(param) = var_name.parse::<ShellParam>() {
return self.get_param(param); return self.get_param(param);
} }
@@ -495,7 +534,10 @@ impl ScopeStack {
return val.clone(); return val.clone();
} }
// Positional params are scope-local; only check the current scope // Positional params are scope-local; only check the current scope
if matches!(param, ShellParam::Pos(_) | ShellParam::AllArgs | ShellParam::AllArgsStr | ShellParam::ArgCount) { if matches!(
param,
ShellParam::Pos(_) | ShellParam::AllArgs | ShellParam::AllArgsStr | ShellParam::ArgCount
) {
if let Some(scope) = self.scopes.last() { if let Some(scope) = self.scopes.last() {
return scope.get_param(param); return scope.get_param(param);
} }
@@ -954,17 +996,17 @@ impl Display for Var {
} }
impl From<Vec<String>> for Var { impl From<Vec<String>> for Var {
fn from(value: Vec<String>) -> Self { fn from(value: Vec<String>) -> Self {
Self::new(VarKind::Arr(value.into()), VarFlags::NONE) Self::new(VarKind::Arr(value.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();
new.extend(value.iter().cloned()); new.extend(value.iter().cloned());
Self::new(VarKind::Arr(new), VarFlags::NONE) Self::new(VarKind::Arr(new), VarFlags::NONE)
} }
} }
macro_rules! impl_var_from { macro_rules! impl_var_from {
@@ -978,19 +1020,7 @@ macro_rules! impl_var_from {
} }
impl_var_from!( impl_var_from!(
i8, i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, String, &str, bool
i16,
i32,
i64,
isize,
u8,
u16,
u32,
u64,
usize,
String,
&str,
bool
); );
#[derive(Default, Clone, Debug)] #[derive(Default, Clone, Debug)]
@@ -1012,7 +1042,7 @@ impl VarTab {
} }
} }
pub fn new() -> Self { pub fn new() -> Self {
let vars = HashMap::new(); let vars = Self::init_sh_vars();
let params = Self::init_params(); let params = Self::init_params();
Self::init_env(); Self::init_env();
let mut var_tab = Self { let mut var_tab = Self {
@@ -1031,6 +1061,11 @@ impl VarTab {
params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any) params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any)
params params
} }
fn init_sh_vars() -> HashMap<String, Var> {
let mut vars = HashMap::new();
vars.insert("COMP_WORDBREAKS".into(), " \t\n\"'@><=;|&(".into());
vars
}
fn init_env() { fn init_env() {
let pathbuf_to_string = let pathbuf_to_string =
|pb: Result<PathBuf, std::io::Error>| pb.unwrap_or_default().to_string_lossy().to_string(); |pb: Result<PathBuf, std::io::Error>| pb.unwrap_or_default().to_string_lossy().to_string();
@@ -1295,6 +1330,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)
@@ -1305,8 +1349,11 @@ impl VarTab {
} }
/// A table of metadata for the shell /// A table of metadata for the shell
#[derive(Clone, Default, Debug)] #[derive(Clone, Debug)]
pub struct MetaTab { pub struct MetaTab {
// Time when the shell was started, used for calculating shell uptime
shell_time: Instant,
// command running duration // command running duration
runtime_start: Option<Instant>, runtime_start: Option<Instant>,
runtime_stop: Option<Instant>, runtime_stop: Option<Instant>,
@@ -1331,6 +1378,25 @@ pub struct MetaTab {
pending_widget_keys: Vec<KeyEvent>, pending_widget_keys: Vec<KeyEvent>,
} }
impl Default for MetaTab {
fn default() -> Self {
Self {
shell_time: Instant::now(),
runtime_start: None,
runtime_stop: None,
system_msg: vec![],
dir_stack: VecDeque::new(),
getopts_offset: 0,
old_path: None,
old_pwd: None,
path_cache: HashSet::new(),
cwd_cache: HashSet::new(),
comp_specs: HashMap::new(),
pending_widget_keys: vec![],
}
}
}
impl MetaTab { impl MetaTab {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -1338,6 +1404,9 @@ impl MetaTab {
..Default::default() ..Default::default()
} }
} }
pub fn shell_time(&self) -> Instant {
self.shell_time
}
pub fn set_pending_widget_keys(&mut self, keys: &str) { pub fn set_pending_widget_keys(&mut self, keys: &str) {
let exp = expand_keymap(keys); let exp = expand_keymap(keys);
self.pending_widget_keys = exp; self.pending_widget_keys = exp;
@@ -1782,6 +1851,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>()

View File

@@ -1,155 +1,273 @@
use std::{ use std::{
collections::HashMap, collections::{HashMap, HashSet},
env, env,
os::fd::{AsRawFd, OwnedFd}, os::fd::{AsRawFd, BorrowedFd, OwnedFd},
path::PathBuf, path::PathBuf,
sync::{self, MutexGuard}, sync::{self, Arc, MutexGuard},
}; };
use nix::{ use nix::{
fcntl::{FcntlArg, OFlag, fcntl}, fcntl::{FcntlArg, OFlag, fcntl},
pty::openpty, pty::openpty,
sys::termios::{OutputFlags, SetArg, tcgetattr, tcsetattr}, sys::termios::{OutputFlags, SetArg, tcgetattr, tcsetattr},
unistd::read, unistd::read,
}; };
use crate::{ use crate::{
libsh::error::ShResult, expand::expand_aliases,
parse::{Redir, RedirType, execute::exec_input}, libsh::error::ShResult,
procio::{IoFrame, IoMode, RedirGuard}, parse::{ParsedSrc, Redir, RedirType, execute::exec_input, lex::LexFlags},
state::{MetaTab, SHED}, procio::{IoFrame, IoMode, RedirGuard},
readline::register::{restore_registers, save_registers},
state::{MetaTab, SHED, read_logic},
}; };
static TEST_MUTEX: sync::Mutex<()> = sync::Mutex::new(()); static TEST_MUTEX: sync::Mutex<()> = sync::Mutex::new(());
pub fn has_cmds(cmds: &[&str]) -> bool { pub fn has_cmds(cmds: &[&str]) -> bool {
let path_cmds = MetaTab::get_cmds_in_path(); let path_cmds = MetaTab::get_cmds_in_path();
path_cmds.iter().all(|c| cmds.iter().any(|&cmd| c == cmd)) path_cmds.iter().all(|c| cmds.iter().any(|&cmd| c == cmd))
} }
pub fn has_cmd(cmd: &str) -> bool { pub fn has_cmd(cmd: &str) -> bool {
MetaTab::get_cmds_in_path().into_iter().any(|c| c == cmd) MetaTab::get_cmds_in_path().into_iter().any(|c| c == cmd)
} }
pub fn test_input(input: impl Into<String>) -> ShResult<()> { pub fn test_input(input: impl Into<String>) -> ShResult<()> {
exec_input(input.into(), None, false, None) exec_input(input.into(), None, false, None)
} }
pub struct TestGuard { pub struct TestGuard {
_lock: MutexGuard<'static, ()>, _lock: MutexGuard<'static, ()>,
_redir_guard: RedirGuard, _redir_guard: RedirGuard,
old_cwd: PathBuf, old_cwd: PathBuf,
saved_env: HashMap<String, String>, saved_env: HashMap<String, String>,
pty_master: OwnedFd, pty_master: OwnedFd,
_pty_slave: OwnedFd, pty_slave: OwnedFd,
cleanups: Vec<Box<dyn FnOnce()>> cleanups: Vec<Box<dyn FnOnce()>>,
} }
impl TestGuard { impl TestGuard {
pub fn new() -> Self { pub fn new() -> Self {
let _lock = TEST_MUTEX.lock().unwrap(); let _lock = TEST_MUTEX.lock().unwrap();
let pty = openpty(None, None).unwrap(); let pty = openpty(None, None).unwrap();
let (pty_master,pty_slave) = (pty.master, pty.slave); let (pty_master, pty_slave) = (pty.master, pty.slave);
let mut attrs = tcgetattr(&pty_slave).unwrap(); let mut attrs = tcgetattr(&pty_slave).unwrap();
attrs.output_flags &= !OutputFlags::ONLCR; attrs.output_flags &= !OutputFlags::ONLCR;
tcsetattr(&pty_slave, SetArg::TCSANOW, &attrs).unwrap(); tcsetattr(&pty_slave, SetArg::TCSANOW, &attrs).unwrap();
let mut frame = IoFrame::new(); let mut frame = IoFrame::new();
frame.push( frame.push(Redir::new(
Redir::new( IoMode::Fd {
IoMode::Fd { tgt_fd: 0,
tgt_fd: 0, src_fd: pty_slave.as_raw_fd(),
src_fd: pty_slave.as_raw_fd(), },
}, RedirType::Input,
RedirType::Input, ));
), frame.push(Redir::new(
); IoMode::Fd {
frame.push( tgt_fd: 1,
Redir::new( src_fd: pty_slave.as_raw_fd(),
IoMode::Fd { },
tgt_fd: 1, RedirType::Output,
src_fd: pty_slave.as_raw_fd(), ));
}, frame.push(Redir::new(
RedirType::Output, IoMode::Fd {
), tgt_fd: 2,
); src_fd: pty_slave.as_raw_fd(),
frame.push( },
Redir::new( RedirType::Output,
IoMode::Fd { ));
tgt_fd: 2,
src_fd: pty_slave.as_raw_fd(),
},
RedirType::Output,
),
);
let _redir_guard = frame.redirect().unwrap(); let _redir_guard = frame.redirect().unwrap();
let old_cwd = env::current_dir().unwrap(); let old_cwd = env::current_dir().unwrap();
let saved_env = env::vars().collect(); let saved_env = env::vars().collect();
SHED.with(|s| s.save()); SHED.with(|s| s.save());
Self { save_registers();
_lock, Self {
_redir_guard, _lock,
old_cwd, _redir_guard,
saved_env, old_cwd,
pty_master, saved_env,
_pty_slave: pty_slave, pty_master,
cleanups: vec![], pty_slave,
} cleanups: vec![],
} }
}
pub fn add_cleanup(&mut self, f: impl FnOnce() + 'static) { pub fn pty_slave(&self) -> BorrowedFd<'_> {
self.cleanups.push(Box::new(f)); unsafe { BorrowedFd::borrow_raw(self.pty_slave.as_raw_fd()) }
} }
pub fn read_output(&self) -> String { pub fn add_cleanup(&mut self, f: impl FnOnce() + 'static) {
let flags = fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_GETFL).unwrap(); self.cleanups.push(Box::new(f));
let flags = OFlag::from_bits_truncate(flags); }
fcntl(
self.pty_master.as_raw_fd(),
FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK),
).unwrap();
let mut out = vec![]; pub fn read_output(&self) -> String {
let mut buf = [0;4096]; let flags = fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_GETFL).unwrap();
loop { let flags = OFlag::from_bits_truncate(flags);
match read(self.pty_master.as_raw_fd(), &mut buf) { fcntl(
Ok(0) => break, self.pty_master.as_raw_fd(),
Ok(n) => out.extend_from_slice(&buf[..n]), FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK),
Err(_) => break, )
} .unwrap();
}
fcntl( let mut out = vec![];
self.pty_master.as_raw_fd(), let mut buf = [0; 4096];
FcntlArg::F_SETFL(flags), loop {
).unwrap(); match read(self.pty_master.as_raw_fd(), &mut buf) {
Ok(0) => break,
Ok(n) => out.extend_from_slice(&buf[..n]),
Err(_) => break,
}
}
String::from_utf8_lossy(&out).to_string() fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_SETFL(flags)).unwrap();
}
String::from_utf8_lossy(&out).to_string()
}
} }
impl Default for TestGuard { impl Default for TestGuard {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
} }
} }
impl Drop for TestGuard { impl Drop for TestGuard {
fn drop(&mut self) { fn drop(&mut self) {
env::set_current_dir(&self.old_cwd).ok(); env::set_current_dir(&self.old_cwd).ok();
for (k, _) in env::vars() { for (k, _) in env::vars() {
unsafe { env::remove_var(&k); } unsafe {
} env::remove_var(&k);
for (k, v) in &self.saved_env { }
unsafe { env::set_var(k, v); } }
} for (k, v) in &self.saved_env {
for cleanup in self.cleanups.drain(..).rev() { unsafe {
cleanup(); env::set_var(k, v);
} }
SHED.with(|s| s.restore()); }
} for cleanup in self.cleanups.drain(..).rev() {
cleanup();
}
SHED.with(|s| s.restore());
restore_registers();
}
}
pub fn get_ast(input: &str) -> ShResult<Vec<crate::parse::Node>> {
let log_tab = read_logic(|l| l.clone());
let input = expand_aliases(input.into(), HashSet::new(), &log_tab);
let source_name = "test_input".to_string();
let mut parser = ParsedSrc::new(Arc::new(input))
.with_lex_flags(LexFlags::empty())
.with_name(source_name.clone());
parser
.parse_src()
.map_err(|e| e.into_iter().next().unwrap())?;
Ok(parser.extract_nodes())
}
impl crate::parse::Node {
pub fn assert_structure(
&mut self,
expected: &mut impl Iterator<Item = NdKind>,
) -> Result<(), String> {
let mut full_structure = vec![];
let mut before = vec![];
let mut after = vec![];
let mut offender = None;
self.walk_tree(&mut |s| {
let expected_rule = expected.next();
full_structure.push(s.class.as_nd_kind());
if offender.is_none()
&& expected_rule
.as_ref()
.is_none_or(|e| *e != s.class.as_nd_kind())
{
offender = Some((s.class.as_nd_kind(), expected_rule));
} else if offender.is_none() {
before.push(s.class.as_nd_kind());
} else {
after.push(s.class.as_nd_kind());
}
});
assert!(
expected.next().is_none(),
"Expected structure has more nodes than actual structure"
);
if let Some((nd_kind, expected_rule)) = offender {
let expected_rule = expected_rule.map_or("(none — expected array too short)".into(), |e| {
format!("{e:?}")
});
let full_structure_hint = full_structure
.into_iter()
.map(|s| format!("\tNdKind::{s:?},"))
.collect::<Vec<String>>()
.join("\n");
let full_structure_hint =
format!("let expected = &mut [\n{full_structure_hint}\n].into_iter();");
let output = [
"Structure assertion failed!\n".into(),
format!(
"Expected node type '{:?}', found '{:?}'",
expected_rule, nd_kind
),
format!("Before offender: {:?}", before),
format!("After offender: {:?}\n", after),
format!("hint: here is the full structure as an array\n {full_structure_hint}"),
]
.join("\n");
Err(output)
} else {
Ok(())
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum NdKind {
IfNode,
LoopNode,
ForNode,
CaseNode,
Command,
Pipeline,
Conjunction,
Assignment,
BraceGrp,
Negate,
Test,
FuncDef,
}
impl crate::parse::NdRule {
pub fn as_nd_kind(&self) -> NdKind {
match self {
Self::Negate { .. } => NdKind::Negate,
Self::IfNode { .. } => NdKind::IfNode,
Self::LoopNode { .. } => NdKind::LoopNode,
Self::ForNode { .. } => NdKind::ForNode,
Self::CaseNode { .. } => NdKind::CaseNode,
Self::Command { .. } => NdKind::Command,
Self::Pipeline { .. } => NdKind::Pipeline,
Self::Conjunction { .. } => NdKind::Conjunction,
Self::Assignment { .. } => NdKind::Assignment,
Self::BraceGrp { .. } => NdKind::BraceGrp,
Self::Test { .. } => NdKind::Test,
Self::FuncDef { .. } => NdKind::FuncDef,
}
}
} }

319
tests/gen_vi_tests.lua Normal file
View File

@@ -0,0 +1,319 @@
-- Generate Rust vi_test! macro invocations using neovim as oracle
-- Usage: nvim --headless --clean -l tests/gen_vi_tests.lua
--
-- Define test cases as { name, input_text, key_sequence }
-- Key sequences use vim notation: <Esc>, <CR>, <C-w>, etc.
-- The script executes each in a fresh buffer and captures the result.
local tests = {
-- ===================== basic char motions =====================
{ "dw_basic", "hello world", "dw" },
{ "dw_middle", "one two three", "wdw" },
{ "dd_whole_line", "hello world", "dd" },
{ "x_single", "hello", "x" },
{ "x_middle", "hello", "llx" },
{ "X_backdelete", "hello", "llX" },
{ "h_motion", "hello", "$h" },
{ "l_motion", "hello", "l" },
{ "h_at_start", "hello", "h" },
{ "l_at_end", "hello", "$l" },
-- ===================== word motions (small) =====================
{ "w_forward", "one two three", "w" },
{ "b_backward", "one two three", "$b" },
{ "e_end", "one two three", "e" },
{ "ge_back_end", "one two three", "$ge" },
{ "w_punctuation", "foo.bar baz", "w" },
{ "e_punctuation", "foo.bar baz", "e" },
{ "b_punctuation", "foo.bar baz", "$b" },
{ "w_at_eol", "hello", "$w" },
{ "b_at_bol", "hello", "b" },
-- ===================== word motions (big) =====================
{ "W_forward", "foo.bar baz", "W" },
{ "B_backward", "foo.bar baz", "$B" },
{ "E_end", "foo.bar baz", "E" },
{ "gE_back_end", "one two three", "$gE" },
{ "W_skip_punct", "one-two three", "W" },
{ "B_skip_punct", "one two-three", "$B" },
{ "E_skip_punct", "one-two three", "E" },
{ "dW_big", "foo.bar baz", "dW" },
{ "cW_big", "foo.bar baz", "cWx<Esc>" },
-- ===================== line motions =====================
{ "zero_bol", " hello", "$0" },
{ "caret_first_char", " hello", "$^" },
{ "dollar_eol", "hello world", "$" },
{ "g_last_nonws", "hello ", "g_" },
{ "g_no_trailing", "hello", "g_" },
{ "pipe_column", "hello world", "6|" },
{ "pipe_col1", "hello world", "1|" },
{ "I_insert_front", " hello", "Iworld <Esc>" },
{ "A_append_end", "hello", "A world<Esc>" },
-- ===================== find motions =====================
{ "f_find", "hello world", "fo" },
{ "F_find_back", "hello world", "$Fo" },
{ "t_till", "hello world", "tw" },
{ "T_till_back", "hello world", "$To" },
{ "f_no_match", "hello", "fz" },
{ "semicolon_repeat", "abcabc", "fa;;" },
{ "comma_reverse", "abcabc", "fa;;," },
{ "df_semicolon", "abcabc", "fa;;dfa" },
{ "t_at_target", "aab", "lta" },
-- ===================== delete operations =====================
{ "D_to_end", "hello world", "wD" },
{ "d_dollar", "hello world", "wd$" },
{ "d0_to_start", "hello world", "$d0" },
{ "dw_multiple", "one two three", "d2w" },
{ "dt_char", "hello world", "dtw" },
{ "df_char", "hello world", "dfw" },
{ "dh_back", "hello", "lldh" },
{ "dl_forward", "hello", "dl" },
{ "dge_back_end", "one two three", "$dge" },
{ "dG_to_end", "hello world", "dG" },
{ "dgg_to_start", "hello world", "$dgg" },
{ "d_semicolon", "abcabc", "fad;" },
-- ===================== change operations =====================
{ "cw_basic", "hello world", "cwfoo<Esc>" },
{ "C_to_end", "hello world", "wCfoo<Esc>" },
{ "cc_whole", "hello world", "ccfoo<Esc>" },
{ "ct_char", "hello world", "ctwfoo<Esc>" },
{ "s_single", "hello", "sfoo<Esc>" },
{ "S_whole_line", "hello world", "Sfoo<Esc>" },
{ "cl_forward", "hello", "clX<Esc>" },
{ "ch_backward", "hello", "llchX<Esc>" },
{ "cb_word_back", "hello world", "$cbfoo<Esc>" },
{ "ce_word_end", "hello world", "cefoo<Esc>" },
{ "c0_to_start", "hello world", "wc0foo<Esc>" },
-- ===================== yank and paste =====================
{ "yw_p_basic", "hello world", "ywwP" },
{ "dw_p_paste", "hello world", "dwP" },
{ "dd_p_paste", "hello world", "ddp" },
{ "y_dollar_p", "hello world", "wy$P" },
{ "ye_p", "hello world", "yewP" },
{ "yy_p", "hello world", "yyp" },
{ "Y_p", "hello world", "Yp" },
{ "p_after_x", "hello", "xp" },
{ "P_before", "hello", "llxP" },
{ "paste_empty", "hello", "p" },
-- ===================== replace =====================
{ "r_replace", "hello", "ra" },
{ "r_middle", "hello", "llra" },
{ "r_at_end", "hello", "$ra" },
{ "r_space", "hello", "r " },
{ "r_with_count", "hello", "3rx" },
-- ===================== case operations =====================
{ "tilde_single", "hello", "~" },
{ "tilde_count", "hello", "3~" },
{ "tilde_at_end", "HELLO", "$~" },
{ "tilde_mixed", "hElLo", "5~" },
{ "gu_word", "HELLO world", "guw" },
{ "gU_word", "hello WORLD", "gUw" },
{ "gu_dollar", "HELLO WORLD", "gu$" },
{ "gU_dollar", "hello world", "gU$" },
{ "gu_0", "HELLO WORLD", "$gu0" },
{ "gU_0", "hello world", "$gU0" },
{ "gtilde_word", "hello WORLD", "g~w" },
{ "gtilde_dollar", "hello WORLD", "g~$" },
-- ===================== text objects: word =====================
{ "diw_inner", "one two three", "wdiw" },
{ "ciw_replace", "hello world", "ciwfoo<Esc>" },
{ "daw_around", "one two three", "wdaw" },
{ "yiw_p", "hello world", "yiwAp <Esc>p" },
{ "diW_big_inner", "one-two three", "diW" },
{ "daW_big_around", "one two-three end", "wdaW" },
{ "ciW_big", "one-two three", "ciWx<Esc>" },
-- ===================== text objects: quotes =====================
{ "di_dquote", 'one "two" three', 'f"di"' },
{ "da_dquote", 'one "two" three', 'f"da"' },
{ "ci_dquote", 'one "two" three', 'f"ci"x<Esc>' },
{ "di_squote", "one 'two' three", "f'di'" },
{ "da_squote", "one 'two' three", "f'da'" },
{ "di_backtick", "one `two` three", "f`di`" },
{ "da_backtick", "one `two` three", "f`da`" },
{ "ci_dquote_empty", 'one "" three', 'f"ci"x<Esc>' },
-- ===================== text objects: delimiters =====================
{ "di_paren", "one (two) three", "f(di(" },
{ "da_paren", "one (two) three", "f(da(" },
{ "ci_paren", "one (two) three", "f(ci(x<Esc>" },
{ "di_brace", "one {two} three", "f{di{" },
{ "da_brace", "one {two} three", "f{da{" },
{ "di_bracket", "one [two] three", "f[di[" },
{ "da_bracket", "one [two] three", "f[da[" },
{ "di_angle", "one <two> three", "f<di<" },
{ "da_angle", "one <two> three", "f<da<" },
{ "di_paren_nested", "fn(a, (b, c))", "f(di(" },
{ "di_paren_empty", "fn() end", "f(di(" },
{ "dib_alias", "one (two) three", "f(dib" },
{ "diB_alias", "one {two} three", "f{diB" },
-- ===================== delimiter matching =====================
{ "percent_paren", "(hello) world", "%" },
{ "percent_brace", "{hello} world", "%" },
{ "percent_bracket", "[hello] world", "%" },
{ "percent_from_close", "(hello) world", "f)%" },
{ "d_percent_paren", "(hello) world", "d%" },
-- ===================== insert mode entry =====================
{ "i_insert", "hello", "iX<Esc>" },
{ "a_append", "hello", "aX<Esc>" },
{ "I_front", " hello", "IX<Esc>" },
{ "A_end", "hello", "AX<Esc>" },
{ "o_open_below", "hello", "oworld<Esc>" },
{ "O_open_above", "hello", "Oworld<Esc>" },
-- ===================== insert mode operations =====================
{ "empty_input", "", "i hello<Esc>" },
{ "insert_escape", "hello", "aX<Esc>" },
{ "ctrl_w_del_word", "hello world", "A<C-w><Esc>" },
{ "ctrl_h_backspace", "hello", "A<C-h><Esc>" },
-- ===================== undo / redo =====================
{ "u_undo_delete", "hello world", "dwu" },
{ "u_undo_change", "hello world", "ciwfoo<Esc>u" },
{ "u_undo_x", "hello", "xu" },
{ "ctrl_r_redo", "hello", "xu<C-r>" },
{ "u_multiple", "hello world", "xdwu" },
{ "redo_after_undo", "hello world", "dwu<C-r>" },
-- ===================== dot repeat =====================
{ "dot_repeat_x", "hello", "x." },
{ "dot_repeat_dw", "one two three", "dw." },
{ "dot_repeat_cw", "one two three", "cwfoo<Esc>w." },
{ "dot_repeat_r", "hello", "ra.." },
{ "dot_repeat_s", "hello", "sX<Esc>l." },
-- ===================== counts =====================
{ "count_h", "hello world", "$3h" },
{ "count_l", "hello world", "3l" },
{ "count_w", "one two three four", "2w" },
{ "count_b", "one two three four", "$2b" },
{ "count_x", "hello", "3x" },
{ "count_dw", "one two three four", "2dw" },
{ "verb_count_motion", "one two three four", "d2w" },
{ "count_s", "hello", "3sX<Esc>" },
-- ===================== indent / dedent =====================
{ "indent_line", "hello", ">>" },
{ "dedent_line", "\thello", "<<" },
{ "indent_double", "hello", ">>>>" },
-- ===================== join =====================
{ "J_join_lines", "hello\nworld", "J" },
-- ===================== case in visual =====================
{ "v_u_lower", "HELLO", "vlllu" },
{ "v_U_upper", "hello", "vlllU" },
-- ===================== visual mode =====================
{ "v_d_delete", "hello world", "vwwd" },
{ "v_x_delete", "hello world", "vwwx" },
{ "v_c_change", "hello world", "vwcfoo<Esc>" },
{ "v_y_p_yank", "hello world", "vwyAp <Esc>p" },
{ "v_dollar_d", "hello world", "wv$d" },
{ "v_0_d", "hello world", "$v0d" },
{ "ve_d", "hello world", "ved" },
{ "v_o_swap", "hello world", "vllod" },
{ "v_r_replace", "hello", "vlllrx" },
{ "v_tilde_case", "hello", "vlll~" },
-- ===================== visual line mode =====================
{ "V_d_delete", "hello world", "Vd" },
{ "V_y_p", "hello world", "Vyp" },
{ "V_S_change", "hello world", "VSfoo<Esc>" },
-- ===================== increment / decrement =====================
{ "ctrl_a_inc", "num 5 end", "w<C-a>" },
{ "ctrl_x_dec", "num 5 end", "w<C-x>" },
{ "ctrl_a_negative", "num -3 end", "w<C-a>" },
{ "ctrl_x_to_neg", "num 0 end", "w<C-x>" },
{ "ctrl_a_count", "num 5 end", "w3<C-a>" },
-- ===================== misc / edge cases =====================
{ "delete_empty", "", "x" },
{ "undo_on_empty", "", "u" },
{ "w_single_char", "a b c", "w" },
{ "dw_last_word", "hello", "dw" },
{ "dollar_single", "h", "$" },
{ "caret_no_ws", "hello", "$^" },
{ "f_last_char", "hello", "fo" },
{ "r_on_space", "hello world", "5|r-" },
}
-- Map vim special key names to Rust string escape sequences
local key_to_bytes = {
["<Esc>"] = "\\x1b",
["<CR>"] = "\\r",
["<BS>"] = "\\x7f",
["<Tab>"] = "\\t",
["<Del>"] = "\\x1b[3~",
["<Up>"] = "\\x1b[A",
["<Down>"] = "\\x1b[B",
["<Right>"] = "\\x1b[C",
["<Left>"] = "\\x1b[D",
["<Home>"] = "\\x1b[H",
["<End>"] = "\\x1b[F",
}
-- Convert vim key notation to Rust string escape sequences
local function keys_to_rust(keys)
local result = keys
result = result:gsub("<C%-(.)>", function(ch)
local byte = string.byte(ch:lower()) - string.byte('a') + 1
return string.format("\\x%02x", byte)
end)
for name, bytes in pairs(key_to_bytes) do
result = result:gsub(vim.pesc(name), bytes)
end
return result
end
-- Escape a string for use in a Rust string literal
local function rust_escape(s)
return s:gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\t", "\\t")
end
io.write("vi_test! {\n")
for i, test in ipairs(tests) do
local name, input, keys = test[1], test[2], test[3]
-- Fresh buffer and register state
local input_lines = vim.split(input, "\n", { plain = true })
vim.api.nvim_buf_set_lines(0, 0, -1, false, input_lines)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
vim.fn.setreg('"', '')
-- Execute the key sequence synchronously
local translated = vim.api.nvim_replace_termcodes(keys, true, false, true)
vim.api.nvim_feedkeys(translated, "ntx", false)
vim.api.nvim_exec_autocmds("CursorMoved", {})
-- Capture result
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local result = table.concat(lines, "\n")
local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
local rust_keys = keys_to_rust(keys)
local rust_input = rust_escape(input)
local rust_result = rust_escape(result)
local sep = ";"
if i == #tests then sep = "" end
io.write(string.format('\tvi_%s: "%s" => "%s" => "%s", %d%s\n',
name, rust_input, rust_keys, rust_result, cursor_col, sep))
end
io.write("}\n")
vim.cmd("qa!")