Implemented -o opt for complete/compgen builtins

Completion candidates now come with a space by default, unless it's a directory
This commit is contained in:
2026-02-27 09:44:33 -05:00
parent 30bc394d18
commit 3d3693e2c3
11 changed files with 161 additions and 68 deletions

View File

@@ -1,9 +1,9 @@
use std::{collections::HashSet, env, fmt::Debug, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
use crate::{
builtin::{BUILTINS, complete::{CompFlags, CompOpts}},
builtin::{BUILTINS, complete::{CompFlags, CompOptFlags, CompOpts}},
libsh::{error::{ShErr, ShErrKind, ShResult}, utils::TkVecUtils},
parse::{execute::{VarCtxGuard, exec_input}, lex::{self, LexFlags, Tk, TkFlags, TkRule}},
parse::{execute::{VarCtxGuard, exec_input}, lex::{self, LexFlags, Tk, TkFlags, TkRule, ends_with_unescaped}},
readline::{
Marker, annotate_input, annotate_input_recursive, get_insertions,
markers::{self, is_marker},
@@ -167,6 +167,12 @@ fn complete_filename(start: &str) -> Vec<String> {
candidates
}
pub enum CompSpecResult {
NoSpec, // No compspec registered
NoMatch { flags: CompOptFlags }, // Compspec found but no candidates matched, returns behavior flags
Match(CompResult) // Compspec found and candidates returned
}
#[derive(Default,Debug,Clone)]
pub struct BashCompSpec {
/// -F: The name of a function to generate the possible completions.
@@ -186,6 +192,7 @@ pub struct BashCompSpec {
/// -A signal: complete signal names
pub signals: bool,
pub flags: CompOptFlags,
/// The original command
pub source: String
}
@@ -231,7 +238,7 @@ impl BashCompSpec {
self
}
pub fn from_comp_opts(opts: CompOpts) -> Self {
let CompOpts { func, wordlist, action: _, flags } = opts;
let CompOpts { func, wordlist, action: _, flags, opt_flags } = opts;
Self {
function: func,
wordlist,
@@ -240,6 +247,7 @@ impl BashCompSpec {
commands: flags.contains(CompFlags::CMDS),
users: flags.contains(CompFlags::USERS),
vars: flags.contains(CompFlags::VARS),
flags: opt_flags,
signals: false, // TODO: implement signal completion
source: String::new()
}
@@ -320,11 +328,18 @@ impl CompSpec for BashCompSpec {
fn source(&self) -> &str {
&self.source
}
fn get_flags(&self) -> CompOptFlags {
self.flags
}
}
pub trait CompSpec: Debug + CloneCompSpec {
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<String>>;
fn source(&self) -> &str;
fn get_flags(&self) -> CompOptFlags {
CompOptFlags::empty()
}
}
pub trait CloneCompSpec {
@@ -376,23 +391,20 @@ impl CompResult {
}
}
#[derive(Default,Debug,Clone)]
pub struct Completer {
pub candidates: Vec<String>,
pub selected_idx: usize,
pub original_input: String,
pub token_span: (usize, usize),
pub active: bool,
pub dirs_only: bool,
pub no_space: bool
}
impl Completer {
pub fn new() -> Self {
Self {
candidates: vec![],
selected_idx: 0,
original_input: String::new(),
token_span: (0, 0),
active: false,
}
Self::default()
}
pub fn slice_line(line: &str, cursor_pos: usize) -> (&str, &str) {
@@ -487,11 +499,29 @@ impl Completer {
self.get_completed_line()
}
pub fn add_spaces(&mut self) {
if !self.no_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<Option<String>> {
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;
@@ -500,6 +530,7 @@ impl Completer {
}
CompResult::Single { result } => {
self.candidates = vec![result.clone()];
self.add_spaces();
self.selected_idx = 0;
self.original_input = line;
self.active = false;
@@ -578,20 +609,20 @@ impl Completer {
Ok(ctx)
}
pub fn try_comp_spec(&self, ctx: &CompContext) -> ShResult<CompResult> {
pub fn try_comp_spec(&self, ctx: &CompContext) -> ShResult<CompSpecResult> {
let Some(cmd) = ctx.cmd() else {
return Ok(CompResult::NoMatch);
return Ok(CompSpecResult::NoSpec);
};
let Some(spec) = read_meta(|m| m.get_comp_spec(cmd)) else {
return Ok(CompResult::NoMatch);
return Ok(CompSpecResult::NoSpec);
};
let candidates = spec.complete(ctx)?;
if candidates.is_empty() {
Ok(CompResult::NoMatch)
Ok(CompSpecResult::NoMatch { flags: spec.get_flags() })
} else {
Ok(CompResult::from_candidates(candidates))
Ok(CompSpecResult::Match(CompResult::from_candidates(candidates)))
}
}
@@ -610,10 +641,26 @@ impl Completer {
}
// Try programmable completion first
let res = self.try_comp_spec(&ctx)?;
if !matches!(res, CompResult::NoMatch) {
return Ok(res);
}
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::NOSPACE) {
self.no_space = true;
}
}
CompSpecResult::Match(comp_result) => {
return Ok(comp_result);
}
CompSpecResult::NoSpec => { /* carry on */ }
}
// Get the current token from CompContext
let Some(mut cur_token) = ctx.words.get(ctx.cword).cloned() else {
@@ -648,6 +695,7 @@ impl Completer {
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) => {
let var_candidates = complete_vars(&raw_tk);
@@ -682,9 +730,3 @@ impl Completer {
}
}
impl Default for Completer {
fn default() -> Self {
Self::new()
}
}

View File

@@ -292,19 +292,19 @@ impl Highlighter {
fn is_valid(command: &str) -> bool {
let cmd_path = Path::new(&command);
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
true
} else {
let Ok(meta) = cmd_path.metadata() else {
return false;
};
// this is a file that is executable by someone
meta.permissions().mode() & 0o111 != 0
}
} else {
if cmd_path.is_dir() && read_shopts(|o| o.core.autocd) {
// this is a directory and autocd is enabled
return true;
}
if cmd_path.is_absolute() {
// the user has given us an absolute path
let Ok(meta) = cmd_path.metadata() else {
return false;
};
// this is a file that is executable by someone
meta.permissions().mode() & 0o111 != 0
} else {
read_meta(|m| m.cached_cmds().get(command).is_some())
}
}

View File

@@ -224,7 +224,7 @@ impl History {
pub fn new() -> ShResult<Self> {
let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes);
let max_hist = crate::state::read_shopts(|s| s.core.max_hist);
let path = PathBuf::from(env::var("FERNHIST").unwrap_or({
let path = PathBuf::from(env::var("SHEDHIST").unwrap_or({
let home = env::var("HOME").unwrap();
format!("{home}/.shed_history")
}));

View File

@@ -771,6 +771,14 @@ impl LineBuf {
}
Some(self.line_bounds(line_no))
}
pub fn this_line_exclusive(&mut self) -> (usize, usize) {
let line_no = self.cursor_line_number();
let (start, mut end) = self.line_bounds(line_no);
if self.read_grapheme_before(end).is_some_and(|gr| gr == "\n") {
end = end.saturating_sub(1);
}
(start, end)
}
pub fn this_line(&mut self) -> (usize, usize) {
let line_no = self.cursor_line_number();
self.line_bounds(line_no)
@@ -781,6 +789,9 @@ impl LineBuf {
pub fn end_of_line(&mut self) -> usize {
self.this_line().1
}
pub fn end_of_line_exclusive(&mut self) -> usize {
self.this_line_exclusive().1
}
pub fn select_lines_up(&mut self, n: usize) -> Option<(usize, usize)> {
if self.start_of_line() == 0 {
return None;
@@ -1932,7 +1943,7 @@ impl LineBuf {
for tk in tokens {
if tk.flags.contains(TkFlags::KEYWORD) {
match tk.as_str() {
"then" | "do" => level += 1,
"then" | "do" | "in" => level += 1,
"done" | "fi" | "esac" => level = level.saturating_sub(1),
_ => { /* Continue */ }
}
@@ -2476,7 +2487,7 @@ impl LineBuf {
log::debug!("self.grapheme_indices().len(): {}", self.grapheme_indices().len());
let mut do_indent = false;
if verb == Verb::Change && (start,end) == self.this_line() {
if verb == Verb::Change && (start,end) == self.this_line_exclusive() {
do_indent = read_shopts(|o| o.prompt.auto_indent);
}

View File

@@ -257,14 +257,19 @@ impl ShedVi {
/// Reset readline state for a new prompt
pub fn reset(&mut self) {
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 = Prompt::new();
self.editor = Default::default();
self.mode = Box::new(ViInsert::new());
self.old_layout = None;
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 {
@@ -276,6 +281,9 @@ impl ShedVi {
}
fn should_submit(&mut self) -> ShResult<bool> {
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::<ShResult<Vec<_>>>();
@@ -562,6 +570,7 @@ impl ShedVi {
self.writer.flush_write(&self.mode.cursor_style())?;
self.old_layout = Some(new_layout);
self.needs_redraw = false;
Ok(())
}

View File

@@ -675,7 +675,6 @@ impl ViNormal {
// Double inputs
('?', Some(VerbCmd(_, Verb::Rot13)))
| ('d', Some(VerbCmd(_, Verb::Delete)))
| ('c', Some(VerbCmd(_, Verb::Change)))
| ('y', Some(VerbCmd(_, Verb::Yank)))
| ('=', Some(VerbCmd(_, Verb::Equalize)))
| ('u', Some(VerbCmd(_, Verb::ToLower)))
@@ -685,6 +684,9 @@ impl ViNormal {
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineInclusive));
}
('c', Some(VerbCmd(_, Verb::Change))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLineExclusive));
}
('W', Some(VerbCmd(_, Verb::Change))) => {
// Same with 'W'
break 'motion_parse Some(MotionCmd(