//! The editing model behind the input line. //! //! A [`Query`] moves through two phases. In [`Phase::Command`] the user is //! typing a command-name prefix and every keystroke re-filters the matches. In //! [`Phase::Arguments`] the command has been committed to a concrete name and //! further typing accumulates argument text, which is *not* completed. Space is //! the hinge: in the command phase it commits the highlighted match and crosses //! into the argument phase; in the argument phase it is an ordinary separator. //! The highlight defaults to the first match listed — what was typed, if that //! names a command, otherwise the candidate earliest on `$PATH` — and is moved //! by the arrow keys. use crate::executables::Executables; /// Which part of the input the next keystroke edits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Phase { /// Typing a command-name prefix; keystrokes refine the match list. Command, /// A command name is fixed; keystrokes build the argument string. Arguments, } /// A fully-formed intention to run something: the resolved command name plus /// its parsed argument vector. Produced by [`Query::resolve`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Invocation { pub command: String, pub args: Vec, } /// The mutable state of the input line. pub struct Query { /// In [`Phase::Command`] this is the prefix being typed. In /// [`Phase::Arguments`] it is the committed command name. command: String, /// Raw, unsplit argument text. Only meaningful in [`Phase::Arguments`]. args: String, /// Index into the current match list of the highlighted candidate — the one /// space or enter will act on. Defaults to 0 (the first match listed) and is /// reset there whenever the prefix changes; the arrow keys move it. Only /// meaningful in [`Phase::Command`]. selected: usize, phase: Phase, } impl Query { pub fn new() -> Self { Query { command: String::new(), args: String::new(), selected: 0, phase: Phase::Command, } } pub fn phase(&self) -> Phase { self.phase } /// The command text as it currently stands: the typed prefix while /// completing, or the committed name afterward. pub fn command(&self) -> &str { &self.command } /// The raw argument text (empty in the command phase). pub fn args(&self) -> &str { &self.args } /// Insert a typed character. Routed to the command prefix or the argument /// text depending on the current phase. pub fn insert_char(&mut self, c: char) { match self.phase { Phase::Command => { self.command.push(c); // The match list just changed; start again from the top. self.selected = 0; } Phase::Arguments => self.args.push(c), } } /// Handle the space key. /// /// In the command phase this commits the highlighted match: the typed prefix /// is replaced by the full command name and editing moves to arguments. If /// nothing matches, there is nothing to commit and the press is ignored so /// the user isn't stranded in an argument phase for a command that cannot /// run. In the argument phase, space is a literal separator. /// /// Returns `true` if the press was consumed as a commit or separator, /// `false` if it was ignored (no match to commit to). pub fn space(&mut self, executables: &Executables) -> bool { match self.phase { Phase::Command => match self.selected_match(executables) { Some(name) => { self.command = name.to_string(); self.args.clear(); self.phase = Phase::Arguments; true } None => false, }, Phase::Arguments => { self.args.push(' '); true } } } /// Handle backspace. /// /// Within the argument text it deletes one character. When the argument /// text is empty, backspace steps back across the phase boundary: editing /// returns to the (now full) command name so it can be revised, rather than /// doing nothing. Within the command phase it deletes one prefix character. pub fn backspace(&mut self) { match self.phase { Phase::Command => { self.command.pop(); self.selected = 0; } Phase::Arguments => { if self.args.is_empty() { self.phase = Phase::Command; self.selected = 0; } else { self.args.pop(); } } } } /// Move the highlight to the next (further down the list) candidate, /// clamping at the last match. A no-op outside the command phase. pub fn select_next(&mut self, executables: &Executables) { if self.phase != Phase::Command { return; } let count = executables.matching(&self.command).len(); if self.selected + 1 < count { self.selected += 1; } } /// Move the highlight to the previous (further up the list) candidate, /// clamping at the first match. A no-op outside the command phase. pub fn select_previous(&mut self) { if self.phase == Phase::Command { self.selected = self.selected.saturating_sub(1); } } /// Index of the highlighted candidate within the current match list. pub fn selected_index(&self) -> usize { self.selected } /// The currently highlighted match, or `None` when nothing matches. The /// candidate space and enter act on. fn selected_match<'e>(&self, executables: &'e Executables) -> Option<&'e str> { executables .matching(&self.command) .get(self.selected) .copied() } /// The full command name the launcher would act on right now: the committed /// name in the argument phase, or the highlighted match of the current /// prefix in the command phase. `None` when nothing matches. pub fn effective_command<'e>(&'e self, executables: &'e Executables) -> Option<&'e str> { match self.phase { Phase::Arguments => Some(&self.command), Phase::Command => self.selected_match(executables), } } /// The remaining, not-yet-typed suffix of the highlighted match — the /// greyed-out "ghost" the UI shows after the cursor while completing. /// /// Only a genuine prefix match has a meaningful inline continuation, so the /// ghost is shown solely when the highlighted name starts with exactly what /// was typed. For a substring or case-insensitive fallback match the typed /// text isn't a prefix of the name — there is nothing to append after the /// cursor (and slicing at the typed length could split a UTF-8 character) — /// so the ghost is empty and the match is conveyed by the highlighted row. /// Also empty in the argument phase or when nothing matches. pub fn completion_suffix(&self, executables: &Executables) -> String { if self.phase != Phase::Command { return String::new(); } match self.selected_match(executables) { Some(name) if name.starts_with(&self.command) => name[self.command.len()..].to_string(), _ => String::new(), } } /// Resolve the current input into a concrete [`Invocation`], or `None` if /// no command matches. The command is always a real name drawn from /// `executables` (either committed or the highlighted match), so callers can /// assume the target exists on `$PATH`. /// /// Argument text is split with shell-like word rules ([`shlex`]). If the /// text is not well-formed (for instance an unclosed quote mid-typing) we /// fall back to whitespace splitting rather than refusing to launch. pub fn resolve(&self, executables: &Executables) -> Option { let command = self.effective_command(executables)?.to_string(); let args = split_args(&self.args); Some(Invocation { command, args }) } /// The command names to list beneath the input in the command phase, in the /// order they are shown. Empty in the argument phase. pub fn matches<'e>(&self, executables: &'e Executables) -> Vec<&'e str> { match self.phase { Phase::Command => executables.matching(&self.command), Phase::Arguments => Vec::new(), } } } impl Default for Query { fn default() -> Self { Self::new() } } /// Split argument text into words with shell-like quoting, falling back to /// whitespace splitting when the text cannot be parsed (e.g. a half-typed /// quote). Never panics and never refuses. fn split_args(text: &str) -> Vec { if text.trim().is_empty() { return Vec::new(); } shlex::split(text).unwrap_or_else(|| text.split_whitespace().map(str::to_string).collect()) } #[cfg(test)] mod tests { use super::*; use std::fs::{self, File}; use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; /// Build an [`Executables`] over a scratch directory containing the named /// commands, so the query logic runs against real matching behavior. fn executables(tag: &str, names: &[&str]) -> (Executables, PathBuf) { let root = std::env::temp_dir().join(format!("liftoff-query-{tag}")); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).unwrap(); for name in names { let path = root.join(name); let mut file = File::create(&path).unwrap(); file.write_all(b"#!/bin/sh\n").unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); perms.set_mode(0o755); fs::set_permissions(&path, perms).unwrap(); } (Executables::from_dirs([&root]), root) } fn cleanup(root: &Path) { let _ = fs::remove_dir_all(root); } fn type_str(query: &mut Query, s: &str) { for c in s.chars() { query.insert_char(c); } } #[test] fn prefix_typing_stays_in_command_phase() { let (execs, root) = executables("prefix", &["firefox", "firejail"]); let mut q = Query::new(); type_str(&mut q, "fire"); assert_eq!(q.phase(), Phase::Command); assert_eq!(q.command(), "fire"); assert_eq!(q.completion_suffix(&execs), "fox"); assert_eq!(q.effective_command(&execs), Some("firefox")); cleanup(&root); } #[test] fn space_commits_first_match_and_enters_arguments() { let (execs, root) = executables("commit", &["firefox", "firejail"]); let mut q = Query::new(); type_str(&mut q, "fire"); assert!(q.space(&execs)); assert_eq!(q.phase(), Phase::Arguments); assert_eq!(q.command(), "firefox"); assert_eq!(q.args(), ""); // No ghost/list in the argument phase. assert_eq!(q.completion_suffix(&execs), ""); assert!(q.matches(&execs).is_empty()); cleanup(&root); } #[test] fn space_with_no_match_is_ignored() { let (execs, root) = executables("nomatch", &["firefox"]); let mut q = Query::new(); type_str(&mut q, "zzz"); assert!(!q.space(&execs)); assert_eq!(q.phase(), Phase::Command); assert_eq!(q.command(), "zzz"); cleanup(&root); } #[test] fn arguments_accumulate_with_literal_spaces() { let (execs, root) = executables("args", &["grep"]); let mut q = Query::new(); type_str(&mut q, "grep"); q.space(&execs); type_str(&mut q, "-i foo"); assert_eq!(q.args(), "-i foo"); let inv = q.resolve(&execs).unwrap(); assert_eq!(inv.command, "grep"); assert_eq!(inv.args, vec!["-i".to_string(), "foo".to_string()]); cleanup(&root); } #[test] fn backspace_from_empty_args_returns_to_command_editing() { let (execs, root) = executables("back", &["firefox", "firejail"]); let mut q = Query::new(); type_str(&mut q, "fire"); q.space(&execs); // command = "firefox", phase = Arguments q.backspace(); // args empty -> back to command phase assert_eq!(q.phase(), Phase::Command); assert_eq!(q.command(), "firefox"); q.backspace(); // now edits the name assert_eq!(q.command(), "firefo"); assert_eq!(q.effective_command(&execs), Some("firefox")); cleanup(&root); } #[test] fn backspace_deletes_argument_characters_first() { let (execs, root) = executables("backargs", &["ls"]); let mut q = Query::new(); type_str(&mut q, "ls"); q.space(&execs); type_str(&mut q, "-la"); q.backspace(); assert_eq!(q.phase(), Phase::Arguments); assert_eq!(q.args(), "-l"); cleanup(&root); } #[test] fn arrow_keys_move_the_highlight_and_change_what_commits() { let (execs, root) = executables("select", &["vim", "vimdiff", "vimtutor"]); let mut q = Query::new(); type_str(&mut q, "vim"); assert_eq!(q.selected_index(), 0); assert_eq!(q.effective_command(&execs), Some("vim")); q.select_next(&execs); assert_eq!(q.selected_index(), 1); assert_eq!(q.effective_command(&execs), Some("vimdiff")); // The ghost completion follows the highlight. assert_eq!(q.completion_suffix(&execs), "diff"); // Enter now resolves to the highlighted candidate, not the first. let inv = q.resolve(&execs).unwrap(); assert_eq!(inv.command, "vimdiff"); cleanup(&root); } #[test] fn selection_clamps_at_both_ends() { let (execs, root) = executables("clamp", &["ab", "abc"]); let mut q = Query::new(); type_str(&mut q, "ab"); q.select_previous(); // already at the top assert_eq!(q.selected_index(), 0); q.select_next(&execs); q.select_next(&execs); // only two matches, so clamp at the last assert_eq!(q.selected_index(), 1); q.select_previous(); assert_eq!(q.selected_index(), 0); cleanup(&root); } #[test] fn editing_the_prefix_resets_the_highlight() { let (execs, root) = executables("reset", &["vim", "vimdiff"]); let mut q = Query::new(); type_str(&mut q, "vim"); q.select_next(&execs); assert_eq!(q.selected_index(), 1); // Narrowing the prefix rebuilds the list; the highlight returns to top. type_str(&mut q, "d"); assert_eq!(q.selected_index(), 0); assert_eq!(q.effective_command(&execs), Some("vimdiff")); cleanup(&root); } #[test] fn space_commits_the_highlighted_match() { let (execs, root) = executables("commit-sel", &["vim", "vimdiff"]); let mut q = Query::new(); type_str(&mut q, "vim"); q.select_next(&execs); assert!(q.space(&execs)); assert_eq!(q.phase(), Phase::Arguments); assert_eq!(q.command(), "vimdiff"); cleanup(&root); } #[test] fn arrows_do_nothing_in_the_argument_phase() { let (execs, root) = executables("arg-nav", &["vim", "vimdiff"]); let mut q = Query::new(); type_str(&mut q, "vim"); q.space(&execs); // now in the argument phase q.select_next(&execs); q.select_previous(); assert_eq!(q.selected_index(), 0); assert_eq!(q.command(), "vim"); cleanup(&root); } #[test] fn resolve_uses_first_match_in_command_phase() { let (execs, root) = executables("resolve", &["vim", "vimdiff"]); let mut q = Query::new(); type_str(&mut q, "vi"); let inv = q.resolve(&execs).unwrap(); assert_eq!(inv.command, "vim"); assert!(inv.args.is_empty()); cleanup(&root); } #[test] fn resolve_is_none_without_a_match() { let (execs, root) = executables("none", &["vim"]); let mut q = Query::new(); type_str(&mut q, "xyz"); assert_eq!(q.resolve(&execs), None); cleanup(&root); } #[test] fn resolve_honors_quoted_arguments() { let (execs, root) = executables("quoted", &["echo"]); let mut q = Query::new(); type_str(&mut q, "echo"); q.space(&execs); type_str(&mut q, "\"hello world\" bye"); let inv = q.resolve(&execs).unwrap(); assert_eq!(inv.args, vec!["hello world".to_string(), "bye".to_string()]); cleanup(&root); } #[test] fn resolve_tolerates_unbalanced_quotes() { let (execs, root) = executables("unbalanced", &["echo"]); let mut q = Query::new(); type_str(&mut q, "echo"); q.space(&execs); type_str(&mut q, "foo \"bar"); // half-typed quote // Falls back to whitespace splitting rather than refusing to resolve. let inv = q.resolve(&execs).unwrap(); assert_eq!(inv.command, "echo"); assert_eq!(inv.args, vec!["foo".to_string(), "\"bar".to_string()]); cleanup(&root); } }