From 829b37f7a8ad45f1ef0082ebef9f8a1753b2832d Mon Sep 17 00:00:00 2001 From: Pierre Warnier Date: Fri, 3 Apr 2026 12:18:18 +0200 Subject: [PATCH] completions: add clap_complete shell completion generator (#106) Feature-gated binary (--features completions) that generates bash/zsh/fish/ elvish/powershell completions from each tool's uu_app() definition. Usage: shadow-rs-completions passwd --shell bash shadow-rs-completions --all --shell zsh --dir completions/ Also updates generate-completions.sh to use the new binary and adds windows-sys skip to deny.toml for transitive clap dep. Fixes #106 --- Cargo.toml | 13 +++ deny.toml | 9 +- src/bin/completions.rs | 187 +++++++++++++++++++++++++++++++++++ util/generate-completions.sh | 46 ++++----- 4 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 src/bin/completions.rs diff --git a/Cargo.toml b/Cargo.toml index fec7961..8d382d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ shadow-core = { path = "src/shadow-core" } # CLI clap = { version = "4", features = ["derive", "wrap_help"] } +clap_complete = "4" # uutils integration uucore = "0.7" @@ -57,12 +58,16 @@ libc = "0.2" # Security zeroize = "1" +landlock = "0.4" # Testing proptest = "1" tempfile = "3" [dependencies] +clap = { workspace = true } +clap_complete = { workspace = true, optional = true } + # Tool crates (optional, enabled by features) passwd = { optional = true, version = "0.0.1", package = "uu_passwd", path = "src/uu/passwd" } pwck = { optional = true, version = "0.0.1", package = "uu_pwck", path = "src/uu/pwck" } @@ -105,10 +110,18 @@ feat_phase2 = ["feat_useradd", "feat_userdel", "feat_usermod", "feat_chpasswd", feat_phase3 = ["feat_groupadd", "feat_groupdel", "feat_groupmod", "feat_grpck", "feat_chfn", "feat_chsh", "feat_newgrp"] feat_common = ["feat_phase1", "feat_phase2"] +# Shell completions generator +completions = ["dep:clap_complete"] + [[bin]] name = "shadow-rs" path = "src/bin/shadow-rs.rs" +[[bin]] +name = "shadow-rs-completions" +path = "src/bin/completions.rs" +required-features = ["completions"] + [[test]] name = "test_chage" path = "tests/by-util/test_chage.rs" diff --git a/deny.toml b/deny.toml index c893be1..8a564a9 100644 --- a/deny.toml +++ b/deny.toml @@ -26,9 +26,12 @@ multiple-versions = "deny" wildcards = "allow" highlight = "all" -# Known duplicate dependencies (uucore pulls in older versions of some crates). -# Update this list when bumping uucore. -skip = [] +# Known duplicate dependencies from transitive deps. +# Update this list when bumping clap/uucore. +skip = [ + # clap -> terminal_size (0.60) vs clap -> anstyle-query (0.61) + { crate = "windows-sys@0.60", reason = "transitive clap dep, Linux-only project" }, +] [sources] unknown-registry = "warn" diff --git a/src/bin/completions.rs b/src/bin/completions.rs new file mode 100644 index 0000000..3761993 --- /dev/null +++ b/src/bin/completions.rs @@ -0,0 +1,187 @@ +// This file is part of the shadow-rs package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore completions + +//! Shell completion generator for all shadow-rs tools. +//! +//! Generates completions by calling each tool's `uu_app()` function, +//! so completions always match the actual CLI definition. +//! +//! Usage: +//! shadow-rs-completions passwd --shell bash +//! shadow-rs-completions --all --shell zsh --dir completions/ +//! +//! Supported shells: bash, zsh, fish, elvish, powershell + +use clap::{Arg, ArgAction, Command}; +use clap_complete::generate; +use clap_complete::Shell; +use std::io; + +fn get_tool_app(name: &str) -> Option { + match name { + #[cfg(feature = "chage")] + "chage" => Some(chage::uu_app()), + #[cfg(feature = "chfn")] + "chfn" => Some(chfn::uu_app()), + #[cfg(feature = "chpasswd")] + "chpasswd" => Some(chpasswd::uu_app()), + #[cfg(feature = "chsh")] + "chsh" => Some(chsh::uu_app()), + #[cfg(feature = "groupadd")] + "groupadd" => Some(groupadd::uu_app()), + #[cfg(feature = "groupdel")] + "groupdel" => Some(groupdel::uu_app()), + #[cfg(feature = "groupmod")] + "groupmod" => Some(groupmod::uu_app()), + #[cfg(feature = "grpck")] + "grpck" => Some(grpck::uu_app()), + #[cfg(feature = "newgrp")] + "newgrp" => Some(newgrp::uu_app()), + #[cfg(feature = "passwd")] + "passwd" => Some(passwd::uu_app()), + #[cfg(feature = "pwck")] + "pwck" => Some(pwck::uu_app()), + #[cfg(feature = "useradd")] + "useradd" => Some(useradd::uu_app()), + #[cfg(feature = "userdel")] + "userdel" => Some(userdel::uu_app()), + #[cfg(feature = "usermod")] + "usermod" => Some(usermod::uu_app()), + _ => None, + } +} + +#[allow(clippy::vec_init_then_push)] // cfg attributes on each push prevent using vec![] +fn all_tool_names() -> Vec<&'static str> { + let mut names = Vec::new(); + #[cfg(feature = "chage")] + names.push("chage"); + #[cfg(feature = "chfn")] + names.push("chfn"); + #[cfg(feature = "chpasswd")] + names.push("chpasswd"); + #[cfg(feature = "chsh")] + names.push("chsh"); + #[cfg(feature = "groupadd")] + names.push("groupadd"); + #[cfg(feature = "groupdel")] + names.push("groupdel"); + #[cfg(feature = "groupmod")] + names.push("groupmod"); + #[cfg(feature = "grpck")] + names.push("grpck"); + #[cfg(feature = "newgrp")] + names.push("newgrp"); + #[cfg(feature = "passwd")] + names.push("passwd"); + #[cfg(feature = "pwck")] + names.push("pwck"); + #[cfg(feature = "useradd")] + names.push("useradd"); + #[cfg(feature = "userdel")] + names.push("userdel"); + #[cfg(feature = "usermod")] + names.push("usermod"); + names +} + +fn cli() -> Command { + Command::new("shadow-rs-completions") + .about("Generate shell completions for shadow-rs tools") + .arg( + Arg::new("tool") + .help("Tool name (or use --all)") + .required_unless_present("all"), + ) + .arg( + Arg::new("shell") + .long("shell") + .short('s') + .help("Target shell: bash, zsh, fish, elvish, powershell") + .required(true), + ) + .arg( + Arg::new("all") + .long("all") + .help("Generate completions for all tools") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("dir") + .long("dir") + .help("Output directory (one file per tool; default: stdout)") + .requires("all"), + ) +} + +fn generate_for_tool(name: &str, shell: Shell, out: &mut dyn io::Write) { + if let Some(mut cmd) = get_tool_app(name) { + generate(shell, &mut cmd, name, out); + } else { + eprintln!("unknown tool: {name}"); + eprintln!("available: {}", all_tool_names().join(", ")); + std::process::exit(1); + } +} + +fn shell_extension(shell: Shell) -> &'static str { + match shell { + Shell::Bash => "bash", + Shell::Zsh => "zsh", + Shell::Fish => "fish", + Shell::Elvish => "elv", + Shell::PowerShell => "ps1", + _ => "txt", + } +} + +fn main() { + let matches = cli().get_matches(); + let shell: Shell = matches + .get_one::("shell") + .expect("shell is required") + .parse() + .unwrap_or_else(|e| { + eprintln!("invalid shell: {e}"); + eprintln!("supported: bash, zsh, fish, elvish, powershell"); + std::process::exit(1); + }); + + if matches.get_flag("all") { + let tools = all_tool_names(); + if let Some(dir) = matches.get_one::("dir") { + std::fs::create_dir_all(dir).unwrap_or_else(|e| { + eprintln!("cannot create directory '{dir}': {e}"); + std::process::exit(1); + }); + let ext = shell_extension(shell); + for name in &tools { + let path = format!("{dir}/{name}.{ext}"); + let mut file = std::fs::File::create(&path).unwrap_or_else(|e| { + eprintln!("cannot create '{path}': {e}"); + std::process::exit(1); + }); + if let Some(mut cmd) = get_tool_app(name) { + generate(shell, &mut cmd, *name, &mut file); + } + } + eprintln!("generated {} completions in {dir}/", tools.len()); + } else { + let stdout = io::stdout(); + let mut out = stdout.lock(); + for name in &tools { + if let Some(mut cmd) = get_tool_app(name) { + generate(shell, &mut cmd, *name, &mut out); + } + } + } + } else { + let tool = matches.get_one::("tool").expect("tool is required"); + let stdout = io::stdout(); + let mut out = stdout.lock(); + generate_for_tool(tool, shell, &mut out); + } +} diff --git a/util/generate-completions.sh b/util/generate-completions.sh index 6687bb1..208c46f 100755 --- a/util/generate-completions.sh +++ b/util/generate-completions.sh @@ -2,37 +2,27 @@ # Generate shell completions for all shadow-rs tools. # # Usage: -# ./util/generate-completions.sh bash -# ./util/generate-completions.sh zsh -# ./util/generate-completions.sh fish +# ./util/generate-completions.sh bash [output-dir] +# ./util/generate-completions.sh zsh [output-dir] +# ./util/generate-completions.sh fish [output-dir] # -# For proper clap-based completions, build with clap_complete. -# This is a minimal placeholder until clap_complete is integrated. +# Requires: cargo build --features completions set -euo pipefail SHELL_TYPE="${1:-bash}" -TOOLS="passwd pwck useradd userdel usermod chpasswd chage groupadd groupdel groupmod grpck chfn chsh newgrp" +OUTPUT_DIR="${2:-}" -case "$SHELL_TYPE" in - bash) - for tool in $TOOLS; do - echo "complete -W '--help' $tool" - done - ;; - zsh) - echo "#compdef shadow-rs" - for tool in $TOOLS; do - echo "complete -c $tool -s h -l help -d 'Show help'" - done - ;; - fish) - for tool in $TOOLS; do - echo "complete -c $tool -s h -l help -d 'Show help'" - done - ;; - *) - echo "Usage: $0 {bash|zsh|fish}" >&2 - exit 1 - ;; -esac +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Build the completions binary +cargo build --manifest-path "$PROJECT_DIR/Cargo.toml" --features completions --bin shadow-rs-completions + +BINARY="$PROJECT_DIR/target/debug/shadow-rs-completions" + +if [ -n "$OUTPUT_DIR" ]; then + "$BINARY" --all --shell "$SHELL_TYPE" --dir "$OUTPUT_DIR" +else + "$BINARY" --all --shell "$SHELL_TYPE" +fi