diff --git a/Cargo.lock b/Cargo.lock index 6e8447bfc..6ad8a0c41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,6 +515,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" dependencies = [ "clap", + "clap_lex", + "is_executable", + "shlex", ] [[package]] @@ -1676,6 +1679,15 @@ dependencies = [ "serde", ] +[[package]] +name = "is_executable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1980,6 +1992,7 @@ dependencies = [ "clap_mangen", "config", "container", + "criterion", "dag", "derive_more", "fnmatch", diff --git a/Cargo.toml b/Cargo.toml index 30c542ea3..1346f98e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ bytes = "1.6.0" camino = "1.1.10" chrono = "0.4.38" clap = { version = "4.5.8", features = ["derive", "string"] } -clap_complete = "4.5.37" +clap_complete = { version = "4.5.37", features = ["unstable-dynamic"] } clap_mangen = "0.2.24" criterion = { version = "0.8.2", features = ["html_reports"] } crossterm = "0.29.0" diff --git a/moss/Cargo.toml b/moss/Cargo.toml index 8e06d801d..7446fb271 100644 --- a/moss/Cargo.toml +++ b/moss/Cargo.toml @@ -70,6 +70,11 @@ tempfile.workspace = true moss = { path = ".", features = ["testing"] } tempfile.workspace = true +criterion.workspace = true + +[[bench]] +name = "completions" +harness = false [lints] workspace = true diff --git a/moss/benches/completions.rs b/moss/benches/completions.rs new file mode 100644 index 000000000..d1f5d89ba --- /dev/null +++ b/moss/benches/completions.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 AerynOS Developers +// SPDX-License-Identifier: MPL-2.0 + +use std::hint::black_box; + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use moss::completions; +use moss::package::Flags; +use moss::{Client, Installation}; + +fn criterion_benchmark(c: &mut Criterion) { + // Use actual moss database for benchmarks + let installation = match Installation::open("/", None) { + Ok(installation) => installation, + Err(err) => { + eprintln!("Skipping completions benchmark: {err}"); + return; + } + }; + let client = match Client::new("moss", installation) { + Ok(client) => client, + Err(err) => { + eprintln!("Skipping completions benchmark: {err}"); + return; + } + }; + + let flags = Flags::default().with_available(); + let prefixes = &["a", "g", "l", "lib", "p", "py"]; + let mut group = c.benchmark_group("prefix_completion"); + for prefix in prefixes { + group.bench_with_input(BenchmarkId::new("available", prefix), prefix, |b, &p| { + b.iter(|| completions::generate_results(&client, flags, black_box(p))); + }); + } + group.finish(); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/moss/src/cli/install.rs b/moss/src/cli/install.rs index 08bec59a6..19b49ca12 100644 --- a/moss/src/cli/install.rs +++ b/moss/src/cli/install.rs @@ -4,11 +4,14 @@ use std::path::PathBuf; use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser}; +use clap_complete::ArgValueCompleter; +use moss::completions::prefix_completer; use moss::{Installation, client::Client, environment}; use tracing::instrument; pub use moss::client::Error; +use moss::package; pub fn command() -> clap::Command { Command::command() @@ -23,6 +26,7 @@ pub fn command() -> clap::Command { )] pub struct Command { /// Packages to install + #[arg(add=ArgValueCompleter::new(prefix_completer(package::Flags::default().with_available())))] packages: Vec, /// Simulate the operation (dry-run) diff --git a/moss/src/cli/mod.rs b/moss/src/cli/mod.rs index 18ff22933..45a93bd49 100644 --- a/moss/src/cli/mod.rs +++ b/moss/src/cli/mod.rs @@ -4,10 +4,7 @@ use std::{env, io, path::Path, path::PathBuf}; use clap::{Arg, ArgAction, Command}; -use clap_complete::{ - generate_to, - shells::{Bash, Fish, Zsh}, -}; +use clap_complete::CompleteEnv; use clap_mangen::Man; use fs_err as fs; use moss::{Installation, installation}; @@ -94,14 +91,6 @@ fn command() -> Command { .value_name("DIR") .hide(true), ) - .arg( - Arg::new("generate-completions") - .long("generate-completions") - .help("Generate shell completions") - .action(ArgAction::Set) - .value_name("DIR") - .hide(true), - ) .arg_required_else_help(true) .subcommand(boot::command()) .subcommand(cache::command()) @@ -147,16 +136,11 @@ fn generate_manpages(cmd: &Command, dir: &Path, prefix: Option<&str>) -> io::Res Ok(()) } -/// Generate shell completions -fn generate_completions(cmd: &mut Command, dir: &Path) -> io::Result<()> { - generate_to(Bash, cmd, "moss", dir)?; - generate_to(Fish, cmd, "moss", dir)?; - generate_to(Zsh, cmd, "moss", dir)?; - Ok(()) -} - /// Process all CLI arguments pub fn process() -> Result<(), Error> { + // Generate shell completions + CompleteEnv::with_factory(command).complete(); + let args = replace_aliases(env::args()); let matches = command().get_matches_from(args); @@ -178,13 +162,6 @@ pub fn process() -> Result<(), Error> { return Ok(()); } - if let Some(dir) = matches.get_one::("generate-completions") { - let dir = Path::new(dir); - fs::create_dir_all(dir)?; - generate_completions(&mut command(), dir)?; - return Ok(()); - } - // Print the version, but not if the user is using the version subcommand if verbose && let Some(command) = matches.subcommand_name() diff --git a/moss/src/cli/remove.rs b/moss/src/cli/remove.rs index ba58cdaa0..2d374ff2e 100644 --- a/moss/src/cli/remove.rs +++ b/moss/src/cli/remove.rs @@ -2,6 +2,10 @@ // SPDX-License-Identifier: MPL-2.0 use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser}; +use clap_complete::ArgValueCompleter; + +use moss::completions::prefix_completer; +use moss::package; use moss::{Installation, client::Client, environment}; use tracing::instrument; @@ -21,6 +25,7 @@ pub fn command() -> clap::Command { )] pub struct Command { /// Packages to remove + #[arg(add=ArgValueCompleter::new(prefix_completer(package::Flags::default().with_installed())))] packages: Vec, /// Simulate the operation (dry-run) diff --git a/moss/src/client/mod.rs b/moss/src/client/mod.rs index 880d34993..53cdb423f 100644 --- a/moss/src/client/mod.rs +++ b/moss/src/client/mod.rs @@ -335,6 +335,16 @@ impl Client { self.registry.by_keyword(keyword, flags) } + /// Returns the name of all packages with names starting with the + /// provided prefix and match the given flags + pub fn search_package_summaries_by_prefix<'a>( + &'a self, + prefix: &'a str, + flags: package::Flags, + ) -> impl Iterator + 'a { + self.registry.package_summaries_by_prefix(prefix, flags) + } + /// Activates the provided state and runs system triggers once applied. /// /// The current state gets archived.\ diff --git a/moss/src/completions.rs b/moss/src/completions.rs new file mode 100644 index 000000000..4c44cf33d --- /dev/null +++ b/moss/src/completions.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 AerynOS Developers +// SPDX-License-Identifier: MPL-2.0 + +use clap::builder::StyledStr; +use clap_complete::CompletionCandidate; +use std::path::PathBuf; + +use crate::{Installation, client, package}; + +const MAX_RESULTS: usize = 100; + +pub fn generate_results(client: &client::Client, flags: package::Flags, prefix: &str) -> Vec { + client + .search_package_summaries_by_prefix(prefix, flags) + .take(MAX_RESULTS) + .map(|pkg| CompletionCandidate::new(pkg.name.to_string()).help(Some(StyledStr::from(pkg.summary)))) + .collect() +} + +fn default_client() -> Result { + let root = PathBuf::from("/"); + let installation = Installation::open(root, None)?; + client::Client::new("moss", installation) +} + +pub fn prefix_completer(flags: package::Flags) -> impl Fn(&std::ffi::OsStr) -> Vec { + move |prefix: &std::ffi::OsStr| { + let Some(prefix) = prefix.to_str() else { + return vec![]; + }; + if prefix.is_empty() { + return vec![]; + } + let Ok(client) = default_client() else { + return vec![]; + }; + generate_results(&client, flags, prefix) + } +} diff --git a/moss/src/db/meta/mod.rs b/moss/src/db/meta/mod.rs index 97050f793..34f715896 100644 --- a/moss/src/db/meta/mod.rs +++ b/moss/src/db/meta/mod.rs @@ -30,6 +30,7 @@ pub enum Filter<'a> { Dependency(Dependency), Name(package::Name), Keyword(&'a str), + Prefix(&'a str), All, } @@ -76,6 +77,62 @@ impl Database { }) } + pub fn package_summaries(&self, filter: Filter<'_>) -> Result, Error> { + self.conn.exec(|conn| { + let mut stmt; + let meta_query = match filter { + Filter::Id(id) => { + stmt = conn.prepare("SELECT name, summary FROM meta WHERE package = ?")?; + stmt.query([id.as_str()]) + } + Filter::Provider(provider) => { + stmt = conn.prepare(indoc! {" + SELECT m.name, m.summary + FROM meta m + INNER JOIN meta_providers mp ON m.package = mp.package + WHERE mp.provider = ?"})?; + stmt.query([provider.to_string()]) + } + Filter::Dependency(dependency) => { + stmt = conn.prepare(indoc! {" + SELECT m.name, m.summary + FROM meta m + INNER JOIN meta_dependencies md ON m.package = md.package + WHERE md.dependency = ?"})?; + stmt.query([dependency.to_string()]) + } + Filter::Name(name) => { + stmt = conn.prepare("SELECT name, summary FROM meta WHERE name = ?")?; + stmt.query([name.to_string()]) + } + Filter::Keyword(kw) => { + stmt = conn.prepare( + "SELECT name, summary FROM meta WHERE name LIKE concat('%', ?1, '%') OR summary LIKE concat('%', ?1, '%')", + )?; + stmt.query([kw.to_owned()]) + } + Filter::All => { + stmt = conn.prepare("SELECT name, summary FROM meta")?; + stmt.query([]) + } + Filter::Prefix(prefix) => { + stmt = conn.prepare("SELECT name, summary FROM meta WHERE name LIKE concat(?1, '%')")?; + stmt.query([prefix.to_owned()]) + } + }?; + + let entries = meta_query + .mapped(|row| { + let name = package::Name::from(row.get::<_, String>("name")?); + let summary = row.get::<_, String>("summary")?; + Ok( package::PackageSummary {name, summary}) + }) + .collect::, _>>()?; + + Ok(entries) + }) + } + pub fn query(&self, filter: Filter<'_>) -> Result, Error> { self.conn.exec(|conn| { let mut stmt; @@ -114,6 +171,10 @@ impl Database { stmt = conn.prepare("SELECT * FROM meta")?; stmt.query([]) } + Filter::Prefix(prefix) => { + stmt = conn.prepare("SELECT * FROM meta WHERE name LIKE concat(?1, '%')")?; + stmt.query([prefix.to_owned()]) + } }?; let mut entries: BTreeMap = meta_base_query diff --git a/moss/src/lib.rs b/moss/src/lib.rs index 8a6efa66c..3e568d70f 100644 --- a/moss/src/lib.rs +++ b/moss/src/lib.rs @@ -12,6 +12,7 @@ pub use self::state::State; pub use self::system_model::SystemModel; pub mod client; +pub mod completions; pub mod db; pub mod dependency; pub mod environment; diff --git a/moss/src/package/meta.rs b/moss/src/package/meta.rs index db12aa6e0..1b0a884bf 100644 --- a/moss/src/package/meta.rs +++ b/moss/src/package/meta.rs @@ -27,6 +27,17 @@ impl Name { pub fn contains(&self, text: &str) -> bool { self.0.contains(text) } + + pub fn starts_with(&self, prefix: &str) -> bool { + self.0.starts_with(prefix) + } +} + +/// A short package summary +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub struct PackageSummary { + pub name: Name, + pub summary: String, } /// The metadata of a [`super::Package`] diff --git a/moss/src/package/mod.rs b/moss/src/package/mod.rs index b30c260cf..e4500d661 100644 --- a/moss/src/package/mod.rs +++ b/moss/src/package/mod.rs @@ -7,7 +7,7 @@ use astr::AStr; use derive_more::{Debug, Display, From, Into}; use itertools::Itertools; -pub use self::meta::{Meta, MissingMetaFieldError, Name}; +pub use self::meta::{Meta, MissingMetaFieldError, Name, PackageSummary}; pub mod meta; pub mod render; diff --git a/moss/src/registry/mod.rs b/moss/src/registry/mod.rs index f1a1d899a..3bf32c12d 100644 --- a/moss/src/registry/mod.rs +++ b/moss/src/registry/mod.rs @@ -75,6 +75,15 @@ impl Registry { self.query(move |plugin| plugin.query_keyword(keyword, flags)) } + /// Return a sorted stream of `(id, name)` for packages whose name starts with `prefix` + pub fn package_summaries_by_prefix<'a>( + &'a self, + prefix: &'a str, + flags: package::Flags, + ) -> impl Iterator + 'a { + self.query(move |plugin| plugin.package_summaries_by_prefix(prefix, flags)) + } + /// Return a sorted stream of [`Package`] matching the given [`Flags`] /// /// [`Flags`]: package::Flags diff --git a/moss/src/registry/plugin/active.rs b/moss/src/registry/plugin/active.rs index d884e12ee..718c947cb 100644 --- a/moss/src/registry/plugin/active.rs +++ b/moss/src/registry/plugin/active.rs @@ -75,6 +75,21 @@ impl Active { self.query(flags, db::meta::Filter::Keyword(keyword)) } + pub fn package_summaries_by_prefix(&self, prefix: &str, flags: package::Flags) -> Vec { + let filter = db::meta::Filter::Prefix(prefix); + if flags.installed || flags == package::Flags::default() { + match self.db.package_summaries(filter) { + Ok(names) => names, + Err(error) => { + warn!("failed to query repository packages: {error}"); + vec![] + } + } + } else { + vec![] + } + } + /// Query all packages that match the given provider identity pub fn query_provider(&self, provider: &Provider, flags: package::Flags) -> Vec { self.query(flags, db::meta::Filter::Provider(provider.clone())) diff --git a/moss/src/registry/plugin/cobble.rs b/moss/src/registry/plugin/cobble.rs index 659410cbe..381b163be 100644 --- a/moss/src/registry/plugin/cobble.rs +++ b/moss/src/registry/plugin/cobble.rs @@ -76,6 +76,21 @@ impl Cobble { }) } + pub fn package_summaries_by_prefix(&self, prefix: &str, flags: package::Flags) -> Vec { + if flags.available { + self.packages + .iter() + .filter(|(_, state)| state.meta.name.starts_with(prefix)) + .map(|(_, state)| package::PackageSummary { + name: state.meta.name.clone(), + summary: state.meta.summary.clone(), + }) + .collect() + } else { + vec![] + } + } + pub fn query_provider(&self, provider: &Provider, flags: package::Flags) -> Vec { self.query(flags, |meta| meta.providers.contains(provider)) } diff --git a/moss/src/registry/plugin/mod.rs b/moss/src/registry/plugin/mod.rs index 648bf6d30..719d4b1a0 100644 --- a/moss/src/registry/plugin/mod.rs +++ b/moss/src/registry/plugin/mod.rs @@ -72,6 +72,21 @@ impl Plugin { }) } + pub fn package_summaries_by_prefix( + &self, + prefix: &str, + flags: package::Flags, + ) -> package::Sorted> { + package::Sorted::new(match self { + Plugin::Active(plugin) => plugin.package_summaries_by_prefix(prefix, flags), + Plugin::Cobble(plugin) => plugin.package_summaries_by_prefix(prefix, flags), + Plugin::Repository(plugin) => plugin.package_summaries_by_prefix(prefix, flags), + + #[cfg(any(test, feature = "testing"))] + Plugin::Test(plugin) => plugin.package_summaries_by_prefix(prefix, flags), + }) + } + /// Returns a list of packages with matching `provider` and `flags` pub fn query_provider(&self, provider: &Provider, flags: package::Flags) -> package::Sorted> { package::Sorted::new(match self { @@ -169,6 +184,22 @@ pub mod test { .collect() } + pub fn package_summaries_by_prefix( + &self, + prefix: &str, + _flags: package::Flags, + ) -> Vec { + let prefix_lower = prefix.to_ascii_lowercase(); + self.packages + .iter() + .filter(|pkg| pkg.meta.name.as_str().to_ascii_lowercase().starts_with(&prefix_lower)) + .map(|pkg| package::PackageSummary { + name: pkg.meta.name.clone(), + summary: pkg.meta.summary.clone(), + }) + .collect() + } + pub fn query_provider(&self, provider: &Provider, flags: package::Flags) -> Vec { self.packages .iter() diff --git a/moss/src/registry/plugin/repository.rs b/moss/src/registry/plugin/repository.rs index 398f428e3..72a19bac7 100644 --- a/moss/src/registry/plugin/repository.rs +++ b/moss/src/registry/plugin/repository.rs @@ -82,6 +82,20 @@ impl Repository { self.query(flags, db::meta::Filter::Keyword(keyword)) } + pub fn package_summaries_by_prefix(&self, prefix: &str, flags: package::Flags) -> Vec { + if flags.available || flags == package::Flags::default() { + match self.active.db.package_summaries(db::meta::Filter::Prefix(prefix)) { + Ok(names) => names, + Err(error) => { + warn!("failed to query repository packages: {error}"); + vec![] + } + } + } else { + vec![] + } + } + /// Query all packages that match the given provider identity pub fn query_provider(&self, provider: &Provider, flags: package::Flags) -> Vec { self.query(flags, db::meta::Filter::Provider(provider.clone()))