Various line editor fixes and optimizations

This commit is contained in:
2026-02-25 15:43:08 -05:00
parent 415c9b4a53
commit 28ce008234
18 changed files with 359 additions and 152 deletions

View File

@@ -7,7 +7,7 @@ use std::{
use crate::{
libsh::term::{Style, StyleSet, Styled},
prompt::readline::{annotate_input, markers::{self, is_marker}},
state::{read_logic, read_shopts},
state::{read_logic, read_meta, read_shopts},
};
/// Syntax highlighter for shell input using Unicode marker-based annotation
@@ -173,7 +173,6 @@ impl Highlighter {
}
cmd_name.push(ch);
}
log::debug!("Command name: '{}'", Self::strip_markers(&cmd_name));
let style = if matches!(Self::strip_markers(&cmd_name).as_str(), "break" | "continue" | "return") {
Style::Magenta.into()
} else if Self::is_valid(&Self::strip_markers(&cmd_name)) {
@@ -291,54 +290,35 @@ impl Highlighter {
/// 2. All directories in PATH environment variable
/// 3. Shell functions and aliases in the current shell state
fn is_valid(command: &str) -> bool {
let path = env::var("PATH").unwrap_or_default();
let paths = path.split(':');
let cmd_path = PathBuf::from(&command);
let cmd_path = Path::new(&command);
if cmd_path.exists() {
if cmd_path.is_absolute() {
// the user has given us an absolute path
if cmd_path.is_dir() && read_shopts(|o| o.core.autocd) {
// this is a directory and autocd is enabled
return true;
true
} else {
let Ok(meta) = cmd_path.metadata() else {
return false;
};
// this is a file that is executable by someone
return meta.permissions().mode() & 0o111 == 0;
meta.permissions().mode() & 0o111 != 0
}
} else {
// they gave us a command name
// now we must traverse the PATH env var
// and see if we find any matches
for path in paths {
let path = PathBuf::from(path).join(command);
if path.exists() {
let Ok(meta) = path.metadata() else { continue };
return meta.permissions().mode() & 0o111 != 0;
}
}
// also check shell functions and aliases for any matches
let found = read_logic(|l| l.get_func(command).is_some() || l.get_alias(command).is_some());
if found {
return true;
}
}
false
read_meta(|m| m.cached_cmds().get(command).is_some())
}
}
fn is_filename(arg: &str) -> bool {
let path = PathBuf::from(arg);
let path = Path::new(arg);
if path.exists() {
if path.is_absolute() && path.exists() {
return true;
}
if let Some(parent_dir) = path.parent()
&& let Ok(entries) = parent_dir.read_dir()
{
if path.is_absolute()
&& let Some(parent_dir) = path.parent()
&& let Ok(entries) = parent_dir.read_dir() {
let files = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
@@ -354,22 +334,17 @@ impl Highlighter {
return true;
}
}
};
}
if let Ok(this_dir) = env::current_dir()
&& let Ok(entries) = this_dir.read_dir()
{
let this_dir_files = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>();
for file in this_dir_files {
if file.starts_with(arg) {
return true;
}
}
};
false
read_meta(|m| {
let files = m.cwd_cache();
for file in files {
if file.starts_with(arg) {
return true;
}
}
false
})
}
/// Emits a reset ANSI code to the output, with deduplication

View File

@@ -496,6 +496,12 @@ impl LineBuf {
pub fn grapheme_at_cursor(&mut self) -> Option<&str> {
self.grapheme_at(self.cursor.get())
}
pub fn grapheme_before_cursor(&mut self) -> Option<&str> {
if self.cursor.get() == 0 {
return None;
}
self.grapheme_at(self.cursor.ret_sub(1))
}
pub fn mark_insert_mode_start_pos(&mut self) {
self.insert_mode_start_pos = Some(self.cursor.get())
}
@@ -1884,7 +1890,11 @@ impl LineBuf {
self.buffer.replace_range(start..end, new);
}
pub fn calc_indent_level(&mut self) {
let input = Arc::new(self.buffer.clone());
let to_cursor = self
.slice_to_cursor()
.map(|s| s.to_string())
.unwrap_or(self.buffer.clone());
let input = Arc::new(to_cursor);
let Ok(tokens) = LexStream::new(input, LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<Tk>>>() else {
log::error!("Failed to lex buffer for indent calculation");
return;
@@ -1914,8 +1924,10 @@ impl LineBuf {
}
let eval = match motion {
MotionCmd(count, Motion::WholeLine) => {
let Some((start, end)) = (if count == 1 {
MotionCmd(count, motion @ (Motion::WholeLineInclusive | Motion::WholeLineExclusive)) => {
let exclusive = matches!(motion, Motion::WholeLineExclusive);
let Some((start, mut end)) = (if count == 1 {
Some(self.this_line())
} else {
self.select_lines_down(count)
@@ -1923,6 +1935,10 @@ impl LineBuf {
return MotionKind::Null;
};
if exclusive && self.grapheme_before(end).is_some_and(|gr| gr == "\n") {
end = end.saturating_sub(1);
}
let target_col = if let Some(col) = self.saved_col {
col
} else {
@@ -1938,6 +1954,7 @@ impl LineBuf {
if self.cursor.exclusive
&& line.ends_with("\n")
&& self.grapheme_at(target_pos) == Some("\n")
&& line != "\n" // Allow landing on newline for empty lines
{
target_pos = target_pos.saturating_sub(1); // Don't land on the
// newline
@@ -2098,7 +2115,7 @@ impl LineBuf {
MotionCmd(_, Motion::BeginningOfLine) => MotionKind::On(self.start_of_line()),
MotionCmd(count, Motion::EndOfLine) => {
let pos = if count == 1 {
self.end_of_line()
self.end_of_line()
} else if let Some((_, end)) = self.select_lines_down(count) {
end
} else {
@@ -2171,12 +2188,14 @@ impl LineBuf {
};
let Some(line) = self.slice(start..end).map(|s| s.to_string()) else {
log::warn!("Failed to get line slice for motion, start: {start}, end: {end}");
return MotionKind::Null;
};
let mut target_pos = self.grapheme_index_for_display_col(&line, target_col);
if self.cursor.exclusive
&& line.ends_with("\n")
&& self.grapheme_at(target_pos) == Some("\n")
&& line != "\n" // Allow landing on newline for empty lines
{
target_pos = target_pos.saturating_sub(1); // Don't land on the
// newline
@@ -2188,6 +2207,7 @@ impl LineBuf {
_ => unreachable!(),
};
MotionKind::InclusiveWithTargetCol((start, end), target_pos)
}
MotionCmd(count, Motion::LineDownCharwise) | MotionCmd(count, Motion::LineUpCharwise) => {
@@ -2412,9 +2432,15 @@ impl LineBuf {
) -> ShResult<()> {
match verb {
Verb::Delete | Verb::Yank | Verb::Change => {
let Some((start, end)) = self.range_from_motion(&motion) else {
let Some((mut start, mut end)) = self.range_from_motion(&motion) else {
return Ok(());
};
let mut do_indent = false;
if verb == Verb::Change && (start,end) == self.this_line() {
do_indent = read_shopts(|o| o.prompt.auto_indent);
}
let register_text = if verb == Verb::Yank {
self
.slice(start..end)
@@ -2426,15 +2452,18 @@ impl LineBuf {
drained
};
register.write_to_register(register_text);
match motion {
MotionKind::ExclusiveWithTargetCol((_, _), pos)
| MotionKind::InclusiveWithTargetCol((_, _), pos) => {
let (start, end) = self.this_line();
self.cursor.set(start);
self.cursor.add(end.min(pos));
}
_ => self.cursor.set(start),
}
self.cursor.set(start);
if do_indent {
self.calc_indent_level();
let tabs = (0..self.auto_indent_level).map(|_| '\t');
for tab in tabs {
self.insert_at_cursor(tab);
self.cursor.add(1);
}
} else if verb != Verb::Change
&& let MotionKind::InclusiveWithTargetCol((_,_), col) = motion {
self.cursor.add(col);
}
}
Verb::Rot13 => {
let Some((start, end)) = self.range_from_motion(&motion) else {
@@ -2667,7 +2696,11 @@ impl LineBuf {
let Some((start, end)) = self.range_from_motion(&motion) else {
return Ok(());
};
let move_cursor = self.cursor.get() == start;
self.insert_at(start, '\t');
if move_cursor {
self.cursor.add(1);
}
let mut range_indices = self.grapheme_indices()[start..end].to_vec().into_iter();
while let Some(idx) = range_indices.next() {
let gr = self.grapheme_at(idx).unwrap();
@@ -2733,12 +2766,9 @@ impl LineBuf {
Anchor::After => {
self.push('\n');
if auto_indent {
log::debug!("Calculating indent level for new line");
self.calc_indent_level();
log::debug!("Auto-indent level: {}", self.auto_indent_level);
let tabs = (0..self.auto_indent_level).map(|_| '\t');
for tab in tabs {
log::debug!("Pushing tab for auto-indent");
self.push(tab);
}
}
@@ -2793,8 +2823,24 @@ impl LineBuf {
Verb::AcceptLineOrNewline => {
// If this verb has reached this function, it means we have incomplete input
// and therefore must insert a newline instead of accepting the input
self.push('\n');
if self.cursor.exclusive {
// in this case we are in normal/visual mode, so we don't insert anything
// and just move down a line
let motion = self.eval_motion(None, MotionCmd(1, Motion::LineDownCharwise));
self.apply_motion(motion);
return Ok(());
}
let auto_indent = read_shopts(|o| o.prompt.auto_indent);
self.insert_at_cursor('\n');
self.cursor.add(1);
if auto_indent {
self.calc_indent_level();
let tabs = (0..self.auto_indent_level).map(|_| '\t');
for tab in tabs {
self.insert_at_cursor(tab);
self.cursor.add(1);
}
}
}
Verb::Complete
@@ -2813,7 +2859,7 @@ impl LineBuf {
pub fn exec_cmd(&mut self, cmd: ViCmd) -> ShResult<()> {
let clear_redos = !cmd.is_undo_op() || cmd.verb.as_ref().is_some_and(|v| v.1.is_edit());
let is_char_insert = cmd.verb.as_ref().is_some_and(|v| v.1.is_char_insert());
let is_line_motion = cmd.is_line_motion();
let is_line_motion = cmd.is_line_motion() || cmd.verb.as_ref().is_some_and(|v| v.1 == Verb::AcceptLineOrNewline);
let is_undo_op = cmd.is_undo_op();
let edit_is_merging = self.undo_stack.last().is_some_and(|edit| edit.merging);
@@ -2886,6 +2932,14 @@ impl LineBuf {
self.apply_motion(motion_eval);
}
if self.cursor.exclusive
&& self.grapheme_at_cursor().is_some_and(|gr| gr == "\n")
&& self.grapheme_before_cursor().is_some_and(|gr| gr != "\n") {
// we landed on a newline, and we aren't inbetween two newlines.
self.cursor.sub(1);
self.update_select_range();
}
/* Done executing, do some cleanup */
let after = self.buffer.clone();

View File

@@ -129,11 +129,78 @@ pub enum ReadlineEvent {
Pending,
}
pub struct Prompt {
ps1_expanded: String,
ps1_raw: String,
psr_expanded: Option<String>,
psr_raw: Option<String>,
}
impl Prompt {
const DEFAULT_PS1: &str = "\\e[0m\\n\\e[1;0m\\u\\e[1;36m@\\e[1;31m\\h\\n\\e[1;36m\\W\\e[1;32m/\\n\\e[1;32m\\$\\e[0m ";
pub fn new() -> Self {
let Ok(ps1_raw) = env::var("PS1") else {
return Self::default();
};
let Ok(ps1_expanded) = expand_prompt(&ps1_raw) else {
return Self::default();
};
Self {
ps1_expanded,
ps1_raw,
psr_expanded: None,
psr_raw: None,
}
}
pub fn with_psr(mut self, psr_raw: String) -> ShResult<Self> {
let psr_expanded = expand_prompt(&psr_raw)?;
self.psr_expanded = Some(psr_expanded);
self.psr_raw = Some(psr_raw);
Ok(self)
}
pub fn get_ps1(&self) -> &str {
&self.ps1_expanded
}
pub fn set_ps1(&mut self, ps1_raw: String) -> ShResult<()> {
self.ps1_expanded = expand_prompt(&ps1_raw)?;
self.ps1_raw = ps1_raw;
Ok(())
}
pub fn set_psr(&mut self, psr_raw: String) -> ShResult<()> {
self.psr_expanded = Some(expand_prompt(&psr_raw)?);
self.psr_raw = Some(psr_raw);
Ok(())
}
pub fn get_psr(&self) -> Option<&str> {
self.psr_expanded.as_deref()
}
pub fn refresh(&mut self) -> ShResult<()> {
self.ps1_expanded = expand_prompt(&self.ps1_raw)?;
if let Some(psr_raw) = &self.psr_raw {
self.psr_expanded = Some(expand_prompt(psr_raw)?);
}
Ok(())
}
}
impl Default for Prompt {
fn default() -> Self {
Self {
ps1_expanded: expand_prompt(Self::DEFAULT_PS1).unwrap_or_else(|_| Self::DEFAULT_PS1.to_string()),
ps1_raw: Self::DEFAULT_PS1.to_string(),
psr_expanded: None,
psr_raw: None,
}
}
}
pub struct ShedVi {
pub reader: PollReader,
pub writer: TermWriter,
pub prompt: String,
pub prompt: Prompt,
pub highlighter: Highlighter,
pub completer: Completer,
@@ -149,11 +216,11 @@ pub struct ShedVi {
}
impl ShedVi {
pub fn new(prompt: Option<String>, tty: RawFd) -> ShResult<Self> {
pub fn new(prompt: Prompt, tty: RawFd) -> ShResult<Self> {
let mut new = Self {
reader: PollReader::new(),
writer: TermWriter::new(tty),
prompt: prompt.unwrap_or("$ ".styled(Style::Green)),
prompt,
completer: Completer::new(),
highlighter: Highlighter::new(),
mode: Box::new(ViInsert::new()),
@@ -187,11 +254,10 @@ impl ShedVi {
self.needs_redraw = true;
}
/// Reset readline state for a new prompt
pub fn reset(&mut self, prompt: Option<String>) {
if let Some(p) = prompt {
self.prompt = p;
}
pub fn reset(&mut self, prompt: Prompt) {
self.prompt = prompt;
self.editor = Default::default();
self.mode = Box::new(ViInsert::new());
self.old_layout = None;
@@ -200,9 +266,12 @@ impl ShedVi {
self.history.reset();
}
pub fn update_prompt(&mut self, prompt: String) {
self.prompt = prompt;
self.needs_redraw = true;
pub fn prompt(&self) -> &Prompt {
&self.prompt
}
pub fn prompt_mut(&mut self) -> &mut Prompt {
&mut self.prompt
}
fn should_submit(&mut self) -> ShResult<bool> {
@@ -366,7 +435,7 @@ impl ShedVi {
pub fn get_layout(&mut self, line: &str) -> Layout {
let to_cursor = self.editor.slice_to_cursor().unwrap_or_default();
let (cols, _) = get_win_size(*TTY_FILENO);
Layout::from_parts(cols, &self.prompt, to_cursor, line)
Layout::from_parts(cols, self.prompt.get_ps1(), to_cursor, line)
}
pub fn scroll_history(&mut self, cmd: ViCmd) {
/*
@@ -457,6 +526,7 @@ impl ShedVi {
}
let row0_used = self.prompt
.get_ps1()
.lines()
.next()
.map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 }))
@@ -469,7 +539,7 @@ impl ShedVi {
self.writer.clear_rows(layout)?;
}
self.writer.redraw(&self.prompt, &line, &new_layout)?;
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());

View File

@@ -299,7 +299,8 @@ impl Verb {
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Motion {
WholeLine,
WholeLineInclusive, // whole line including the linebreak
WholeLineExclusive, // whole line excluding the linebreak
TextObj(TextObj),
EndOfLastWord,
BeginningOfFirstWord,
@@ -381,7 +382,7 @@ impl Motion {
pub fn is_linewise(&self) -> bool {
matches!(
self,
Self::WholeLine | Self::LineUp | Self::LineDown | Self::ScreenLineDown | Self::ScreenLineUp
Self::WholeLineInclusive | Self::WholeLineExclusive | Self::LineUp | Self::LineDown | Self::ScreenLineDown | Self::ScreenLineUp
)
}
}

View File

@@ -451,7 +451,7 @@ impl ViNormal {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Delete)),
motion: Some(MotionCmd(1, Motion::ForwardChar)),
motion: Some(MotionCmd(1, Motion::ForwardCharForced)),
raw_seq: self.take_cmd(),
flags: self.flags(),
});
@@ -478,7 +478,7 @@ impl ViNormal {
return Some(ViCmd {
register,
verb: Some(VerbCmd(count, Verb::Change)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineExclusive)),
raw_seq: self.take_cmd(),
flags: self.flags(),
});
@@ -684,7 +684,7 @@ impl ViNormal {
| ('~', Some(VerbCmd(_, Verb::ToggleCaseRange)))
| ('>', Some(VerbCmd(_, Verb::Indent)))
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineInclusive));
}
('W', Some(VerbCmd(_, Verb::Change))) => {
// Same with 'W'
@@ -1218,7 +1218,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Delete)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1227,7 +1227,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Yank)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1236,7 +1236,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Delete)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1245,7 +1245,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Change)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineExclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1254,7 +1254,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Indent)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1263,7 +1263,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Dedent)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1272,7 +1272,7 @@ impl ViVisual {
return Some(ViCmd {
register,
verb: Some(VerbCmd(1, Verb::Equalize)),
motion: Some(MotionCmd(1, Motion::WholeLine)),
motion: Some(MotionCmd(1, Motion::WholeLineInclusive)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
});
@@ -1389,13 +1389,15 @@ impl ViVisual {
};
match (ch, &verb) {
('d', Some(VerbCmd(_, Verb::Delete)))
| ('c', Some(VerbCmd(_, Verb::Change)))
| ('y', Some(VerbCmd(_, Verb::Yank)))
| ('=', Some(VerbCmd(_, Verb::Equalize)))
| ('>', Some(VerbCmd(_, Verb::Indent)))
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineInclusive));
}
('c', Some(VerbCmd(_, Verb::Change))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineExclusive));
}
_ => {}
}
match ch {