From b0325b6bbb8d5e2281f4a399d523710391a932e9 Mon Sep 17 00:00:00 2001 From: pagedmov Date: Tue, 17 Mar 2026 01:25:55 -0400 Subject: [PATCH] Add Candidate type for case-insensitive completion, shopt_group macro, escape fixes, and vi mode tweaks --- src/builtin/shopt.rs | 13 +- src/expand.rs | 18 +- src/readline/complete.rs | 292 +++++++++++++---- src/readline/history.rs | 2 +- src/readline/mod.rs | 85 ++--- src/readline/vicmd.rs | 3 + src/shopt.rs | 653 +++++++++++---------------------------- src/state.rs | 9 +- 8 files changed, 477 insertions(+), 598 deletions(-) diff --git a/src/builtin/shopt.rs b/src/builtin/shopt.rs index 1e61f9b..ef3bfe1 100644 --- a/src/builtin/shopt.rs +++ b/src/builtin/shopt.rs @@ -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 ===================== diff --git a/src/expand.rs b/src/expand.rs index d03f7a3..a1d510d 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -1223,14 +1223,16 @@ pub fn unescape_str(raw: &str) -> String { result.push(markers::SNG_QUOTE); 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), + } + } '\'' => { result.push(markers::SNG_QUOTE); break; diff --git a/src/readline/complete.rs b/src/readline/complete.rs index af4b8aa..80ef28d 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -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 { +#[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 { + Some(self.cmp(other)) + } +} + +impl Ord for Candidate { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } +} + +impl From 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 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 { + 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 { Signal::iterator() .map(|s| { s.to_string() @@ -37,29 +133,31 @@ pub fn complete_signals(start: &str) -> Vec { .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 { +pub fn complete_aliases(start: &str) -> Vec { read_logic(|l| { l.aliases() - .iter() - .filter(|a| a.0.starts_with(start)) - .map(|a| a.0.clone()) + .keys() + .map(Candidate::from) + .filter(|a| a.is_match(start)) .collect() }) } -pub fn complete_jobs(start: &str) -> Vec { +pub fn complete_jobs(start: &str) -> Vec { if let Some(prefix) = start.strip_prefix('%') { read_jobs(|j| { j.jobs() .iter() .filter_map(|j| j.as_ref()) .filter_map(|j| j.name()) - .filter(|name| name.starts_with(prefix)) - .map(|name| format!("%{name}")) + .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 { 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 { +pub fn complete_users(start: &str) -> Vec { let Ok(passwd) = std::fs::read_to_string("/etc/passwd") else { return vec![]; }; passwd .lines() .filter_map(|line| line.split(':').next()) - .filter(|username| username.starts_with(start)) - .map(|s| s.to_string()) + .map(Candidate::from) + .filter(|username| username.is_match(start)) .collect() } -pub fn complete_vars(start: &str) -> Vec { +pub fn complete_vars(start: &str) -> Vec { 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 { .keys() .filter(|k| k.starts_with(&var_name) && *k != &var_name) .map(|k| format!("{prefix}{k}")) + .map(Candidate::from) .collect::>() }) } -pub fn complete_vars_raw(raw: &str) -> Vec { +pub fn complete_vars_raw(raw: &str) -> Vec { if !read_vars(|v| v.get_var(raw)).is_empty() { return vec![]; } @@ -115,7 +214,7 @@ pub fn complete_vars_raw(raw: &str) -> Vec { v.flatten_vars() .keys() .filter(|k| k.starts_with(raw) && *k != raw) - .map(|k| k.to_string()) + .map(Candidate::from) .collect::>() }) } @@ -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 { - let mut candidates: Vec = read_meta(|m| { +fn complete_commands(start: &str) -> Vec { + let mut candidates: Vec = 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 { candidates } -fn complete_dirs(start: &str) -> Vec { +fn complete_dirs(start: &str) -> Vec { let filenames = complete_filename(start); + filenames .into_iter() - .filter(|f| std::fs::metadata(f).map(|m| m.is_dir()).unwrap_or(false)) + .filter(|f| std::fs::metadata(&f.0).map(|m| m.is_dir()).unwrap_or(false)) .collect() } -fn complete_filename(start: &str) -> Vec { +fn complete_filename(start: &str) -> Vec { let mut candidates = vec![]; let has_dotslash = start.starts_with("./"); @@ -202,18 +302,18 @@ fn complete_filename(start: &str) -> Vec { // 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 { 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 { 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> { + pub fn exec_comp_func(&self, ctx: &CompContext) -> ShResult> { 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> { - let mut candidates = vec![]; + fn complete(&self, ctx: &CompContext) -> ShResult> { + let mut candidates: Vec = 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>; + fn complete(&self, ctx: &CompContext) -> ShResult>; 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 }, + Single { result: Candidate }, + Many { candidates: Vec }, } impl CompResult { - pub fn from_candidates(candidates: Vec) -> Self { + pub fn from_candidates(mut candidates: Vec) -> 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 { + fn all_candidates(&self) -> Vec { vec![] } fn selected_candidate(&self) -> Option; @@ -671,6 +779,15 @@ impl From for ScoredCandidate { } } +impl From 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, - candidates: Vec, + candidates: Vec, cursor: ClampedUsize, number_candidates: bool, old_layout: Option, @@ -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) { + pub fn activate(&mut self, candidates: Vec) { 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 { + fn all_candidates(&self) -> Vec { 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(); - start += slice.width(); - let completion = selected.strip_prefix(slice).unwrap_or(&selected); - let escaped = escape_str(completion, false); + 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); + (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, + pub candidates: Vec, 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 { + fn all_candidates(&self) -> Vec { self.candidates.clone() } fn reset_stay_active(&mut self) { @@ -1299,7 +1438,7 @@ impl Completer for SimpleCompleter { } fn selected_candidate(&self) -> Option { - 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(""); - start += slice.width(); - let completion = selected.strip_prefix(slice).unwrap_or(selected); - let escaped = escape_str(completion, false); + 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.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] diff --git a/src/readline/history.rs b/src/readline/history.rs index 06d6778..421b4f8 100644 --- a/src/readline/history.rs +++ b/src/readline/history.rs @@ -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 } diff --git a/src/readline/mod.rs b/src/readline/mod.rs index d07a8f5..a8030ae 100644 --- a/src/readline/mod.rs +++ b/src/readline/mod.rs @@ -346,17 +346,17 @@ impl ShedVi { self } - /// A mutable reference to the currently focused editor - /// This includes the main LineBuf, and sub-editors for modes like Ex mode. - pub fn focused_editor(&mut self) -> &mut LineBuf { - self.mode.editor().unwrap_or(&mut self.editor) - } + /// A mutable reference to the currently focused editor + /// This includes the main LineBuf, and sub-editors for modes like Ex mode. + pub fn focused_editor(&mut self) -> &mut LineBuf { + self.mode.editor().unwrap_or(&mut self.editor) + } - /// A mutable reference to the currently focused history, if any. - /// This includes the main history struct, and history for sub-editors like Ex mode. - pub fn focused_history(&mut self) -> &mut History { - self.mode.history().unwrap_or(&mut self.history) - } + /// A mutable reference to the currently focused history, if any. + /// This includes the main history struct, and history for sub-editors like Ex mode. + pub fn focused_history(&mut self) -> &mut History { + self.mode.history().unwrap_or(&mut self.history) + } /// Feed raw bytes from stdin into the reader's buffer pub fn feed_bytes(&mut self, bytes: &[u8]) { @@ -475,21 +475,21 @@ impl ShedVi { SelectorResponse::Accept(cmd) => { let post_cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::OnHistorySelect)); - { - let editor = self.focused_editor(); - editor.set_buffer(cmd.to_string()); - editor.move_cursor_to_end(); - } + { + let editor = self.focused_editor(); + editor.set_buffer(cmd.to_string()); + editor.move_cursor_to_end(); + } self .history .update_pending_cmd((self.editor.as_str(), self.editor.cursor.get())); - self.editor.set_hint(None); - { - let mut writer = std::mem::take(&mut self.writer); - self.focused_history().fuzzy_finder.clear(&mut writer)?; - self.writer = writer; - } + self.editor.set_hint(None); + { + let mut writer = std::mem::take(&mut self.writer); + self.focused_history().fuzzy_finder.clear(&mut writer)?; + self.writer = writer; + } self.focused_history().fuzzy_finder.reset(); with_vars([("_HIST_ENTRY".into(), cmd.clone())], || { @@ -513,11 +513,11 @@ impl ShedVi { post_cmds.exec(); self.editor.set_hint(None); - { - let mut writer = std::mem::take(&mut self.writer); - self.focused_history().fuzzy_finder.clear(&mut writer)?; - self.writer = writer; - } + { + let mut writer = std::mem::take(&mut self.writer); + self.focused_history().fuzzy_finder.clear(&mut writer)?; + self.writer = writer; + } write_vars(|v| { v.set_var( "SHED_VI_MODE", @@ -674,9 +674,8 @@ impl ShedVi { } if let KeyEvent(KeyCode::Tab, mod_keys) = key { - if self.mode.report_mode() != ModeReport::Ex - && self.editor.attempt_history_expansion(&self.history) - { + if self.mode.report_mode() != ModeReport::Ex + && 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(); @@ -1031,11 +1032,11 @@ impl ShedVi { let one_line = new_layout.end.row == 0; self.completer.clear(&mut self.writer)?; - { - let mut writer = std::mem::take(&mut self.writer); - self.focused_history().fuzzy_finder.clear(&mut writer)?; - self.writer = writer; - } + { + let mut writer = std::mem::take(&mut self.writer); + self.focused_history().fuzzy_finder.clear(&mut writer)?; + self.writer = writer; + } if let Some(layout) = self.old_layout.as_ref() { self.writer.clear_rows(layout)?; @@ -1132,11 +1133,11 @@ impl ShedVi { .fuzzy_finder .set_prompt_line_context(preceding_width, new_layout.cursor.col); - { - let mut writer = std::mem::take(&mut self.writer); - self.focused_history().fuzzy_finder.draw(&mut writer)?; - self.writer = writer; - } + { + let mut writer = std::mem::take(&mut self.writer); + self.focused_history().fuzzy_finder.draw(&mut writer)?; + self.writer = writer; + } self.old_layout = Some(new_layout); self.needs_redraw = false; @@ -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 = Box::new(ViNormal::new()); self.swap_mode(&mut mode); diff --git a/src/readline/vicmd.rs b/src/readline/vicmd.rs index 9de0af8..f9cb2f6 100644 --- a/src/readline/vicmd.rs +++ b/src/readline/vicmd.rs @@ -307,6 +307,9 @@ impl Verb { | Self::JoinLines | Self::InsertChar(_) | Self::Insert(_) + | Self::Dedent + | Self::Indent + | Self::Equalize | Self::Rot13 | Self::EndOfFile | Self::IncrementNumber(_) diff --git a/src/shopt.rs b/src/shopt.rs index 6bb6c62..c3e0f95 100644 --- a/src/shopt.rs +++ b/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, -} - -impl FromStr for ShedEditMode { - type Err = ShErr; - fn from_str(s: &str) -> Result { - match s.to_ascii_lowercase().as_str() { - "vi" => Ok(Self::Vi), - "emacs" => Ok(Self::Emacs), - _ => Err(ShErr::simple( - ShErrKind::SyntaxErr, - format!("Invalid edit mode '{s}'"), - )), +/// 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 + ),* $(,)? } - } -} - -impl Display for ShedEditMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ShedEditMode::Vi => write!(f, "vi"), - ShedEditMode::Emacs => write!(f, "emacs"), + ) => { + $(#[$struct_meta])* + pub struct $name { + $(pub $field: $ty,)* } - } + + 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> { + 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!("shopt: unexpected '{}' option '{query}'", $group_name), + )), + } + } + } + + impl Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + 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 { 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 { } } -#[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, -} +shopt_group! { + #[derive(Clone, Debug)] + 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::() else { - return Err(ShErr::simple( - ShErrKind::SyntaxErr, - "shopt: expected 'true' or 'false' for dotglob value", - )); - }; - self.dotglob = val; - } - "autocd" => { - let Ok(val) = val.parse::() 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::() 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::() 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::() 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::() 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::() 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::() 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::() 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::() 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}'"), - )); - } - } - Ok(()) - } - pub fn get(&self, query: &str) -> ShResult> { - if query.is_empty() { - return Ok(Some(format!("{self}"))); - } + /// Allow navigation to directories by passing the directory as a command directly + autocd: bool = false, - 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}'"), - )), - } + /// 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(()) + })] + max_hist: isize = 10_000, + + /// 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, } } -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)); +shopt_group! { + #[derive(Clone, Debug)] + pub struct ShOptPrompt ("prompt") { + /// Maximum number of path segments used in the '\W' prompt escape sequence + trunc_prompt_path: usize = 4, - let final_output = output.join("\n"); + /// Maximum number of completion candidates displayed upon pressing tab + comp_limit: usize = 100, - writeln!(f, "{final_output}") - } -} + /// Whether to enable or disable syntax highlighting on the prompt + highlight: bool = true, -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 to automatically indent new lines in multiline commands + auto_indent: bool = true, -#[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, -} + /// Whether to automatically insert a newline when the input is incomplete + linebreak_on_incomplete: bool = true, -impl ShOptPrompt { - pub fn set(&mut self, opt: &str, val: &str) -> ShResult<()> { - match opt { - "trunc_prompt_path" => { - let Ok(val) = val.parse::() 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::() 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::() 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::() 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::() 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::() 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::() 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::() 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> { - if query.is_empty() { - return Ok(Some(format!("{self}"))); - } + /// The leader key sequence used in keymap bindings + leader: String = " ".to_string(), - 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 display line numbers in multiline input + line_numbers: bool = true, -impl Display for ShOptPrompt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut output = vec![]; + /// Command to execute as a screensaver after idle timeout + screensaver_cmd: String = String::new(), - 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 - )); + /// Idle time in seconds before running screensaver_cmd (0 = disabled) + screensaver_idle_time: usize = 0, - let final_output = output.join("\n"); - - writeln!(f, "{final_output}") - } -} - -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, - } + /// 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")); } diff --git a/src/state.rs b/src/state.rs index 44624bc..f6e6e86 100644 --- a/src/state.rs +++ b/src/state.rs @@ -31,7 +31,7 @@ use crate::{ }, prelude::*, readline::{ - complete::{BashCompSpec, CompSpec}, + complete::{BashCompSpec, Candidate, CompSpec}, keys::KeyEvent, markers, }, @@ -1001,6 +1001,13 @@ impl From> for Var { } } +impl From> for Var { + fn from(value: Vec) -> Self { + let as_strs = value.into_iter().map(|c| c.0).collect::>(); + Self::new(VarKind::Arr(as_strs.into()), VarFlags::NONE) + } +} + impl From<&[String]> for Var { fn from(value: &[String]) -> Self { let mut new = VecDeque::new();