diff --git a/src/bash_with_descriptions.rs b/src/bash_with_descriptions.rs new file mode 100644 index 0000000..f3e16a0 --- /dev/null +++ b/src/bash_with_descriptions.rs @@ -0,0 +1,202 @@ +use crate::Command; + +fn find_desc_in_cmd<'a>(token: &'a str, cmd: &'a Command) -> Option<&'a str> { + for arg in &cmd.args { + if let Some(short) = &arg.short { + let s = short.trim_start_matches('-'); + if token == short || token == format!("-{}", s) { + if let Some(d) = &arg.description { + return Some(d.as_str()); + } + } + } + if let Some(long) = &arg.long { + let l = long.trim_start_matches('-'); + if token == long || token == format!("--{}", l) { + if let Some(d) = &arg.description { + return Some(d.as_str()); + } + } + } + if let Some(vn) = &arg.value_name { + let clean_vn = vn.trim_matches(|c| c == '<' || c == '>'); + if token == vn + || token == clean_vn + || token == format!("[{}]", clean_vn) + || token == format!("<{}>", clean_vn) + { + if let Some(d) = &arg.description { + return Some(d.as_str()); + } + } + } + } + + for sub in &cmd.subcommands { + if let Some(name) = &sub.name { + if token == name { + if let Some(d) = &sub.description { + return Some(d.as_str()); + } + } + } + for alias in &sub.aliases { + if token == alias { + if let Some(d) = &sub.description { + return Some(d.as_str()); + } + } + } + } + + None +} + +fn find_command_by_path<'a>(root: &'a Command, path: &[String]) -> Option<&'a Command> { + if path.is_empty() { + return Some(root); + } + let mut current = root; + for segment in path.iter().skip(1) { + if let Some(sub) = current.subcommands.iter().find(|s| { + s.name.as_deref() == Some(segment.as_str()) + || s.aliases.iter().any(|a| a == segment.as_str()) + }) { + current = sub; + } else { + return None; + } + } + Some(current) +} + +fn find_description_for_token<'a>( + token: &'a str, + cmd_path: &[String], + root_cmd: &'a Command, +) -> Option<&'a str> { + let target_cmd = find_command_by_path(root_cmd, cmd_path).unwrap_or(root_cmd); + if let Some(desc) = find_desc_in_cmd(token, target_cmd) { + return Some(desc); + } + if (target_cmd as *const Command) != (root_cmd as *const Command) { + if let Some(desc) = find_desc_in_cmd(token, root_cmd) { + return Some(desc); + } + } + None +} + +pub fn try_apply_bash_descriptions(script: &str, root_cmd: &Command) -> Option { + if !script.contains("opts=") || !script.contains("compgen -W") { + return None; + } + + let mut lines = Vec::new(); + let mut current_cmd_path: Vec = Vec::new(); + let mut modified_any = false; + + for line in script.lines() { + let trimmed = line.trim(); + if trimmed.ends_with(')') && !trimmed.starts_with('*') && !trimmed.starts_with(';') { + let pattern = trimmed.trim_end_matches(')').trim(); + if pattern.contains("__") || pattern == root_cmd.name.as_deref().unwrap_or("") { + current_cmd_path = pattern.split("__").map(|s| s.to_string()).collect(); + } + } + + if trimmed == "opts=\"\"" { + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + lines.push(format!("{}opts=()", indent)); + modified_any = true; + continue; + } + + if trimmed.starts_with("opts=\"") && trimmed.ends_with('"') && trimmed.len() >= 7 { + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + let content = &trimmed[6..trimmed.len() - 1]; + + if !content.is_empty() { + let tokens: Vec<&str> = content.split_whitespace().collect(); + let mut array_elements = Vec::new(); + + for token in tokens { + let desc = find_description_for_token(token, ¤t_cmd_path, root_cmd); + let clean_desc = desc.unwrap_or_default().replace(['\n', '\r', '\t'], " "); + let clean_desc = clean_desc.trim(); + let escaped_element = format!("{}\t{}", token, clean_desc).replace('"', "\\\""); + array_elements.push(format!("\"{}\"", escaped_element)); + } + + let new_line = format!("{}opts=({})", indent, array_elements.join(" ")); + lines.push(new_line); + modified_any = true; + continue; + } + } + + if trimmed.contains("compgen -W \"${opts}\"") + || trimmed.contains("compgen -W \"${opts[*]}\"") + { + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + lines.push(format!("{}COMPREPLY=()", indent)); + lines.push(format!("{}for item in \"${{opts[@]}}\"; do", indent)); + lines.push(format!( + "{} if [[ \"$item\" == \"$cur\"* ]]; then", + indent + )); + lines.push(format!("{} COMPREPLY+=(\"$item\")", indent)); + lines.push(format!("{} fi", indent)); + lines.push(format!("{}done", indent)); + modified_any = true; + continue; + } + + lines.push(line.to_string()); + } + + if !modified_any { + return None; + } + + let mut result = lines.join("\n"); + if script.ends_with('\n') { + result.push('\n'); + } + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse_help; + + #[test] + fn test_bash_with_descriptions_output() { + const HELP: &str = r#"Usage: greet [OPTIONS] + +Options: + -n, --name Name to greet + -h, --help Print help +"#; + let cmd = parse_help(HELP); + let script = try_apply_bash_descriptions( + r#"_greet() { + opts="-n --name -h --help" + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) +}"#, + &cmd, + ) + .expect("should transform expected format"); + assert!(script.contains( + "opts=(\"-n\tName to greet\" \"--name\tName to greet\" \"-h\tPrint help\" \"--help\tPrint help\")" + )); + + // Test fallback when script format does not match expected format + let fallback = try_apply_bash_descriptions("echo 'custom bash script'", &cmd); + assert!(fallback.is_none()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7b5c3f9..69195ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,9 +54,11 @@ pub fn is_sandboxing_available() -> Option { } } +mod bash_with_descriptions; mod parse_help; pub mod parse_man; +use bash_with_descriptions::try_apply_bash_descriptions; pub use parse_help::{parse_help, parse_help_argparse, parse_help_clap, parse_help_generic}; #[derive( @@ -277,10 +279,18 @@ impl FlycompSettings { } } -#[derive(Clone, Debug, clap::ValueEnum)] +#[derive( + Clone, Copy, Debug, clap::ValueEnum, PartialEq, Eq, serde::Serialize, serde::Deserialize, +)] #[value(rename_all = "lower")] pub enum OutputFormat { Bash, + #[value( + name = "bash-with-descriptions", + alias = "bashwithdescriptions", + alias = "bash_with_descriptions" + )] + BashWithDescriptions, Elvish, Fish, Powershell, @@ -289,9 +299,11 @@ pub enum OutputFormat { } impl OutputFormat { - fn shell(self) -> Option { + fn shell(&self) -> Option { match self { - OutputFormat::Bash => Some(clap_complete::Shell::Bash), + OutputFormat::Bash | OutputFormat::BashWithDescriptions => { + Some(clap_complete::Shell::Bash) + } OutputFormat::Elvish => Some(clap_complete::Shell::Elvish), OutputFormat::Fish => Some(clap_complete::Shell::Fish), OutputFormat::Powershell => Some(clap_complete::Shell::PowerShell), @@ -2600,6 +2612,9 @@ pub fn run_help( } } +/// Run `command_path --help`, synthesize its completion model, and render a +/// shell completion script. + /// Run `command_path --help`, synthesize its completion model, and render a /// shell completion script. pub fn generate_completion_script( @@ -2610,75 +2625,22 @@ pub fn generate_completion_script( timeout_ms: u64, recurse_limit: usize, ) -> anyhow::Result { - reset_stats(); - let outcome = synthesize_completion_with( + let output_format = match shell { + clap_complete::Shell::Bash => OutputFormat::Bash, + clap_complete::Shell::Elvish => OutputFormat::Elvish, + clap_complete::Shell::Fish => OutputFormat::Fish, + clap_complete::Shell::PowerShell => OutputFormat::Powershell, + clap_complete::Shell::Zsh => OutputFormat::Zsh, + _ => OutputFormat::Bash, + }; + generate_completion_output( command_path, - &|args| run_help(command_path, args, sandbox, timeout_ms), + output_format, strategy, + sandbox, + timeout_ms, recurse_limit, - )?; - let cmd_name = command_basename(command_path).replace(' ', "-"); - - let mut clap_cmd = to_clap_command(&outcome.command); - let mut output = Vec::new(); - clap_complete::generate(shell, &mut clap_cmd, &cmd_name, &mut output); - - let script = std::str::from_utf8(&output) - .map_err(|e| anyhow::anyhow!("failed to encode completion script: {}", e))? - .to_string(); - - let output_format = match shell { - clap_complete::Shell::Bash => "bash", - clap_complete::Shell::Elvish => "elvish", - clap_complete::Shell::Fish => "fish", - clap_complete::Shell::PowerShell => "powershell", - clap_complete::Shell::Zsh => "zsh", - _ => "unknown", - }; - - let comment_char = "#"; - let metadata_header = format!( - "{comment} Generated by flycomp version {version} ({git_hash}) built {build_time}\n\ - {comment} Generated at: {generated_at}\n\ - {comment} Output format: {output_format}\n\ - {comment} Requested strategy: {requested_strategy}\n\ - {comment} Strategy used: {strategy_used}\n\ - {comment} Sandboxed: {sandboxed}\n\ - {comment} Timeout (ms): {timeout_ms}\n\ - {comment} Command path: {command_path}\n\ - {comment} Man pages read: {man_pages_read}\n\ - {comment} Help runs: {help_runs}\n\n", - comment = comment_char, - version = env!("CARGO_PKG_VERSION"), - git_hash = env!("GIT_HASH"), - build_time = env!("BUILD_TIME"), - generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - output_format = output_format, - requested_strategy = Into::<&'static str>::into(strategy), - strategy_used = Into::<&'static str>::into(outcome.strategy_used), - sandboxed = sandbox, - timeout_ms = timeout_ms, - command_path = command_path, - man_pages_read = get_man_pages_read(), - help_runs = get_help_runs(), - ); - - let mut final_script = String::new(); - let lines: Vec<&str> = script.lines().collect(); - if !lines.is_empty() && (lines[0].starts_with("#compdef") || lines[0].starts_with("#!")) { - final_script.push_str(lines[0]); - final_script.push('\n'); - final_script.push_str(&metadata_header); - for line in lines.iter().skip(1) { - final_script.push_str(line); - final_script.push('\n'); - } - } else { - final_script.push_str(&metadata_header); - final_script.push_str(&script); - } - - Ok(final_script) + ) } /// Generate completion output for a command as either a shell script or JSON. @@ -2707,15 +2669,86 @@ pub fn generate_completion_output( outcome.command, ) } else { - let shell = output.shell().expect("non-JSON output has shell mapping"); - generate_completion_script( + let outcome = synthesize_completion_with( command_path, - shell, + &|args| run_help(command_path, args, sandbox, timeout_ms), strategy, - sandbox, - timeout_ms, recurse_limit, - ) + )?; + let cmd_name = command_basename(command_path).replace(' ', "-"); + + let mut clap_cmd = to_clap_command(&outcome.command); + let mut output_bytes = Vec::new(); + let shell = output.shell().expect("non-JSON output has shell mapping"); + clap_complete::generate(shell, &mut clap_cmd, &cmd_name, &mut output_bytes); + + let mut script = std::str::from_utf8(&output_bytes) + .map_err(|e| anyhow::anyhow!("failed to encode completion script: {}", e))? + .to_string(); + + if matches!(output, OutputFormat::BashWithDescriptions) { + if let Some(transformed) = try_apply_bash_descriptions(&script, &outcome.command) { + script = transformed; + } else { + log::warn!( + "flycomp: generated script format unexpected for BashWithDescriptions; falling back to standard Bash script" + ); + } + } + + let output_format_str = match output { + OutputFormat::Bash => "bash", + OutputFormat::BashWithDescriptions => "bash-with-descriptions", + OutputFormat::Elvish => "elvish", + OutputFormat::Fish => "fish", + OutputFormat::Powershell => "powershell", + OutputFormat::Zsh => "zsh", + OutputFormat::Json => "json", + }; + + let comment_char = "#"; + let metadata_header = format!( + "{comment} Generated by flycomp version {version} ({git_hash}) built {build_time}\n\ + {comment} Generated at: {generated_at}\n\ + {comment} Output format: {output_format}\n\ + {comment} Requested strategy: {requested_strategy}\n\ + {comment} Strategy used: {strategy_used}\n\ + {comment} Sandboxed: {sandboxed}\n\ + {comment} Timeout (ms): {timeout_ms}\n\ + {comment} Command path: {command_path}\n\ + {comment} Man pages read: {man_pages_read}\n\ + {comment} Help runs: {help_runs}\n\n", + comment = comment_char, + version = env!("CARGO_PKG_VERSION"), + git_hash = env!("GIT_HASH"), + build_time = env!("BUILD_TIME"), + generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + output_format = output_format_str, + requested_strategy = Into::<&'static str>::into(strategy), + strategy_used = Into::<&'static str>::into(outcome.strategy_used), + sandboxed = sandbox, + timeout_ms = timeout_ms, + command_path = command_path, + man_pages_read = get_man_pages_read(), + help_runs = get_help_runs(), + ); + + let mut final_script = String::new(); + let lines: Vec<&str> = script.lines().collect(); + if !lines.is_empty() && (lines[0].starts_with("#compdef") || lines[0].starts_with("#!")) { + final_script.push_str(lines[0]); + final_script.push('\n'); + final_script.push_str(&metadata_header); + for line in lines.iter().skip(1) { + final_script.push_str(line); + final_script.push('\n'); + } + } else { + final_script.push_str(&metadata_header); + final_script.push_str(&script); + } + + Ok(final_script) } }