help_probe/
completion.rs

1use crate::model::ProbeResult;
2
3/// Clean a flag name by removing trailing `...` (used for variadic flags).
4/// This ensures flags are valid in all shell completion formats.
5fn clean_flag_name(flag: &str) -> String {
6    flag.trim_end_matches("...").to_string()
7}
8
9/// Clean a subcommand name by removing trailing commas and trimming.
10/// Also filters out invalid entries like `...`.
11fn clean_subcommand_name(name: &str) -> Option<String> {
12    let cleaned = name.trim_end_matches(',').trim().to_string();
13    if cleaned.is_empty() || cleaned == "..." {
14        None
15    } else {
16        Some(cleaned)
17    }
18}
19
20/// Collect and clean all subcommands from a ProbeResult.
21/// Returns a vector of cleaned subcommand names.
22fn collect_clean_subcommands(result: &ProbeResult) -> Vec<String> {
23    result
24        .subcommands
25        .iter()
26        .filter_map(|s| clean_subcommand_name(&s.name))
27        .collect()
28}
29
30/// Shell types for which we can generate completions.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Shell {
33    /// Bash shell
34    Bash,
35    /// Zsh shell
36    Zsh,
37    /// Fish shell
38    Fish,
39    /// PowerShell
40    PowerShell,
41    /// NuShell
42    NuShell,
43}
44
45impl Shell {
46    /// Parse shell name from string (case-insensitive).
47    ///
48    /// # Examples
49    ///
50    /// ```
51    /// use help_probe::completion::Shell;
52    ///
53    /// assert_eq!(Shell::from_str("bash"), Some(Shell::Bash));
54    /// assert_eq!(Shell::from_str("BASH"), Some(Shell::Bash));
55    /// assert_eq!(Shell::from_str("zsh"), Some(Shell::Zsh));
56    /// assert_eq!(Shell::from_str("powershell"), Some(Shell::PowerShell));
57    /// assert_eq!(Shell::from_str("pwsh"), Some(Shell::PowerShell));
58    /// assert_eq!(Shell::from_str("nushell"), Some(Shell::NuShell));
59    /// assert_eq!(Shell::from_str("nu"), Some(Shell::NuShell));
60    /// assert_eq!(Shell::from_str("invalid"), None);
61    /// ```
62    pub fn from_str(s: &str) -> Option<Self> {
63        match s.to_lowercase().as_str() {
64            "bash" => Some(Shell::Bash),
65            "zsh" => Some(Shell::Zsh),
66            "fish" => Some(Shell::Fish),
67            "powershell" | "pwsh" => Some(Shell::PowerShell),
68            "nushell" | "nu" => Some(Shell::NuShell),
69            _ => None,
70        }
71    }
72}
73
74/// Generate shell completion script for a command.
75///
76/// This generates a complete completion script that can be sourced
77/// to provide tab completion for the probed command.
78///
79/// # Examples
80///
81/// ```
82/// use help_probe::{completion::{generate_shell_completion, Shell}, model::ProbeResult};
83///
84/// let result = ProbeResult {
85///     command: "mytool".to_string(),
86///     args: vec![],
87///     exit_code: Some(0),
88///     timed_out: false,
89///     help_flag_detected: true,
90///     usage_blocks: vec![],
91///     options: vec![],
92///     subcommands: vec![],
93///     arguments: vec![],
94///     examples: vec![],
95///     environment_variables: vec![],
96///     validation_rules: vec![],
97///     raw_stdout: String::new(),
98///     raw_stderr: String::new(),
99/// };
100///
101/// let bash_completion = generate_shell_completion(&result, Shell::Bash);
102/// assert!(bash_completion.contains("mytool"));
103/// assert!(bash_completion.contains("complete -F"));
104/// ```
105pub fn generate_shell_completion(result: &ProbeResult, shell: Shell) -> String {
106    match shell {
107        Shell::Bash => generate_bash_completion(result),
108        Shell::Zsh => generate_zsh_completion(result),
109        Shell::Fish => generate_fish_completion(result),
110        Shell::PowerShell => generate_powershell_completion(result),
111        Shell::NuShell => generate_nushell_completion(result),
112    }
113}
114
115/// Generate bash completion script.
116fn generate_bash_completion(result: &ProbeResult) -> String {
117    let cmd_name = &result.command;
118    let func_name = format!(
119        "_{}_completion",
120        cmd_name.replace('-', "_").replace('/', "_")
121    );
122
123    let mut script = String::new();
124    script.push_str(&format!("# Bash completion for {}\n", cmd_name));
125    script.push_str(&format!("# Generated by help-probe\n\n"));
126
127    script.push_str(&format!("{}() {{\n", func_name));
128    script.push_str("    local cur prev words cword\n");
129    script.push_str("    COMPREPLY=()\n");
130    script.push_str("    cur=\"${COMP_WORDS[COMP_CWORD]}\"\n");
131    script.push_str("    prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n");
132    script.push_str("    words=(\"${COMP_WORDS[@]}\")\n");
133    script.push_str("    cword=$COMP_CWORD\n\n");
134
135    // Collect all options (clean trailing ... from flags)
136    let mut all_options: Vec<String> = Vec::new();
137    for opt in &result.options {
138        all_options.extend(opt.short_flags.iter().map(|f| clean_flag_name(f)));
139        all_options.extend(opt.long_flags.iter().map(|f| clean_flag_name(f)));
140    }
141
142    // Collect all subcommands (clean trailing commas)
143    let subcommands = collect_clean_subcommands(result);
144
145    script.push_str("    # Options\n");
146    script.push_str(&format!(
147        "    local opts=({})\n",
148        all_options
149            .iter()
150            .map(|o| format!("\"{}\"", o))
151            .collect::<Vec<_>>()
152            .join(" ")
153    ));
154
155    if !subcommands.is_empty() {
156        script.push_str("    # Subcommands\n");
157        script.push_str(&format!(
158            "    local subcommands=({})\n",
159            subcommands
160                .iter()
161                .map(|s| format!("\"{}\"", s))
162                .collect::<Vec<_>>()
163                .join(" ")
164        ));
165    }
166
167    script.push_str("\n");
168    script.push_str("    # Complete options and subcommands\n");
169    script.push_str("    if [[ \"$cur\" == -* ]]; then\n");
170    script.push_str("        COMPREPLY=($(compgen -W \"${opts[*]}\" -- \"$cur\"))\n");
171    script.push_str("    elif [[ ${#words[@]} -eq 2 ]]; then\n");
172    if !subcommands.is_empty() {
173        script.push_str("        COMPREPLY=($(compgen -W \"${subcommands[*]}\" -- \"$cur\"))\n");
174    } else {
175        script.push_str("        COMPREPLY=($(compgen -f -- \"$cur\"))\n");
176    }
177    script.push_str("    else\n");
178    script.push_str("        # Complete files for arguments\n");
179    script.push_str("        COMPREPLY=($(compgen -f -- \"$cur\"))\n");
180    script.push_str("    fi\n");
181    script.push_str("}\n\n");
182
183    script.push_str(&format!("complete -F {} {}\n", func_name, cmd_name));
184
185    script
186}
187
188/// Generate zsh completion script.
189fn generate_zsh_completion(result: &ProbeResult) -> String {
190    let cmd_name = &result.command;
191    let func_name = format!(
192        "_{}_completion",
193        cmd_name.replace('-', "_").replace('/', "_")
194    );
195
196    let mut script = String::new();
197    script.push_str(&format!("#compdef {}\n", cmd_name));
198    script.push_str(&format!("# Zsh completion for {}\n", cmd_name));
199    script.push_str(&format!("# Generated by help-probe\n\n"));
200
201    // Wrap in a function to avoid issues when sourcing directly
202    script.push_str(&format!("{}() {{\n", func_name));
203
204    // Collect all options (clean trailing ... from flags)
205    let mut all_options: Vec<String> = Vec::new();
206    for opt in &result.options {
207        all_options.extend(opt.short_flags.iter().map(|f| clean_flag_name(f)));
208        all_options.extend(opt.long_flags.iter().map(|f| clean_flag_name(f)));
209    }
210
211    // Collect subcommands (clean trailing commas)
212    let subcommands = collect_clean_subcommands(result);
213
214    script.push_str("  local -a opts=(\n");
215    for opt in &all_options {
216        script.push_str(&format!("    '{}'\n", opt));
217    }
218    script.push_str("  )\n\n");
219
220    if !subcommands.is_empty() {
221        script.push_str("  local -a subcommands=(\n");
222        for sub in &subcommands {
223            script.push_str(&format!("    '{}'\n", sub));
224        }
225        script.push_str("  )\n\n");
226    }
227
228    script.push_str("  _arguments \\\n");
229
230    // Add options
231    for opt in &result.options {
232        for long_flag in &opt.long_flags {
233            // Clean trailing ... from flag name
234            let mut arg_spec = clean_flag_name(long_flag);
235            if opt.takes_argument {
236                if let Some(arg_name) = &opt.argument_name {
237                    arg_spec.push_str(&format!(":{}:", arg_name));
238                } else {
239                    arg_spec.push_str(":value:");
240                }
241            }
242            // Clean short flags too
243            let clean_short_flags: Vec<String> =
244                opt.short_flags.iter().map(|f| clean_flag_name(f)).collect();
245            script.push_str(&format!(
246                "    '({}){}' \\\n",
247                clean_short_flags.join(","),
248                arg_spec
249            ));
250        }
251    }
252
253    if !subcommands.is_empty() {
254        script.push_str(&format!("    '1: :->subcommands' \\\n"));
255        script.push_str("    '*: :->files'\n\n");
256        script.push_str("  case $state in\n");
257        script.push_str("    subcommands)\n");
258        script.push_str("      _describe 'subcommands' subcommands\n");
259        script.push_str("      ;;\n");
260        script.push_str("    files)\n");
261        script.push_str("      _files\n");
262        script.push_str("      ;;\n");
263        script.push_str("  esac\n");
264    } else {
265        script.push_str("    '*: :_files'\n");
266    }
267
268    script.push_str("}\n\n");
269    script.push_str(&format!("{}\n", func_name));
270
271    script
272}
273
274/// Generate fish completion script.
275/// Fish completions should have each option as a separate `complete` command.
276fn generate_fish_completion(result: &ProbeResult) -> String {
277    let cmd_name = &result.command;
278
279    let mut script = String::new();
280    script.push_str(&format!("# Fish completion for {}\n", cmd_name));
281    script.push_str(&format!("# Generated by help-probe\n\n"));
282
283    // Add options - each option gets its own complete command
284    for opt in &result.options {
285        // Process long flags
286        for long_flag in &opt.long_flags {
287            // Strip -- prefix and clean trailing ... (Fish doesn't allow ... in flag names)
288            let mut flag_name = long_flag.trim_start_matches("--").to_string();
289            flag_name = clean_flag_name(&flag_name);
290
291            script.push_str(&format!("complete -c {} -l {} ", cmd_name, flag_name));
292
293            if let Some(desc) = &opt.description {
294                // Escape single quotes in description
295                let escaped_desc = desc.replace('\'', "'\\''");
296                script.push_str(&format!("-d '{}' ", escaped_desc));
297            }
298
299            if opt.takes_argument {
300                script.push_str("-r "); // require argument
301            }
302
303            script.push_str("\n");
304        }
305
306        // Process short flags
307        for short_flag in &opt.short_flags {
308            // Strip - prefix and clean trailing ...
309            let mut flag_name = short_flag.trim_start_matches("-").to_string();
310            flag_name = clean_flag_name(&flag_name);
311
312            script.push_str(&format!("complete -c {} -s {} ", cmd_name, flag_name));
313
314            if let Some(desc) = &opt.description {
315                let escaped_desc = desc.replace('\'', "'\\''");
316                script.push_str(&format!("-d '{}' ", escaped_desc));
317            }
318
319            if opt.takes_argument {
320                script.push_str("-r ");
321            }
322
323            script.push_str("\n");
324        }
325    }
326
327    // Add subcommands as a separate complete command
328    // Use condition to show subcommands only when no flag is present
329    let clean_subcommands = collect_clean_subcommands(result);
330    if !clean_subcommands.is_empty() {
331        if !clean_subcommands.is_empty() {
332            // Condition: show subcommands when we're in a position to use a subcommand
333            // -f flag prevents file completion (shows only the subcommands, not files)
334            // __fish_use_subcommand is a built-in Fish function that returns true when
335            // we're at a position where a subcommand can be used (not in a flag context)
336            script.push_str(&format!(
337                "complete -c {} -f -n '__fish_use_subcommand' -a '{}'\n",
338                cmd_name,
339                clean_subcommands.join(" ")
340            ));
341        }
342    }
343
344    script
345}
346
347/// Generate PowerShell completion script.
348fn generate_powershell_completion(result: &ProbeResult) -> String {
349    let cmd_name = &result.command;
350
351    let mut script = String::new();
352    script.push_str(&format!("# PowerShell completion for {}\n", cmd_name));
353    script.push_str(&format!("# Generated by help-probe\n\n"));
354
355    script.push_str(&format!(
356        "Register-ArgumentCompleter -Native -CommandName '{}' -ScriptBlock {{\n",
357        cmd_name
358    ));
359    script.push_str("    param($wordToComplete, $commandAst, $cursorPosition)\n\n");
360
361    // Collect options (clean trailing ... from flags)
362    let mut all_options: Vec<String> = Vec::new();
363    for opt in &result.options {
364        all_options.extend(opt.long_flags.iter().map(|f| clean_flag_name(f)));
365    }
366
367    // Collect subcommands (clean trailing commas)
368    let subcommands = collect_clean_subcommands(result);
369
370    script.push_str(&format!(
371        "    $options = @({})\n",
372        all_options
373            .iter()
374            .map(|o| format!("'{}'", o))
375            .collect::<Vec<_>>()
376            .join(", ")
377    ));
378
379    if !subcommands.is_empty() {
380        script.push_str(&format!(
381            "    $subcommands = @({})\n",
382            subcommands
383                .iter()
384                .map(|s| format!("'{}'", s))
385                .collect::<Vec<_>>()
386                .join(", ")
387        ));
388    }
389
390    script.push_str("\n");
391    script.push_str("    if ($wordToComplete -match '^-') {\n");
392    script.push_str("        $options | Where-Object { $_ -like \"$wordToComplete*\" }\n");
393    script.push_str("    } else {\n");
394    if !subcommands.is_empty() {
395        script.push_str("        $subcommands | Where-Object { $_ -like \"$wordToComplete*\" }\n");
396    } else {
397        script.push_str(
398            "        Get-ChildItem | Where-Object { $_.Name -like \"$wordToComplete*\" }\n",
399        );
400    }
401    script.push_str("    }\n");
402    script.push_str("}\n");
403
404    script
405}
406
407/// Generate Nushell completion script.
408///
409/// Nushell uses the `extern` command to define external command signatures.
410/// This generates a completion script that can be added to config.nu.
411fn generate_nushell_completion(result: &ProbeResult) -> String {
412    let cmd_name = &result.command;
413
414    let mut script = String::new();
415    script.push_str(&format!("# Nushell completion for {}\n", cmd_name));
416    script.push_str(&format!("# Generated by help-probe\n"));
417    script.push_str(&format!("# Add this to your config.nu or source it\n\n"));
418
419    // Generate extern command signature
420    script.push_str(&format!("extern {} [\n", cmd_name));
421
422    // Add options as flags
423    for opt in &result.options {
424        for long_flag in &opt.long_flags {
425            // Strip -- prefix and clean trailing ... (Nushell doesn't allow ... in flag names)
426            let mut flag_name = long_flag.trim_start_matches("--").to_string();
427            flag_name = clean_flag_name(&flag_name);
428
429            // Nushell syntax: --flag: type (not --flag: argname: type)
430            let flag_type = if opt.takes_argument {
431                infer_nushell_type(opt).to_string()
432            } else {
433                "".to_string()
434            };
435
436            script.push_str(&format!("  --{}", flag_name));
437            if !flag_type.is_empty() {
438                script.push_str(&format!(": {}", flag_type));
439            }
440            if let Some(desc) = &opt.description {
441                script.push_str(&format!("  # {}", desc.replace('\n', " ")));
442            }
443            script.push_str("\n");
444        }
445
446        // Add short flags
447        for short_flag in &opt.short_flags {
448            // Strip - prefix and clean trailing ... (Nushell doesn't allow ... in flag names)
449            let mut flag_name = short_flag.trim_start_matches("-").to_string();
450            flag_name = clean_flag_name(&flag_name);
451
452            script.push_str(&format!("  -{}", flag_name));
453            if opt.takes_argument {
454                script.push_str(&format!(": {}", infer_nushell_type(opt)));
455            }
456            if let Some(desc) = &opt.description {
457                script.push_str(&format!("  # {}", desc.replace('\n', " ")));
458            }
459            script.push_str("\n");
460        }
461    }
462
463    // Add subcommands as positional arguments with completion
464    if !result.subcommands.is_empty() {
465        script.push_str("  subcommand?: string  # Subcommand to run\n");
466    }
467
468    // Add other arguments (only if no subcommands, or as additional args)
469    // Nushell extern format: positional args come after flags
470    if result.subcommands.is_empty() {
471        for arg in &result.arguments {
472            let arg_type = arg
473                .arg_type
474                .as_ref()
475                .map(|t| match t {
476                    crate::model::ArgumentType::Path => "path".to_string(),
477                    crate::model::ArgumentType::Number => "number".to_string(),
478                    crate::model::ArgumentType::Url => "string".to_string(),
479                    crate::model::ArgumentType::Email => "string".to_string(),
480                    _ => "string".to_string(),
481                })
482                .unwrap_or_else(|| "string".to_string());
483
484            let marker = if arg.required { "" } else { "?" };
485            let variadic = if arg.variadic { "..." } else { "" };
486            let arg_name = arg.name.to_lowercase().replace(['<', '>'], "");
487            script.push_str(&format!(
488                "  {}{}: {}  # {}{}\n",
489                arg_name,
490                marker,
491                arg_type,
492                variadic,
493                arg.description.as_deref().unwrap_or("argument")
494            ));
495        }
496    } else {
497        // If there are subcommands, add a catch-all for additional args
498        if !result.arguments.is_empty() {
499            script.push_str("  ...args: string  # Additional arguments\n");
500        }
501    }
502
503    script.push_str("]\n\n");
504
505    // Add custom completion for subcommands if they exist
506    if !result.subcommands.is_empty() {
507        let completer_name = format!("nu-complete-{}", cmd_name.replace('-', "_"));
508        script.push_str(&format!("\n# Custom completion function for subcommands\n"));
509        script.push_str(&format!("def {} [] {{\n", completer_name));
510        script.push_str("  [\n");
511        for subcmd in &result.subcommands {
512            // Clean subcommand name: remove trailing commas and invalid entries
513            let Some(clean_name) = clean_subcommand_name(&subcmd.name) else {
514                continue;
515            };
516
517            script.push_str(&format!("    \"{}\"", clean_name));
518            if let Some(desc) = &subcmd.description {
519                // Clean up description for comment
520                let clean_desc = desc.replace('\n', " ").trim().to_string();
521                if !clean_desc.is_empty() {
522                    script.push_str(&format!("  # {}", clean_desc));
523                }
524            }
525            script.push_str("\n");
526        }
527        script.push_str("  ]\n");
528        script.push_str("}\n\n");
529
530        // Update extern to use the completer
531        script.push_str(&format!(
532            "# To enable subcommand completion, replace the subcommand line above with:\n"
533        ));
534        script.push_str(&format!(
535            "#   subcommand?: string@{}  # Subcommand to run\n",
536            completer_name
537        ));
538    }
539
540    script
541}
542
543/// Infer Nushell type from option metadata.
544fn infer_nushell_type(opt: &crate::model::OptionSpec) -> &'static str {
545    match opt.option_type {
546        crate::model::OptionType::Number => "number",
547        crate::model::OptionType::Path => "path",
548        crate::model::OptionType::Boolean => "bool",
549        _ => "string",
550    }
551}