Add Candidate type for case-insensitive completion, shopt_group macro, escape fixes, and vi mode tweaks
This commit is contained in:
@@ -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 =====================
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
'\'' => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -307,6 +307,9 @@ impl Verb {
|
||||
| Self::JoinLines
|
||||
| Self::InsertChar(_)
|
||||
| Self::Insert(_)
|
||||
| Self::Dedent
|
||||
| Self::Indent
|
||||
| Self::Equalize
|
||||
| Self::Rot13
|
||||
| Self::EndOfFile
|
||||
| Self::IncrementNumber(_)
|
||||
|
||||
631
src/shopt.rs
631
src/shopt.rs
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user