query.rs
raw
//! 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<String>,
}
/// 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<Invocation> {
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<String> {
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);
}
}