use std::{ collections::HashSet, fmt::{Debug, Write}, path::PathBuf, sync::Arc, }; use nix::sys::signal::Signal; use crate::{ builtin::complete::{CompFlags, CompOptFlags, CompOpts}, libsh::{error::ShResult, guards::var_ctx_guard, sys::TTY_FILENO, utils::TkVecUtils}, parse::{ execute::exec_input, lex::{self, LexFlags, Tk, TkRule, ends_with_unescaped}, }, readline::{ Marker, annotate_input_recursive, keys::{KeyCode as C, KeyEvent as K, ModKeys as M}, linebuf::{ClampedUsize, LineBuf}, markers::{self, is_marker}, term::{LineWriter, TermWriter, calc_str_width, get_win_size}, vimode::{ViInsert, ViMode}, }, state::{VarFlags, VarKind, read_jobs, read_logic, read_meta, read_vars, write_vars}, }; pub fn complete_signals(start: &str) -> Vec { Signal::iterator() .map(|s| { s.to_string() .strip_prefix("SIG") .unwrap_or(s.as_ref()) .to_string() }) .filter(|s| s.starts_with(start)) .collect() } pub fn complete_aliases(start: &str) -> Vec { read_logic(|l| { l.aliases() .iter() .filter(|a| a.0.starts_with(start)) .map(|a| a.0.clone()) .collect() }) } pub fn complete_jobs(start: &str) -> Vec { if let Some(prefix) = start.strip_prefix('%') { read_jobs(|j| { j.jobs() .iter() .filter_map(|j| j.as_ref()) .filter_map(|j| j.name()) .filter(|name| name.starts_with(prefix)) .map(|name| format!("%{name}")) .collect() }) } else { read_jobs(|j| { j.jobs() .iter() .filter_map(|j| j.as_ref()) .map(|j| j.pgid().to_string()) .filter(|pgid| pgid.starts_with(start)) .collect() }) } } pub fn complete_users(start: &str) -> Vec { let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else { return vec![]; }; passwd .lines() .filter_map(|line| line.split(':').next()) .filter(|username| username.starts_with(start)) .map(|s| s.to_string()) .collect() } pub fn complete_vars(start: &str) -> Vec { let Some((var_name, name_start, _end)) = extract_var_name(start) else { return vec![]; }; if !read_vars(|v| v.get_var(&var_name)).is_empty() { return vec![]; } // if we are here, we have a variable substitution that isn't complete // so let's try to complete it let prefix = &start[..name_start]; // e.g. "$" or "${" read_vars(|v| { v.flatten_vars() .keys() .filter(|k| k.starts_with(&var_name) && *k != &var_name) .map(|k| format!("{prefix}{k}")) .collect::>() }) } pub fn extract_var_name(text: &str) -> Option<(String, usize, usize)> { let mut chars = text.chars().peekable(); let mut name = String::new(); let mut reading_name = false; let mut pos = 0; let mut name_start = 0; let mut name_end = 0; while let Some(ch) = chars.next() { match ch { '$' => { if chars.peek() == Some(&'{') { continue; } reading_name = true; name_start = pos + 1; // Start after the '$' } '{' if !reading_name => { reading_name = true; name_start = pos + 1; } ch if ch.is_alphanumeric() || ch == '_' => { if reading_name { name.push(ch); } } _ => { if reading_name { name_end = pos; // End before the non-alphanumeric character break; } } } pos += 1; } if !reading_name { return None; } if name_end == 0 { name_end = pos; } Some((name, name_start, name_end)) } fn complete_commands(start: &str) -> Vec { let mut candidates: Vec = read_meta(|m| { m.cached_cmds() .iter() .filter(|c| c.starts_with(start)) .cloned() .collect() }); candidates.sort(); candidates } fn complete_dirs(start: &str) -> Vec { let filenames = complete_filename(start); filenames .into_iter() .filter(|f| std::fs::metadata(f).map(|m| m.is_dir()).unwrap_or(false)) .collect() } fn complete_filename(start: &str) -> Vec { let mut candidates = vec![]; let has_dotslash = start.starts_with("./"); // Split path into directory and filename parts // Use "." if start is empty (e.g., after "foo=") let path = PathBuf::from(if start.is_empty() { "." } else { start }); let (dir, prefix) = if start.ends_with('/') || start.is_empty() { // Completing inside a directory: "src/" → dir="src/", prefix="" (path, "") } else if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { // Has directory component: "src/ma" → dir="src", prefix="ma" ( parent.to_path_buf(), path.file_name().unwrap().to_str().unwrap_or(""), ) } else { // No directory: "fil" → dir=".", prefix="fil" (PathBuf::from("."), start) }; let Ok(entries) = std::fs::read_dir(&dir) else { return candidates; }; for entry in entries.flatten() { let file_name = entry.file_name(); let file_str = file_name.to_string_lossy(); // Skip hidden files unless explicitly requested if !prefix.starts_with('.') && file_str.starts_with('.') { continue; } if file_str.starts_with(prefix) { // Reconstruct full path let mut full_path = dir.join(&file_name); // Add trailing slash for directories if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { full_path.push(""); // adds trailing / } let mut path_raw = full_path.to_string_lossy().to_string(); if path_raw.starts_with("./") && !has_dotslash { path_raw = path_raw.trim_start_matches("./").to_string(); } candidates.push(path_raw); } } candidates.sort(); candidates } pub enum CompSpecResult { NoSpec, // No compspec registered NoMatch { flags: CompOptFlags, }, /* Compspec found but no candidates matched, returns * behavior flags */ Match { result: CompResult, flags: CompOptFlags, }, // Compspec found and candidates returned } #[derive(Default, Debug, Clone)] pub struct BashCompSpec { /// -F: The name of a function to generate the possible completions. pub function: Option, /// -W: The list of words pub wordlist: Option>, /// -f: complete file names pub files: bool, /// -d: complete directory names pub dirs: bool, /// -c: complete command names pub commands: bool, /// -u: complete user names pub users: bool, /// -v: complete variable names pub vars: bool, /// -A signal: complete signal names pub signals: bool, /// -j: complete job pids or names pub jobs: bool, /// -a: complete aliases pub aliases: bool, pub flags: CompOptFlags, /// The original command pub source: String, } impl BashCompSpec { pub fn new() -> Self { Self::default() } pub fn with_func(mut self, func: String) -> Self { self.function = Some(func); self } pub fn with_wordlist(mut self, wordlist: Vec) -> Self { self.wordlist = Some(wordlist); self } pub fn with_source(mut self, source: String) -> Self { self.source = source; self } pub fn files(mut self, enable: bool) -> Self { self.files = enable; self } pub fn dirs(mut self, enable: bool) -> Self { self.dirs = enable; self } pub fn commands(mut self, enable: bool) -> Self { self.commands = enable; self } pub fn users(mut self, enable: bool) -> Self { self.users = enable; self } pub fn vars(mut self, enable: bool) -> Self { self.vars = enable; self } pub fn signals(mut self, enable: bool) -> Self { self.signals = enable; self } pub fn jobs(mut self, enable: bool) -> Self { self.jobs = enable; self } pub fn aliases(mut self, enable: bool) -> Self { self.aliases = enable; self } pub fn from_comp_opts(opts: CompOpts) -> Self { let CompOpts { func, wordlist, action: _, flags, opt_flags, } = opts; Self { function: func, wordlist, files: flags.contains(CompFlags::FILES), dirs: flags.contains(CompFlags::DIRS), commands: flags.contains(CompFlags::CMDS), users: flags.contains(CompFlags::USERS), vars: flags.contains(CompFlags::VARS), jobs: flags.contains(CompFlags::JOBS), aliases: flags.contains(CompFlags::ALIAS), flags: opt_flags, signals: false, // TODO: implement signal completion source: String::new(), } } pub fn exec_comp_func(&self, ctx: &CompContext) -> ShResult> { let mut vars_to_unset = HashSet::new(); for var in [ "COMP_WORDS", "COMP_CWORD", "COMP_LINE", "COMP_POINT", "COMPREPLY", ] { vars_to_unset.insert(var.to_string()); } let _guard = var_ctx_guard(vars_to_unset); let CompContext { words, cword, line, cursor_pos, } = ctx; let raw_words = words.iter().clone().map(|tk| tk.to_string()).collect(); write_vars(|v| { v.set_var( "COMP_WORDS", VarKind::arr_from_vec(raw_words), VarFlags::NONE, ) })?; write_vars(|v| { v.set_var( "COMP_CWORD", VarKind::Str(cword.to_string()), VarFlags::NONE, ) })?; write_vars(|v| v.set_var("COMP_LINE", VarKind::Str(line.to_string()), VarFlags::NONE))?; write_vars(|v| { v.set_var( "COMP_POINT", VarKind::Str(cursor_pos.to_string()), VarFlags::NONE, ) })?; let cmd_name = words.first().map(|s| s.to_string()).unwrap_or_default(); let cword_str = words.get(*cword).map(|s| s.to_string()).unwrap_or_default(); let pword_str = if *cword > 0 { words .get(cword - 1) .map(|s| s.to_string()) .unwrap_or_default() } else { String::new() }; let input = format!( "{} {cmd_name} {cword_str} {pword_str}", self.function.as_ref().unwrap() ); exec_input(input, None, false, Some("comp_function".into()))?; Ok(read_vars(|v| v.get_arr_elems("COMPREPLY")).unwrap_or_default()) } } impl CompSpec for BashCompSpec { fn complete(&self, ctx: &CompContext) -> ShResult> { let mut candidates = vec![]; let prefix = &ctx.words[ctx.cword]; let expanded = prefix.clone().expand()?.get_words().join(" "); if self.files { candidates.extend(complete_filename(&expanded)); } if self.dirs { candidates.extend(complete_dirs(&expanded)); } if self.commands { candidates.extend(complete_commands(&expanded)); } if self.vars { candidates.extend(complete_vars(&expanded)); } if self.users { candidates.extend(complete_users(&expanded)); } if self.jobs { candidates.extend(complete_jobs(&expanded)); } if self.aliases { candidates.extend(complete_aliases(&expanded)); } if self.signals { candidates.extend(complete_signals(&expanded)); } if let Some(words) = &self.wordlist { candidates.extend(words.iter().filter(|w| w.starts_with(&expanded)).cloned()); } if self.function.is_some() { candidates.extend(self.exec_comp_func(ctx)?); } candidates = candidates .into_iter() .map(|c| { let stripped = c.strip_prefix(&expanded).unwrap_or_default(); format!("{prefix}{stripped}") }) .collect(); candidates.sort_by_key(|c| c.len()); // sort by length to prioritize shorter completions, ties are then sorted alphabetically Ok(candidates) } fn source(&self) -> &str { &self.source } fn get_flags(&self) -> CompOptFlags { self.flags } } pub trait CompSpec: Debug + CloneCompSpec { fn complete(&self, ctx: &CompContext) -> ShResult>; fn source(&self) -> &str; fn get_flags(&self) -> CompOptFlags { CompOptFlags::empty() } } pub trait CloneCompSpec { fn clone_box(&self) -> Box; } impl CloneCompSpec for T { fn clone_box(&self) -> Box { Box::new(self.clone()) } } impl Clone for Box { fn clone(&self) -> Self { self.clone_box() } } pub struct CompContext { pub words: Vec, pub cword: usize, pub line: String, pub cursor_pos: usize, } impl CompContext { pub fn cmd(&self) -> Option<&str> { self.words.first().map(|s| s.as_str()) } } pub enum CompResult { NoMatch, Single { result: String }, Many { candidates: Vec }, } impl CompResult { pub fn from_candidates(candidates: Vec) -> Self { if candidates.is_empty() { Self::NoMatch } else if candidates.len() == 1 { Self::Single { result: candidates[0].clone(), } } else { Self::Many { candidates } } } } pub enum CompResponse { Passthrough, // key falls through Accept(String), // user accepted completion Dismiss, // user canceled completion Consumed, // key was handled, but completion remains active } pub enum SelectorResponse { Accept(String), Dismiss, Consumed, } pub trait Completer { fn complete( &mut self, line: String, cursor_pos: usize, direction: i32, ) -> ShResult>; fn reset(&mut self); fn reset_stay_active(&mut self); fn is_active(&self) -> bool; fn all_candidates(&self) -> Vec { vec![] } fn selected_candidate(&self) -> Option; fn token_span(&self) -> (usize, usize); fn original_input(&self) -> &str; fn token(&self) -> &str { let orig = self.original_input(); let (s,e) = self.token_span(); orig.get(s..e).unwrap_or(orig) } fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()>; fn clear(&mut self, _writer: &mut TermWriter) -> ShResult<()> { Ok(()) } fn set_prompt_line_context(&mut self, _line_width: u16, _cursor_col: u16) {} fn handle_key(&mut self, key: K) -> ShResult; fn get_completed_line(&self, candidate: &str) -> String; } #[derive(Default, Debug, Clone)] pub struct ScoredCandidate { pub content: String, pub score: Option, } impl ScoredCandidate { const BONUS_BOUNDARY: i32 = 10; const BONUS_CONSECUTIVE: i32 = 8; const BONUS_FIRST_CHAR: i32 = 5; const PENALTY_GAP_START: i32 = 3; const PENALTY_GAP_EXTEND: i32 = 1; pub fn new(content: String) -> Self { Self { content, score: None, } } fn is_word_bound(prev: char, curr: char) -> bool { match prev { '/' | '_' | '-' | '.' | ' ' => true, c if c.is_lowercase() && curr.is_uppercase() => true, // camelCase boundary _ => false, } } pub fn fuzzy_score(&mut self, other: &str) -> i32 { if other.is_empty() { self.score = Some(0); return 0; } let query_chars: Vec = other.chars().collect(); let content_chars: Vec = self.content.chars().collect(); let mut indices = vec![]; let mut qi = 0; for (ci, c_ch) in self.content.chars().enumerate() { if qi < query_chars.len() && c_ch.eq_ignore_ascii_case(&query_chars[qi]) { indices.push(ci); qi += 1; } } if indices.len() != query_chars.len() { self.score = Some(i32::MIN); return i32::MIN; } let mut score: i32 = 0; for (i, &idx) in indices.iter().enumerate() { if idx == 0 { score += Self::BONUS_FIRST_CHAR; } if idx == 0 || Self::is_word_bound(content_chars[idx - 1], content_chars[idx]) { score += Self::BONUS_BOUNDARY; } if i > 0 { let gap = idx - indices[i - 1] - 1; if gap == 0 { score += Self::BONUS_CONSECUTIVE; } else { score -= Self::PENALTY_GAP_START + (gap as i32 - 1) * Self::PENALTY_GAP_EXTEND; } } } self.score = Some(score); score } } impl From for ScoredCandidate { fn from(content: String) -> Self { Self { content, score: None, } } } #[derive(Debug, Clone)] pub struct FuzzyLayout { rows: u16, cols: u16, cursor_col: u16, /// Width of the prompt line above the `\n` that starts the fuzzy window. /// If PSR was drawn, this is `t_cols`; otherwise the content width. preceding_line_width: u16, /// Cursor column on the prompt line before the fuzzy window was drawn. preceding_cursor_col: u16, } #[derive(Default, Debug, Clone)] pub struct QueryEditor { mode: ViInsert, scroll_offset: usize, available_width: usize, linebuf: LineBuf, } impl QueryEditor { pub fn clear(&mut self) { self.linebuf = LineBuf::new(); self.mode = ViInsert::default(); self.scroll_offset = 0; } pub fn set_available_width(&mut self, width: usize) { self.available_width = width; } pub fn update_scroll_offset(&mut self) { self.linebuf.update_graphemes(); let cursor_pos = self.linebuf.cursor.get(); if cursor_pos < self.scroll_offset + 1 { self.scroll_offset = self.linebuf.cursor.ret_sub(1); } if cursor_pos >= self.scroll_offset + self.available_width.saturating_sub(1) { self.scroll_offset = self .linebuf .cursor .ret_sub(self.available_width.saturating_sub(1)); } let max_offset = self .linebuf .grapheme_indices() .len() .saturating_sub(self.available_width); self.scroll_offset = self.scroll_offset.min(max_offset); } pub fn get_window(&mut self) -> String { self.linebuf.update_graphemes(); let buf_len = self.linebuf.grapheme_indices().len(); if buf_len <= self.available_width { return self.linebuf.as_str().to_string(); } let start = self .scroll_offset .min(buf_len.saturating_sub(self.available_width)); let end = (start + self.available_width).min(buf_len); self.linebuf.slice(start..end).unwrap_or("").to_string() } pub fn handle_key(&mut self, key: K) -> ShResult<()> { let Some(cmd) = self.mode.handle_key(key) else { return Ok(()); }; self.linebuf.exec_cmd(cmd) } } #[derive(Clone, Debug)] pub struct FuzzySelector { query: QueryEditor, filtered: Vec, candidates: Vec, cursor: ClampedUsize, number_candidates: bool, old_layout: Option, max_height: usize, scroll_offset: usize, active: bool, prompt_line_width: u16, prompt_cursor_col: u16, title: String, } #[derive(Clone, Debug)] pub struct FuzzyCompleter { completer: SimpleCompleter, pub selector: FuzzySelector, } impl FuzzySelector { const BOT_LEFT: &str = "\x1b[90m╰\x1b[0m"; const BOT_RIGHT: &str = "\x1b[90m╯\x1b[0m"; const TOP_LEFT: &str = "\x1b[90m╭\x1b[0m"; const TOP_RIGHT: &str = "\x1b[90m╮\x1b[0m"; const HOR_LINE: &str = "\x1b[90m─\x1b[0m"; const VERT_LINE: &str = "\x1b[90m│\x1b[0m"; const SELECTOR_GRAY: &str = "\x1b[90m▌\x1b[0m"; const SELECTOR_HL: &str = "\x1b[38;2;200;0;120m▌\x1b[1;39;48;5;237m"; const PROMPT_ARROW: &str = "\x1b[1;36m>\x1b[0m"; const TREE_LEFT: &str = "\x1b[90m├\x1b[0m"; const TREE_RIGHT: &str = "\x1b[90m┤\x1b[0m"; pub fn new(title: impl Into) -> Self { Self { max_height: 8, query: QueryEditor::default(), filtered: vec![], candidates: vec![], cursor: ClampedUsize::new(0, 0, true), number_candidates: false, old_layout: None, scroll_offset: 0, active: false, prompt_line_width: 0, prompt_cursor_col: 0, title: title.into(), } } pub fn number_candidates(self, enable: bool) -> Self { Self { number_candidates: enable, ..self } } pub fn candidates(&self) -> &[String] { &self.candidates } pub fn filtered(&self) -> &[ScoredCandidate] { &self.filtered } pub fn filtered_len(&self) -> usize { self.filtered.len() } pub fn candidates_len(&self) -> usize { self.candidates.len() } pub fn activate(&mut self, candidates: Vec) { self.active = true; self.candidates = candidates; self.score_candidates(); } pub fn set_query(&mut self, query: String) { self.query.linebuf = LineBuf::new().with_initial(&query, query.len()); self.query.update_scroll_offset(); self.score_candidates(); } pub fn reset(&mut self) { self.query.clear(); self.filtered.clear(); self.candidates.clear(); self.cursor = ClampedUsize::new(0, 0, true); self.old_layout = None; self.scroll_offset = 0; self.active = false; } pub fn reset_stay_active(&mut self) { if self.active { self.query.clear(); self.score_candidates(); } } pub fn is_active(&self) -> bool { self.active } pub fn selected_candidate(&self) -> Option { self .filtered .get(self.cursor.get()) .map(|c| c.content.clone()) } pub fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) { self.prompt_line_width = line_width; self.prompt_cursor_col = cursor_col; } fn candidate_height(&self, idx: usize) -> usize { self .filtered .get(idx) .map(|c| c.content.trim_end().lines().count().max(1)) .unwrap_or(1) } fn get_window(&mut self) -> &[ScoredCandidate] { self.update_scroll_offset(); let mut lines = 0; let mut end = self.scroll_offset; while end < self.filtered.len() { if lines >= self.max_height { break; } lines += self.candidate_height(end); end += 1; } &self.filtered[self.scroll_offset..end] } pub fn update_scroll_offset(&mut self) { let cursor = self.cursor.get(); // Scroll up: cursor above window if cursor < self.scroll_offset { self.scroll_offset = cursor; return; } // Scroll down: ensure all candidates from scroll_offset through cursor // fit within max_height rendered lines loop { let mut lines = 0; let last = cursor.min(self.filtered.len().saturating_sub(1)); for idx in self.scroll_offset..=last { lines += self.candidate_height(idx); } if lines <= self.max_height || self.scroll_offset >= cursor { break; } self.scroll_offset += 1; } } pub fn score_candidates(&mut self) { let mut scored: Vec<_> = self .candidates .clone() .into_iter() .filter_map(|c| { let mut sc = ScoredCandidate::new(c); let score = sc.fuzzy_score(self.query.linebuf.as_str()); if score > i32::MIN { Some(sc) } else { None } }) .collect(); scored.sort_by_key(|sc| sc.score.unwrap_or(i32::MIN)); scored.reverse(); self.cursor.set_max(scored.len()); self.filtered = scored; } pub fn handle_key(&mut self, key: K) -> ShResult { match key { K(C::Char('D'), M::CTRL) | K(C::Esc, M::NONE) => { self.active = false; self.filtered.clear(); Ok(SelectorResponse::Dismiss) } K(C::Enter, M::NONE) => { self.active = false; if let Some(selected) = self .filtered .get(self.cursor.get()) .map(|c| c.content.clone()) { Ok(SelectorResponse::Accept(selected)) } else { Ok(SelectorResponse::Dismiss) } } K(C::Tab, M::SHIFT) | K(C::Up, M::NONE) => { self.cursor.wrap_sub(1); self.update_scroll_offset(); Ok(SelectorResponse::Consumed) } K(C::Tab, M::NONE) | K(C::Down, M::NONE) => { self.cursor.wrap_add(1); self.update_scroll_offset(); Ok(SelectorResponse::Consumed) } _ => { self.query.handle_key(key)?; self.score_candidates(); Ok(SelectorResponse::Consumed) } } } pub fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { if !self.active { return Ok(()); } let (cols, _) = get_win_size(*TTY_FILENO); let mut buf = String::new(); let cursor_pos = self.cursor.get(); let offset = self.scroll_offset; self .query .set_available_width(cols.saturating_sub(6) as usize); self.query.update_scroll_offset(); let query = self.query.get_window(); let num_filtered = format!("\x1b[33m{}\x1b[0m", self.filtered.len()); let num_candidates = format!("\x1b[33m{}\x1b[0m", self.candidates.len()); let title = self.title.clone(); let title_width = title.len() as u16; let number_candidates = self.number_candidates; let min_pad = self .candidates .len() .to_string() .len() .saturating_add(1) .max(6); let max_height = self.max_height; let visible = self.get_window(); let mut rows: u16 = 0; let top_bar = format!( "\n{}{} \x1b[1m{}\x1b[0m {}{}", Self::TOP_LEFT, Self::HOR_LINE, title, Self::HOR_LINE.repeat(cols.saturating_sub(title_width + 5) as usize), Self::TOP_RIGHT ); buf.push_str(&top_bar); rows += 1; for _ in 0..rows {} let prompt = format!("{} {} {}", Self::VERT_LINE, Self::PROMPT_ARROW, &query); let cols_used = calc_str_width(&prompt); let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize); let prompt_line_final = format!("{}{}{}", prompt, right_pad, Self::VERT_LINE); buf.push_str(&prompt_line_final); rows += 1; let sep_line_left = format!( "{}{}{}/{}", Self::TREE_LEFT, Self::HOR_LINE.repeat(2), &num_filtered, &num_candidates ); let cols_used = calc_str_width(&sep_line_left); let right_pad = Self::HOR_LINE.repeat(cols.saturating_sub(cols_used + 1) as usize); let sep_line_final = format!("{}{}{}", sep_line_left, right_pad, Self::TREE_RIGHT); buf.push_str(&sep_line_final); rows += 1; let mut lines_drawn = 0; for (i, candidate) in visible.iter().enumerate() { if lines_drawn >= max_height { break; } let selector = if i + offset == cursor_pos { Self::SELECTOR_HL } else { Self::SELECTOR_GRAY }; let mut drew_number = false; for line in candidate.content.trim_end().lines() { if lines_drawn >= max_height { break; } let mut line = line.trim_end().replace('\t', " "); let col_lim = if number_candidates { cols.saturating_sub(3 + min_pad as u16) } else { cols.saturating_sub(3) }; if calc_str_width(&line) > col_lim { line.truncate(col_lim.saturating_sub(6) as usize); line.push_str("..."); } let left = if number_candidates { if !drew_number { let this_num = i + offset + 1; let right_pad = " ".repeat(min_pad.saturating_sub(this_num.to_string().len())); format!( "{} {}\x1b[33m{}\x1b[39m{right_pad}{}\x1b[0m", Self::VERT_LINE, &selector, i + offset + 1, &line ) } else { let right_pad = " ".repeat(min_pad); format!( "{} {}{}{}\x1b[0m", Self::VERT_LINE, &selector, right_pad, &line ) } } else { format!("{} {}{}\x1b[0m", Self::VERT_LINE, &selector, &line) }; let cols_used = calc_str_width(&left); let right_pad = " ".repeat(cols.saturating_sub(cols_used + 1) as usize); let hl_cand_line = format!("{}{}{}", left, right_pad, Self::VERT_LINE); buf.push_str(&hl_cand_line); rows += 1; drew_number = true; lines_drawn += 1; } } let bot_bar = format!( "{}{}{}", Self::BOT_LEFT, Self::HOR_LINE .to_string() .repeat(cols.saturating_sub(2) as usize), Self::BOT_RIGHT ); buf.push_str(&bot_bar); rows += 1; let lines_below_prompt = rows.saturating_sub(2); let cursor_in_window = self .query .linebuf .cursor .get() .saturating_sub(self.query.scroll_offset); let cursor_col = (cursor_in_window + 4) as u16; write!(buf, "\x1b[{}A\r\x1b[{}C", lines_below_prompt, cursor_col).unwrap(); let new_layout = FuzzyLayout { rows, cols, cursor_col, preceding_line_width: self.prompt_line_width, preceding_cursor_col: self.prompt_cursor_col, }; writer.flush_write(&buf)?; self.old_layout = Some(new_layout); Ok(()) } pub fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { if let Some(layout) = self.old_layout.take() { let (new_cols, _) = get_win_size(*TTY_FILENO); let total_cells = layout.rows as u32 * layout.cols as u32; let physical_rows = if new_cols > 0 { total_cells.div_ceil(new_cols as u32) as u16 } else { layout.rows }; let cursor_offset = layout.cols as u32 + layout.cursor_col as u32; let cursor_phys_row = if new_cols > 0 { (cursor_offset / new_cols as u32) as u16 } else { 1 }; let lines_below = physical_rows.saturating_sub(cursor_phys_row + 1); let gap_extra = if new_cols > 0 && layout.preceding_line_width > new_cols { let wrap_rows = (layout.preceding_line_width as u32).div_ceil(new_cols as u32) as u16; let cursor_wrap_row = layout.preceding_cursor_col / new_cols; wrap_rows.saturating_sub(cursor_wrap_row + 1) } else { 0 }; let mut buf = String::new(); if lines_below > 0 { write!(buf, "\x1b[{}B", lines_below).unwrap(); } for _ in 0..physical_rows { buf.push_str("\x1b[2K\x1b[A"); } buf.push_str("\x1b[2K"); for _ in 0..gap_extra { buf.push_str("\x1b[A\x1b[2K"); } writer.flush_write(&buf)?; } Ok(()) } } impl Default for FuzzyCompleter { fn default() -> Self { Self { completer: SimpleCompleter::default(), selector: FuzzySelector::new("Complete"), } } } impl Completer for FuzzyCompleter { fn all_candidates(&self) -> Vec { self.selector.candidates.clone() } fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) { self .selector .set_prompt_line_context(line_width, cursor_col); } fn reset_stay_active(&mut self) { self.selector.reset_stay_active(); } fn get_completed_line(&self, _candidate: &str) -> String { log::debug!("Getting completed line for candidate: {}", _candidate); let selected = self.selector.selected_candidate().unwrap_or_default(); log::debug!("Selected candidate: {}", selected); let (start, end) = self.completer.token_span; log::debug!("Token span: ({}, {})", start, end); let ret = format!( "{}{}{}", &self.completer.original_input[..start], selected, &self.completer.original_input[end..] ); log::debug!("Completed line: {}", ret); ret } fn complete( &mut self, line: String, cursor_pos: usize, direction: i32, ) -> ShResult> { self.completer.complete(line, cursor_pos, direction)?; let candidates: Vec<_> = self.completer.candidates.clone(); if candidates.is_empty() { self.completer.reset(); self.selector.active = false; return Ok(None); } else if candidates.len() == 1 { self.selector.filtered = candidates.into_iter().map(ScoredCandidate::from).collect(); let selected = self.selector.filtered[0].content.clone(); let completed = self.get_completed_line(&selected); self.selector.active = false; return Ok(Some(completed)); } self.selector.activate(candidates); Ok(None) } fn handle_key(&mut self, key: K) -> ShResult { match self.selector.handle_key(key)? { SelectorResponse::Accept(s) => Ok(CompResponse::Accept(s)), SelectorResponse::Dismiss => Ok(CompResponse::Dismiss), SelectorResponse::Consumed => Ok(CompResponse::Consumed), } } fn clear(&mut self, writer: &mut TermWriter) -> ShResult<()> { self.selector.clear(writer) } fn draw(&mut self, writer: &mut TermWriter) -> ShResult<()> { self.selector.draw(writer) } fn reset(&mut self) { self.completer.reset(); self.selector.reset(); } fn token_span(&self) -> (usize, usize) { self.completer.token_span() } fn is_active(&self) -> bool { self.selector.is_active() } fn selected_candidate(&self) -> Option { self.selector.selected_candidate() } fn original_input(&self) -> &str { &self.completer.original_input } } #[derive(Default, Debug, Clone)] pub struct SimpleCompleter { pub candidates: Vec, pub selected_idx: usize, pub original_input: String, pub token_span: (usize, usize), pub active: bool, pub dirs_only: bool, pub add_space: bool, } impl Completer for SimpleCompleter { fn all_candidates(&self) -> Vec { self.candidates.clone() } fn reset_stay_active(&mut self) { let active = self.is_active(); self.reset(); self.active = active; } fn get_completed_line(&self, _candidate: &str) -> String { self.get_completed_line() } fn complete( &mut self, line: String, cursor_pos: usize, direction: i32, ) -> ShResult> { if self.active { Ok(Some(self.cycle_completion(direction))) } else { self.start_completion(line, cursor_pos) } } fn reset(&mut self) { *self = Self::default(); } fn is_active(&self) -> bool { self.active } fn selected_candidate(&self) -> Option { self.candidates.get(self.selected_idx).cloned() } fn token_span(&self) -> (usize, usize) { self.token_span } fn draw(&mut self, _writer: &mut TermWriter) -> ShResult<()> { Ok(()) } fn original_input(&self) -> &str { &self.original_input } fn handle_key(&mut self, _key: K) -> ShResult { Ok(CompResponse::Passthrough) } } impl SimpleCompleter { pub fn new() -> Self { Self::default() } pub fn slice_line(line: &str, cursor_pos: usize) -> (&str, &str) { let (before_cursor, after_cursor) = line.split_at(cursor_pos); (before_cursor, after_cursor) } pub fn get_subtoken_completion(&self, line: &str, cursor_pos: usize) -> (Vec, usize) { let annotated = annotate_input_recursive(line); let mut ctx = vec![markers::NULL]; let mut last_priority = 0; let mut ctx_start = 0; let mut pos = 0; for ch in annotated.chars() { match ch { _ if is_marker(ch) => match ch { markers::COMMAND | markers::BUILTIN => { if last_priority < 2 { if last_priority > 0 { ctx.pop(); } ctx_start = pos; last_priority = 2; ctx.push(markers::COMMAND); } } markers::VAR_SUB => { if last_priority < 3 { if last_priority > 0 { ctx.pop(); } ctx_start = pos; last_priority = 3; ctx.push(markers::VAR_SUB); } } markers::ARG | markers::ASSIGNMENT => { if last_priority < 1 { ctx_start = pos; ctx.push(markers::ARG); } } markers::RESET => { if ctx.len() > 1 { ctx.pop(); last_priority = 0; } } _ => {} }, _ => { last_priority = 0; // reset priority on normal characters pos += 1; // we hit a normal character, advance our position if pos >= cursor_pos { break; } } } } (ctx, ctx_start) } pub fn cycle_completion(&mut self, direction: i32) -> String { if self.candidates.is_empty() { return self.original_input.clone(); } let len = self.candidates.len(); self.selected_idx = (self.selected_idx as i32 + direction).rem_euclid(len as i32) as usize; self.get_completed_line() } pub fn add_spaces(&mut self) { if self.add_space { self.candidates = std::mem::take(&mut self.candidates) .into_iter() .map(|c| { if !ends_with_unescaped(&c, "/") // directory && !ends_with_unescaped(&c, "=") // '='-type arg && !ends_with_unescaped(&c, " ") { // already has a space format!("{} ", c) } else { c } }) .collect() } } pub fn start_completion(&mut self, line: String, cursor_pos: usize) -> ShResult> { let result = self.get_candidates(line.clone(), cursor_pos)?; match result { CompResult::Many { candidates } => { self.candidates = candidates.clone(); self.add_spaces(); self.selected_idx = 0; self.original_input = line; self.active = true; Ok(Some(self.get_completed_line())) } CompResult::Single { result } => { self.candidates = vec![result.clone()]; self.add_spaces(); self.selected_idx = 0; self.original_input = line; self.active = false; Ok(Some(self.get_completed_line())) } CompResult::NoMatch => Ok(None), } } pub fn get_completed_line(&self) -> String { if self.candidates.is_empty() { return self.original_input.clone(); } let selected = &self.candidates[self.selected_idx]; let (start, end) = self.token_span; format!( "{}{}{}", &self.original_input[..start], selected, &self.original_input[end..] ) } pub fn build_comp_ctx(&self, tks: &[Tk], line: &str, cursor_pos: usize) -> ShResult { let mut ctx = CompContext { words: vec![], cword: 0, line: line.to_string(), cursor_pos, }; let segments = tks .iter() .filter(|&tk| !matches!(tk.class, TkRule::SOI | TkRule::EOI)) .cloned() .collect::>() .split_at_separators(); if segments.is_empty() { return Ok(ctx); } let relevant_pos = segments .iter() .position(|tks| { tks .iter() .next() .is_some_and(|tk| tk.span.range().start > cursor_pos) }) .map(|i| i.saturating_sub(1)) .unwrap_or(segments.len().saturating_sub(1)); let mut relevant = segments[relevant_pos].to_vec(); let cword = if let Some(pos) = relevant .iter() .position(|tk| cursor_pos >= tk.span.range().start && cursor_pos <= tk.span.range().end) { pos } else { let insert_pos = relevant .iter() .position(|tk| tk.span.range().start > cursor_pos) .unwrap_or(relevant.len()); let mut new_tk = Tk::default(); if let Some(tk) = relevant.last() { let mut span = tk.span.clone(); span.set_range(cursor_pos..cursor_pos); new_tk.span = span; } relevant.insert(insert_pos, new_tk); insert_pos }; ctx.words = relevant; ctx.cword = cword; Ok(ctx) } pub fn try_comp_spec(&self, ctx: &CompContext) -> ShResult { let Some(cmd) = ctx.cmd() else { return Ok(CompSpecResult::NoSpec); }; let Some(spec) = read_meta(|m| m.get_comp_spec(cmd)) else { return Ok(CompSpecResult::NoSpec); }; let candidates = spec.complete(ctx)?; if candidates.is_empty() { Ok(CompSpecResult::NoMatch { flags: spec.get_flags(), }) } else { Ok(CompSpecResult::Match { result: CompResult::from_candidates(candidates), flags: spec.get_flags(), }) } } pub fn get_candidates(&mut self, line: String, cursor_pos: usize) -> ShResult { let source = Arc::new(line.clone()); let tokens = lex::LexStream::new(source, LexFlags::LEX_UNFINISHED).collect::>>()?; let ctx = self.build_comp_ctx(&tokens, &line, cursor_pos)?; // Set token_span from CompContext's current word if let Some(cur) = ctx.words.get(ctx.cword) { self.token_span = (cur.span.range().start, cur.span.range().end); } else { self.token_span = (cursor_pos, cursor_pos); } // Use marker-based context detection for sub-token awareness (e.g. VAR_SUB // inside a token). Run this before comp specs so variable completions take // priority over programmable completion. let (mut marker_ctx, token_start) = self.get_subtoken_completion(&line, cursor_pos); if marker_ctx.last() == Some(&markers::VAR_SUB) && let Some(cur) = ctx.words.get(ctx.cword) { self.token_span.0 = token_start; let mut span = cur.span.clone(); span.set_range(token_start..self.token_span.1); let raw_tk = span.as_str(); let candidates = complete_vars(raw_tk); if !candidates.is_empty() { return Ok(CompResult::from_candidates(candidates)); } } // Try programmable completion match self.try_comp_spec(&ctx)? { CompSpecResult::NoMatch { flags } => { if flags.contains(CompOptFlags::DIRNAMES) { self.dirs_only = true; } else if flags.contains(CompOptFlags::DEFAULT) { /* fall through */ } else { return Ok(CompResult::NoMatch); } if flags.contains(CompOptFlags::SPACE) { self.add_space = true; } } CompSpecResult::Match { result, flags } => { if flags.contains(CompOptFlags::SPACE) { self.add_space = true; } return Ok(result); } CompSpecResult::NoSpec => { /* carry on */ } } // Get the current token from CompContext let Some(mut cur_token) = ctx.words.get(ctx.cword).cloned() else { let candidates = complete_filename("./"); let end_pos = line.len(); self.token_span = (end_pos, end_pos); return Ok(CompResult::from_candidates(candidates)); }; self.token_span = (cur_token.span.range().start, cur_token.span.range().end); if token_start >= self.token_span.0 && token_start <= self.token_span.1 { self.token_span.0 = token_start; cur_token .span .set_range(self.token_span.0..self.token_span.1); } // If token contains '=', only complete after the '=' 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; cur_token .span .set_range(self.token_span.0..self.token_span.1); } let raw_tk = cur_token.as_str().to_string(); let expanded_tk = cur_token.expand()?; let expanded_words = expanded_tk.get_words().into_iter().collect::>(); let expanded = expanded_words.join("\\ "); let last_marker = marker_ctx.last().copied(); let mut candidates = match marker_ctx.pop() { _ if self.dirs_only => complete_dirs(&expanded), Some(markers::COMMAND) => complete_commands(&expanded), Some(markers::VAR_SUB) => { // Variable completion already tried above and had no matches, // fall through to filename completion complete_filename(&expanded) } Some(markers::ARG) => complete_filename(&expanded), _ => complete_filename(&expanded), }; // Graft unexpanded prefix onto candidates to preserve things like // $SOME_PATH/file.txt Skip for var completions — complete_vars already // returns the full $VAR form let is_var_completion = last_marker == Some(markers::VAR_SUB) && !candidates.is_empty() && candidates.iter().any(|c| c.starts_with('$')); if !is_var_completion { candidates = candidates .into_iter() .map(|c| match c.strip_prefix(&expanded) { Some(suffix) => format!("{raw_tk}{suffix}"), None => c, }) .collect(); } let limit = crate::state::read_shopts(|s| s.prompt.comp_limit); candidates.truncate(limit); Ok(CompResult::from_candidates(candidates)) } }