use crate::model::ProbeResult;
fn clean_flag_name(flag: &str) -> String {
flag.trim_end_matches("...").to_string()
}
fn clean_subcommand_name(name: &str) -> Option<String> {
let cleaned = name.trim_end_matches(',').trim().to_string();
if cleaned.is_empty() || cleaned == "..." {
None
} else {
Some(cleaned)
}
}
fn collect_clean_subcommands(result: &ProbeResult) -> Vec<String> {
result
.subcommands
.iter()
.filter_map(|s| clean_subcommand_name(&s.name))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
NuShell,
}
impl Shell {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
"fish" => Some(Shell::Fish),
"powershell" | "pwsh" => Some(Shell::PowerShell),
"nushell" | "nu" => Some(Shell::NuShell),
_ => None,
}
}
}
pub fn generate_shell_completion(result: &ProbeResult, shell: Shell) -> String {
match shell {
Shell::Bash => generate_bash_completion(result),
Shell::Zsh => generate_zsh_completion(result),
Shell::Fish => generate_fish_completion(result),
Shell::PowerShell => generate_powershell_completion(result),
Shell::NuShell => generate_nushell_completion(result),
}
}
fn generate_bash_completion(result: &ProbeResult) -> String {
let cmd_name = &result.command;
let func_name = format!(
"_{}_completion",
cmd_name.replace('-', "_").replace('/', "_")
);
let mut script = String::new();
script.push_str(&format!("# Bash completion for {}\n", cmd_name));
script.push_str(&format!("# Generated by help-probe\n\n"));
script.push_str(&format!("{}() {{\n", func_name));
script.push_str(" local cur prev words cword\n");
script.push_str(" COMPREPLY=()\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\n");
let mut all_options: Vec<String> = Vec::new();
for opt in &result.options {
all_options.extend(opt.short_flags.iter().map(|f| clean_flag_name(f)));
all_options.extend(opt.long_flags.iter().map(|f| clean_flag_name(f)));
}
let subcommands = collect_clean_subcommands(result);
script.push_str(" # Options\n");
script.push_str(&format!(
" local opts=({})\n",
all_options
.iter()
.map(|o| format!("\"{}\"", o))
.collect::<Vec<_>>()
.join(" ")
));
if !subcommands.is_empty() {
script.push_str(" # Subcommands\n");
script.push_str(&format!(
" local subcommands=({})\n",
subcommands
.iter()
.map(|s| format!("\"{}\"", s))
.collect::<Vec<_>>()
.join(" ")
));
}
script.push_str("\n");
script.push_str(" # Complete options and subcommands\n");
script.push_str(" if [[ \"$cur\" == -* ]]; then\n");
script.push_str(" COMPREPLY=($(compgen -W \"${opts[*]}\" -- \"$cur\"))\n");
script.push_str(" elif [[ ${#words[@]} -eq 2 ]]; then\n");
if !subcommands.is_empty() {
script.push_str(" COMPREPLY=($(compgen -W \"${subcommands[*]}\" -- \"$cur\"))\n");
} else {
script.push_str(" COMPREPLY=($(compgen -f -- \"$cur\"))\n");
}
script.push_str(" else\n");
script.push_str(" # Complete files for arguments\n");
script.push_str(" COMPREPLY=($(compgen -f -- \"$cur\"))\n");
script.push_str(" fi\n");
script.push_str("}\n\n");
script.push_str(&format!("complete -F {} {}\n", func_name, cmd_name));
script
}
fn generate_zsh_completion(result: &ProbeResult) -> String {
let cmd_name = &result.command;
let func_name = format!(
"_{}_completion",
cmd_name.replace('-', "_").replace('/', "_")
);
let mut script = String::new();
script.push_str(&format!("#compdef {}\n", cmd_name));
script.push_str(&format!("# Zsh completion for {}\n", cmd_name));
script.push_str(&format!("# Generated by help-probe\n\n"));
script.push_str(&format!("{}() {{\n", func_name));
let mut all_options: Vec<String> = Vec::new();
for opt in &result.options {
all_options.extend(opt.short_flags.iter().map(|f| clean_flag_name(f)));
all_options.extend(opt.long_flags.iter().map(|f| clean_flag_name(f)));
}
let subcommands = collect_clean_subcommands(result);
script.push_str(" local -a opts=(\n");
for opt in &all_options {
script.push_str(&format!(" '{}'\n", opt));
}
script.push_str(" )\n\n");
if !subcommands.is_empty() {
script.push_str(" local -a subcommands=(\n");
for sub in &subcommands {
script.push_str(&format!(" '{}'\n", sub));
}
script.push_str(" )\n\n");
}
script.push_str(" _arguments \\\n");
for opt in &result.options {
for long_flag in &opt.long_flags {
let mut arg_spec = clean_flag_name(long_flag);
if opt.takes_argument {
if let Some(arg_name) = &opt.argument_name {
arg_spec.push_str(&format!(":{}:", arg_name));
} else {
arg_spec.push_str(":value:");
}
}
let clean_short_flags: Vec<String> =
opt.short_flags.iter().map(|f| clean_flag_name(f)).collect();
script.push_str(&format!(
" '({}){}' \\\n",
clean_short_flags.join(","),
arg_spec
));
}
}
if !subcommands.is_empty() {
script.push_str(&format!(" '1: :->subcommands' \\\n"));
script.push_str(" '*: :->files'\n\n");
script.push_str(" case $state in\n");
script.push_str(" subcommands)\n");
script.push_str(" _describe 'subcommands' subcommands\n");
script.push_str(" ;;\n");
script.push_str(" files)\n");
script.push_str(" _files\n");
script.push_str(" ;;\n");
script.push_str(" esac\n");
} else {
script.push_str(" '*: :_files'\n");
}
script.push_str("}\n\n");
script.push_str(&format!("{}\n", func_name));
script
}
fn generate_fish_completion(result: &ProbeResult) -> String {
let cmd_name = &result.command;
let mut script = String::new();
script.push_str(&format!("# Fish completion for {}\n", cmd_name));
script.push_str(&format!("# Generated by help-probe\n\n"));
for opt in &result.options {
for long_flag in &opt.long_flags {
let mut flag_name = long_flag.trim_start_matches("--").to_string();
flag_name = clean_flag_name(&flag_name);
script.push_str(&format!("complete -c {} -l {} ", cmd_name, flag_name));
if let Some(desc) = &opt.description {
let escaped_desc = desc.replace('\'', "'\\''");
script.push_str(&format!("-d '{}' ", escaped_desc));
}
if opt.takes_argument {
script.push_str("-r "); }
script.push_str("\n");
}
for short_flag in &opt.short_flags {
let mut flag_name = short_flag.trim_start_matches("-").to_string();
flag_name = clean_flag_name(&flag_name);
script.push_str(&format!("complete -c {} -s {} ", cmd_name, flag_name));
if let Some(desc) = &opt.description {
let escaped_desc = desc.replace('\'', "'\\''");
script.push_str(&format!("-d '{}' ", escaped_desc));
}
if opt.takes_argument {
script.push_str("-r ");
}
script.push_str("\n");
}
}
let clean_subcommands = collect_clean_subcommands(result);
if !clean_subcommands.is_empty() {
if !clean_subcommands.is_empty() {
script.push_str(&format!(
"complete -c {} -f -n '__fish_use_subcommand' -a '{}'\n",
cmd_name,
clean_subcommands.join(" ")
));
}
}
script
}
fn generate_powershell_completion(result: &ProbeResult) -> String {
let cmd_name = &result.command;
let mut script = String::new();
script.push_str(&format!("# PowerShell completion for {}\n", cmd_name));
script.push_str(&format!("# Generated by help-probe\n\n"));
script.push_str(&format!(
"Register-ArgumentCompleter -Native -CommandName '{}' -ScriptBlock {{\n",
cmd_name
));
script.push_str(" param($wordToComplete, $commandAst, $cursorPosition)\n\n");
let mut all_options: Vec<String> = Vec::new();
for opt in &result.options {
all_options.extend(opt.long_flags.iter().map(|f| clean_flag_name(f)));
}
let subcommands = collect_clean_subcommands(result);
script.push_str(&format!(
" $options = @({})\n",
all_options
.iter()
.map(|o| format!("'{}'", o))
.collect::<Vec<_>>()
.join(", ")
));
if !subcommands.is_empty() {
script.push_str(&format!(
" $subcommands = @({})\n",
subcommands
.iter()
.map(|s| format!("'{}'", s))
.collect::<Vec<_>>()
.join(", ")
));
}
script.push_str("\n");
script.push_str(" if ($wordToComplete -match '^-') {\n");
script.push_str(" $options | Where-Object { $_ -like \"$wordToComplete*\" }\n");
script.push_str(" } else {\n");
if !subcommands.is_empty() {
script.push_str(" $subcommands | Where-Object { $_ -like \"$wordToComplete*\" }\n");
} else {
script.push_str(
" Get-ChildItem | Where-Object { $_.Name -like \"$wordToComplete*\" }\n",
);
}
script.push_str(" }\n");
script.push_str("}\n");
script
}
fn generate_nushell_completion(result: &ProbeResult) -> String {
let cmd_name = &result.command;
let mut script = String::new();
script.push_str(&format!("# Nushell completion for {}\n", cmd_name));
script.push_str(&format!("# Generated by help-probe\n"));
script.push_str(&format!("# Add this to your config.nu or source it\n\n"));
script.push_str(&format!("extern {} [\n", cmd_name));
for opt in &result.options {
for long_flag in &opt.long_flags {
let mut flag_name = long_flag.trim_start_matches("--").to_string();
flag_name = clean_flag_name(&flag_name);
let flag_type = if opt.takes_argument {
infer_nushell_type(opt).to_string()
} else {
"".to_string()
};
script.push_str(&format!(" --{}", flag_name));
if !flag_type.is_empty() {
script.push_str(&format!(": {}", flag_type));
}
if let Some(desc) = &opt.description {
script.push_str(&format!(" # {}", desc.replace('\n', " ")));
}
script.push_str("\n");
}
for short_flag in &opt.short_flags {
let mut flag_name = short_flag.trim_start_matches("-").to_string();
flag_name = clean_flag_name(&flag_name);
script.push_str(&format!(" -{}", flag_name));
if opt.takes_argument {
script.push_str(&format!(": {}", infer_nushell_type(opt)));
}
if let Some(desc) = &opt.description {
script.push_str(&format!(" # {}", desc.replace('\n', " ")));
}
script.push_str("\n");
}
}
if !result.subcommands.is_empty() {
script.push_str(" subcommand?: string # Subcommand to run\n");
}
if result.subcommands.is_empty() {
for arg in &result.arguments {
let arg_type = arg
.arg_type
.as_ref()
.map(|t| match t {
crate::model::ArgumentType::Path => "path".to_string(),
crate::model::ArgumentType::Number => "number".to_string(),
crate::model::ArgumentType::Url => "string".to_string(),
crate::model::ArgumentType::Email => "string".to_string(),
_ => "string".to_string(),
})
.unwrap_or_else(|| "string".to_string());
let marker = if arg.required { "" } else { "?" };
let variadic = if arg.variadic { "..." } else { "" };
let arg_name = arg.name.to_lowercase().replace(['<', '>'], "");
script.push_str(&format!(
" {}{}: {} # {}{}\n",
arg_name,
marker,
arg_type,
variadic,
arg.description.as_deref().unwrap_or("argument")
));
}
} else {
if !result.arguments.is_empty() {
script.push_str(" ...args: string # Additional arguments\n");
}
}
script.push_str("]\n\n");
if !result.subcommands.is_empty() {
let completer_name = format!("nu-complete-{}", cmd_name.replace('-', "_"));
script.push_str(&format!("\n# Custom completion function for subcommands\n"));
script.push_str(&format!("def {} [] {{\n", completer_name));
script.push_str(" [\n");
for subcmd in &result.subcommands {
let Some(clean_name) = clean_subcommand_name(&subcmd.name) else {
continue;
};
script.push_str(&format!(" \"{}\"", clean_name));
if let Some(desc) = &subcmd.description {
let clean_desc = desc.replace('\n', " ").trim().to_string();
if !clean_desc.is_empty() {
script.push_str(&format!(" # {}", clean_desc));
}
}
script.push_str("\n");
}
script.push_str(" ]\n");
script.push_str("}\n\n");
script.push_str(&format!(
"# To enable subcommand completion, replace the subcommand line above with:\n"
));
script.push_str(&format!(
"# subcommand?: string@{} # Subcommand to run\n",
completer_name
));
}
script
}
fn infer_nushell_type(opt: &crate::model::OptionSpec) -> &'static str {
match opt.option_type {
crate::model::OptionType::Number => "number",
crate::model::OptionType::Path => "path",
crate::model::OptionType::Boolean => "bool",
_ => "string",
}
}