Add Candidate type for case-insensitive completion, shopt_group macro, escape fixes, and vi mode tweaks

This commit is contained in:
2026-03-17 01:25:55 -04:00
parent bce6cd10f7
commit b0325b6bbb
8 changed files with 477 additions and 598 deletions

View File

@@ -32,7 +32,7 @@ pub fn shopt(node: Node) -> ShResult<()> {
}
for (arg, span) in argv {
let Some(mut output) = write_shopts(|s| s.query(&arg)).blame(span)? else {
let Some(mut output) = write_shopts(|s| s.query(&arg)).promote_err(span)? else {
continue;
};
@@ -61,7 +61,7 @@ mod tests {
assert!(out.contains("dotglob"));
assert!(out.contains("autocd"));
assert!(out.contains("max_hist"));
assert!(out.contains("edit_mode"));
assert!(out.contains("comp_limit"));
}
#[test]
@@ -72,7 +72,7 @@ mod tests {
assert!(out.contains("dotglob"));
assert!(out.contains("autocd"));
// Should not contain prompt opts
assert!(!out.contains("edit_mode"));
assert!(!out.contains("comp_limit"));
}
#[test]
@@ -107,11 +107,10 @@ mod tests {
}
#[test]
fn shopt_set_edit_mode() {
fn shopt_set_completion_ignore_case() {
let _g = TestGuard::new();
test_input("shopt prompt.edit_mode=emacs").unwrap();
let mode = read_shopts(|o| format!("{}", o.prompt.edit_mode));
assert_eq!(mode, "emacs");
test_input("shopt prompt.completion_ignore_case=true").unwrap();
assert!(read_shopts(|o| o.prompt.completion_ignore_case));
}
// ===================== Error cases =====================

View File

@@ -1224,11 +1224,13 @@ pub fn unescape_str(raw: &str) -> String {
while let Some(q_ch) = chars.next() {
match q_ch {
'\\' => {
if chars.peek() == Some(&'\'') {
result.push('\'');
chars.next();
} else {
result.push('\\');
match chars.peek() {
Some(&'\\') |
Some(&'\'') => {
let ch = chars.next().unwrap();
result.push(ch);
}
_ => result.push(q_ch),
}
}
'\'' => {

View File

@@ -1,6 +1,6 @@
use std::{
collections::HashSet,
fmt::{Debug, Write},
fmt::{Debug, Display, Write},
path::PathBuf,
sync::Arc,
};
@@ -29,7 +29,103 @@ use crate::{
},
};
pub fn complete_signals(start: &str) -> Vec<String> {
#[derive(Debug, Clone)]
pub struct Candidate(pub String);
impl Eq for Candidate {}
impl PartialEq for Candidate {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl PartialOrd for Candidate {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Candidate {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.0.cmp(&other.0)
}
}
impl From<String> for Candidate {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&String> for Candidate {
fn from(value: &String) -> Self {
Self(value.clone())
}
}
impl From<&str> for Candidate {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl Display for Candidate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", &self.0)
}
}
impl AsRef<str> for Candidate {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for Candidate {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl Candidate {
pub fn is_match(&self, other: &str) -> bool {
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
if ignore_case {
let other_lower = other.to_lowercase();
let self_lower = self.0.to_lowercase();
self_lower.starts_with(&other_lower)
} else {
self.0.starts_with(other)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
pub fn starts_with(&self, pat: char) -> bool {
self.0.starts_with(pat)
}
pub fn strip_prefix(&self, prefix: &str) -> Option<String> {
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
if ignore_case {
let old_len = self.0.len();
let prefix_lower = prefix.to_lowercase();
let self_lower = self.0.to_lowercase();
let stripped = self_lower.strip_prefix(&prefix_lower)?;
let new_len = stripped.len();
let delta = old_len - new_len;
Some(self.0[delta..].to_string())
} else {
self.0.strip_prefix(prefix).map(|s| s.to_string())
}
}
}
pub fn complete_signals(start: &str) -> Vec<Candidate> {
Signal::iterator()
.map(|s| {
s.to_string()
@@ -37,29 +133,31 @@ pub fn complete_signals(start: &str) -> Vec<String> {
.unwrap_or(s.as_ref())
.to_string()
})
.filter(|s| s.starts_with(start))
.map(Candidate::from)
.filter(|s| s.is_match(start))
.collect()
}
pub fn complete_aliases(start: &str) -> Vec<String> {
pub fn complete_aliases(start: &str) -> Vec<Candidate> {
read_logic(|l| {
l.aliases()
.iter()
.filter(|a| a.0.starts_with(start))
.map(|a| a.0.clone())
.keys()
.map(Candidate::from)
.filter(|a| a.is_match(start))
.collect()
})
}
pub fn complete_jobs(start: &str) -> Vec<String> {
pub fn complete_jobs(start: &str) -> Vec<Candidate> {
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}"))
.map(Candidate::from)
.filter(|name| name.is_match(prefix))
.map(|name| format!("%{name}").into())
.collect()
})
} else {
@@ -67,26 +165,26 @@ pub fn complete_jobs(start: &str) -> Vec<String> {
j.jobs()
.iter()
.filter_map(|j| j.as_ref())
.map(|j| j.pgid().to_string())
.filter(|pgid| pgid.starts_with(start))
.map(|j| Candidate::from(j.pgid().to_string()))
.filter(|pgid| pgid.is_match(start))
.collect()
})
}
}
pub fn complete_users(start: &str) -> Vec<String> {
pub fn complete_users(start: &str) -> Vec<Candidate> {
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())
.map(Candidate::from)
.filter(|username| username.is_match(start))
.collect()
}
pub fn complete_vars(start: &str) -> Vec<String> {
pub fn complete_vars(start: &str) -> Vec<Candidate> {
let Some((var_name, name_start, _end)) = extract_var_name(start) else {
return vec![];
};
@@ -101,11 +199,12 @@ pub fn complete_vars(start: &str) -> Vec<String> {
.keys()
.filter(|k| k.starts_with(&var_name) && *k != &var_name)
.map(|k| format!("{prefix}{k}"))
.map(Candidate::from)
.collect::<Vec<_>>()
})
}
pub fn complete_vars_raw(raw: &str) -> Vec<String> {
pub fn complete_vars_raw(raw: &str) -> Vec<Candidate> {
if !read_vars(|v| v.get_var(raw)).is_empty() {
return vec![];
}
@@ -115,7 +214,7 @@ pub fn complete_vars_raw(raw: &str) -> Vec<String> {
v.flatten_vars()
.keys()
.filter(|k| k.starts_with(raw) && *k != raw)
.map(|k| k.to_string())
.map(Candidate::from)
.collect::<Vec<_>>()
})
}
@@ -168,12 +267,12 @@ pub fn extract_var_name(text: &str) -> Option<(String, usize, usize)> {
Some((name, name_start, name_end))
}
fn complete_commands(start: &str) -> Vec<String> {
let mut candidates: Vec<String> = read_meta(|m| {
fn complete_commands(start: &str) -> Vec<Candidate> {
let mut candidates: Vec<Candidate> = read_meta(|m| {
m.cached_cmds()
.iter()
.filter(|c| c.starts_with(start))
.cloned()
.map(Candidate::from)
.filter(|c| c.is_match(start))
.collect()
});
@@ -186,15 +285,16 @@ fn complete_commands(start: &str) -> Vec<String> {
candidates
}
fn complete_dirs(start: &str) -> Vec<String> {
fn complete_dirs(start: &str) -> Vec<Candidate> {
let filenames = complete_filename(start);
filenames
.into_iter()
.filter(|f| std::fs::metadata(f).map(|m| m.is_dir()).unwrap_or(false))
.filter(|f| std::fs::metadata(&f.0).map(|m| m.is_dir()).unwrap_or(false))
.collect()
}
fn complete_filename(start: &str) -> Vec<String> {
fn complete_filename(start: &str) -> Vec<Candidate> {
let mut candidates = vec![];
let has_dotslash = start.starts_with("./");
@@ -202,18 +302,18 @@ fn complete_filename(start: &str) -> Vec<String> {
// 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=""
// 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"
// 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"
// No directory: "fil" -> dir=".", prefix="fil"
(PathBuf::from("."), start)
};
@@ -223,14 +323,16 @@ fn complete_filename(start: &str) -> Vec<String> {
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_str = file_name.to_string_lossy();
let file_str: Candidate = file_name.to_string_lossy().to_string().into();
// Skip hidden files unless explicitly requested
if !prefix.starts_with('.') && file_str.starts_with('.') {
if !prefix.starts_with('.') && file_str.0.starts_with('.') {
continue;
}
if file_str.starts_with(prefix) {
if file_str.is_match(prefix) {
// Reconstruct full path
let mut full_path = dir.join(&file_name);
@@ -244,7 +346,7 @@ fn complete_filename(start: &str) -> Vec<String> {
path_raw = path_raw.trim_start_matches("./").to_string();
}
candidates.push(path_raw);
candidates.push(path_raw.into());
}
}
@@ -363,7 +465,7 @@ impl BashCompSpec {
source: String::new(),
}
}
pub fn exec_comp_func(&self, ctx: &CompContext) -> ShResult<Vec<String>> {
pub fn exec_comp_func(&self, ctx: &CompContext) -> ShResult<Vec<Candidate>> {
let mut vars_to_unset = HashSet::new();
for var in [
"COMP_WORDS",
@@ -426,13 +528,19 @@ impl BashCompSpec {
);
exec_input(input, None, false, Some("comp_function".into()))?;
Ok(read_vars(|v| v.get_arr_elems("COMPREPLY")).unwrap_or_default())
let comp_reply = read_vars(|v| v.get_arr_elems("COMPREPLY"))
.unwrap_or_default()
.into_iter()
.map(Candidate::from)
.collect();
Ok(comp_reply)
}
}
impl CompSpec for BashCompSpec {
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<String>> {
let mut candidates = vec![];
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<Candidate>> {
let mut candidates: Vec<Candidate> = vec![];
let prefix = &ctx.words[ctx.cword];
let expanded = prefix.clone().expand()?.get_words().join(" ");
@@ -461,7 +569,7 @@ impl CompSpec for BashCompSpec {
candidates.extend(complete_signals(&expanded));
}
if let Some(words) = &self.wordlist {
candidates.extend(words.iter().filter(|w| w.starts_with(&expanded)).cloned());
candidates.extend(words.iter().map(Candidate::from).filter(|w| w.is_match(&expanded)));
}
if self.function.is_some() {
candidates.extend(self.exec_comp_func(ctx)?);
@@ -469,12 +577,12 @@ impl CompSpec for BashCompSpec {
candidates = candidates
.into_iter()
.map(|c| {
let stripped = c.strip_prefix(&expanded).unwrap_or_default();
format!("{prefix}{stripped}")
let stripped = c.0.strip_prefix(&expanded).unwrap_or_default();
format!("{prefix}{stripped}").into()
})
.collect();
candidates.sort_by_key(|c| c.len()); // sort by length to prioritize shorter completions, ties are then sorted alphabetically
candidates.sort_by_key(|c| c.0.len()); // sort by length to prioritize shorter completions, ties are then sorted alphabetically
Ok(candidates)
}
@@ -489,7 +597,7 @@ impl CompSpec for BashCompSpec {
}
pub trait CompSpec: Debug + CloneCompSpec {
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<String>>;
fn complete(&self, ctx: &CompContext) -> ShResult<Vec<Candidate>>;
fn source(&self) -> &str;
fn get_flags(&self) -> CompOptFlags {
CompOptFlags::empty()
@@ -527,17 +635,17 @@ impl CompContext {
pub enum CompResult {
NoMatch,
Single { result: String },
Many { candidates: Vec<String> },
Single { result: Candidate },
Many { candidates: Vec<Candidate> },
}
impl CompResult {
pub fn from_candidates(candidates: Vec<String>) -> Self {
pub fn from_candidates(mut candidates: Vec<Candidate>) -> Self {
if candidates.is_empty() {
Self::NoMatch
} else if candidates.len() == 1 {
Self::Single {
result: candidates[0].clone(),
result: candidates.remove(0)
}
} else {
Self::Many { candidates }
@@ -568,7 +676,7 @@ pub trait Completer {
fn reset(&mut self);
fn reset_stay_active(&mut self);
fn is_active(&self) -> bool;
fn all_candidates(&self) -> Vec<String> {
fn all_candidates(&self) -> Vec<Candidate> {
vec![]
}
fn selected_candidate(&self) -> Option<String>;
@@ -671,6 +779,15 @@ impl From<String> for ScoredCandidate {
}
}
impl From<Candidate> for ScoredCandidate {
fn from(candidate: Candidate) -> Self {
Self {
content: candidate.0,
score: None,
}
}
}
#[derive(Debug, Clone)]
pub struct FuzzyLayout {
rows: u16,
@@ -743,7 +860,7 @@ impl QueryEditor {
pub struct FuzzySelector {
query: QueryEditor,
filtered: Vec<ScoredCandidate>,
candidates: Vec<String>,
candidates: Vec<Candidate>,
cursor: ClampedUsize,
number_candidates: bool,
old_layout: Option<FuzzyLayout>,
@@ -798,7 +915,7 @@ impl FuzzySelector {
}
}
pub fn candidates(&self) -> &[String] {
pub fn candidates(&self) -> &[Candidate] {
&self.candidates
}
@@ -814,7 +931,7 @@ impl FuzzySelector {
self.candidates.len()
}
pub fn activate(&mut self, candidates: Vec<String>) {
pub fn activate(&mut self, candidates: Vec<Candidate>) {
self.active = true;
self.candidates = candidates;
self.score_candidates();
@@ -913,7 +1030,7 @@ impl FuzzySelector {
.clone()
.into_iter()
.filter_map(|c| {
let mut sc = ScoredCandidate::new(c);
let mut sc = ScoredCandidate::new(c.to_string());
let score = sc.fuzzy_score(self.query.linebuf.as_str());
if score > i32::MIN { Some(sc) } else { None }
})
@@ -1167,7 +1284,7 @@ impl Default for FuzzyCompleter {
}
impl Completer for FuzzyCompleter {
fn all_candidates(&self) -> Vec<String> {
fn all_candidates(&self) -> Vec<Candidate> {
self.selector.candidates.clone()
}
fn set_prompt_line_context(&mut self, line_width: u16, cursor_col: u16) {
@@ -1188,12 +1305,34 @@ impl Completer for FuzzyCompleter {
.original_input
.get(start..end)
.unwrap_or_default();
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
let (prefix, completion) = if ignore_case {
// Replace the filename part (after last /) with the candidate's casing
// but preserve any unexpanded prefix like $VAR/
if let Some(last_sep) = slice.rfind('/') {
let prefix_end = start + last_sep + 1;
let trailing_slash = selected.ends_with('/');
let trimmed = selected.trim_end_matches('/');
let mut basename = trimmed.rsplit('/').next().unwrap_or(&selected).to_string();
if trailing_slash {
basename.push('/');
}
(
self.completer.original_input[..prefix_end].to_string(),
basename,
)
} else {
(self.completer.original_input[..start].to_string(), selected.clone())
}
} else {
start += slice.width();
let completion = selected.strip_prefix(slice).unwrap_or(&selected);
let escaped = escape_str(completion, false);
(self.completer.original_input[..start].to_string(), completion.to_string())
};
let escaped = escape_str(&completion, false);
let ret = format!(
"{}{}{}",
&self.completer.original_input[..start],
prefix,
escaped,
&self.completer.original_input[end..]
);
@@ -1256,7 +1395,7 @@ impl Completer for FuzzyCompleter {
#[derive(Default, Debug, Clone)]
pub struct SimpleCompleter {
pub candidates: Vec<String>,
pub candidates: Vec<Candidate>,
pub selected_idx: usize,
pub original_input: String,
pub token_span: (usize, usize),
@@ -1266,7 +1405,7 @@ pub struct SimpleCompleter {
}
impl Completer for SimpleCompleter {
fn all_candidates(&self) -> Vec<String> {
fn all_candidates(&self) -> Vec<Candidate> {
self.candidates.clone()
}
fn reset_stay_active(&mut self) {
@@ -1299,7 +1438,7 @@ impl Completer for SimpleCompleter {
}
fn selected_candidate(&self) -> Option<String> {
self.candidates.get(self.selected_idx).cloned()
self.candidates.get(self.selected_idx).map(|c| c.to_string())
}
fn token_span(&self) -> (usize, usize) {
@@ -1407,7 +1546,7 @@ impl SimpleCompleter {
&& !ends_with_unescaped(&c, " ")
{
// already has a space
format!("{} ", c)
Candidate::from(format!("{} ", c))
} else {
c
}
@@ -1449,12 +1588,32 @@ impl SimpleCompleter {
let selected = &self.candidates[self.selected_idx];
let (mut start, end) = self.token_span;
let slice = self.original_input.get(start..end).unwrap_or("");
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
let (prefix, completion) = if ignore_case {
if let Some(last_sep) = slice.rfind('/') {
let prefix_end = start + last_sep + 1;
let trailing_slash = selected.ends_with('/');
let trimmed = selected.trim_end_matches('/');
let mut basename = trimmed.rsplit('/').next().unwrap_or(selected.as_str()).to_string();
if trailing_slash {
basename.push('/');
}
(
self.original_input[..prefix_end].to_string(),
basename,
)
} else {
(self.original_input[..start].to_string(), selected.to_string())
}
} else {
start += slice.width();
let completion = selected.strip_prefix(slice).unwrap_or(selected);
let escaped = escape_str(completion, false);
let completion = selected.strip_prefix(slice).unwrap_or(selected.to_string());
(self.original_input[..start].to_string(), completion)
};
let escaped = escape_str(&completion, false);
format!(
"{}{}{}",
&self.original_input[..start],
prefix,
escaped,
&self.original_input[end..]
)
@@ -1649,11 +1808,12 @@ impl SimpleCompleter {
let is_var_completion = last_marker == Some(markers::VAR_SUB)
&& !candidates.is_empty()
&& candidates.iter().any(|c| c.starts_with('$'));
if !is_var_completion {
let ignore_case = read_shopts(|o| o.prompt.completion_ignore_case);
if !is_var_completion && !ignore_case {
candidates = candidates
.into_iter()
.map(|c| match c.strip_prefix(&expanded) {
Some(suffix) => format!("{raw_tk}{suffix}"),
Some(suffix) => Candidate::from(format!("{raw_tk}{suffix}")),
None => c,
})
.collect();
@@ -1781,7 +1941,7 @@ mod tests {
#[test]
fn complete_signals_int() {
let results = complete_signals("INT");
assert!(results.contains(&"INT".to_string()));
assert!(results.contains(&Candidate::from("INT")));
}
#[test]

View File

@@ -284,7 +284,7 @@ impl History {
.search_mask
.clone()
.into_iter()
.map(|ent| ent.command().to_string());
.map(|ent| super::complete::Candidate::from(ent.command()));
self.fuzzy_finder.activate(raw_entries.collect());
None
}

View File

@@ -675,8 +675,7 @@ impl ShedVi {
if let KeyEvent(KeyCode::Tab, mod_keys) = key {
if self.mode.report_mode() != ModeReport::Ex
&& self.editor.attempt_history_expansion(&self.history)
{
&& 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);
@@ -888,12 +887,14 @@ impl ShedVi {
let has_edit_verb = cmd.verb().is_some_and(|v| v.1.is_edit());
let is_shell_cmd = cmd.verb().is_some_and(|v| matches!(v.1, Verb::ShellCmd(_)));
let is_ex_cmd = cmd.flags.contains(CmdFlags::IS_EX_CMD);
log::debug!("is_ex_cmd: {is_ex_cmd}");
if is_shell_cmd {
self.old_layout = None;
}
if is_ex_cmd {
self.ex_history.push(cmd.raw_seq.clone());
self.ex_history.reset();
log::debug!("ex_history: {:?}", self.ex_history.entries());
}
let before = self.editor.buffer.clone();
@@ -1415,7 +1416,11 @@ impl ShedVi {
self.editor.exec_cmd(cmd.clone())?;
if self.mode.report_mode() == ModeReport::Visual && cmd.verb().is_some() {
if self.mode.report_mode() == ModeReport::Visual
&& cmd
.verb()
.is_some_and(|v| v.1.is_edit() || v.1 == Verb::Yank)
{
self.editor.stop_selecting();
let mut mode: Box<dyn ViMode> = Box::new(ViNormal::new());
self.swap_mode(&mut mode);

View File

@@ -307,6 +307,9 @@ impl Verb {
| Self::JoinLines
| Self::InsertChar(_)
| Self::Insert(_)
| Self::Dedent
| Self::Indent
| Self::Equalize
| Self::Rot13
| Self::EndOfFile
| Self::IncrementNumber(_)

View File

@@ -2,6 +2,35 @@ use std::{fmt::Display, str::FromStr};
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
/// Escapes a string for embedding inside single quotes.
/// Only escapes unescaped `\` and `'` characters.
pub fn escape_for_single_quote(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.peek() {
Some(&'\\') | Some(&'\'') => {
// Already escaped — pass through both characters
result.push(ch);
result.push(chars.next().unwrap());
}
_ => {
// Lone backslash — escape it
result.push('\\');
result.push('\\');
}
}
} else if ch == '\'' {
result.push('\\');
result.push('\'');
} else {
result.push(ch);
}
}
result
}
#[derive(Clone, Copy, Debug)]
pub enum ShedBellStyle {
Audible,
@@ -24,34 +53,97 @@ impl FromStr for ShedBellStyle {
}
}
#[derive(Default, Clone, Copy, Debug)]
pub enum ShedEditMode {
#[default]
Vi,
Emacs,
/// Generates a shopt group struct with `set`, `get`, `Display`, and `Default` impls.
///
/// Doc comments on each field become the description shown by `shopt get`.
/// Every field type must implement `FromStr + Display`.
///
/// Optional per-field validation: `#[validate(|val| expr)]` runs after parsing
/// and must return `Result<(), String>` where the error string is the message.
macro_rules! shopt_group {
(
$(#[$struct_meta:meta])*
pub struct $name:ident ($group_name:literal) {
$(
$(#[doc = $desc:literal])*
$(#[validate($validator:expr)])?
$field:ident : $ty:ty = $default:expr
),* $(,)?
}
) => {
$(#[$struct_meta])*
pub struct $name {
$(pub $field: $ty,)*
}
impl FromStr for ShedEditMode {
type Err = ShErr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"vi" => Ok(Self::Vi),
"emacs" => Ok(Self::Emacs),
impl Default for $name {
fn default() -> Self {
Self {
$($field: $default,)*
}
}
}
impl $name {
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
match opt {
$(
stringify!($field) => {
let parsed = val.parse::<$ty>().map_err(|_| {
ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: invalid value '{}' for {}.{}", val, $group_name, opt),
)
})?;
$(
let validate: fn(&$ty) -> Result<(), String> = $validator;
validate(&parsed).map_err(|msg| {
ShErr::simple(ShErrKind::SyntaxErr, format!("shopt: {msg}"))
})?;
)?
self.$field = parsed;
}
)*
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: unexpected '{}' option '{opt}'", $group_name),
));
}
}
Ok(())
}
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
if query.is_empty() {
return Ok(Some(format!("{self}")));
}
match query {
$(
stringify!($field) => {
let desc = concat!($($desc, "\n",)*);
let output = format!("{}{}", desc, self.$field);
Ok(Some(output))
}
)*
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("Invalid edit mode '{s}'"),
format!("shopt: unexpected '{}' option '{query}'", $group_name),
)),
}
}
}
impl Display for ShedEditMode {
impl Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShedEditMode::Vi => write!(f, "vi"),
ShedEditMode::Emacs => write!(f, "emacs"),
let output = [
$(format!("{}.{}='{}'", $group_name, stringify!($field),
$crate::shopt::escape_for_single_quote(&self.$field.to_string())),)*
];
writeln!(f, "{}", output.join("\n"))
}
}
};
}
#[derive(Clone, Debug)]
@@ -82,8 +174,8 @@ impl ShOpts {
pub fn display_opts(&mut self) -> ShResult<String> {
let output = [
format!("core:\n{}", self.query("core")?.unwrap_or_default()),
format!("prompt:\n{}", self.query("prompt")?.unwrap_or_default()),
self.query("core")?.unwrap_or_default().to_string(),
self.query("prompt")?.unwrap_or_default().to_string(),
];
Ok(output.join("\n"))
@@ -135,459 +227,78 @@ impl ShOpts {
}
}
shopt_group! {
#[derive(Clone, Debug)]
pub struct ShOptCore {
pub dotglob: bool,
pub autocd: bool,
pub hist_ignore_dupes: bool,
pub max_hist: isize,
pub interactive_comments: bool,
pub auto_hist: bool,
pub bell_enabled: bool,
pub max_recurse_depth: usize,
pub xpg_echo: bool,
pub noclobber: bool,
}
pub struct ShOptCore ("core") {
/// Include hidden files in glob patterns
dotglob: bool = false,
impl ShOptCore {
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
match opt {
"dotglob" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for dotglob value",
));
};
self.dotglob = val;
}
"autocd" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for autocd value",
));
};
self.autocd = val;
}
"hist_ignore_dupes" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for hist_ignore_dupes value",
));
};
self.hist_ignore_dupes = val;
}
"max_hist" => {
let Ok(val) = val.parse::<isize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected an integer for max_hist value (-1 for unlimited)",
));
};
if val < -1 {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a non-negative integer or -1 for max_hist value",
));
}
self.max_hist = val;
}
"interactive_comments" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for interactive_comments value",
));
};
self.interactive_comments = val;
}
"auto_hist" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for auto_hist value",
));
};
self.auto_hist = val;
}
"bell_enabled" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for bell_enabled value",
));
};
self.bell_enabled = val;
}
"max_recurse_depth" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for max_recurse_depth value",
));
};
self.max_recurse_depth = val;
}
"xpg_echo" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for xpg_echo value",
));
};
self.xpg_echo = val;
}
"noclobber" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for noclobber value",
));
};
self.noclobber = val;
}
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'core' option '{opt}'"),
));
}
}
/// Allow navigation to directories by passing the directory as a command directly
autocd: bool = false,
/// Ignore consecutive duplicate command history entries
hist_ignore_dupes: bool = true,
/// Maximum number of entries in the command history file (-1 for unlimited)
#[validate(|v: &isize| if *v < -1 {
Err("expected a non-negative integer or -1 for max_hist value".into())
} else {
Ok(())
}
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
if query.is_empty() {
return Ok(Some(format!("{self}")));
}
})]
max_hist: isize = 10_000,
match query {
"dotglob" => {
let mut output = String::from("Include hidden files in glob patterns\n");
output.push_str(&format!("{}", self.dotglob));
Ok(Some(output))
}
"autocd" => {
let mut output = String::from(
"Allow navigation to directories by passing the directory as a command directly\n",
);
output.push_str(&format!("{}", self.autocd));
Ok(Some(output))
}
"hist_ignore_dupes" => {
let mut output = String::from("Ignore consecutive duplicate command history entries\n");
output.push_str(&format!("{}", self.hist_ignore_dupes));
Ok(Some(output))
}
"max_hist" => {
let mut output = String::from(
"Maximum number of entries in the command history file (-1 for unlimited)\n",
);
output.push_str(&format!("{}", self.max_hist));
Ok(Some(output))
}
"interactive_comments" => {
let mut output = String::from("Whether or not to allow comments in interactive mode\n");
output.push_str(&format!("{}", self.interactive_comments));
Ok(Some(output))
}
"auto_hist" => {
let mut output = String::from(
"Whether or not to automatically save commands to the command history file\n",
);
output.push_str(&format!("{}", self.auto_hist));
Ok(Some(output))
}
"bell_enabled" => {
let mut output = String::from("Whether or not to allow shed to trigger the terminal bell");
output.push_str(&format!("{}", self.bell_enabled));
Ok(Some(output))
}
"max_recurse_depth" => {
let mut output = String::from("Maximum limit of recursive shell function calls\n");
output.push_str(&format!("{}", self.max_recurse_depth));
Ok(Some(output))
}
"xpg_echo" => {
let mut output = String::from("Whether echo expands escape sequences by default\n");
output.push_str(&format!("{}", self.xpg_echo));
Ok(Some(output))
}
"noclobber" => {
let mut output =
String::from("Prevent > from overwriting existing files (use >| to override)\n");
output.push_str(&format!("{}", self.noclobber));
Ok(Some(output))
}
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'core' option '{query}'"),
)),
}
}
}
impl Display for ShOptCore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut output = vec![];
output.push(format!("dotglob = {}", self.dotglob));
output.push(format!("autocd = {}", self.autocd));
output.push(format!("hist_ignore_dupes = {}", self.hist_ignore_dupes));
output.push(format!("max_hist = {}", self.max_hist));
output.push(format!(
"interactive_comments = {}",
self.interactive_comments
));
output.push(format!("auto_hist = {}", self.auto_hist));
output.push(format!("bell_enabled = {}", self.bell_enabled));
output.push(format!("max_recurse_depth = {}", self.max_recurse_depth));
output.push(format!("xpg_echo = {}", self.xpg_echo));
output.push(format!("noclobber = {}", self.noclobber));
let final_output = output.join("\n");
writeln!(f, "{final_output}")
}
}
impl Default for ShOptCore {
fn default() -> Self {
ShOptCore {
dotglob: false,
autocd: false,
hist_ignore_dupes: true,
max_hist: 10_000,
interactive_comments: true,
auto_hist: true,
bell_enabled: true,
max_recurse_depth: 1000,
xpg_echo: false,
noclobber: false,
}
/// Whether or not to allow comments in interactive mode
interactive_comments: bool = true,
/// Whether or not to automatically save commands to the command history file
auto_hist: bool = true,
/// Whether or not to allow shed to trigger the terminal bell
bell_enabled: bool = true,
/// Maximum limit of recursive shell function calls
max_recurse_depth: usize = 1000,
/// Whether echo expands escape sequences by default
xpg_echo: bool = false,
/// Prevent > from overwriting existing files (use >| to override)
noclobber: bool = false,
}
}
shopt_group! {
#[derive(Clone, Debug)]
pub struct ShOptPrompt {
pub trunc_prompt_path: usize,
pub edit_mode: ShedEditMode,
pub comp_limit: usize,
pub highlight: bool,
pub auto_indent: bool,
pub linebreak_on_incomplete: bool,
pub leader: String,
pub line_numbers: bool,
pub screensaver_cmd: String,
pub screensaver_idle_time: usize,
}
pub struct ShOptPrompt ("prompt") {
/// Maximum number of path segments used in the '\W' prompt escape sequence
trunc_prompt_path: usize = 4,
impl ShOptPrompt {
pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> {
match opt {
"trunc_prompt_path" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for trunc_prompt_path value",
));
};
self.trunc_prompt_path = val;
}
"edit_mode" => {
let Ok(val) = val.parse::<ShedEditMode>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'vi' or 'emacs' for edit_mode value",
));
};
self.edit_mode = val;
}
"comp_limit" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for comp_limit value",
));
};
self.comp_limit = val;
}
"highlight" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for highlight value",
));
};
self.highlight = val;
}
"auto_indent" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for auto_indent value",
));
};
self.auto_indent = val;
}
"linebreak_on_incomplete" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for linebreak_on_incomplete value",
));
};
self.linebreak_on_incomplete = val;
}
"leader" => {
self.leader = val.to_string();
}
"line_numbers" => {
let Ok(val) = val.parse::<bool>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected 'true' or 'false' for line_numbers value",
));
};
self.line_numbers = val;
}
"screensaver_cmd" => {
self.screensaver_cmd = val.to_string();
}
"screensaver_idle_time" => {
let Ok(val) = val.parse::<usize>() else {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
"shopt: expected a positive integer for screensaver_idle_time value",
));
};
self.screensaver_idle_time = val;
}
"custom" => {
todo!()
}
_ => {
return Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'prompt' option '{opt}'"),
));
}
}
Ok(())
}
pub fn get(&self, query: &str) -> ShResult<Option<String>> {
if query.is_empty() {
return Ok(Some(format!("{self}")));
}
/// Maximum number of completion candidates displayed upon pressing tab
comp_limit: usize = 100,
match query {
"trunc_prompt_path" => {
let mut output = String::from(
"Maximum number of path segments used in the '\\W' prompt escape sequence\n",
);
output.push_str(&format!("{}", self.trunc_prompt_path));
Ok(Some(output))
}
"edit_mode" => {
let mut output =
String::from("The style of editor shortcuts used in the line-editing of the prompt\n");
output.push_str(&format!("{}", self.edit_mode));
Ok(Some(output))
}
"comp_limit" => {
let mut output =
String::from("Maximum number of completion candidates displayed upon pressing tab\n");
output.push_str(&format!("{}", self.comp_limit));
Ok(Some(output))
}
"highlight" => {
let mut output =
String::from("Whether to enable or disable syntax highlighting on the prompt\n");
output.push_str(&format!("{}", self.highlight));
Ok(Some(output))
}
"auto_indent" => {
let mut output =
String::from("Whether to automatically indent new lines in multiline commands\n");
output.push_str(&format!("{}", self.auto_indent));
Ok(Some(output))
}
"linebreak_on_incomplete" => {
let mut output =
String::from("Whether to automatically insert a newline when the input is incomplete\n");
output.push_str(&format!("{}", self.linebreak_on_incomplete));
Ok(Some(output))
}
"leader" => {
let mut output = String::from("The leader key sequence used in keymap bindings\n");
output.push_str(&self.leader);
Ok(Some(output))
}
"line_numbers" => {
let mut output = String::from("Whether to display line numbers in multiline input\n");
output.push_str(&format!("{}", self.line_numbers));
Ok(Some(output))
}
"screensaver_cmd" => {
let mut output = String::from("Command to execute as a screensaver after idle timeout\n");
output.push_str(&self.screensaver_cmd);
Ok(Some(output))
}
"screensaver_idle_time" => {
let mut output =
String::from("Idle time in seconds before running screensaver_cmd (0 = disabled)\n");
output.push_str(&format!("{}", self.screensaver_idle_time));
Ok(Some(output))
}
_ => Err(ShErr::simple(
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'prompt' option '{query}'"),
)),
}
}
}
/// Whether to enable or disable syntax highlighting on the prompt
highlight: bool = true,
impl Display for ShOptPrompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut output = vec![];
/// Whether to automatically indent new lines in multiline commands
auto_indent: bool = true,
output.push(format!("trunc_prompt_path = {}", self.trunc_prompt_path));
output.push(format!("edit_mode = {}", self.edit_mode));
output.push(format!("comp_limit = {}", self.comp_limit));
output.push(format!("highlight = {}", self.highlight));
output.push(format!("auto_indent = {}", self.auto_indent));
output.push(format!(
"linebreak_on_incomplete = {}",
self.linebreak_on_incomplete
));
output.push(format!("leader = {}", self.leader));
output.push(format!("line_numbers = {}", self.line_numbers));
output.push(format!("screensaver_cmd = {}", self.screensaver_cmd));
output.push(format!(
"screensaver_idle_time = {}",
self.screensaver_idle_time
));
/// Whether to automatically insert a newline when the input is incomplete
linebreak_on_incomplete: bool = true,
let final_output = output.join("\n");
/// The leader key sequence used in keymap bindings
leader: String = " ".to_string(),
writeln!(f, "{final_output}")
}
}
/// Whether to display line numbers in multiline input
line_numbers: bool = true,
impl Default for ShOptPrompt {
fn default() -> Self {
ShOptPrompt {
trunc_prompt_path: 4,
edit_mode: ShedEditMode::Vi,
comp_limit: 100,
highlight: true,
auto_indent: true,
linebreak_on_incomplete: true,
leader: "\\".to_string(),
line_numbers: true,
screensaver_cmd: String::new(),
screensaver_idle_time: 0,
}
/// Command to execute as a screensaver after idle timeout
screensaver_cmd: String = String::new(),
/// Idle time in seconds before running screensaver_cmd (0 = disabled)
screensaver_idle_time: usize = 0,
/// Whether tab completion matching is case-insensitive
completion_ignore_case: bool = false,
}
}
@@ -654,12 +365,6 @@ mod tests {
fn set_and_get_prompt_opts() {
let mut opts = ShOpts::default();
opts.set("prompt.edit_mode", "emacs").unwrap();
assert!(matches!(opts.prompt.edit_mode, ShedEditMode::Emacs));
opts.set("prompt.edit_mode", "vi").unwrap();
assert!(matches!(opts.prompt.edit_mode, ShedEditMode::Vi));
opts.set("prompt.comp_limit", "50").unwrap();
assert_eq!(opts.prompt.comp_limit, 50);
@@ -704,7 +409,6 @@ mod tests {
assert!(opts.set("core.dotglob", "notabool").is_err());
assert!(opts.set("core.max_hist", "notanint").is_err());
assert!(opts.set("core.max_recurse_depth", "-5").is_err());
assert!(opts.set("prompt.edit_mode", "notepad").is_err());
assert!(opts.set("prompt.comp_limit", "abc").is_err());
}
@@ -718,7 +422,6 @@ mod tests {
assert!(core_output.contains("bell_enabled"));
let prompt_output = opts.get("prompt").unwrap().unwrap();
assert!(prompt_output.contains("edit_mode"));
assert!(prompt_output.contains("comp_limit"));
assert!(prompt_output.contains("highlight"));
}

View File

@@ -31,7 +31,7 @@ use crate::{
},
prelude::*,
readline::{
complete::{BashCompSpec, CompSpec},
complete::{BashCompSpec, Candidate, CompSpec},
keys::KeyEvent,
markers,
},
@@ -1001,6 +1001,13 @@ impl From<Vec<String>> for Var {
}
}
impl From<Vec<Candidate>> for Var {
fn from(value: Vec<Candidate>) -> Self {
let as_strs = value.into_iter().map(|c| c.0).collect::<Vec<_>>();
Self::new(VarKind::Arr(as_strs.into()), VarFlags::NONE)
}
}
impl From<&[String]> for Var {
fn from(value: &[String]) -> Self {
let mut new = VecDeque::new();