Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: add bash completion for the rad command
✗ CI failure Frederic Lepied committed 8 months ago
commit 4566a719cb7ef8e6648e5790930c6c44bdc098f8
parent 11fc98c9c9c4c681d265e765df05c2f9d503ddc9
2 failed (2 total) View logs
6 files changed +573 -0
modified crates/radicle-cli/src/commands.rs
@@ -10,6 +10,8 @@ pub mod rad_clean;
pub mod rad_clone;
#[path = "commands/cob.rs"]
pub mod rad_cob;
+
#[path = "commands/completion.rs"]
+
pub mod rad_completion;
#[path = "commands/config.rs"]
pub mod rad_config;
#[path = "commands/debug.rs"]
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
@@ -17,6 +17,7 @@ const COMMANDS: &[Help] = &[
    rad_block::HELP,
    rad_checkout::HELP,
    rad_clone::HELP,
+
    rad_completion::HELP,
    rad_config::HELP,
    rad_fork::HELP,
    rad_help::HELP,
added crates/radicle-cli/src/completion.rs
@@ -0,0 +1,490 @@
+
use std::collections::HashMap;
+

+
// Import all command HELP constants for auto-generation
+
use crate::commands::{
+
    rad_auth, rad_block, rad_checkout, rad_clean, rad_clone, rad_cob, rad_completion, rad_config,
+
    rad_debug, rad_diff, rad_follow, rad_fork, rad_help, rad_id, rad_inbox, rad_init, rad_inspect,
+
    rad_issue, rad_ls, rad_node, rad_patch, rad_path, rad_publish, rad_remote, rad_seed, rad_self,
+
    rad_stats, rad_sync, rad_unblock, rad_unfollow, rad_unseed, rad_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", rad_auth::HELP),
+
            ("block", rad_block::HELP),
+
            ("checkout", rad_checkout::HELP),
+
            ("clone", rad_clone::HELP),
+
            ("cob", rad_cob::HELP),
+
            ("completion", rad_completion::HELP),
+
            ("config", rad_config::HELP),
+
            ("debug", rad_debug::HELP),
+
            ("diff", rad_diff::HELP),
+
            ("follow", rad_follow::HELP),
+
            ("fork", rad_fork::HELP),
+
            ("help", rad_help::HELP),
+
            ("id", rad_id::HELP),
+
            ("inbox", rad_inbox::HELP),
+
            ("init", rad_init::HELP),
+
            ("inspect", rad_inspect::HELP),
+
            ("issue", rad_issue::HELP),
+
            ("ls", rad_ls::HELP),
+
            ("node", rad_node::HELP),
+
            ("patch", rad_patch::HELP),
+
            ("path", rad_path::HELP),
+
            ("publish", rad_publish::HELP),
+
            ("clean", rad_clean::HELP),
+
            ("self", rad_self::HELP),
+
            ("seed", rad_seed::HELP),
+
            ("sync", rad_sync::HELP),
+
            ("unblock", rad_unblock::HELP),
+
            ("unfollow", rad_unfollow::HELP),
+
            ("unseed", rad_unseed::HELP),
+
            ("remote", rad_remote::HELP),
+
            ("stats", rad_stats::HELP),
+
            ("watch", rad_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";
@@ -170,6 +171,13 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
+
        "completion" => {
+
            term::run_command_args::<rad_completion::Options, _>(
+
                rad_completion::HELP,
+
                rad_completion::run,
+
                args.to_vec(),
+
            );
+
        }
        "config" => {
            term::run_command_args::<rad_config::Options, _>(
                rad_config::HELP,