diff --git a/README.md b/README.md index cc27592..3f8e84b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # shed -A Unix shell written in Rust. The name is a nod to the two oldest Unix utilities — `sh` and `ed` — reflecting the shell's emphasis on a capable built-in line editor alongside standard shell functionality. +A Unix shell written in Rust. The name is a nod to both `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing. ## Features ### Line Editor -`shed` includes a built-in `vim` emulator as its line editor, written from scratch — not a readline wrapper or external library. 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. -- **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 -- **Visual mode** — character-wise selection with operator support -- **Real-time syntax highlighting** — commands, keywords, strings, variables, redirections, and operators are colored as you type -- **Tab completion** — context-aware completion for commands, file paths, and variables +- **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 +- **Visual mode** - character-wise selection with operator support +- **Real-time syntax highlighting** - commands, keywords, strings, variables, redirections, and operators are colored as you type +- **Tab completion** - context-aware completion for commands, file paths, and variables ### Prompt @@ -29,7 +29,7 @@ The prompt string supports escape sequences for dynamic content: | `\e[...` | ANSI escape sequences for colors and styling | | `\!name` | Execute a shell function and embed its output | -The `\!` escape is particularly useful — it lets you embed the output of any shell function directly in your prompt. Define a function that prints something, then reference it in your prompt string: +The `\!` escape is particularly useful. It lets you embed the output of any shell function directly in your prompt. Define a function that prints something, then reference it in your prompt string: ```sh gitbranch() { git branch --show-current 2>/dev/null; } @@ -42,20 +42,20 @@ Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, s shed's scripting language contains all of the essentials. -- **Control flow** — `if`/`elif`/`else`, `for`, `while`, `until`, `case` with pattern matching and fallthrough -- **Functions** — user-defined with local variable scoping, recursion depth limits, and `return` -- **Pipes and redirections** — `|`, `|&` (pipe stderr), `<`, `>`, `>>`, `<<` (heredoc), `<<<` (herestring), fd duplication (`2>&1`) -- **Process substitution** — `<(...)` and `>(...)` -- **Command substitution** — `$(...)` and backticks -- **Arithmetic expansion** — `$((...))` with `+`, `-`, `*`, `/`, `%`, `**` -- **Parameter expansion** — `${var}`, `${var:-default}`, `${var:=default}`, `${var:+alt}`, `${var:?err}`, `${#var}`, `${var#pattern}`, `${var%pattern}`, `${var/pat/rep}` -- **Brace expansion** — `{a,b,c}`, `{1..10}`, `{1..10..2}` -- **Glob expansion** — `*`, `?`, `[...]` with optional dotglob -- **Tilde expansion** — `~` and `~user` -- **Logical operators** — `&&`, `||`, `&` (background) -- **Test expressions** — `[[ ... ]]` with file tests, string comparison, arithmetic comparison, and regex matching -- **Subshells** — `(...)` for isolated execution -- **Variable attributes** — `export`, `local`, `readonly` +- **Control flow** - `if`/`elif`/`else`, `for`, `while`, `until`, `case` with pattern matching and fallthrough +- **Functions** - user-defined with local variable scoping, recursion depth limits, and `return` +- **Pipes and redirections** - `|`, `|&` (pipe stderr), `<`, `>`, `>>`, `<<` (heredoc), `<<<` (herestring), fd duplication (`2>&1`) +- **Process substitution** - `<(...)` and `>(...)` +- **Command substitution** - `$(...)` and backticks +- **Arithmetic expansion** - `$((...))` with `+`, `-`, `*`, `/`, `%`, `**` +- **Parameter expansion** - `${var}`, `${var:-default}`, `${var:=default}`, `${var:+alt}`, `${var:?err}`, `${#var}`, `${var#pattern}`, `${var%pattern}`, `${var/pat/rep}` +- **Brace expansion** - `{a,b,c}`, `{1..10}`, `{1..10..2}` +- **Glob expansion** - `*`, `?`, `[...]` with optional dotglob +- **Tilde expansion** - `~` and `~user` +- **Logical operators** - `&&`, `||`, `&` (background) +- **Test expressions** - `[[ ... ]]` with file tests, string comparison, arithmetic comparison, and regex matching +- **Subshells** - `(...)` for isolated execution +- **Variable attributes** - `export`, `local`, `readonly` ### Job Control @@ -126,8 +126,8 @@ imports = [ shed.homeModules.shed ]; ## Status -`shed` is experimental software and is currently under active development. It covers most day-to-day interactive shell usage and a good portion of POSIX shell scripting, but it is not yet fully POSIX-compliant. +`shed` is experimental software and is currently under active development. It covers most day-to-day interactive shell usage and a good portion of POSIX shell scripting, but it is not yet fully POSIX-compliant. There is no guarantee that your computer will not explode when you run this. Use it at your own risk, the software is provided as-is. ## Why shed? -This originally started as an educational hobby project, but over the course of about a year or so it's taken the form of an actual daily-drivable shell. I mainly wanted to create a shell where line editing is more frictionless than standard choices. I use vim a lot so I've built up a lot of muscle memory, and a fair amount of that muscle memory does not apply to vi modes in `bash`/`zsh`. For instance, the standard vi mode in `zsh` does not support selection via text objects. I wanted to create a line editor that includes even the obscure stuff like 'g?'. +This originally started as an educational hobby project, but over the course of about a year or so it's taken the form of an actual daily-drivable shell. I mainly wanted to create a shell where line editing is more frictionless than standard choices. I use vim a lot so I've built up a lot of muscle memory, and a fair amount of that muscle memory does not apply to vi modes in `bash`/`zsh`. For instance, the standard vi mode in `zsh` does not support selection via text objects. I wanted to create a line editor that actually feels like you're in an editor. diff --git a/src/parse/execute.rs b/src/parse/execute.rs index bbe9bcb..d61b41d 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -691,7 +691,6 @@ impl Dispatcher { if fork_builtin { cmd.flags |= NdFlags::FORK_BUILTINS; } - log::debug!("current io_frame stack: {:#?}", self.io_stack.curr_frame()); self.dispatch_node(cmd)?; } let job = self.job_stack.finalize_job().unwrap(); @@ -812,6 +811,7 @@ impl Dispatcher { let no_fork = cmd.flags.contains(NdFlags::NO_FORK); if argv.is_empty() { + state::set_status(0); return Ok(()); } diff --git a/src/prompt/readline/linebuf.rs b/src/prompt/readline/linebuf.rs index 030c315..91032d9 100644 --- a/src/prompt/readline/linebuf.rs +++ b/src/prompt/readline/linebuf.rs @@ -2329,7 +2329,14 @@ impl LineBuf { end = self.cursor.get(); } }, - SelectMode::Line(anchor) => todo!(), + SelectMode::Line(anchor) => match anchor { + SelectAnchor::Start => { + start = self.start_of_line(); + } + SelectAnchor::End => { + end = self.end_of_line(); + } + } SelectMode::Block(anchor) => todo!(), } if start >= end { @@ -3008,11 +3015,13 @@ impl LineBuf { } pub fn get_hint_text(&self) -> String { - self + let text = self .hint .clone() - .map(|h| h.styled(Style::BrightBlack)) - .unwrap_or_default() + .map(|h| format!("\x1b[90m{h}\x1b[0m")) + .unwrap_or_default(); + + text.replace("\n", "\n\x1b[90m") } } diff --git a/src/prompt/readline/mod.rs b/src/prompt/readline/mod.rs index 9979bc7..7fee801 100644 --- a/src/prompt/readline/mod.rs +++ b/src/prompt/readline/mod.rs @@ -513,7 +513,6 @@ impl ShedVi { let new_layout = self.get_layout(&line); let pending_seq = self.mode.pending_seq(); let mut prompt_string_right = self.prompt.psr_expanded.clone(); - log::debug!("prompt_string_right before truncation: {prompt_string_right:?}"); if prompt_string_right.as_ref().is_some_and(|psr| psr.lines().count() > 1) { log::warn!("PSR has multiple lines, truncating to one line"); @@ -524,7 +523,7 @@ impl ShedVi { .get_ps1() .lines() .next() - .map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 })) + .map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 }, 0)) .map(|p| p.col) .unwrap_or_default() as usize; let one_line = new_layout.end.row == 0; @@ -537,7 +536,7 @@ impl ShedVi { self.writer.redraw(self.prompt.get_ps1(), &line, &new_layout)?; let seq_fits = pending_seq.as_ref().is_some_and(|seq| row0_used + 1 < self.writer.t_cols as usize - seq.width()); - let psr_fits = prompt_string_right.as_ref().is_some_and(|psr| new_layout.end.col as usize + 1 < self.writer.t_cols as usize - psr.width()); + let psr_fits = prompt_string_right.as_ref().is_some_and(|psr| new_layout.end.col as usize + 1 < (self.writer.t_cols as usize).saturating_sub(psr.width())); if !final_draw && let Some(seq) = pending_seq && !seq.is_empty() && !(prompt_string_right.is_some() && one_line) && seq_fits { let to_col = self.writer.t_cols - calc_str_width(&seq); diff --git a/src/prompt/readline/term.rs b/src/prompt/readline/term.rs index 66b8525..47532b5 100644 --- a/src/prompt/readline/term.rs +++ b/src/prompt/readline/term.rs @@ -100,6 +100,38 @@ pub fn get_win_size(fd: RawFd) -> (Col, Row) { } } +fn enumerate_lines(s: &str, left_pad: usize) -> String { + let total_lines = s.lines().count(); + let max_num_len = total_lines.to_string().len(); + s.lines() + .enumerate() + .fold(String::new(), |mut acc, (i, ln)| { + if i == 0 { + acc.push_str(ln); + acc.push('\n'); + } else { + let num = (i + 1).to_string(); + let num_pad = max_num_len - num.len(); + // " 2 | " — num + padding + " | " + let prefix_len = max_num_len + 3; // "N | " + let trail_pad = left_pad.saturating_sub(prefix_len); + if i == total_lines - 1 { + // Don't add a newline to the last line + write!(acc, "\x1b[90m{}{num} |\x1b[0m {}{ln}", + " ".repeat(num_pad), + " ".repeat(trail_pad), + ).unwrap(); + } else { + writeln!(acc, "\x1b[90m{}{num} |\x1b[0m {}{ln}", + " ".repeat(num_pad), + " ".repeat(trail_pad), + ).unwrap(); + } + } + acc + }) +} + fn write_all(fd: RawFd, buf: &str) -> nix::Result<()> { let mut bytes = buf.as_bytes(); while !bytes.is_empty() { @@ -771,9 +803,9 @@ impl Layout { } } pub fn from_parts(term_width: u16, prompt: &str, to_cursor: &str, to_end: &str) -> Self { - let prompt_end = Self::calc_pos(term_width, prompt, Pos { col: 0, row: 0 }); - let cursor = Self::calc_pos(term_width, to_cursor, prompt_end); - let end = Self::calc_pos(term_width, to_end, prompt_end); + let prompt_end = Self::calc_pos(term_width, prompt, Pos { col: 0, row: 0 }, 0); + let cursor = Self::calc_pos(term_width, to_cursor, prompt_end, prompt_end.col); + let end = Self::calc_pos(term_width, to_end, prompt_end, prompt_end.col); Layout { w_calc: width_calculator(), prompt_end, @@ -782,14 +814,14 @@ impl Layout { } } - pub fn calc_pos(term_width: u16, s: &str, orig: Pos) -> Pos { + pub fn calc_pos(term_width: u16, s: &str, orig: Pos, left_margin: u16) -> Pos { const TAB_STOP: u16 = 8; let mut pos = orig; let mut esc_seq = 0; for c in s.graphemes(true) { if c == "\n" { pos.row += 1; - pos.col = 0; + pos.col = left_margin; } let c_width = if c == "\t" { TAB_STOP - (pos.col % TAB_STOP) @@ -799,12 +831,12 @@ impl Layout { pos.col += c_width; if pos.col > term_width { pos.row += 1; - pos.col = c_width; + pos.col = left_margin + c_width; } } if pos.col >= term_width { pos.row += 1; - pos.col = 0; + pos.col = left_margin; } pos @@ -987,7 +1019,14 @@ impl LineWriter for TermWriter { } self.buffer.push_str(prompt); - self.buffer.push_str(line); + let multiline = line.contains('\n'); + if multiline { + let prompt_end = Layout::calc_pos(self.t_cols, prompt, Pos { col: 0, row: 0 }, 0); + let display_line = enumerate_lines(line, prompt_end.col as usize); + self.buffer.push_str(&display_line); + } else { + self.buffer.push_str(line); + } if end.col == 0 && end.row > 0 && !ends_with_newline(&self.buffer) { // The line has wrapped. We need to use our own line break. diff --git a/src/prompt/readline/vimode.rs b/src/prompt/readline/vimode.rs index 0d438d5..3d5987f 100644 --- a/src/prompt/readline/vimode.rs +++ b/src/prompt/readline/vimode.rs @@ -1041,6 +1041,15 @@ impl ViMode for ViNormal { self.clear_cmd(); None } + E(K::Char('V'), M::SHIFT) => { + Some(ViCmd { + register: Default::default(), + verb: Some(VerbCmd(1, Verb::VisualModeLine)), + motion: None, + raw_seq: "".into(), + flags: self.flags() | CmdFlags::VISUAL_LINE, + }) + } _ => { if let Some(cmd) = common_cmds(key) { self.clear_cmd();