//! Discovery of runnable commands from `$PATH`. use std::collections::BTreeMap; use std::env; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; /// The set of executable command names found on `$PATH`. /// /// Names are held sorted alphabetically and deduplicated, so a prefix match is /// a contiguous range the binary search can find. Each carries the position of /// the `$PATH` directory it came from, which is what results are ordered by — /// see [`search_order`]. pub struct Executables { entries: Vec, } impl Executables { /// Scan every directory on `$PATH` for executable files, collecting their /// names. A name that appears in several directories is listed once, /// belonging to the first (highest-priority) directory that has it — the /// copy a shell would actually run. pub fn from_path() -> Self { let path = env::var_os("PATH").unwrap_or_default(); Self::from_dirs(env::split_paths(&path)) } /// Build the command set from an explicit sequence of directories, in /// search order. Split out from [`Executables::from_path`] so the scanning /// logic is testable against a fixture tree without touching the process /// environment. pub fn from_dirs(dirs: I) -> Self where I: IntoIterator, P: AsRef, { // Keyed by name, so a name found more than once is kept once. The rank // counts every directory on the way, readable or not, so it stays a // true `$PATH` position. let mut found: BTreeMap = BTreeMap::new(); for (rank, dir) in dirs.into_iter().enumerate() { let entries = match fs::read_dir(dir.as_ref()) { Ok(entries) => entries, Err(_) => continue, }; for entry in entries.flatten() { if !is_executable_file(&entry) { continue; } if let Ok(name) = entry.file_name().into_string() { // Only the earliest directory's rank is kept: later copies // are shadowed by it, exactly as they are when executing. found.entry(name).or_insert(rank); } } } Self { // A `BTreeMap` iterates in key order, so this comes out sorted by // name — the invariant the prefix search depends on. entries: found .into_iter() .map(|(name, rank)| Entry { name, rank }) .collect(), } } /// The command names matching `query`, in [`search_order`], found by /// falling back through progressively looser criteria: /// /// 1. **prefix** — names beginning with `query`; /// 2. **substring** — names containing `query` (case-sensitive); /// 3. **case-insensitive substring** — names containing `query` ignoring case. /// /// Each looser tier is consulted only when the tighter one finds nothing, so /// an exact prefix is never diluted by substring hits. An empty query matches /// nothing: the launcher shows suggestions only once the user has begun /// typing, and never launches an arbitrary first-on-`$PATH` command. /// /// Because the backing vector is sorted by name, the prefix tier is a /// contiguous slice located with two binary searches; the fallback tiers /// scan (cheap for a `$PATH`-sized list, and only reached when nothing /// simpler matched). Whichever tier answers is then put into search order. pub fn matching(&self, query: &str) -> Vec<&str> { if query.is_empty() { return Vec::new(); } // Tier 1: prefix, as a contiguous range of the by-name ordering. let start = self .entries .partition_point(|entry| entry.name.as_str() < query); let end = start + self.entries[start..].partition_point(|entry| entry.name.starts_with(query)); if end > start { return search_order(self.entries[start..end].iter().collect(), |name| { name == query }); } // Tier 2: case-sensitive substring. let substring: Vec<&Entry> = self .entries .iter() .filter(|entry| entry.name.contains(query)) .collect(); if !substring.is_empty() { return search_order(substring, |name| name == query); } // Tier 3: case-insensitive substring. Exactness folds case the same way // the matching does, so a typed `steam` promotes `Steam`. let needle = query.to_lowercase(); search_order( self.entries .iter() .filter(|entry| entry.name.to_lowercase().contains(&needle)) .collect(), |name| name.to_lowercase() == needle, ) } /// The first command name matching `query` under the /// [`matching`](Self::matching) fallback and ordering — the command whose /// whole name was typed, if there is one, otherwise the candidate earliest /// on `$PATH` and alphabetically first among its neighbours there — or /// `None` when nothing matches. This is the command the launcher commits to /// on space and runs on enter when the first candidate is highlighted. pub fn first_match(&self, query: &str) -> Option<&str> { self.matching(query).into_iter().next() } /// Total number of distinct commands discovered. pub fn len(&self) -> usize { self.entries.len() } /// Whether no commands were discovered at all. pub fn is_empty(&self) -> bool { self.entries.is_empty() } } /// One discovered command. struct Entry { name: String, /// Position on `$PATH` of the first directory holding this name — the copy /// that would actually run, and what orders it among the results. rank: usize, } /// Put candidates into the order the launcher lists them: an exact match for /// what was typed first, then by `$PATH` position, then alphabetically within a /// directory. /// /// Typing a command's whole name says plainly which one you mean, so it leads /// however late on `$PATH` it sits — without that, `git` from `/usr/bin` would /// be displaced by `gitk` from a directory ahead of it. /// /// What counts as exact is the caller's to decide, because it has to be judged /// on the same terms the tier matched on: the case-sensitive tiers want an /// identical name, while the case-insensitive one should promote `Steam` for a /// typed `steam`. That tier is also the only one that can turn up more than one /// exact match (`Steam` and `STEAM` both answer to `steam`); they simply fall /// through to the same `$PATH` and alphabetical ordering as everything else. /// /// `candidates` is drawn from a vector sorted by name and so arrives in /// alphabetical order already; sorting it *stably* therefore leaves /// same-directory names alphabetical, and that tie-break costs nothing. fn search_order<'e>( mut candidates: Vec<&'e Entry>, is_exact: impl Fn(&str) -> bool, ) -> Vec<&'e str> { // `false` orders before `true`, so the exact matches come first. The keys // are cached rather than recomputed as the sort compares, since folding // case allocates. candidates.sort_by_cached_key(|entry| (!is_exact(&entry.name), entry.rank)); candidates .into_iter() .map(|entry| entry.name.as_str()) .collect() } /// Whether a directory entry is a file we would be willing to exec: a regular /// file (or a symlink resolving to one) carrying an execute bit. /// /// The metadata is resolved *through* symlinks ([`fs::metadata`] rather than /// [`fs::DirEntry::metadata`], which stats the link itself). PATH entries are /// very often symlinks to the real binary — flatpak app exports like /// `com.valvesoftware.Steam`, or wrappers such as `/usr/bin/vi -> vim` — and we /// want the target's file type and permissions, not the link's. A broken link /// yields an error and is correctly rejected. fn is_executable_file(entry: &fs::DirEntry) -> bool { let metadata = match fs::metadata(entry.path()) { Ok(metadata) => metadata, Err(_) => return false, }; metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 } #[cfg(test)] mod tests { use super::*; use std::fs::{self, File}; use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; /// Create a throwaway directory tree under the scratch dir and populate it /// with the named executables (and, optionally, plain files) so scanning /// runs against real filesystem metadata rather than mocks. struct Fixture { root: PathBuf, } impl Fixture { fn new(tag: &str) -> Self { let root = std::env::temp_dir().join(format!("liftoff-test-{tag}")); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).unwrap(); Fixture { root } } fn dir(&self, name: &str) -> PathBuf { let dir = self.root.join(name); fs::create_dir_all(&dir).unwrap(); dir } fn executable(&self, dir: &Path, name: &str) { let path = dir.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(); } fn plain_file(&self, dir: &Path, name: &str) { let path = dir.join(name); let mut file = File::create(&path).unwrap(); file.write_all(b"not runnable\n").unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); perms.set_mode(0o644); fs::set_permissions(&path, perms).unwrap(); } fn symlink(&self, dir: &Path, name: &str, target: &Path) { std::os::unix::fs::symlink(target, dir.join(name)).unwrap(); } } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } #[test] fn collects_only_executables_deduped() { let fx = Fixture::new("collect"); let bin = fx.dir("bin"); let usr_bin = fx.dir("usr-bin"); fx.executable(&bin, "firefox"); fx.executable(&bin, "git"); fx.executable(&usr_bin, "firejail"); fx.executable(&usr_bin, "git"); // duplicate name across dirs fx.plain_file(&bin, "README"); // non-executable, ignored let execs = Executables::from_dirs([&bin, &usr_bin]); assert_eq!(execs.len(), 3); assert_eq!(execs.matching("fir"), vec!["firefox", "firejail"]); // "git" appears once despite being in both directories. assert_eq!(execs.matching("git"), vec!["git"]); } /// Search order beats alphabetical order: a command in an earlier `$PATH` /// directory is listed first even when its name sorts later. #[test] fn earlier_directories_are_listed_first() { let fx = Fixture::new("path-order"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "tool-z"); fx.executable(&second, "tool-a"); let execs = Executables::from_dirs([&first, &second]); assert_eq!(execs.matching("tool"), vec!["tool-z", "tool-a"]); assert_eq!(execs.first_match("tool"), Some("tool-z")); } /// Within one directory the tie is broken alphabetically, so the listing is /// grouped by directory and sorted inside each group. #[test] fn names_from_one_directory_are_alphabetical() { let fx = Fixture::new("tie-break"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "tool-d"); fx.executable(&first, "tool-b"); fx.executable(&second, "tool-c"); fx.executable(&second, "tool-a"); let execs = Executables::from_dirs([&first, &second]); assert_eq!( execs.matching("tool"), vec!["tool-b", "tool-d", "tool-a", "tool-c"] ); } /// A name in several directories is ranked by the first one that has it — /// the copy that would actually run — not by the last one seen. #[test] fn a_shadowed_name_keeps_its_earliest_position() { let fx = Fixture::new("shadowed"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "tool-z"); // also in `second`, shadowing it fx.executable(&second, "tool-a"); fx.executable(&second, "tool-z"); let execs = Executables::from_dirs([&first, &second]); assert_eq!(execs.len(), 2); assert_eq!(execs.matching("tool"), vec!["tool-z", "tool-a"]); } /// An unreadable directory still occupies its place on `$PATH`, so it does /// not shift the ranks of the directories after it. #[test] fn a_missing_directory_does_not_shift_later_ranks() { let fx = Fixture::new("missing-rank"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "tool-z"); fx.executable(&second, "tool-a"); let with_gap = Executables::from_dirs([first.as_path(), Path::new("/no/such/dir"), second.as_path()]); assert_eq!(with_gap.matching("tool"), vec!["tool-z", "tool-a"]); } /// The fallback tiers list their hits in search order too, not just the /// prefix tier. #[test] fn substring_fallbacks_use_search_order() { let fx = Fixture::new("fallback-order"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "zzz-tool"); // substring hit, earlier directory fx.executable(&second, "aaa-tool"); // substring hit, later directory let execs = Executables::from_dirs([&first, &second]); // Nothing begins with "tool", so both are found by the substring tier. assert_eq!(execs.matching("tool"), vec!["zzz-tool", "aaa-tool"]); } #[test] fn follows_symlinks_to_executables() { let fx = Fixture::new("symlinks"); let real = fx.dir("real"); let bin = fx.dir("bin"); fx.executable(&real, "steam-wrapper"); // Flatpak exports apps under dotted ids as symlinks to the real binary. fx.symlink(&bin, "com.valvesoftware.Steam", &real.join("steam-wrapper")); let execs = Executables::from_dirs([&bin]); assert_eq!(execs.matching("com."), vec!["com.valvesoftware.Steam"]); assert_eq!(execs.first_match("com"), Some("com.valvesoftware.Steam")); } #[test] fn broken_symlinks_are_skipped() { let fx = Fixture::new("broken-symlinks"); let bin = fx.dir("bin"); fx.symlink(&bin, "dangling", &bin.join("does-not-exist")); let execs = Executables::from_dirs([&bin]); assert!(execs.matching("dang").is_empty()); } #[test] fn missing_directories_are_skipped() { let fx = Fixture::new("missing"); let bin = fx.dir("bin"); fx.executable(&bin, "vim"); let execs = Executables::from_dirs([bin.as_path(), Path::new("/no/such/dir")]); assert_eq!(execs.matching("v"), vec!["vim"]); } #[test] fn empty_prefix_matches_nothing() { let fx = Fixture::new("empty"); let bin = fx.dir("bin"); fx.executable(&bin, "alpha"); fx.executable(&bin, "beta"); let execs = Executables::from_dirs([&bin]); assert!(execs.matching("").is_empty()); assert_eq!(execs.first_match(""), None); } #[test] fn an_exact_name_is_listed_first() { let fx = Fixture::new("first"); let bin = fx.dir("bin"); // Typing a whole command name and hitting space commits that name, not a // sibling that merely extends it. fx.executable(&bin, "git"); fx.executable(&bin, "gitk"); fx.executable(&bin, "git-lfs"); let execs = Executables::from_dirs([&bin]); assert_eq!(execs.first_match("git"), Some("git")); assert_eq!(execs.matching("git"), vec!["git", "git-lfs", "gitk"]); } /// An exact name outranks `$PATH` position: it leads even when a directory /// ahead of it holds a longer name that also matches. #[test] fn an_exact_name_outranks_an_earlier_directory() { let fx = Fixture::new("exact-beats-path"); let local = fx.dir("local"); let usr = fx.dir("usr"); fx.executable(&local, "gitk"); // earlier directory, longer name fx.executable(&usr, "git"); // later directory, exactly what was typed let execs = Executables::from_dirs([&local, &usr]); assert_eq!(execs.first_match("git"), Some("git")); assert_eq!(execs.matching("git"), vec!["git", "gitk"]); } /// Promoting the exact name does not otherwise disturb the ordering: the /// rest stay in `$PATH` order behind it. #[test] fn the_remaining_matches_keep_search_order() { let fx = Fixture::new("exact-then-path"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "tool-z"); fx.executable(&second, "tool"); // exact, but last on `$PATH` fx.executable(&second, "tool-a"); let execs = Executables::from_dirs([&first, &second]); assert_eq!(execs.matching("tool"), vec!["tool", "tool-z", "tool-a"]); } #[test] fn prefix_range_excludes_non_matches() { let fx = Fixture::new("range"); let bin = fx.dir("bin"); fx.executable(&bin, "cargo"); fx.executable(&bin, "cat"); fx.executable(&bin, "curl"); fx.executable(&bin, "dd"); let execs = Executables::from_dirs([&bin]); assert_eq!(execs.matching("ca"), vec!["cargo", "cat"]); assert_eq!(execs.first_match("cu"), Some("curl")); assert!(execs.matching("z").is_empty()); } #[test] fn falls_back_to_substring_when_no_prefix_matches() { let fx = Fixture::new("substring"); let bin = fx.dir("bin"); fx.executable(&bin, "com.valvesoftware.Steam"); fx.executable(&bin, "gnome-software"); let execs = Executables::from_dirs([&bin]); // Nothing begins with "soft", but both names contain it; from the same // directory, so the alphabetical tie-break orders them. assert_eq!( execs.matching("soft"), vec!["com.valvesoftware.Steam", "gnome-software"] ); } #[test] fn prefix_matches_win_over_substring_matches() { let fx = Fixture::new("prefix-wins"); let bin = fx.dir("bin"); fx.executable(&bin, "steamcmd"); // prefix match for "steam" fx.executable(&bin, "com.valvesoftware.Steam"); // only a substring match let execs = Executables::from_dirs([&bin]); // A prefix hit exists, so the substring-only candidate is not diluted in. assert_eq!(execs.matching("steam"), vec!["steamcmd"]); } #[test] fn falls_back_to_case_insensitive_substring_last() { let fx = Fixture::new("case-insensitive"); let bin = fx.dir("bin"); fx.executable(&bin, "com.valvesoftware.Steam"); let execs = Executables::from_dirs([&bin]); // Sanity: the correctly-cased "Steam" is found by the case-sensitive tier. assert_eq!(execs.matching("Steam"), vec!["com.valvesoftware.Steam"]); // "steam" is neither a prefix nor a case-sensitive substring (the S is // capitalized), so only the case-insensitive tier finds it. assert_eq!(execs.matching("steam"), vec!["com.valvesoftware.Steam"]); assert_eq!(execs.first_match("steam"), Some("com.valvesoftware.Steam")); } /// In the case-insensitive tier, exactness folds case too: typing the whole /// name in the wrong case still promotes it over a mere substring hit from /// an earlier directory. #[test] fn a_case_insensitive_exact_name_is_promoted() { let fx = Fixture::new("exact-ignoring-case"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "com.valvesoftware.Steam"); // substring hit only fx.executable(&second, "Steam"); // the whole name, wrongly cased let execs = Executables::from_dirs([&first, &second]); // Neither name begins with "steam", and neither contains it with that // casing, so only the case-insensitive tier answers. assert_eq!( execs.matching("steam"), vec!["Steam", "com.valvesoftware.Steam"] ); assert_eq!(execs.first_match("steam"), Some("Steam")); } /// Several names can be exact once case is folded away. They lead together, /// ordered among themselves by the usual `$PATH` rule. #[test] fn case_insensitive_exact_matches_tie_break_by_path_order() { let fx = Fixture::new("exact-ignoring-case-tie"); let first = fx.dir("first"); let second = fx.dir("second"); fx.executable(&first, "STEAM"); fx.executable(&second, "Steam"); // Matches once case is folded, but is not the whole name. fx.executable(&second, "run-Steam"); let execs = Executables::from_dirs([&first, &second]); assert_eq!(execs.matching("steam"), vec!["STEAM", "Steam", "run-Steam"]); } #[test] fn case_sensitive_substring_is_preferred_over_case_insensitive() { let fx = Fixture::new("case-precedence"); let bin = fx.dir("bin"); fx.executable(&bin, "myTOOL"); // contains "TOOL" exactly fx.executable(&bin, "othertool"); // contains "tool" only when lowercased let execs = Executables::from_dirs([&bin]); // "TOOL" matches case-sensitively, so the case-insensitive tier (which // would also pull in "othertool") is never reached. assert_eq!(execs.matching("TOOL"), vec!["myTOOL"]); } }