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);
    }
}