1use crate::model::ProbeResult;
2
3fn clean_flag_name(flag: &str) -> String {
6 flag.trim_end_matches("...").to_string()
7}
8
9fn 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
20fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Shell {
33 Bash,
35 Zsh,
37 Fish,
39 PowerShell,
41 NuShell,
43}
44
45impl Shell {
46 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
74pub 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
115fn 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 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 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
188fn 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 script.push_str(&format!("{}() {{\n", func_name));
203
204 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 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 for opt in &result.options {
232 for long_flag in &opt.long_flags {
233 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 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
274fn 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 for opt in &result.options {
285 for long_flag in &opt.long_flags {
287 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 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 "); }
302
303 script.push_str("\n");
304 }
305
306 for short_flag in &opt.short_flags {
308 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 let clean_subcommands = collect_clean_subcommands(result);
330 if !clean_subcommands.is_empty() {
331 if !clean_subcommands.is_empty() {
332 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
347fn 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 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 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
407fn 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 script.push_str(&format!("extern {} [\n", cmd_name));
421
422 for opt in &result.options {
424 for long_flag in &opt.long_flags {
425 let mut flag_name = long_flag.trim_start_matches("--").to_string();
427 flag_name = clean_flag_name(&flag_name);
428
429 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 for short_flag in &opt.short_flags {
448 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 if !result.subcommands.is_empty() {
465 script.push_str(" subcommand?: string # Subcommand to run\n");
466 }
467
468 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 !result.arguments.is_empty() {
499 script.push_str(" ...args: string # Additional arguments\n");
500 }
501 }
502
503 script.push_str("]\n\n");
504
505 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 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 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 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
543fn 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}