use history::History; use keys::{KeyCode, KeyEvent, ModKeys}; use linebuf::{LineBuf, SelectAnchor, SelectMode}; use std::fmt::Write; use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size}; use unicode_width::UnicodeWidthStr; use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd}; use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual}; use crate::builtin::keymap::{KeyMapFlags, KeyMapMatch}; use crate::expand::expand_prompt; use crate::libsh::sys::TTY_FILENO; use crate::libsh::utils::AutoCmdVecUtils; use crate::parse::lex::{LexStream, QuoteState}; use crate::readline::complete::{FuzzyCompleter, SelectorResponse}; use crate::readline::term::{Pos, TermReader, calc_str_width}; use crate::readline::vimode::{ViEx, ViVerbatim}; use crate::state::{ AutoCmdKind, ShellParam, Var, VarFlags, VarKind, read_logic, read_shopts, with_vars, write_meta, write_vars }; use crate::{ libsh::error::ShResult, parse::lex::{self, LexFlags, Tk, TkFlags, TkRule}, readline::{ complete::{CompResponse, Completer}, highlight::Highlighter, }, }; use crate::{prelude::*, state}; pub mod complete; pub mod highlight; pub mod history; pub mod keys; pub mod layout; pub mod linebuf; pub mod register; pub mod term; pub mod vicmd; pub mod vimode; pub mod markers { use super::Marker; /* * These are invisible Unicode characters used to annotate * strings with various contextual metadata. */ /* Highlight Markers */ // token-level (derived from token class) pub const COMMAND: Marker = '\u{e100}'; pub const BUILTIN: Marker = '\u{e101}'; pub const ARG: Marker = '\u{e102}'; pub const KEYWORD: Marker = '\u{e103}'; pub const OPERATOR: Marker = '\u{e104}'; pub const REDIRECT: Marker = '\u{e105}'; pub const COMMENT: Marker = '\u{e106}'; pub const ASSIGNMENT: Marker = '\u{e107}'; pub const CMD_SEP: Marker = '\u{e108}'; pub const CASE_PAT: Marker = '\u{e109}'; pub const SUBSH: Marker = '\u{e10a}'; pub const SUBSH_END: Marker = '\u{e10b}'; // sub-token (needs scanning) pub const VAR_SUB: Marker = '\u{e10c}'; pub const VAR_SUB_END: Marker = '\u{e10d}'; pub const CMD_SUB: Marker = '\u{e10e}'; pub const CMD_SUB_END: Marker = '\u{e10f}'; pub const PROC_SUB: Marker = '\u{e110}'; pub const PROC_SUB_END: Marker = '\u{e111}'; pub const STRING_DQ: Marker = '\u{e112}'; pub const STRING_DQ_END: Marker = '\u{e113}'; pub const STRING_SQ: Marker = '\u{e114}'; pub const STRING_SQ_END: Marker = '\u{e115}'; pub const ESCAPE: Marker = '\u{e116}'; pub const GLOB: Marker = '\u{e117}'; pub const HIST_EXP: Marker = '\u{e11c}'; pub const HIST_EXP_END: Marker = '\u{e11d}'; // other pub const VISUAL_MODE_START: Marker = '\u{e118}'; pub const VISUAL_MODE_END: Marker = '\u{e119}'; pub const RESET: Marker = '\u{e11a}'; pub const NULL: Marker = '\u{e11b}'; /* Expansion Markers */ /// Double quote '"' marker pub const DUB_QUOTE: Marker = '\u{e001}'; /// Single quote '\\'' marker pub const SNG_QUOTE: Marker = '\u{e002}'; /// Tilde sub marker pub const TILDE_SUB: Marker = '\u{e003}'; /// Input process sub marker pub const PROC_SUB_IN: Marker = '\u{e005}'; /// Output process sub marker pub const PROC_SUB_OUT: Marker = '\u{e006}'; /// Marker for null expansion /// This is used for when "$@" or "$*" are used in quotes and there are no /// arguments Without this marker, it would be handled like an empty string, /// which breaks some commands pub const NULL_EXPAND: Marker = '\u{e007}'; /// Explicit marker for argument separation /// This is used to join the arguments given by "$@", and preserves exact /// formatting of the original arguments, including quoting pub const ARG_SEP: Marker = '\u{e008}'; pub const VI_SEQ_EXP: Marker = '\u{e009}'; pub const END_MARKERS: [Marker; 7] = [ VAR_SUB_END, CMD_SUB_END, PROC_SUB_END, STRING_DQ_END, STRING_SQ_END, SUBSH_END, RESET, ]; pub const TOKEN_LEVEL: [Marker; 10] = [ SUBSH, COMMAND, BUILTIN, ARG, KEYWORD, OPERATOR, REDIRECT, CMD_SEP, CASE_PAT, ASSIGNMENT, ]; pub const SUB_TOKEN: [Marker; 6] = [VAR_SUB, CMD_SUB, PROC_SUB, STRING_DQ, STRING_SQ, GLOB]; pub const MISC: [Marker; 3] = [ESCAPE, VISUAL_MODE_START, VISUAL_MODE_END]; pub fn is_marker(c: Marker) -> bool { ('\u{e000}'..'\u{efff}').contains(&c) } } type Marker = char; /// Non-blocking readline result pub enum ReadlineEvent { /// A complete line was entered Line(String), /// Ctrl+D on empty line - request to exit Eof, /// No complete input yet, need more bytes Pending, } pub struct Prompt { ps1_expanded: String, ps1_raw: String, psr_expanded: Option, psr_raw: Option, dirty: bool, } 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(); }; // PS1 expansion may involve running commands (e.g., for \h or \W), which can modify shell state let saved_status = state::get_status(); let Ok(ps1_expanded) = expand_prompt(&ps1_raw) else { return Self::default(); }; let psr_raw = env::var("PSR").ok(); let psr_expanded = psr_raw .clone() .map(|r| expand_prompt(&r)) .transpose() .ok() .flatten(); // Restore shell state after prompt expansion, since it may have been modified by command substitutions in the prompt state::set_status(saved_status); Self { ps1_expanded, ps1_raw, psr_expanded, psr_raw, dirty: false, } } pub fn get_ps1(&mut self) -> &str { if self.dirty { self.refresh_now(); } &self.ps1_expanded } pub fn set_ps1(&mut self, ps1_raw: String) -> ShResult<()> { self.ps1_raw = ps1_raw; self.dirty = true; Ok(()) } pub fn set_psr(&mut self, psr_raw: String) -> ShResult<()> { self.psr_raw = Some(psr_raw); self.dirty = true; Ok(()) } pub fn get_psr(&mut self) -> Option<&str> { if self.dirty { self.refresh_now(); } self.psr_expanded.as_deref() } /// Mark the prompt as needing re-expansion on next access. pub fn invalidate(&mut self) { self.dirty = true; } fn refresh_now(&mut self) { let saved_status = state::get_status(); *self = Self::new(); state::set_status(saved_status); self.dirty = false; } pub fn refresh(&mut self) { self.invalidate(); } } 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, dirty: false, } } } pub struct ShedVi { pub reader: PollReader, pub writer: TermWriter, pub prompt: Prompt, pub highlighter: Highlighter, pub completer: Box, pub mode: Box, pub saved_mode: Option>, pub pending_keymap: Vec, pub repeat_action: Option, pub repeat_motion: Option, pub editor: LineBuf, pub next_is_escaped: bool, pub old_layout: Option, pub history: History, pub needs_redraw: bool, } impl ShedVi { pub fn new(prompt: Prompt, tty: RawFd) -> ShResult { let mut new = Self { reader: PollReader::new(), writer: TermWriter::new(tty), prompt, completer: Box::new(FuzzyCompleter::default()), highlighter: Highlighter::new(), mode: Box::new(ViInsert::new()), next_is_escaped: false, saved_mode: None, pending_keymap: Vec::new(), old_layout: None, repeat_action: None, repeat_motion: None, editor: LineBuf::new(), history: History::new()?, 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 { self.editor = LineBuf::new().with_initial(initial, 0); self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); self } /// Feed raw bytes from stdin into the reader's buffer pub fn feed_bytes(&mut self, bytes: &[u8]) { self.reader.feed_bytes(bytes); } /// Mark that the display needs to be redrawn (e.g., after SIGWINCH) pub fn mark_dirty(&mut self) { self.needs_redraw = true; } pub fn fix_column(&mut self) -> ShResult<()> { self .writer .fix_cursor_column(&mut TermReader::new(*TTY_FILENO)) } pub fn reset_active_widget(&mut self, full_redraw: bool) -> ShResult<()> { if self.completer.is_active() { self.completer.reset_stay_active(); self.needs_redraw = true; Ok(()) } else if self.history.fuzzy_finder.is_active() { self.history.fuzzy_finder.reset_stay_active(); self.needs_redraw = true; Ok(()) } else { self.reset(full_redraw) } } /// Reset readline state for a new prompt pub fn reset(&mut self, full_redraw: bool) -> ShResult<()> { // Clear old display before resetting state — old_layout must survive // so print_line can call clear_rows with the full multi-line layout self.prompt.refresh(); self.editor = Default::default(); self.swap_mode(&mut (Box::new(ViInsert::new()) as Box)); self.needs_redraw = true; if full_redraw { self.old_layout = None; } self.history.pending = None; self.history.reset(); self.print_line(false) } pub fn prompt(&self) -> &Prompt { &self.prompt } pub fn prompt_mut(&mut self) -> &mut Prompt { &mut self.prompt } pub fn curr_keymap_flags(&self) -> KeyMapFlags { let mut flags = KeyMapFlags::empty(); match self.mode.report_mode() { ModeReport::Insert => flags |= KeyMapFlags::INSERT, ModeReport::Normal => flags |= KeyMapFlags::NORMAL, ModeReport::Ex => flags |= KeyMapFlags::EX, ModeReport::Visual => flags |= KeyMapFlags::VISUAL, ModeReport::Replace => flags |= KeyMapFlags::REPLACE, ModeReport::Verbatim => flags |= KeyMapFlags::VERBATIM, ModeReport::Unknown => todo!(), } if self.mode.pending_seq().is_some_and(|seq| !seq.is_empty()) { flags |= KeyMapFlags::OP_PENDING; } flags } fn should_submit(&mut self) -> ShResult { if self.mode.report_mode() == ModeReport::Normal { return Ok(true); } let input = Arc::new(self.editor.buffer.clone()); self.editor.calc_indent_level(); let lex_result1 = LexStream::new(Arc::clone(&input), LexFlags::LEX_UNFINISHED).collect::>>(); let lex_result2 = LexStream::new(Arc::clone(&input), LexFlags::empty()).collect::>>(); let is_top_level = self.editor.auto_indent_level == 0; let is_complete = match (lex_result1.is_err(), lex_result2.is_err()) { (true, true) => { return Err(lex_result2.unwrap_err()); } (true, false) => { return Err(lex_result1.unwrap_err()); } (false, true) => false, (false, false) => true, }; Ok(is_complete && is_top_level) } /// Process any available input and return readline event /// This is non-blocking - returns Pending if no complete line yet pub fn process_input(&mut self) -> ShResult { // Redraw if needed if self.needs_redraw { self.print_line(false)?; self.needs_redraw = false; } // Process all available keys while let Some(key) = self.reader.read_key()? { log::debug!("Read key: {key:?} in mode {:?}, self.reader.verbatim = {}", self.mode.report_mode(), self.reader.verbatim); // If completer or history search are active, delegate input to it if self.history.fuzzy_finder.is_active() { self.print_line(false)?; match self.history.fuzzy_finder.handle_key(key)? { SelectorResponse::Accept(cmd) => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); self.editor.set_buffer(cmd.to_string()); self.editor.move_cursor_to_end(); self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); self.editor.set_hint(None); self.history.fuzzy_finder.clear(&mut self.writer)?; self.history.fuzzy_finder.reset(); with_vars([("_HIST_ENTRY".into(), cmd.clone())], || { post_cmds.exec_with(&cmd); }); write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE, ) }) .ok(); self.prompt.refresh(); self.needs_redraw = true; continue; } SelectorResponse::Dismiss => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryClose)); post_cmds.exec(); self.editor.set_hint(None); self.history.fuzzy_finder.clear(&mut self.writer)?; write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE, ) }) .ok(); self.prompt.refresh(); self.needs_redraw = true; continue; } SelectorResponse::Consumed => { self.needs_redraw = true; continue; } } } else if self.completer.is_active() { self.print_line(false)?; match self.completer.handle_key(key.clone())? { CompResponse::Accept(candidate) => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionSelect)); let span_start = self.completer.token_span().0; let new_cursor = span_start + candidate.len(); let line = self.completer.get_completed_line(&candidate); self.editor.set_buffer(line); self.editor.cursor.set(new_cursor); // Don't reset yet — clear() needs old_layout to erase the selector. if !self.history.at_pending() { self.history.reset_to_pending(); } self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); let hint = self.history.get_hint(); self.editor.set_hint(hint); self.completer.clear(&mut self.writer)?; self.needs_redraw = true; self.completer.reset(); with_vars([("_COMP_CANDIDATE".into(), candidate.clone())], || { post_cmds.exec_with(&candidate); }); continue; } CompResponse::Dismiss => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionCancel)); post_cmds.exec(); let hint = self.history.get_hint(); self.editor.set_hint(hint); self.completer.clear(&mut self.writer)?; write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE, ) }) .ok(); self.prompt.refresh(); self.completer.reset(); continue; } CompResponse::Consumed => { /* just redraw */ self.needs_redraw = true; continue; } CompResponse::Passthrough => { /* fall through to normal handling below */ } } } else { let keymap_flags = self.curr_keymap_flags(); self.pending_keymap.push(key.clone()); let matches = read_logic(|l| l.keymaps_filtered(keymap_flags, &self.pending_keymap)); if matches.is_empty() { // No matches. Drain the buffered keys and execute them. for key in std::mem::take(&mut self.pending_keymap) { if let Some(event) = self.handle_key(key)? { return Ok(event); } } self.needs_redraw = true; continue; } else if matches.len() == 1 && matches[0].compare(&self.pending_keymap) == KeyMapMatch::IsExact { // We have a single exact match. Execute it. let keymap = matches[0].clone(); self.pending_keymap.clear(); let action = keymap.action_expanded(); for key in action { if let Some(event) = self.handle_key(key)? { return Ok(event); } } self.needs_redraw = true; continue; } else { // There is ambiguity. Allow the timeout in the main loop to handle this. continue; } } if let Some(event) = self.handle_key(key)? { return Ok(event); } } if !self.completer.is_active() && !self.history.fuzzy_finder.is_active() { write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE, ) }) .ok(); } // Redraw if we processed any input if self.needs_redraw { self.print_line(false)?; self.needs_redraw = false; } Ok(ReadlineEvent::Pending) } pub fn handle_key(&mut self, key: KeyEvent) -> ShResult> { if self.should_accept_hint(&key) { log::debug!( "Accepting hint on key {key:?} in mode {:?}", self.mode.report_mode() ); self.editor.accept_hint(); if !self.history.at_pending() { self.history.reset_to_pending(); } self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); self.needs_redraw = true; return Ok(None); } if let KeyEvent(KeyCode::Tab, mod_keys) = key { if self.editor.attempt_history_expansion(&self.history) { // If history expansion occurred, don't attempt completion yet // allow the user to see the expanded command and accept or edit it before completing return Ok(None); } let direction = match mod_keys { ModKeys::SHIFT => -1, _ => 1, }; let line = self.editor.as_str().to_string(); let cursor_pos = self.editor.cursor_byte_pos(); match self.completer.complete(line, cursor_pos, direction) { Err(e) => { e.print_error(); // Printing the error invalidates the layout self.old_layout = None; } Ok(Some(line)) => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionSelect)); let cand = self.completer.selected_candidate().unwrap_or_default(); with_vars([("_COMP_CANDIDATE".into(), cand.clone())], || { post_cmds.exec_with(&cand); }); let span_start = self.completer.token_span().0; let new_cursor = span_start + self .completer .selected_candidate() .map(|c| c.len()) .unwrap_or_default(); self.editor.set_buffer(line.clone()); self.editor.cursor.set(new_cursor); if !self.history.at_pending() { self.history.reset_to_pending(); } self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); let hint = self.history.get_hint(); self.editor.set_hint(hint); // 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 self.completer.reset(); } Ok(None) => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnCompletionStart)); let candidates = self.completer.all_candidates(); let num_candidates = candidates.len(); with_vars([ ("_NUM_MATCHES".into(), Into::::into(num_candidates)), ("_MATCHES".into(), Into::::into(candidates)), ("_SEARCH_STR".into(), Into::::into(self.completer.token())), ], || { }); post_cmds.exec(); if self.completer.is_active() { write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str("COMPLETE".to_string()), VarFlags::NONE, ) }) .ok(); self.prompt.refresh(); self.needs_redraw = true; self.editor.set_hint(None); } else { self.writer.send_bell().ok(); } } } self.needs_redraw = true; return Ok(None); } else if let KeyEvent(KeyCode::Char('R'), ModKeys::CTRL) = key { let initial = self.editor.as_str(); match self.history.start_search(initial) { Some(entry) => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); with_vars([ ("_HIST_ENTRY".into(), entry.clone()), ], || { post_cmds.exec_with(&entry); }); self.editor.set_buffer(entry); self.editor.move_cursor_to_end(); self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); self.editor.set_hint(None); } None => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistoryOpen)); let entries = self.history.fuzzy_finder.candidates(); let matches = self.history.fuzzy_finder .filtered() .iter() .cloned() .map(|sc| sc.content) .collect::>(); let num_entries = entries.len(); let num_matches = matches.len(); with_vars([ ("_ENTRIES".into(),Into::::into(entries)), ("_NUM_ENTRIES".into(),Into::::into(num_entries)), ("_MATCHES".into(),Into::::into(matches)), ("_NUM_MATCHES".into(),Into::::into(num_matches)), ("_SEARCH_STR".into(), Into::::into(initial)), ], || { post_cmds.exec(); }); if self.history.fuzzy_finder.is_active() { write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str("SEARCH".to_string()), VarFlags::NONE, ) }) .ok(); self.prompt.refresh(); self.needs_redraw = true; self.editor.set_hint(None); } else { 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 { // it's an ex mode error self.mode = Box::new(ViNormal::new()) as Box; return Ok(None); }; let Some(mut cmd) = cmd else { return Ok(None); }; cmd.alter_line_motion_if_no_verb(); if self.should_grab_history(&cmd) { self.scroll_history(cmd); self.needs_redraw = true; return Ok(None); } if cmd.is_submit_action() && !self.next_is_escaped && !self.editor.buffer.ends_with('\\') && (self.should_submit()? || !read_shopts(|o| o.prompt.linebreak_on_incomplete)) { if self.editor.attempt_history_expansion(&self.history) { // If history expansion occurred, don't submit yet // allow the user to see the expanded command and accept or edit it before submitting return Ok(None); } self.editor.set_hint(None); self.editor.cursor.set(self.editor.cursor_max()); self.print_line(true)?; self.writer.flush_write("\n")?; let buf = self.editor.take_buf(); self.history.reset(); return Ok(Some(ReadlineEvent::Line(buf))); } if cmd.verb().is_some_and(|v| v.1 == Verb::EndOfFile) { if self.editor.buffer.is_empty() { return Ok(Some(ReadlineEvent::Eof)); } else { self.editor = LineBuf::new(); self.mode = Box::new(ViInsert::new()); self.needs_redraw = true; return Ok(None); } } let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit()); let before = self.editor.buffer.clone(); self.exec_cmd(cmd)?; if let Some(keys) = write_meta(|m| m.take_pending_widget_keys()) { for key in keys { self.handle_key(key)?; } } let after = self.editor.as_str(); if before != after { self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); } else if before == after && has_edit_verb { self.writer.send_bell().ok(); } let hint = self.history.get_hint(); self.editor.set_hint(hint); self.needs_redraw = true; Ok(None) } 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.get_ps1(), to_cursor, line) } pub fn scroll_history(&mut self, cmd: ViCmd) { /* if self.history.cursor_entry().is_some_and(|ent| ent.is_new()) { let constraint = SearchConstraint::new(SearchKind::Prefix, self.editor.to_string()); self.history.constrain_entries(constraint); } */ let count = &cmd.motion().unwrap().0; let motion = &cmd.motion().unwrap().1; let count = match motion { Motion::LineUpCharwise => -(*count as isize), Motion::LineDownCharwise => *count as isize, _ => unreachable!(), }; let entry = self.history.scroll(count); if let Some(entry) = entry { let editor = std::mem::take(&mut self.editor); self.editor.set_buffer(entry.command().to_string()); if self.history.pending.is_none() { self.history.pending = Some(editor); } self.editor.set_hint(None); self.editor.move_cursor_to_end(); } else if let Some(pending) = self.history.pending.take() { self.editor = pending; } else { // If we are here it should mean we are on our pending command // And the user tried to scroll history down // Since there is no "future" history, we should just bell and do nothing self.writer.send_bell().ok(); } } pub fn should_accept_hint(&self, event: &KeyEvent) -> bool { if self.editor.cursor_at_max() && self.editor.has_hint() { match self.mode.report_mode() { ModeReport::Replace | ModeReport::Insert => { matches!(event, KeyEvent(KeyCode::Right, ModKeys::NONE)) } ModeReport::Visual | ModeReport::Normal => { matches!(event, KeyEvent(KeyCode::Right, ModKeys::NONE)) || (self.mode.pending_seq().unwrap(/* always Some on normal mode */).is_empty() && matches!(event, KeyEvent(KeyCode::Char('l'), ModKeys::NONE))) } ModeReport::Ex | ModeReport::Verbatim | ModeReport::Unknown => false, } } else { false } } pub fn should_grab_history(&mut self, cmd: &ViCmd) -> bool { cmd.verb().is_none() && (cmd .motion() .is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineUpCharwise))) && self.editor.start_of_line() == 0) || (cmd .motion() .is_some_and(|m| matches!(m, MotionCmd(_, Motion::LineDownCharwise))) && self.editor.end_of_line() == self.editor.cursor_max()) } pub fn line_text(&mut self) -> String { let line = self.editor.to_string(); let hint = self.editor.get_hint_text(); let do_hl = state::read_shopts(|s| s.prompt.highlight); self.highlighter.only_visual(!do_hl); self .highlighter .load_input(&line, self.editor.cursor_byte_pos()); self.highlighter.expand_control_chars(); self.highlighter.highlight(); let highlighted = self.highlighter.take(); format!("{highlighted}{hint}") } pub fn print_line(&mut self, final_draw: bool) -> ShResult<()> { let line = self.line_text(); let mut new_layout = self.get_layout(&line); let pending_seq = self.mode.pending_seq(); let mut prompt_string_right = self.prompt.psr_expanded.clone(); if prompt_string_right .as_ref() .is_some_and(|psr| psr.lines().count() > 1) { log::warn!("PSR has multiple lines, truncating to one line"); prompt_string_right = prompt_string_right.map(|psr| psr.lines().next().unwrap_or_default().to_string()); } let mut buf = String::new(); let row0_used = self .prompt .get_ps1() .lines() .next() .map(|l| Layout::calc_pos(self.writer.t_cols, l, Pos { col: 0, row: 0 }, 0, false)) .map(|p| p.col) .unwrap_or_default() as usize; let one_line = new_layout.end.row == 0; self.completer.clear(&mut self.writer)?; self.history.fuzzy_finder.clear(&mut self.writer)?; if let Some(layout) = self.old_layout.as_ref() { self.writer.clear_rows(layout)?; } let pre_prompt = read_logic(|l| l.get_autocmds(AutoCmdKind::PrePrompt)); pre_prompt.exec(); 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).saturating_sub(psr.width()) }); if !final_draw && let Some(seq) = pending_seq && !seq.is_empty() && !(prompt_string_right.is_some() && one_line) && seq_fits && self.mode.report_mode() != ModeReport::Ex { let to_col = self.writer.t_cols - calc_str_width(&seq); let up = new_layout.cursor.row; // rows to move up from cursor to top line of prompt let move_up = if up > 0 { format!("\x1b[{up}A") } else { String::new() }; // Save cursor, move up to top row, move right to column, write sequence, // restore cursor write!(buf, "\x1b7{move_up}\x1b[{to_col}G{seq}\x1b8").unwrap(); } else if !final_draw && let Some(psr) = prompt_string_right && psr_fits { let to_col = self.writer.t_cols - calc_str_width(&psr); let down = new_layout.end.row - new_layout.cursor.row; let move_down = if down > 0 { format!("\x1b[{down}B") } else { String::new() }; write!(buf, "\x1b7{move_down}\x1b[{to_col}G{psr}\x1b8").unwrap(); // Record where the PSR ends so clear_rows can account for wrapping // if the terminal shrinks. let psr_start = Pos { row: new_layout.end.row, col: to_col, }; new_layout.psr_end = Some(Layout::calc_pos( self.writer.t_cols, &psr, psr_start, 0, false, )); } if let ModeReport::Ex = self.mode.report_mode() { let pending_seq = self.mode.pending_seq().unwrap_or_default(); write!(buf, "\n: {pending_seq}").unwrap(); new_layout.end.row += 1; } write!(buf, "{}", &self.mode.cursor_style()).unwrap(); self.writer.flush_write(&buf)?; // Tell the completer the width of the prompt line above its \n so it can // account for wrapping when clearing after a resize. let preceding_width = if new_layout.psr_end.is_some() { self.writer.t_cols } else { // Without PSR, use the content width on the cursor's row (new_layout.end.col + 1).max(new_layout.cursor.col + 1) }; self .completer .set_prompt_line_context(preceding_width, new_layout.cursor.col); self.completer.draw(&mut self.writer)?; self .history .fuzzy_finder .set_prompt_line_context(preceding_width, new_layout.cursor.col); self.history.fuzzy_finder.draw(&mut self.writer)?; self.old_layout = Some(new_layout); self.needs_redraw = false; let post_prompt = read_logic(|l| l.get_autocmds(AutoCmdKind::PostPrompt)); post_prompt.exec(); Ok(()) } pub fn swap_mode(&mut self, mode: &mut Box) { let pre_mode_change = read_logic(|l| l.get_autocmds(AutoCmdKind::PreModeChange)); pre_mode_change.exec(); std::mem::swap(&mut self.mode, mode); self.editor.set_cursor_clamp(self.mode.clamp_cursor()); write_vars(|v| { v.set_var( "SHED_VI_MODE", VarKind::Str(self.mode.report_mode().to_string()), VarFlags::NONE, ) }) .ok(); self.prompt.refresh(); let post_mode_change = read_logic(|l| l.get_autocmds(AutoCmdKind::PostModeChange)); post_mode_change.exec(); } pub fn exec_cmd(&mut self, mut cmd: ViCmd) -> ShResult<()> { let mut select_mode = None; let mut is_insert_mode = false; if cmd.is_mode_transition() { let count = cmd.verb_count(); let mut mode: Box = if matches!( self.mode.report_mode(), ModeReport::Ex | ModeReport::Verbatim ) && cmd.flags.contains(CmdFlags::EXIT_CUR_MODE) { if let Some(saved) = self.saved_mode.take() { saved } else { Box::new(ViNormal::new()) } } else { match cmd.verb().unwrap().1 { Verb::Change | Verb::InsertModeLineBreak(_) | Verb::InsertMode => { is_insert_mode = true; Box::new(ViInsert::new().with_count(count as u16)) } Verb::ExMode => Box::new(ViEx::new()), Verb::VerbatimMode => Box::new(ViVerbatim::read_one().with_count(count as u16)), 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 = 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() { 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(); return Ok(()); } else if cmd.is_cmd_repeat() { let Some(replay) = self.repeat_action.clone() else { return Ok(()); }; let ViCmd { verb, .. } = cmd; let VerbCmd(count, _) = verb.unwrap(); match replay { CmdReplay::ModeReplay { cmds, mut repeat } => { if count > 1 { repeat = count as u16; } for _ in 0..repeat { let cmds = cmds.clone(); for cmd in cmds { self.editor.exec_cmd(cmd)? } } } CmdReplay::Single(mut cmd) => { if count > 1 { // Override the counts with the one passed to the '.' command if cmd.verb.is_some() { if let Some(v_mut) = cmd.verb.as_mut() { v_mut.0 = count } if let Some(m_mut) = cmd.motion.as_mut() { m_mut.0 = 1 } } else { return Ok(()); // it has to have a verb to be repeatable, // something weird happened } } self.editor.exec_cmd(cmd)?; } _ => unreachable!("motions should be handled in the other branch"), } return Ok(()); } else if cmd.is_motion_repeat() { match cmd.motion.as_ref().unwrap() { MotionCmd(count, Motion::RepeatMotion) => { let Some(motion) = self.repeat_motion.clone() else { return Ok(()); }; let repeat_cmd = ViCmd { register: RegisterName::default(), verb: None, motion: Some(motion), raw_seq: format!("{count};"), flags: CmdFlags::empty(), }; return self.editor.exec_cmd(repeat_cmd); } MotionCmd(count, Motion::RepeatMotionRev) => { let Some(motion) = self.repeat_motion.clone() else { return Ok(()); }; let mut new_motion = motion.invert_char_motion(); new_motion.0 = *count; let repeat_cmd = ViCmd { register: RegisterName::default(), verb: None, motion: Some(new_motion), raw_seq: format!("{count},"), flags: CmdFlags::empty(), }; return self.editor.exec_cmd(repeat_cmd); } _ => unreachable!(), } } if self.mode.report_mode() == ModeReport::Visual && self.editor.select_range().is_none() { self.editor.stop_selecting(); let mut mode: Box = Box::new(ViNormal::new()); self.swap_mode(&mut mode); } if cmd.is_repeatable() { if self.mode.report_mode() == ModeReport::Visual { // 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 if let Some(range) = self.editor.select_range() { cmd.motion = Some(MotionCmd(1, Motion::Range(range.0, range.1))) } else { log::warn!("You're in visual mode with no select range??"); }; } self.repeat_action = Some(CmdReplay::Single(cmd.clone())); } if cmd.is_char_search() { self.repeat_motion = cmd.motion.clone() } self.editor.exec_cmd(cmd.clone())?; if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some_and(|v| v.1.is_edit()) { self.editor.stop_selecting(); let mut mode: Box = Box::new(ViNormal::new()); self.swap_mode(&mut mode); } if self.mode.report_mode() != ModeReport::Visual && self.editor.select_range().is_some() { self.editor.stop_selecting(); } if cmd.flags.contains(CmdFlags::EXIT_CUR_MODE) { let mut mode: Box = if matches!( self.mode.report_mode(), ModeReport::Ex | ModeReport::Verbatim ) { if let Some(saved) = self.saved_mode.take() { saved } else { Box::new(ViNormal::new()) } } else { Box::new(ViNormal::new()) }; self.swap_mode(&mut mode); } Ok(()) } } /// Annotates shell input with invisible Unicode markers for syntax highlighting /// /// Takes raw shell input and inserts non-character markers (U+FDD0-U+FDEF /// range) around syntax elements. These markers indicate: /// - Token-level context (commands, arguments, operators, keywords) /// - Sub-token constructs (strings, variables, command substitutions, globs) /// /// The annotated string is suitable for processing by the highlighter, which /// interprets the markers and generates ANSI escape codes. /// /// # Strategy /// Tokens are processed in reverse order so that later insertions don't /// invalidate earlier positions. Each token is annotated independently. /// /// # Example /// ```text /// "echo $USER" -> "COMMAND echo RESET ARG VAR_SUB $USER VAR_SUB_END RESET" /// ``` /// (where COMMAND, RESET, etc. are invisible Unicode markers) pub fn annotate_input(input: &str) -> String { let mut annotated = input.to_string(); let input = Arc::new(input.to_string()); let tokens: Vec = lex::LexStream::new(input, LexFlags::LEX_UNFINISHED) .flatten() .filter(|tk| !matches!(tk.class, TkRule::SOI | TkRule::EOI | TkRule::Null)) .collect(); for tk in tokens.into_iter().rev() { let insertions = annotate_token(tk); for (pos, marker) in insertions { let pos = pos.max(0).min(annotated.len()); annotated.insert(pos, marker); } } annotated } /// Recursively annotates nested constructs in the input string pub fn annotate_input_recursive(input: &str) -> String { let mut annotated = annotate_input(input); let mut chars = annotated.char_indices().peekable(); let mut changes = vec![]; while let Some((pos, ch)) = chars.next() { match ch { markers::CMD_SUB | markers::SUBSH | markers::PROC_SUB => { let mut body = String::new(); let span_start = pos + ch.len_utf8(); let mut span_end = span_start; let closing_marker = match ch { markers::CMD_SUB => markers::CMD_SUB_END, markers::SUBSH => markers::SUBSH_END, markers::PROC_SUB => markers::PROC_SUB_END, _ => unreachable!(), }; while let Some((sub_pos, sub_ch)) = chars.next() { match sub_ch { _ if sub_ch == closing_marker => { span_end = sub_pos; break; } _ => body.push(sub_ch), } } let prefix = match ch { markers::PROC_SUB => match chars.peek().map(|(_, c)| *c) { Some('>') => ">(", Some('<') => "<(", _ => "<(", }, markers::CMD_SUB => "$(", markers::SUBSH => "(", _ => unreachable!(), }; body = body.trim_start_matches(prefix).to_string(); let annotated_body = annotate_input_recursive(&body); let final_str = format!("{prefix}{annotated_body})"); changes.push((span_start, span_end, final_str)); } _ => {} } } for change in changes.into_iter().rev() { let (start, end, replacement) = change; annotated.replace_range(start..end, &replacement); } annotated } pub fn get_insertions(input: &str) -> Vec<(usize, Marker)> { let input = Arc::new(input.to_string()); let tokens: Vec = lex::LexStream::new(input, LexFlags::LEX_UNFINISHED) .flatten() .collect(); let mut insertions = vec![]; for tk in tokens.into_iter().rev() { insertions.extend(annotate_token(tk)); } insertions } /// Maps token class to its corresponding marker character /// /// Returns the appropriate Unicode marker for token-level syntax elements. /// Token-level markers are derived directly from the lexer's token /// classification and represent complete tokens (operators, separators, etc.). /// /// Returns `None` for: /// - String tokens (which need sub-token scanning for variables, quotes, etc.) /// - Structural markers (SOI, EOI, Null) /// - Unimplemented features (comments, brace groups) pub fn marker_for(class: &TkRule) -> Option { match class { TkRule::Pipe | TkRule::ErrPipe | TkRule::And | TkRule::Or | TkRule::Bg | TkRule::BraceGrpStart | TkRule::BraceGrpEnd => Some(markers::OPERATOR), TkRule::Sep => Some(markers::CMD_SEP), TkRule::Redir => Some(markers::REDIRECT), TkRule::Comment => Some(markers::COMMENT), TkRule::Expanded { exp: _ } | TkRule::EOI | TkRule::SOI | TkRule::Null | TkRule::Str | TkRule::CasePattern => None, } } pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> { // Sort by position descending, with priority ordering at same position: // - RESET first (inserted first, ends up rightmost) // - Regular markers middle // - END markers last (inserted last, ends up leftmost) // Result: [END][TOGGLE][RESET] let sort_insertions = |insertions: &mut Vec<(usize, Marker)>| { insertions.sort_by(|a, b| match b.0.cmp(&a.0) { std::cmp::Ordering::Equal => { let priority = |m: Marker| -> u8 { match m { markers::VISUAL_MODE_END | markers::VISUAL_MODE_START | markers::RESET => 0, markers::VAR_SUB | markers::VAR_SUB_END | markers::CMD_SUB | markers::CMD_SUB_END | markers::PROC_SUB | markers::PROC_SUB_END | markers::STRING_DQ | markers::STRING_DQ_END | markers::STRING_SQ | markers::STRING_SQ_END | markers::SUBSH_END => 2, markers::ARG => 3, _ => 1, } }; priority(a.1).cmp(&priority(b.1)) } other => other, }); }; let in_context = |c: Marker, insertions: &[(usize, Marker)]| -> bool { let mut stack = insertions.to_vec(); stack.sort_by(|a, b| { match b.0.cmp(&a.0) { std::cmp::Ordering::Equal => { let priority = |m: Marker| -> u8 { match m { markers::VISUAL_MODE_END | markers::VISUAL_MODE_START | markers::RESET => 0, markers::VAR_SUB | markers::VAR_SUB_END | markers::CMD_SUB | markers::CMD_SUB_END | markers::PROC_SUB | markers::PROC_SUB_END | markers::STRING_DQ | markers::STRING_DQ_END | markers::STRING_SQ | markers::STRING_SQ_END | markers::SUBSH_END => 2, markers::ARG => 3, // Lowest priority - processed first, overridden by sub-tokens _ => 1, } }; priority(a.1).cmp(&priority(b.1)) } other => other, } }); stack.retain(|(i, m)| *i <= token.span.range().start && !markers::END_MARKERS.contains(m)); let Some(ctx) = stack.last() else { return false; }; ctx.1 == c }; let mut insertions: Vec<(usize, Marker)> = vec![]; if token.class != TkRule::Str && let Some(marker) = marker_for(&token.class) { insertions.push((token.span.range().end, markers::RESET)); insertions.push((token.span.range().start, marker)); return insertions; } else if token.flags.contains(TkFlags::IS_SUBSH) { let token_raw = token.span.as_str(); if token_raw.ends_with(')') { insertions.push((token.span.range().end, markers::SUBSH_END)); } insertions.push((token.span.range().start, markers::SUBSH)); return insertions; } else if token.class == TkRule::CasePattern { insertions.push((token.span.range().end, markers::RESET)); insertions.push((token.span.range().end - 1, markers::CASE_PAT)); insertions.push((token.span.range().start, markers::OPERATOR)); return insertions; } let token_raw = token.span.as_str(); let mut token_chars = token_raw.char_indices().peekable(); let span_start = token.span.range().start; let mut qt_state = QuoteState::default(); let mut cmd_sub_depth = 0; let mut proc_sub_depth = 0; if token.flags.contains(TkFlags::BUILTIN) { insertions.insert(0, (span_start, markers::BUILTIN)); } else if token.flags.contains(TkFlags::IS_CMD) { insertions.insert(0, (span_start, markers::COMMAND)); } else if !token.flags.contains(TkFlags::KEYWORD) && !token.flags.contains(TkFlags::ASSIGN) { insertions.insert(0, (span_start, markers::ARG)); } if token.flags.contains(TkFlags::KEYWORD) { insertions.insert(0, (span_start, markers::KEYWORD)); } if token.flags.contains(TkFlags::ASSIGN) { insertions.insert(0, (span_start, markers::ASSIGNMENT)); } insertions.insert(0, (token.span.range().end, markers::RESET)); // reset at the end of the token while let Some((i, ch)) = token_chars.peek() { let index = *i; // we have to dereference this here because rustc is a very pedantic program match ch { ')' if cmd_sub_depth > 0 || proc_sub_depth > 0 => { token_chars.next(); // consume the paren if cmd_sub_depth > 0 { cmd_sub_depth -= 1; if cmd_sub_depth == 0 { insertions.push((span_start + index + 1, markers::CMD_SUB_END)); } } else if proc_sub_depth > 0 { proc_sub_depth -= 1; if proc_sub_depth == 0 { insertions.push((span_start + index + 1, markers::PROC_SUB_END)); } } } '$' if !qt_state.in_single() => { let dollar_pos = index; token_chars.next(); // consume the dollar if let Some((_, dollar_ch)) = token_chars.peek() { match dollar_ch { '(' => { cmd_sub_depth += 1; if cmd_sub_depth == 1 { // only mark top level command subs insertions.push((span_start + dollar_pos, markers::CMD_SUB)); } token_chars.next(); // consume the paren } '{' if cmd_sub_depth == 0 => { insertions.push((span_start + dollar_pos, markers::VAR_SUB)); token_chars.next(); // consume the brace let mut end_pos; // position after ${ while let Some((cur_i, br_ch)) = token_chars.peek() { end_pos = *cur_i; // TODO: implement better parameter expansion awareness here // this is a little too permissive if br_ch.is_ascii_alphanumeric() || *br_ch == '_' || *br_ch == '!' || *br_ch == '#' || *br_ch == '%' || *br_ch == ':' || *br_ch == '-' || *br_ch == '+' || *br_ch == '=' || *br_ch == '/' // parameter expansion symbols || *br_ch == '?' || *br_ch == '$' // we're in some expansion like $foo$bar or ${foo$bar} { token_chars.next(); } else if *br_ch == '}' { token_chars.next(); // consume the closing brace insertions.push((span_start + end_pos + 1, markers::VAR_SUB_END)); break; } else { // malformed, insert end at current position insertions.push((span_start + end_pos, markers::VAR_SUB_END)); break; } } } _ if cmd_sub_depth == 0 && (dollar_ch.is_ascii_alphanumeric() || *dollar_ch == '_') => { insertions.push((span_start + dollar_pos, markers::VAR_SUB)); let mut end_pos = dollar_pos + 1; // consume the var name while let Some((cur_i, var_ch)) = token_chars.peek() { if var_ch.is_ascii_alphanumeric() || ShellParam::from_char(var_ch).is_some() || *var_ch == '_' { end_pos = *cur_i + 1; token_chars.next(); } else { break; } } insertions.push((span_start + end_pos, markers::VAR_SUB_END)); } _ => { /* Just a plain dollar sign, no marker needed */ } } } } ch if cmd_sub_depth > 0 || proc_sub_depth > 0 => { // We are inside of a command sub or process sub right now // We don't mark any of this text. It will later be recursively annotated // by the syntax highlighter token_chars.next(); // consume the char with no special handling } '\\' if !qt_state.in_single() => { token_chars.next(); // consume the backslash if token_chars.peek().is_some() { token_chars.next(); // consume the escaped char } } '\\' if qt_state.in_single() => { token_chars.next(); if let Some(&(_, '\'')) = token_chars.peek() { token_chars.next(); // consume the escaped single quote } } '<' | '>' if !qt_state.in_quote() && cmd_sub_depth == 0 && proc_sub_depth == 0 => { token_chars.next(); if let Some((_, proc_sub_ch)) = token_chars.peek() && *proc_sub_ch == '(' { proc_sub_depth += 1; token_chars.next(); // consume the paren if proc_sub_depth == 1 { insertions.push((span_start + index, markers::PROC_SUB)); } } } '"' if !qt_state.in_single() => { if qt_state.in_double() { insertions.push((span_start + *i + 1, markers::STRING_DQ_END)); } else { insertions.push((span_start + *i, markers::STRING_DQ)); } qt_state.toggle_double(); token_chars.next(); // consume the quote } '\'' if !qt_state.in_double() => { if qt_state.in_single() { insertions.push((span_start + *i + 1, markers::STRING_SQ_END)); } else { insertions.push((span_start + *i, markers::STRING_SQ)); } qt_state.toggle_single(); token_chars.next(); // consume the quote } '[' if !qt_state.in_quote() && !token.flags.contains(TkFlags::ASSIGN) => { token_chars.next(); // consume the opening bracket let start_pos = span_start + index; let mut is_glob_pat = false; const VALID_CHARS: &[char] = &['!', '^', '-']; while let Some((cur_i, ch)) = token_chars.peek() { if *ch == ']' { is_glob_pat = true; insertions.push((span_start + *cur_i + 1, markers::RESET)); insertions.push((span_start + *cur_i, markers::GLOB)); token_chars.next(); // consume the closing bracket break; } else if !ch.is_ascii_alphanumeric() && !VALID_CHARS.contains(ch) { token_chars.next(); break; } else { token_chars.next(); } } if is_glob_pat { insertions.push((start_pos + 1, markers::RESET)); insertions.push((start_pos, markers::GLOB)); } } '*' | '?' if !qt_state.in_quote() => { let glob_ch = *ch; token_chars.next(); // consume the first glob char if !in_context(markers::COMMAND, &insertions) { let offset = if glob_ch == '*' && token_chars.peek().is_some_and(|(_, c)| *c == '*') { // it's one of these probably: ./dir/**/*.txt token_chars.next(); // consume the second * 2 } else { // just a regular glob char 1 }; insertions.push((span_start + index + offset, markers::RESET)); insertions.push((span_start + index, markers::GLOB)); } } '!' if !qt_state.in_single() && cmd_sub_depth == 0 && proc_sub_depth == 0 => { let bang_pos = index; token_chars.next(); // consume the '!' if let Some((_, next_ch)) = token_chars.peek() { match next_ch { '!' | '$' => { // !! or !$ token_chars.next(); insertions.push((span_start + bang_pos, markers::HIST_EXP)); insertions.push((span_start + bang_pos + 2, markers::HIST_EXP_END)); } c if c.is_ascii_alphanumeric() || *c == '-' => { // !word, !-N, !N let mut end_pos = bang_pos + 1; while let Some((cur_i, wch)) = token_chars.peek() { if wch.is_ascii_alphanumeric() || *wch == '_' || *wch == '-' { end_pos = *cur_i + 1; token_chars.next(); } else { break; } } insertions.push((span_start + bang_pos, markers::HIST_EXP)); insertions.push((span_start + end_pos, markers::HIST_EXP_END)); } _ => { /* lone ! before non-expansion char, ignore */ } } } } _ => { token_chars.next(); // consume the char with no special handling } } } sort_insertions(&mut insertions); insertions }