Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: add bash completion for the rad command
Archived did:key:z6Mku263...gR76 opened 8 months ago

add rad completion bash to generate a bash completion file.

6 files changed +582 -7 ed8b0860 7d0851de
modified crates/radicle-cli/src/commands.rs
@@ -4,6 +4,7 @@ pub mod checkout;
pub mod clean;
pub mod clone;
pub mod cob;
+
pub mod completion;
pub mod config;
pub mod debug;
pub mod diff;
@@ -28,6 +29,5 @@ pub mod unblock;
pub mod unfollow;
pub mod unseed;
pub mod watch;
-

#[path = "commands/self.rs"]
pub mod rad_self;
added crates/radicle-cli/src/commands/completion.rs
@@ -0,0 +1,71 @@
+
use std::ffi::OsString;
+

+
use crate::terminal as term;
+
use crate::terminal::args::{Args, Error, Help};
+

+
pub const HELP: Help = Help {
+
    name: "completion",
+
    description: "Generate shell completion scripts",
+
    version: env!("RADICLE_VERSION"),
+
    usage: "Usage: rad completion <shell> [--help]",
+
};
+

+
#[derive(Default)]
+
pub struct Options {
+
    pub shell: String,
+
}
+

+
impl Args for Options {
+
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
+
        let mut parser = lexopt::Parser::from_args(args);
+
        let mut shell = String::new();
+

+
        while let Some(arg) = parser.next()? {
+
            match arg {
+
                lexopt::Arg::Value(val) => {
+
                    if shell.is_empty() {
+
                        shell = val.to_string_lossy().to_string();
+
                    } else {
+
                        anyhow::bail!("Multiple shell arguments provided");
+
                    }
+
                }
+
                lexopt::Arg::Long("help") | lexopt::Arg::Short('h') => {
+
                    return Err(Error::HelpManual {
+
                        name: "rad-completion",
+
                    }
+
                    .into());
+
                }
+
                _ => anyhow::bail!(arg.unexpected()),
+
            }
+
        }
+

+
        if shell.is_empty() {
+
            shell = "bash".to_string();
+
        }
+

+
        Ok((Options { shell }, vec![]))
+
    }
+
}
+

+
pub fn run(options: Options, _ctx: impl term::Context) -> anyhow::Result<()> {
+
    match options.shell.to_lowercase().as_str() {
+
        "bash" => {
+
            let completion_script =
+
                crate::completion::init_command_registry().generate_bash_completion();
+
            println!("{}", completion_script);
+
        }
+
        "zsh" => {
+
            println!("zsh completion not yet implemented");
+
        }
+
        "fish" => {
+
            println!("fish completion not yet implemented");
+
        }
+
        _ => {
+
            anyhow::bail!(
+
                "Unsupported shell '{}'. Supported shells: bash, zsh, fish",
+
                options.shell
+
            );
+
        }
+
    }
+
    Ok(())
+
}
modified crates/radicle-cli/src/commands/help.rs
@@ -14,29 +14,35 @@ const COMMANDS: &[Help] = &[
    crate::commands::auth::HELP,
    crate::commands::block::HELP,
    crate::commands::checkout::HELP,
+
    crate::commands::clean::HELP,
    crate::commands::clone::HELP,
+
    crate::commands::cob::HELP,
+
    crate::commands::completion::HELP,
    crate::commands::config::HELP,
+
    crate::commands::debug::HELP,
+
    crate::commands::diff::HELP,
+
    crate::commands::follow::HELP,
    crate::commands::fork::HELP,
    crate::commands::help::HELP,
    crate::commands::id::HELP,
-
    crate::commands::init::HELP,
    crate::commands::inbox::HELP,
+
    crate::commands::init::HELP,
    crate::commands::inspect::HELP,
    crate::commands::issue::HELP,
    crate::commands::ls::HELP,
    crate::commands::node::HELP,
    crate::commands::patch::HELP,
    crate::commands::path::HELP,
-
    crate::commands::clean::HELP,
+
    crate::commands::publish::HELP,
    crate::commands::rad_self::HELP,
+
    crate::commands::remote::HELP,
    crate::commands::seed::HELP,
-
    crate::commands::follow::HELP,
+
    crate::commands::stats::HELP,
+
    crate::commands::sync::HELP,
    crate::commands::unblock::HELP,
    crate::commands::unfollow::HELP,
    crate::commands::unseed::HELP,
-
    crate::commands::remote::HELP,
-
    crate::commands::stats::HELP,
-
    crate::commands::sync::HELP,
+
    crate::commands::watch::HELP,
];

#[derive(Default)]
added crates/radicle-cli/src/completion.rs
@@ -0,0 +1,489 @@
+
use std::collections::HashMap;
+

+
// Import all command HELP constants for auto-generation
+
use crate::commands::{
+
    auth, block, checkout, clean, clone, cob, completion, config, debug, diff, follow, fork, help,
+
    id, inbox, init, inspect, issue, ls, node, patch, path, publish, rad_self, remote, seed, stats,
+
    sync, unblock, unfollow, unseed, watch,
+
};
+

+
/// Command metadata structure
+
#[derive(Debug, Clone)]
+
pub struct CommandMetadata {
+
    pub name: String,
+
    pub description: String,
+
    pub options: Vec<OptionInfo>,
+
    pub subcommands: Vec<SubcommandInfo>,
+
    pub dynamic_completion: Option<DynamicCompletionType>,
+
}
+

+
/// Option information
+
#[derive(Debug, Clone)]
+
pub struct OptionInfo {
+
    pub long: &'static str,
+
    pub short: Option<char>,
+
    pub description: &'static str,
+
    pub takes_value: bool,
+
}
+

+
/// Subcommand metadata structure
+
#[derive(Debug, Clone)]
+
pub struct SubcommandInfo {
+
    pub name: String,
+
    pub description: String,
+
    pub options: Vec<OptionInfo>,
+
    pub subcommands: Vec<SubcommandInfo>,
+
    pub dynamic_completion: Option<DynamicCompletionType>,
+
}
+

+
/// Type of dynamic completion for a command
+
#[derive(Debug, Clone)]
+
pub enum DynamicCompletionType {
+
    PatchIds,
+
    IssueIds,
+
    BranchNames,
+
    RemoteNames,
+
    NodeIds,
+
}
+

+
/// Command registry that holds all completion metadata
+
#[derive(Debug)]
+
pub struct CommandRegistry {
+
    commands: HashMap<&'static str, CommandMetadata>,
+
}
+

+
impl CommandRegistry {
+
    /// Initialize the command registry with auto-generated metadata
+
    pub fn init() -> Self {
+
        let mut commands = HashMap::new();
+

+
        // Auto-generate command metadata from the actual command HELP constants
+
        // This ensures completion stays in sync with command implementations
+
        let command_helps = [
+
            ("auth", auth::HELP),
+
            ("block", block::HELP),
+
            ("checkout", checkout::HELP),
+
            ("clone", clone::HELP),
+
            ("cob", cob::HELP),
+
            ("completion", completion::HELP),
+
            ("config", config::HELP),
+
            ("debug", debug::HELP),
+
            ("diff", diff::HELP),
+
            ("follow", follow::HELP),
+
            ("fork", fork::HELP),
+
            ("help", help::HELP),
+
            ("id", id::HELP),
+
            ("inbox", inbox::HELP),
+
            ("init", init::HELP),
+
            ("inspect", inspect::HELP),
+
            ("issue", issue::HELP),
+
            ("ls", ls::HELP),
+
            ("node", node::HELP),
+
            ("patch", patch::HELP),
+
            ("path", path::HELP),
+
            ("publish", publish::HELP),
+
            ("clean", clean::HELP),
+
            ("self", rad_self::HELP),
+
            ("seed", seed::HELP),
+
            ("sync", sync::HELP),
+
            ("unblock", unblock::HELP),
+
            ("unfollow", unfollow::HELP),
+
            ("unseed", unseed::HELP),
+
            ("remote", remote::HELP),
+
            ("stats", stats::HELP),
+
            ("watch", watch::HELP),
+
        ];
+

+
        for (name, help) in command_helps {
+
            commands.insert(
+
                name,
+
                CommandMetadata {
+
                    name: name.to_string(),
+
                    description: help.description.to_string(),
+
                    options: Self::extract_options_from_help(help.usage),
+
                    subcommands: Self::extract_subcommands_from_help(help.usage),
+
                    dynamic_completion: Self::get_dynamic_completion_type(name),
+
                },
+
            );
+
        }
+

+
        Self { commands }
+
    }
+

+
    /// Extract options from help usage text
+
    fn extract_options_from_help(usage: &str) -> Vec<OptionInfo> {
+
        let mut options = vec![
+
            // Common options that most commands support
+
            OptionInfo {
+
                long: "help",
+
                short: Some('h'),
+
                description: "Print help",
+
                takes_value: false,
+
            },
+
        ];
+

+
        // Parse usage text to find additional options
+
        // This is a simple parser - could be enhanced
+
        if usage.contains("--json") {
+
            options.push(OptionInfo {
+
                long: "json",
+
                short: None,
+
                description: "Output as JSON",
+
                takes_value: false,
+
            });
+
        }
+

+
        if usage.contains("--verbose") || usage.contains("-v") {
+
            options.push(OptionInfo {
+
                long: "verbose",
+
                short: Some('v'),
+
                description: "Verbose output",
+
                takes_value: false,
+
            });
+
        }
+

+
        options
+
    }
+

+
    /// Extract sub-subcommands from help usage text for a specific subcommand
+
    fn extract_sub_subcommands_from_help(usage: &str, subcommand: &str) -> Vec<SubcommandInfo> {
+
        let mut sub_subcommands = vec![];
+

+
        // Look for patterns like "rad <command> <subcommand> <sub-subcommand> [<option>...]"
+
        let lines: Vec<&str> = usage.lines().collect();
+

+
        for line in lines {
+
            let line = line.trim();
+

+
            // Look for lines that contain the specific subcommand pattern
+
            // We need to be more flexible here since we don't know the exact command name
+
            let parts: Vec<&str> = line.split_whitespace().collect();
+

+
            // We need at least 4 parts: "rad", "<command>", "<subcommand>", "<sub-subcommand>"
+
            if parts.len() >= 4 && parts[0] == "rad" {
+
                // Check if this line contains our subcommand
+
                if parts.len() > 2 && parts[2] == subcommand {
+
                    // Look for the sub-subcommand (4th part)
+
                    if parts.len() > 3 {
+
                        let sub_subcommand_name = parts[3];
+

+
                        // Skip if it's an option, placeholder, or already known
+
                        if !sub_subcommand_name.starts_with('-')
+
                            && !sub_subcommand_name.starts_with('<')
+
                            && !sub_subcommand_name.starts_with('[')
+
                            && !sub_subcommands
+
                                .iter()
+
                                .any(|s: &SubcommandInfo| s.name == sub_subcommand_name)
+
                        {
+
                            sub_subcommands.push(SubcommandInfo {
+
                                name: sub_subcommand_name.to_string(),
+
                                description: "Sub-subcommand".to_string(),
+
                                options: vec![],
+
                                subcommands: vec![], // Could be enhanced for even deeper nesting
+
                                dynamic_completion: None,
+
                            });
+
                        }
+
                    }
+
                }
+
            }
+
        }
+

+
        sub_subcommands
+
    }
+

+
    /// Extract subcommands from help usage text
+
    fn extract_subcommands_from_help(usage: &str) -> Vec<SubcommandInfo> {
+
        let mut subcommands = vec![];
+

+
        // Parse the usage text to find subcommands at any level
+
        // Look for patterns like "rad <command> <subcommand> [<option>...]"
+
        // and "rad <command> <subcommand> <sub-subcommand> [<option>...]"
+
        let lines: Vec<&str> = usage.lines().collect();
+

+
        for line in lines {
+
            let line = line.trim();
+

+
            // Skip lines that don't contain the command pattern
+
            if !line.contains("rad ") {
+
                continue;
+
            }
+

+
            // Look for subcommand patterns in lines like:
+
            // "rad patch list [<option>...]"
+
            // "rad node db <command> [<option>..]"
+
            // "rad issue edit <issue-id> [--title <title>] [<option>...]"
+
            let parts: Vec<&str> = line.split_whitespace().collect();
+

+
            // We need at least 3 parts: "rad", "<command>", "<subcommand>"
+
            if parts.len() >= 3 {
+
                let subcommand = parts[2];
+

+
                // Skip if it's an option (starts with - or --) or placeholder (starts with < or [)
+
                if !subcommand.starts_with('-')
+
                    && !subcommand.starts_with('<')
+
                    && !subcommand.starts_with('[')
+
                {
+
                    // Check if we already have this subcommand
+
                    if !subcommands
+
                        .iter()
+
                        .any(|s: &SubcommandInfo| s.name == subcommand)
+
                    {
+
                        // Try to extract description from the line
+
                        let description = Self::extract_description_from_line(line);
+

+
                        subcommands.push(SubcommandInfo {
+
                            name: subcommand.to_string(),
+
                            description: description.to_string(),
+
                            options: vec![],
+
                            subcommands: Self::extract_sub_subcommands_from_help(usage, subcommand),
+
                            dynamic_completion: None,
+
                        });
+
                    }
+
                }
+
            }
+
        }
+

+
        subcommands
+
    }
+

+
    /// Extract description from a help line
+
    fn extract_description_from_line(line: &str) -> &'static str {
+
        // Try to find a description after the command
+
        // Look for patterns like "rad patch list [<option>...] # List patches"
+
        if let Some(comment_start) = line.find('#') {
+
            let comment = &line[comment_start + 1..].trim();
+
            if !comment.is_empty() {
+
                // For now, return a generic description since we can't store dynamic strings
+
                // In the future, we could enhance this to parse actual descriptions
+
                return "Subcommand";
+
            }
+
        }
+

+
        // Default description
+
        "Subcommand"
+
    }
+

+
    /// Get dynamic completion type based on command name
+
    fn get_dynamic_completion_type(command: &str) -> Option<DynamicCompletionType> {
+
        match command {
+
            "patch" => Some(DynamicCompletionType::PatchIds),
+
            "issue" => Some(DynamicCompletionType::IssueIds),
+
            "checkout" => Some(DynamicCompletionType::BranchNames),
+
            "remote" => Some(DynamicCompletionType::RemoteNames),
+
            "node" => Some(DynamicCompletionType::NodeIds),
+
            _ => None,
+
        }
+
    }
+

+
    /// Generate bash completion script
+
    pub fn generate_bash_completion(&self) -> String {
+
        let mut script = String::new();
+

+
        // Generate the main completion function
+
        script.push_str(&self.generate_main_completion_function());
+

+
        // Generate subcommand completion functions
+
        for (name, metadata) in &self.commands {
+
            script.push_str(&self.generate_subcommand_completion_function(name, metadata));
+
        }
+

+
        // Add the completion entry that tells bash to use _rad for the rad command
+
        script.push_str("\n# Register the completion function for the 'rad' command\n");
+
        script.push_str("complete -F _rad rad\n");
+

+
        script
+
    }
+

+
    /// Generate the main completion function
+
    fn generate_main_completion_function(&self) -> String {
+
        let mut script = String::new();
+

+
        script.push_str("# bash completion for rad\n");
+
        script.push_str("# Auto-generated from command implementations\n\n");
+

+
        // Generate the main _rad function
+
        script.push_str("_rad() {\n");
+
        script.push_str("    local cur prev words cword\n");
+
        script.push_str("    _init_completion -n =: 2>/dev/null || {\n");
+
        script.push_str("        # Fallback for environments without _init_completion\n");
+
        script.push_str("        cur=${COMP_WORDS[COMP_CWORD]}\n");
+
        script.push_str("        prev=${COMP_WORDS[COMP_CWORD-1]}\n");
+
        script.push_str("        words=(\"${COMP_WORDS[@]}\")\n");
+
        script.push_str("        cword=$COMP_CWORD\n");
+
        script.push_str("    }\n");
+
        script.push('\n');
+

+
        // Generate command list
+
        let command_names: Vec<&str> = self.commands.keys().copied().collect();
+

+
        // Split long command lists across multiple lines for better readability
+
        if command_names.len() > 10 {
+
            script.push_str("    local commands=\"");
+
            for (i, name) in command_names.iter().enumerate() {
+
                if i > 0 && i % 8 == 0 {
+
                    script.push_str(" \\\n        ");
+
                }
+
                script.push_str(name);
+
                if i < command_names.len() - 1 {
+
                    script.push_str(" ");
+
                }
+
            }
+
            script.push_str("\"\n");
+
        } else {
+
            script.push_str(&format!(
+
                "    local commands=\"{}\"\n",
+
                command_names.join(" ")
+
            ));
+
        }
+
        script.push('\n');
+

+
        // Generate the main completion logic
+
        script.push_str("    case $cword in\n");
+
        script.push_str("        1)\n");
+
        script.push_str("            COMPREPLY=($(compgen -W \"$commands\" -- \"$cur\"))\n");
+
        script.push_str("            ;;\n");
+
        script.push_str("        *)\n");
+
        script.push_str("            case \"${words[1]}\" in\n");
+

+
        // Generate cases for each command
+
        for name in self.commands.keys() {
+
            script.push_str(&format!("                {})\n", name));
+
            script.push_str(&format!("                    _rad_{}\n", name));
+
            script.push_str("                    ;;\n");
+
        }
+

+
        script.push_str("                *)\n");
+
        script.push_str("                    COMPREPLY=($(compgen -W \"--help --version --json\" -- \"$cur\"))\n");
+
        script.push_str("                    ;;\n");
+
        script.push_str("            esac\n");
+
        script.push_str("            ;;\n");
+
        script.push_str("    esac\n");
+
        script.push_str("}\n\n");
+

+
        script
+
    }
+

+
    /// Generate subcommand completion function
+
    fn generate_subcommand_completion_function(
+
        &self,
+
        name: &str,
+
        metadata: &CommandMetadata,
+
    ) -> String {
+
        let mut script = String::new();
+

+
        script.push_str(&format!("_rad_{}() {{\n", name));
+
        script.push_str("    local cur prev words cword\n");
+
        script.push_str("    _init_completion -n =: 2>/dev/null || {\n");
+
        script.push_str("        # Fallback for environments without _init_completion\n");
+
        script.push_str("        cur=${COMP_WORDS[COMP_CWORD]}\n");
+
        script.push_str("        prev=${COMP_WORDS[COMP_CWORD-1]}\n");
+
        script.push_str("        words=(\"${COMP_WORDS[@]}\")\n");
+
        script.push_str("        cword=$COMP_CWORD\n");
+
        script.push_str("    }\n");
+
        script.push('\n');
+

+
        // Generate subcommand list if any
+
        if !metadata.subcommands.is_empty() {
+
            let subcommand_names: Vec<&str> = metadata
+
                .subcommands
+
                .iter()
+
                .map(|s| s.name.as_str())
+
                .collect();
+
            script.push_str(&format!(
+
                "    local subcommands=\"{}\"\n",
+
                subcommand_names.join(" ")
+
            ));
+
            script.push('\n');
+
        }
+

+
        // Generate options list
+
        let option_strings: Vec<String> = metadata
+
            .options
+
            .iter()
+
            .map(|opt| {
+
                if let Some(short) = opt.short {
+
                    format!("--{} -{}", opt.long, short)
+
                } else {
+
                    format!("--{}", opt.long)
+
                }
+
            })
+
            .collect();
+

+
        if !option_strings.is_empty() {
+
            script.push_str(&format!(
+
                "    local options=\"{}\"\n",
+
                option_strings.join(" ")
+
            ));
+
            script.push('\n');
+
        }
+

+
        // Generate completion logic
+
        script.push_str("    case $cword in\n");
+
        script.push_str("        2)\n");
+

+
        if !metadata.subcommands.is_empty() {
+
            script.push_str("            COMPREPLY=($(compgen -W \"$subcommands\" -- \"$cur\"))\n");
+
        } else {
+
            script.push_str("            COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))\n");
+
        }
+

+
        script.push_str("            ;;\n");
+
        script.push_str("        *)\n");
+

+
        // Handle dynamic completion
+
        if let Some(dynamic_type) = &metadata.dynamic_completion {
+
            match dynamic_type {
+
                DynamicCompletionType::PatchIds => {
+
                    script.push_str("            # Dynamic completion for patch IDs\n");
+
                    script.push_str("            local patch_ids=$(rad patch list 2>/dev/null | awk 'NR>2 {print $3}' | grep \"^$cur\" || echo \"\")\n");
+
                    script.push_str("            if [[ -n \"$patch_ids\" ]]; then\n");
+
                    script.push_str(
+
                        "                COMPREPLY=($(compgen -W \"$patch_ids\" -- \"$cur\"))\n",
+
                    );
+
                    script.push_str("            else\n");
+
                    script.push_str(
+
                        "                COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))\n",
+
                    );
+
                    script.push_str("            fi\n");
+
                }
+
                DynamicCompletionType::IssueIds => {
+
                    script.push_str("            # Dynamic completion for issue IDs\n");
+
                    script.push_str("            local issue_ids=$(rad issue list 2>/dev/null | awk 'NR>2 {print $3}' | grep \"^$cur\" || echo \"\")\n");
+
                    script.push_str("            if [[ -n \"$issue_ids\" ]]; then\n");
+
                    script.push_str(
+
                        "                COMPREPLY=($(compgen -W \"$issue_ids\" -- \"$cur\"))\n",
+
                    );
+
                    script.push_str("            else\n");
+
                    script.push_str(
+
                        "                COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))\n",
+
                    );
+
                    script.push_str("            fi\n");
+
                }
+
                _ => {
+
                    script.push_str(
+
                        "            COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))\n",
+
                    );
+
                }
+
            }
+
        } else {
+
            script.push_str("            COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))\n");
+
        }
+

+
        script.push_str("            ;;\n");
+
        script.push_str("    esac\n");
+
        script.push_str("}\n\n");
+

+
        script
+
    }
+
}
+

+
impl Default for CommandRegistry {
+
    fn default() -> Self {
+
        Self::init()
+
    }
+
}
+

+
/// Initialize the command registry
+
pub fn init_command_registry() -> CommandRegistry {
+
    CommandRegistry::init()
+
}
modified crates/radicle-cli/src/lib.rs
@@ -2,6 +2,7 @@
#![allow(clippy::or_fun_call)]
#![allow(clippy::too_many_arguments)]
pub mod commands;
+
pub mod completion;
pub mod git;
pub mod node;
pub mod pager;
modified crates/radicle-cli/src/main.rs
@@ -6,6 +6,7 @@ use anyhow::anyhow;

use radicle::version::Version;
use radicle_cli::commands::*;
+

use radicle_cli::terminal as term;

pub const NAME: &str = "rad";
@@ -154,6 +155,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
        "cob" => {
            term::run_command_args::<cob::Options, _>(cob::HELP, cob::run, args.to_vec());
        }
+
        "completion" => {
+
            term::run_command_args::<completion::Options, _>(
+
                completion::HELP,
+
                completion::run,
+
                args.to_vec(),
+
            );
+
        }
        "config" => {
            term::run_command_args::<config::Options, _>(config::HELP, config::run, args.to_vec());
        }