use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedCommand {
Mkdir(String),
Touch(String),
Rename(String),
Mv(String),
Cp(String),
MvMatching {
pattern: String,
dest: String,
},
CpMatching {
pattern: String,
dest: String,
},
Git(String),
Set { key: String, value: String },
Sort {
by: Option<crate::app::sort::SortBy>,
reverse: bool,
},
Shell(String),
Bookmark(String),
Jump(String),
Plugin { name: String, arg: String },
Term(crate::terminal::TermCommand),
Unmark,
ConfigWrite,
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum ParseError {
#[error("unknown command '{0}' — try :mkdir, :touch, :rename, :mv, :cp, :git, :set, :sort, :bookmark, :jump, :unmark, :config, :plugin, :term")]
UnknownVerb(String),
#[error("{0} requires an argument")]
MissingArgument(String),
#[error("{0}")]
InvalidArgument(String),
#[error("empty command")]
Empty,
}
pub fn parse(buffer: &str, is_shell: bool) -> Result<ParsedCommand, ParseError> {
if is_shell {
let cmd = buffer.trim();
if cmd.is_empty() {
return Err(ParseError::MissingArgument("!".to_owned()));
}
return Ok(ParsedCommand::Shell(cmd.to_owned()));
}
let trimmed = buffer.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
let (verb, rest) = match trimmed.split_once(' ') {
Some((v, r)) => (v, r.trim()),
None => (trimmed, ""),
};
match verb {
"mkdir" => {
if rest.is_empty() {
return Err(ParseError::MissingArgument("mkdir".to_owned()));
}
let name = rest.to_owned();
if name.contains('/') || name.contains('\\') {
return Err(ParseError::InvalidArgument(
"mkdir: name must not contain path separators".to_owned(),
));
}
Ok(ParsedCommand::Mkdir(name))
}
"touch" => {
if rest.is_empty() {
return Err(ParseError::MissingArgument("touch".to_owned()));
}
let name = rest.to_owned();
if name.contains('/') || name.contains('\\') {
return Err(ParseError::InvalidArgument(
"touch: name must not contain path separators".to_owned(),
));
}
Ok(ParsedCommand::Touch(name))
}
"rename" | "ren" => {
if rest.is_empty() {
return Err(ParseError::MissingArgument("rename".to_owned()));
}
let new_name = rest.to_owned();
if new_name.contains('/') || new_name.contains('\\') {
return Err(ParseError::InvalidArgument(
"rename: new name must not contain path separators".to_owned(),
));
}
Ok(ParsedCommand::Rename(new_name))
}
"mv" => {
if rest.is_empty() {
return Err(ParseError::MissingArgument("mv".to_owned()));
}
match split_pattern_and_dest("mv", rest)? {
Some((pattern, dest)) => Ok(ParsedCommand::MvMatching { pattern, dest }),
None => Ok(ParsedCommand::Mv(rest.to_owned())),
}
}
"cp" => {
if rest.is_empty() {
return Err(ParseError::MissingArgument("cp".to_owned()));
}
match split_pattern_and_dest("cp", rest)? {
Some((pattern, dest)) => Ok(ParsedCommand::CpMatching { pattern, dest }),
None => Ok(ParsedCommand::Cp(rest.to_owned())),
}
}
"git" => {
if rest.is_empty() {
return Err(ParseError::MissingArgument("git".to_owned()));
}
Ok(ParsedCommand::Git(rest.to_owned()))
}
"set" => {
let (key, value) = match rest.split_once(' ') {
Some((k, v)) => (k.trim(), v.trim()),
None => {
return Err(ParseError::MissingArgument(
"set requires <key> <value>".to_owned(),
));
}
};
if key.is_empty() {
return Err(ParseError::MissingArgument("set: key is empty".to_owned()));
}
if value.is_empty() {
return Err(ParseError::MissingArgument(
"set: value is empty".to_owned(),
));
}
Ok(ParsedCommand::Set {
key: key.to_owned(),
value: value.to_owned(),
})
}
"sort" => {
let mut by = None;
let mut reverse = false;
for word in rest.split_whitespace() {
if word.eq_ignore_ascii_case("reverse") || word.eq_ignore_ascii_case("rev") {
reverse = true;
} else if let Some(key) = crate::app::sort::SortBy::parse(word) {
by = Some(key);
} else {
return Err(ParseError::InvalidArgument(format!(
"sort: '{word}' is not a sort key ({})",
crate::app::sort::SORT_BY_REASON
)));
}
}
if by.is_none() && !reverse {
return Err(ParseError::MissingArgument(
"sort requires a key (name, size, modified, extension) or 'reverse'".to_owned(),
));
}
Ok(ParsedCommand::Sort { by, reverse })
}
"bookmark" | "bm" => {
let name = rest.trim().to_owned();
if name.contains('/') || name.contains('\\') {
return Err(ParseError::InvalidArgument(
"bookmark: name must not contain path separators".to_owned(),
));
}
Ok(ParsedCommand::Bookmark(name))
}
"jump" | "j" => {
let name = rest.trim();
if name.is_empty() {
return Err(ParseError::MissingArgument("jump".to_owned()));
}
Ok(ParsedCommand::Jump(name.to_owned()))
}
"plugin" => {
let rest_trimmed = rest.trim();
if rest_trimmed.is_empty() {
return Err(ParseError::MissingArgument("plugin".to_owned()));
}
let (name, arg) = match rest_trimmed.split_once(' ') {
Some((n, a)) => (n.trim(), a.trim()),
None => (rest_trimmed, ""),
};
if name.is_empty() {
return Err(ParseError::MissingArgument("plugin".to_owned()));
}
Ok(ParsedCommand::Plugin {
name: name.to_owned(),
arg: arg.to_owned(),
})
}
"term" => parse_term(rest),
"config" => match rest {
"write" => Ok(ParsedCommand::ConfigWrite),
"" => Err(ParseError::MissingArgument(
"config needs a subcommand: write".to_owned(),
)),
other => Err(ParseError::InvalidArgument(format!(
"config: '{other}' is not a subcommand (write)"
))),
},
"unmark" => {
if !rest.is_empty() {
return Err(ParseError::InvalidArgument(
"unmark takes no argument".to_owned(),
));
}
Ok(ParsedCommand::Unmark)
}
other => Err(ParseError::UnknownVerb(other.to_owned())),
}
}
fn parse_term(rest: &str) -> Result<ParsedCommand, ParseError> {
use crate::terminal::TermCommand;
let mut words = rest.split_whitespace();
let command = match words.next() {
None => TermCommand::Toggle,
Some("new") => TermCommand::New(words.next().map(str::to_owned)),
Some("close") => TermCommand::Close,
Some("max") => TermCommand::Max,
Some(word) => match word.parse::<usize>() {
Ok(n) if n > 0 => TermCommand::Select(n),
_ => {
return Err(ParseError::InvalidArgument(format!(
"term: '{word}' is not new, close, max or a shell number"
)))
}
},
};
if let Some(extra) = words.next() {
return Err(ParseError::InvalidArgument(format!(
"term: unexpected '{extra}'"
)));
}
Ok(ParsedCommand::Term(command))
}
fn split_pattern_and_dest(verb: &str, rest: &str) -> Result<Option<(String, String)>, ParseError> {
let (first, remainder) = match rest.split_once(char::is_whitespace) {
Some((first, remainder)) => (first, remainder.trim()),
None => (rest, ""),
};
if !crate::actions::fs_ops::is_pattern(first) {
return Ok(None);
}
if remainder.is_empty() {
return Err(ParseError::MissingArgument(format!(
"{verb} {first} needs a destination directory"
)));
}
Ok(Some((first.to_owned(), remainder.to_owned())))
}
#[allow(dead_code)]
pub fn completions(buffer: &str, cwd: &Path, is_shell: bool) -> Vec<String> {
completions_with_sources(buffer, cwd, is_shell, &CompletionSources::default())
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CompletionSources<'a> {
pub plugin_actions: &'a [&'a str],
pub bookmarks: &'a [(String, PathBuf)],
}
pub fn completions_with_sources(
buffer: &str,
cwd: &Path,
is_shell: bool,
sources: &CompletionSources<'_>,
) -> Vec<String> {
if is_shell {
return Vec::new(); }
let trimmed = buffer.trim_start();
if !trimmed.contains(' ') {
let prefix = trimmed;
let verbs = [
"mkdir", "touch", "rename", "mv", "cp", "git", "set", "sort", "bookmark", "jump",
"unmark", "config", "plugin", "term",
];
return verbs
.iter()
.filter(|v| v.starts_with(prefix))
.map(|v| format!("{v} "))
.collect();
}
let (verb, partial) = match trimmed.split_once(' ') {
Some((v, p)) => (v, p),
None => return Vec::new(),
};
if verb == "plugin" {
let mut candidates: Vec<String> = sources
.plugin_actions
.iter()
.filter(|a| partial.is_empty() || a.starts_with(partial))
.map(|a| a.to_string())
.collect();
candidates.sort();
return candidates;
}
if !matches!(verb, "mv" | "cp") {
return Vec::new();
}
if let Some(after_at) = partial.strip_prefix('@') {
return bookmark_completions(after_at, sources.bookmarks);
}
path_completions(partial, cwd)
}
fn bookmark_completions(after_at: &str, bookmarks: &[(String, PathBuf)]) -> Vec<String> {
match after_at.find(std::path::is_separator) {
None => {
let mut names: Vec<String> = bookmarks
.iter()
.filter(|(name, _)| name.starts_with(after_at))
.map(|(name, _)| format!("@{name}{}", std::path::MAIN_SEPARATOR))
.collect();
names.sort();
names
}
Some(split) => {
let (name, route) = after_at.split_at(split);
let Some((_, base)) = bookmarks.iter().find(|(n, _)| n == name) else {
return Vec::new();
};
let (sep, inner) = route.split_at(1);
let respell = !inner.contains(std::path::is_separator);
path_completions(inner, base)
.into_iter()
.map(
|candidate| match candidate.strip_suffix(std::path::MAIN_SEPARATOR) {
Some(dir) if respell => format!("@{name}{sep}{dir}{sep}"),
_ => format!("@{name}{sep}{candidate}"),
},
)
.collect()
}
}
}
fn path_completions(partial: &str, cwd: &Path) -> Vec<String> {
let split_at = partial.rfind(std::path::is_separator).map_or(0, |i| i + 1);
let (route, name_prefix) = partial.split_at(split_at);
let search_dir = if route.is_empty() {
cwd.to_owned()
} else {
cwd.join(route)
};
let read = match fs::read_dir(&search_dir) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let separator = route
.chars()
.next_back()
.filter(|c| std::path::is_separator(*c))
.unwrap_or(std::path::MAIN_SEPARATOR);
let mut candidates: Vec<String> = read
.filter_map(|e| e.ok())
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
if !name.starts_with(name_prefix) {
return None;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
Some(format!("{route}{name}{separator}"))
} else {
Some(format!("{route}{name}"))
}
})
.collect();
candidates.sort();
candidates
}
const HISTORY_CAPACITY: usize = 100;
#[derive(Debug, Default)]
pub struct CommandHistory {
entries: Vec<String>,
path: Option<PathBuf>,
}
impl CommandHistory {
pub fn new() -> Self {
CommandHistory {
entries: Vec::new(),
path: None,
}
}
#[allow(dead_code)]
pub fn with_path(path: PathBuf) -> Self {
let entries = load_history(&path).unwrap_or_default();
CommandHistory {
entries,
path: Some(path),
}
}
pub fn push(&mut self, command: String) {
if command.is_empty() {
return;
}
if self.entries.last().map(|s| s.as_str()) == Some(command.as_str()) {
return;
}
if self.entries.len() >= HISTORY_CAPACITY {
self.entries.remove(0);
}
self.entries.push(command);
if let Some(ref path) = self.path {
if let Err(e) = save_history(path, &self.entries) {
tracing::debug!("failed to save command history to {path:?}: {e}");
}
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, index: usize) -> Option<&str> {
self.entries.get(index).map(String::as_str)
}
#[allow(dead_code)]
pub fn prev(&self, offset: usize) -> Option<&str> {
let n = self.entries.len();
n.checked_sub(offset + 1).and_then(|i| self.get(i))
}
}
#[allow(dead_code)]
fn load_history(path: &Path) -> Option<Vec<String>> {
let content = fs::read_to_string(path).ok()?;
let entries: Vec<String> = content
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_owned())
.collect();
let start = entries.len().saturating_sub(HISTORY_CAPACITY);
Some(entries[start..].to_vec())
}
fn save_history(path: &Path, entries: &[String]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)?;
}
}
let content = entries.join("\n");
fs::write(path, content.as_bytes())?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FeedResult {
Updated,
Submit(String),
Cancel,
Completion {
candidates: Vec<String>,
index: usize,
},
}
#[allow(dead_code)]
#[allow(clippy::too_many_arguments)]
pub fn feed(
key: crossterm::event::KeyEvent,
buffer: &mut String,
cursor: &mut usize,
history_index: &mut Option<usize>,
tab_state: &mut TabState,
history: &CommandHistory,
cwd: &Path,
is_shell: bool,
) -> FeedResult {
feed_with_sources(
key,
buffer,
cursor,
history_index,
tab_state,
history,
cwd,
is_shell,
&CompletionSources::default(),
)
}
#[allow(clippy::too_many_arguments)]
pub fn feed_with_sources(
key: crossterm::event::KeyEvent,
buffer: &mut String,
cursor: &mut usize,
history_index: &mut Option<usize>,
tab_state: &mut TabState,
history: &CommandHistory,
cwd: &Path,
is_shell: bool,
sources: &CompletionSources<'_>,
) -> FeedResult {
use crossterm::event::{KeyCode, KeyModifiers};
match key.code {
KeyCode::Esc => FeedResult::Cancel,
KeyCode::Enter => {
FeedResult::Submit(buffer.clone())
}
KeyCode::Backspace => {
if *cursor > 0 {
let new_cursor = prev_char_boundary(buffer, *cursor);
buffer.drain(new_cursor..*cursor);
*cursor = new_cursor;
tab_state.reset();
}
FeedResult::Updated
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if *cursor > 0 {
let new_cursor = prev_char_boundary(buffer, *cursor);
buffer.drain(new_cursor..*cursor);
*cursor = new_cursor;
tab_state.reset();
}
FeedResult::Updated
}
KeyCode::Delete => {
if *cursor < buffer.len() {
let next = next_char_boundary(buffer, *cursor);
buffer.drain(*cursor..next);
tab_state.reset();
}
FeedResult::Updated
}
KeyCode::Left => {
if *cursor > 0 {
*cursor = prev_char_boundary(buffer, *cursor);
}
tab_state.reset();
FeedResult::Updated
}
KeyCode::Right => {
if *cursor < buffer.len() {
*cursor = next_char_boundary(buffer, *cursor);
}
tab_state.reset();
FeedResult::Updated
}
KeyCode::Up => {
let next_idx = match *history_index {
None => {
if history.is_empty() {
return FeedResult::Updated;
}
history.len() - 1
}
Some(i) => i.saturating_sub(1),
};
if let Some(entry) = history.get(next_idx) {
*buffer = entry.to_owned();
*cursor = buffer.len();
*history_index = Some(next_idx);
}
tab_state.reset();
FeedResult::Updated
}
KeyCode::Down => {
match *history_index {
None => FeedResult::Updated,
Some(i) => {
if i + 1 < history.len() {
let next_idx = i + 1;
if let Some(entry) = history.get(next_idx) {
*buffer = entry.to_owned();
*cursor = buffer.len();
*history_index = Some(next_idx);
}
} else {
buffer.clear();
*cursor = 0;
*history_index = None;
}
tab_state.reset();
FeedResult::Updated
}
}
}
KeyCode::Tab => {
let prefix = tab_state.cycle_prefix(buffer);
let candidates = completions_with_sources(&prefix, cwd, is_shell, sources);
if candidates.is_empty() {
return FeedResult::Updated;
}
let idx = tab_state.advance(candidates.len());
apply_completion(buffer, cursor, &prefix, &candidates[idx], is_shell);
FeedResult::Completion {
candidates,
index: idx,
}
}
KeyCode::Home => {
*cursor = 0;
tab_state.reset();
FeedResult::Updated
}
KeyCode::End => {
*cursor = buffer.len();
tab_state.reset();
FeedResult::Updated
}
KeyCode::Char(ch)
if crate::input::is_text_modifiers(key.modifiers)
|| key.modifiers.contains(KeyModifiers::ALT) =>
{
buffer.insert(*cursor, ch);
*cursor += ch.len_utf8();
*history_index = None;
tab_state.reset();
FeedResult::Updated
}
_ => FeedResult::Updated,
}
}
#[derive(Debug, Default)]
pub struct TabState {
index: Option<usize>,
prefix: Option<String>,
}
impl TabState {
pub fn new() -> Self {
TabState {
index: None,
prefix: None,
}
}
pub fn reset(&mut self) {
self.index = None;
self.prefix = None;
}
pub fn cycle_prefix(&mut self, buffer: &str) -> String {
self.prefix.get_or_insert_with(|| buffer.to_owned()).clone()
}
pub fn advance(&mut self, len: usize) -> usize {
let next = match self.index {
None => 0,
Some(i) => (i + 1) % len,
};
self.index = Some(next);
next
}
}
fn apply_completion(
buffer: &mut String,
cursor: &mut usize,
prefix: &str,
candidate: &str,
is_shell: bool,
) {
if is_shell {
return; }
*buffer = match prefix.rfind(' ') {
Some(last_space) => format!("{}{candidate}", &prefix[..=last_space]),
None => candidate.to_owned(),
};
*cursor = buffer.len();
}
fn prev_char_boundary(s: &str, cursor: usize) -> usize {
let mut pos = cursor.saturating_sub(1);
while pos > 0 && !s.is_char_boundary(pos) {
pos -= 1;
}
pos
}
fn next_char_boundary(s: &str, cursor: usize) -> usize {
let mut pos = cursor + 1;
while pos < s.len() && !s.is_char_boundary(pos) {
pos += 1;
}
pos
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mkdir_valid() {
let cmd = parse("mkdir foo", false).unwrap();
assert_eq!(cmd, ParsedCommand::Mkdir("foo".to_owned()));
}
#[test]
fn parse_mkdir_empty_arg_is_error() {
let err = parse("mkdir", false).unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn parse_mkdir_with_separator_is_error() {
let err = parse("mkdir foo/bar", false).unwrap_err();
assert!(matches!(err, ParseError::InvalidArgument(_)));
}
#[test]
fn parse_touch_valid() {
let cmd = parse("touch new_file.txt", false).unwrap();
assert_eq!(cmd, ParsedCommand::Touch("new_file.txt".to_owned()));
}
#[test]
fn parse_rename_valid() {
let cmd = parse("rename new_name", false).unwrap();
assert_eq!(cmd, ParsedCommand::Rename("new_name".to_owned()));
}
#[test]
fn parse_rename_alias_ren() {
let cmd = parse("ren new_name", false).unwrap();
assert_eq!(cmd, ParsedCommand::Rename("new_name".to_owned()));
}
#[test]
fn parse_rename_with_slash_is_error() {
let err = parse("rename foo/bar", false).unwrap_err();
assert!(matches!(err, ParseError::InvalidArgument(_)));
}
#[test]
fn parse_mv_valid() {
let cmd = parse("mv ../somewhere", false).unwrap();
assert_eq!(cmd, ParsedCommand::Mv("../somewhere".to_owned()));
}
#[test]
fn parse_cp_valid() {
let cmd = parse("cp backup.txt", false).unwrap();
assert_eq!(cmd, ParsedCommand::Cp("backup.txt".to_owned()));
}
#[test]
fn parse_git_valid() {
let cmd = parse("git status --short", false).unwrap();
assert_eq!(cmd, ParsedCommand::Git("status --short".to_owned()));
}
#[test]
fn parse_git_no_subcommand_is_error() {
let err = parse("git", false).unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn parse_set_valid() {
let cmd = parse("set git_status_enabled true", false).unwrap();
assert_eq!(
cmd,
ParsedCommand::Set {
key: "git_status_enabled".to_owned(),
value: "true".to_owned(),
}
);
}
#[test]
fn parse_set_no_value_is_error() {
let err = parse("set key_only", false).unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn parse_sort_key_only() {
use crate::app::sort::SortBy;
assert_eq!(
parse("sort size", false).unwrap(),
ParsedCommand::Sort {
by: Some(SortBy::Size),
reverse: false
}
);
}
#[test]
fn parse_sort_key_and_reverse_in_either_word_order() {
use crate::app::sort::SortBy;
let expected = ParsedCommand::Sort {
by: Some(SortBy::Modified),
reverse: true,
};
assert_eq!(parse("sort modified reverse", false).unwrap(), expected);
assert_eq!(parse("sort reverse modified", false).unwrap(), expected);
}
#[test]
fn parse_sort_reverse_alone_keeps_the_current_key() {
assert_eq!(
parse("sort reverse", false).unwrap(),
ParsedCommand::Sort {
by: None,
reverse: true
}
);
}
#[test]
fn parse_sort_with_no_argument_is_error() {
let err = parse("sort", false).unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn parse_sort_with_an_unknown_key_is_error() {
let err = parse("sort created", false).unwrap_err();
assert!(matches!(err, ParseError::InvalidArgument(_)), "{err:?}");
}
#[test]
fn parse_empty_is_error() {
let err = parse("", false).unwrap_err();
assert!(matches!(err, ParseError::Empty));
}
#[test]
fn parse_unknown_verb_is_error() {
let err = parse("zap foo", false).unwrap_err();
assert!(matches!(err, ParseError::UnknownVerb(_)));
}
#[test]
fn parse_plugin_valid() {
let cmd = parse("plugin my_action", false).unwrap();
assert_eq!(
cmd,
ParsedCommand::Plugin {
name: "my_action".to_owned(),
arg: "".to_owned(),
}
);
}
#[test]
fn parse_plugin_with_args() {
let cmd = parse("plugin log_note hello world", false).unwrap();
assert_eq!(
cmd,
ParsedCommand::Plugin {
name: "log_note".to_owned(),
arg: "hello world".to_owned(),
}
);
}
#[test]
fn parse_plugin_no_action_is_error() {
let err = parse("plugin", false).unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn parse_shell_command() {
let cmd = parse("ls -la", true).unwrap();
assert_eq!(cmd, ParsedCommand::Shell("ls -la".to_owned()));
}
#[test]
fn parse_shell_empty_is_error() {
let err = parse("", true).unwrap_err();
assert!(matches!(err, ParseError::MissingArgument(_)));
}
#[test]
fn verb_completion_prefix_mk() {
let dir = tempfile::tempdir().unwrap();
let candidates = completions("mk", dir.path(), false);
assert!(
candidates.contains(&"mkdir ".to_owned()),
"expected 'mkdir ' in candidates: {candidates:?}"
);
}
#[test]
fn verb_completion_empty_returns_all_verbs() {
let dir = tempfile::tempdir().unwrap();
let candidates = completions("", dir.path(), false);
let mut got: Vec<String> = candidates.iter().map(|c| c.trim().to_owned()).collect();
got.sort();
assert_eq!(
got,
vec![
"bookmark", "config", "cp", "git", "jump", "mkdir", "mv", "plugin", "rename",
"set", "sort", "term", "touch", "unmark",
]
);
}
#[test]
fn plugin_action_completion() {
let dir = tempfile::tempdir().unwrap();
let candidates = completions_with_sources(
"plugin ",
dir.path(),
false,
&CompletionSources {
plugin_actions: &["log_note", "bookmark_add"],
bookmarks: &[],
},
);
assert_eq!(candidates, vec!["bookmark_add", "log_note"]);
}
#[test]
fn verb_completion_no_match_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let candidates = completions("zzz", dir.path(), false);
assert!(candidates.is_empty());
}
#[test]
fn path_completion_for_mv() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("alpha.txt"), b"").unwrap();
fs::write(dir.path().join("beta.txt"), b"").unwrap();
let candidates = completions("mv al", dir.path(), false);
assert!(
candidates.contains(&"alpha.txt".to_owned()),
"expected 'alpha.txt' in candidates: {candidates:?}"
);
assert!(
!candidates.contains(&"beta.txt".to_owned()),
"beta.txt should not match prefix 'al'"
);
}
#[test]
fn no_path_completion_for_mkdir() {
let dir = tempfile::tempdir().unwrap();
let candidates = completions("mkdir so", dir.path(), false);
assert!(
candidates.is_empty(),
"path completion should not apply to mkdir"
);
}
#[test]
fn history_push_and_retrieve() {
let mut h = CommandHistory::new();
h.push("mkdir foo".to_owned());
h.push("touch bar".to_owned());
assert_eq!(h.len(), 2);
assert_eq!(h.prev(0), Some("touch bar"));
assert_eq!(h.prev(1), Some("mkdir foo"));
}
#[test]
fn history_deduplicates_consecutive_identical() {
let mut h = CommandHistory::new();
h.push("mkdir foo".to_owned());
h.push("mkdir foo".to_owned());
assert_eq!(h.len(), 1);
}
#[test]
fn history_allows_non_consecutive_duplicate() {
let mut h = CommandHistory::new();
h.push("mkdir foo".to_owned());
h.push("touch bar".to_owned());
h.push("mkdir foo".to_owned());
assert_eq!(h.len(), 3);
}
#[test]
fn history_evicts_oldest_at_capacity() {
let mut h = CommandHistory::new();
for i in 0..HISTORY_CAPACITY + 5 {
h.push(format!("cmd{i}"));
}
assert_eq!(h.len(), HISTORY_CAPACITY);
assert_eq!(
h.prev(0),
Some(format!("cmd{}", HISTORY_CAPACITY + 4).as_str())
);
}
#[test]
fn history_persist_and_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.txt");
let mut h = CommandHistory::with_path(path.clone());
h.push("mkdir foo".to_owned());
h.push("touch bar".to_owned());
let h2 = CommandHistory::with_path(path);
assert_eq!(h2.len(), 2);
assert_eq!(h2.prev(0), Some("touch bar"));
}
fn tab_cycle(buffer: &str, cwd: &Path, times: usize) -> Vec<String> {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let mut buf = buffer.to_owned();
let mut cursor = buf.len();
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let key = KeyEvent {
code: KeyCode::Tab,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
(0..times)
.map(|_| {
feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
cwd,
false,
);
buf.clone()
})
.collect()
}
#[test]
fn tab_cycles_through_verb_completions_and_wraps() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
tab_cycle("m", dir.path(), 3),
vec!["mkdir ", "mv ", "mkdir "],
"Tab must step through every verb sharing the prefix, then wrap"
);
}
#[test]
fn tab_cycles_through_path_completions() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("alpha.txt"), b"").unwrap();
fs::write(dir.path().join("alpine.txt"), b"").unwrap();
assert_eq!(
tab_cycle("mv al", dir.path(), 3),
vec!["mv alpha.txt", "mv alpine.txt", "mv alpha.txt"],
"a destination with two matches must offer both"
);
}
#[test]
fn editing_restarts_the_completion_cycle() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = "m".to_owned();
let mut cursor = buf.len();
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let press = |code| KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let mut send = |code, buf: &mut String, cursor: &mut usize| {
feed(
press(code),
buf,
cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
};
send(KeyCode::Tab, &mut buf, &mut cursor);
assert_eq!(buf, "mkdir ");
send(KeyCode::Backspace, &mut buf, &mut cursor);
send(KeyCode::Tab, &mut buf, &mut cursor);
assert_eq!(
buf, "mkdir ",
"the new cycle completes the edited text, not the old prefix"
);
}
#[test]
fn feed_char_appends_to_buffer() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = String::new();
let mut cursor = 0usize;
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let key = KeyEvent {
code: KeyCode::Char('m'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let result = feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
assert_eq!(result, FeedResult::Updated);
assert_eq!(buf, "m");
assert_eq!(cursor, 1);
}
#[test]
fn feed_keeps_altgr_backslashes_of_a_pasted_path() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = String::new();
let mut cursor = 0usize;
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let pasted = r"C:\Users\me\file.txt";
for ch in pasted.chars() {
let modifiers = match ch {
'\\' => KeyModifiers::CONTROL | KeyModifiers::ALT,
':' | 'C' | 'U' => KeyModifiers::SHIFT,
_ => KeyModifiers::NONE,
};
let key = KeyEvent {
code: KeyCode::Char(ch),
modifiers,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
}
assert_eq!(buf, pasted);
assert_eq!(cursor, pasted.len());
}
#[test]
fn feed_bare_ctrl_char_is_not_text() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = String::new();
let mut cursor = 0usize;
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let key = KeyEvent {
code: KeyCode::Char('x'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
assert!(buf.is_empty());
}
#[test]
fn feed_enter_submits_buffer() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = "mkdir foo".to_owned();
let mut cursor = buf.len();
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let key = KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let result = feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
assert_eq!(result, FeedResult::Submit("mkdir foo".to_owned()));
}
#[test]
fn feed_esc_cancels() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = "mkdir foo".to_owned();
let mut cursor = buf.len();
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let key = KeyEvent {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let result = feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
assert_eq!(result, FeedResult::Cancel);
}
#[test]
fn feed_backspace_removes_last_char() {
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
let dir = tempfile::tempdir().unwrap();
let mut buf = "abc".to_owned();
let mut cursor = 3usize;
let mut hist_idx = None;
let mut tab = TabState::new();
let h = CommandHistory::new();
let key = KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
feed(
key,
&mut buf,
&mut cursor,
&mut hist_idx,
&mut tab,
&h,
dir.path(),
false,
);
assert_eq!(buf, "ab");
assert_eq!(cursor, 2);
}
}