From 150f5f23db9ea798090bdddf7acc54efde84d389 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 16:10:51 +0530 Subject: [PATCH 1/5] feat: Add shell completion support (bash, zsh, fish, powershell, elvish) This PR adds shell completion support for major shells using a bridge approach with a hidden __complete command. This enables dynamic completion for subcommands and flags based on the Discovery Document for each service. Fixes #323 --- .changeset/feat-shell-completion.md | 5 + Cargo.lock | 18 +- Cargo.toml | 1 + src/completion.rs | 426 ++++++++++++++++++++++++++++ src/main.rs | 83 ++++++ 5 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 .changeset/feat-shell-completion.md create mode 100644 src/completion.rs diff --git a/.changeset/feat-shell-completion.md b/.changeset/feat-shell-completion.md new file mode 100644 index 00000000..d83be34c --- /dev/null +++ b/.changeset/feat-shell-completion.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add shell completion support via `gws completion `. Supports bash, zsh, fish, powershell, and elvish. diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..fb88917c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -301,6 +301,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -896,6 +905,7 @@ dependencies = [ "chrono", "chrono-tz", "clap", + "clap_complete", "crossterm", "derive_builder", "dirs", @@ -2741,9 +2751,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..33396289 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ path = "src/main.rs" aes-gcm = "0.10" anyhow = "1" clap = { version = "4", features = ["derive", "string"] } +clap_complete = "4" dirs = "5" dotenvy = "0.15" hostname = "0.4" diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 00000000..9ad1feae --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,426 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::error::GwsError; +use crate::{commands, discovery}; +use clap::Command; + +/// Generates a bridge script for the given shell. +pub fn print_bridge_script(shell: &str) -> Result<(), GwsError> { + match shell.to_lowercase().as_str() { + "bash" => { + println!( + r#" +_gws_complete() {{ + local word="${{COMP_WORDS[COMP_CWORD]}}" + local args=("${{COMP_WORDS[@]:1}}") + + local completions + completions=$(gws __complete "${{args[@]}}") + + local filtered_completions="" + while IFS= read -r line; do + filtered_completions+="${{line%%:*}} " + done <<< "$completions" + + COMPREPLY=( $(compgen -W "$filtered_completions" -- "$word") ) +}} +complete -F _gws_complete gws +"# + ); + } + "zsh" => { + println!( + r#" +#compdef gws + +_gws() {{ + local -a completions + local -a words_to_complete + + # Use the current words on the command line, skipping the first one ('gws') + words_to_complete=("${{(@)words[2,$CURRENT]}}") + + # Get completions in 'name:description' format + completions=("${{(@f)$(gws __complete "${{words_to_complete[@]}}")}}") + + if [[ -n "$completions" ]]; then + _describe 'gws commands' completions + fi +}} + +compdef _gws gws +"# + ); + } + "fish" => { + println!( + r#" +function _gws_complete + set -l cmd (commandline -opc) + set -e cmd[1] # remove 'gws' + + # Fish expects 'name\tDescription' + gws __complete $cmd | string replace -r ':(.*)' '\t$1' +end + +complete -c gws -f -a "(_gws_complete)" +"# + ); + } + "powershell" => { + println!( + r#" +Register-ArgumentCompleter -CommandName gws -ScriptBlock {{ + param($commandName, $wordToComplete, $cursorPosition) + $args = $wordToComplete.Split(" ") + gws __complete $args | ForEach-Object {{ + $parts = $_.Split(":") + $name = $parts[0] + $desc = if ($parts.Length -gt 1) {{ $parts[1] }} else {{ "" }} + [System.Management.Automation.CompletionResult]::new($name, $name, 'ParameterValue', $desc) + }} +}} +"# + ); + } + "elvish" => { + println!( + r#" +set edit:completion:arg-completer[gws] = {{ |@args| + # remove 'gws' + set args = $args[1..] + gws __complete $@args | each {{ |line| + local parts = [(str:split : $line)] + local name = $parts[0] + local desc = '' + if (> (count $parts) 1) {{ + set desc = $parts[1] + }} + edit:complex-candidate $name &display=$name' ('$desc')' + }} +}} +"# + ); + } + other => { + return Err(GwsError::Validation(format!( + "Dynamic completion bridge is not supported for '{}'. Supported: bash, zsh, fish, powershell, elvish", + other + ))); + } + } + Ok(()) +} + +/// Recursively find the subcommand matching the current arguments and print available next tokens. +pub fn handle_dynamic_completion(cli: &Command, args: &[String]) { + let completions = get_completions(cli, args); + for completion in completions { + println!("{}", completion); + } +} + +fn get_completions(cli: &Command, args: &[String]) -> Vec { + let mut current_cmd = cli; + let mut it = args.iter().peekable(); + let mut completions = Vec::new(); + + // Skip 'gws' if present + if let Some(&first) = it.peek() { + if first == "gws" { + it.next(); + } + } + + let mut last_arg = None; + let mut active_flag: Option<&clap::Arg> = None; + + // Walk the command tree using the provided args + while let Some(arg) = it.next() { + if it.peek().is_none() { + // This is the last argument, we'll use it for filtering + last_arg = Some(arg); + break; + } + + if arg.starts_with('-') && arg != "-" && arg != "--" { + // This is a flag. We need to check if it takes a value and consume it + // to avoid misinterpreting it as a subcommand. + let arg_def = if arg.starts_with("--") { + let name = arg.split('=').next().unwrap()[2..].to_string(); + current_cmd + .get_arguments() + .find(|a| a.get_long() == Some(&name)) + } else { + // Short flag - handle combined flags like -vF + let chars: Vec = arg[1..].chars().collect(); + let mut found = None; + for (i, &c) in chars.iter().enumerate() { + if let Some(def) = current_cmd + .get_arguments() + .find(|a| a.get_short() == Some(c)) + { + if def.get_action().takes_values() { + // Only the last flag in a group can take a value + if i == chars.len() - 1 { + found = Some(def); + } + break; + } + found = Some(def); + } + } + found + }; + + if let Some(def) = arg_def { + // If it's a flag that takes a value and isn't in `--key=value` form, + // we need to consume the next argument as its value. + if def.get_action().takes_values() && !arg.contains('=') { + if let Some(val) = it.next() { + if it.peek().is_none() { + // This value is the last arg, so we are completing it! + active_flag = Some(def); + last_arg = Some(val); + break; + } + } else { + // The flag was the last arg? (Should be handled by peek().is_none() above) + } + } + } + continue; + } + + if let Some(subcmd) = current_cmd.find_subcommand(arg) { + current_cmd = subcmd; + } else { + // Invalid subcommand, stop here + break; + } + } + + let filter = last_arg.map(|s| s.as_str()).unwrap_or(""); + + // If we are completing a flag value, try to suggest allowed values + if let Some(flag) = active_flag { + let val_parser = flag.get_value_parser(); + if let Some(pv) = val_parser.possible_values() { + for val in pv { + if val.get_name().starts_with(filter) { + completions.push(format!( + "{}:{}", + val.get_name(), + val.get_help().map(|h| h.to_string()).unwrap_or_default() + )); + } + } + } + return completions; + } + + // Special case: if the last arg looks like a flag, complete flags + if let Some(arg_filter) = filter.strip_prefix("--") { + for arg in current_cmd.get_arguments() { + if let Some(long) = arg.get_long() { + if long.starts_with(arg_filter) { + let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); + completions.push(format!("--{}:{}", long, help)); + } + } + } + return completions; + } else if let Some(arg_filter) = filter.strip_prefix('-') { + for arg in current_cmd.get_arguments() { + if let Some(short) = arg.get_short() { + if short.to_string().starts_with(arg_filter) { + let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); + completions.push(format!("-{}:{}", short, help)); + } + } + } + return completions; + } + + // Print subcommands with descriptions + for subcmd in current_cmd.get_subcommands() { + if !subcmd.is_hide_set() { + let name = subcmd.get_name(); + if name.starts_with(filter) { + let about = subcmd + .get_about() + .map(|a| a.to_string()) + .unwrap_or_default(); + completions.push(format!("{}:{}", name, about)); + } + } + } + completions +} + +/// High-level handler for the `__complete` command. +pub async fn handle_complete_command( + args: Vec, + static_cli_builder: fn() -> Command, +) -> Result<(), GwsError> { + if args.is_empty() { + let cmd = static_cli_builder(); + handle_dynamic_completion(&cmd, &[]); + return Ok(()); + } + + let first_arg = &args[0]; + + // Check if the first argument is a static subcommand (not a dynamic service) + let is_service = crate::services::SERVICES + .iter() + .any(|s| s.aliases.contains(&first_arg.as_str())); + if !is_service { + let cmd = static_cli_builder(); + if cmd.find_subcommand(first_arg).is_some() { + handle_dynamic_completion(&cmd, &args); + return Ok(()); + } + } + + match crate::parse_service_and_version(&args, first_arg) { + Ok((api_name, version)) => { + let doc_result = if api_name == "workflow" { + Ok(discovery::RestDescription { + name: "workflow".to_string(), + description: Some("Cross-service productivity workflows".to_string()), + ..Default::default() + }) + } else { + discovery::fetch_discovery_document(&api_name, &version) + .await + .map_err(|e| GwsError::Discovery(format!("{e:#}"))) + }; + + if let Ok(doc) = doc_result { + let cmd = commands::build_cli(&doc); + let sub_args = crate::filter_args_for_subcommand(&args, &api_name); + handle_dynamic_completion(&cmd, &sub_args); + } + } + Err(_) => { + let cmd = static_cli_builder(); + handle_dynamic_completion(&cmd, &args); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{builder::PossibleValue, Arg, Command}; + + fn test_cli() -> Command { + Command::new("gws") + .subcommand( + Command::new("drive") + .about("Google Drive API") + .subcommand( + Command::new("files") + .about("Files resource") + .subcommand(Command::new("list").about("List files")) + .subcommand(Command::new("get").about("Get file metadata")), + ) + .arg( + Arg::new("api-version") + .long("api-version") + .action(clap::ArgAction::Set), + ) + .arg(Arg::new("format").long("format").value_parser([ + PossibleValue::new("json").help("JSON format"), + PossibleValue::new("table").help("Table format"), + ])), + ) + .subcommand(Command::new("auth").about("Authentication commands")) + } + + #[test] + fn test_get_completions_empty() { + let cli = test_cli(); + let completions = get_completions(&cli, &[]); + assert!(completions.contains(&"drive:Google Drive API".to_string())); + assert!(completions.contains(&"auth:Authentication commands".to_string())); + } + + #[test] + fn test_get_completions_partial_subcommand() { + let cli = test_cli(); + let completions = get_completions(&cli, &["dr".to_string()]); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0], "drive:Google Drive API"); + } + + #[test] + fn test_get_completions_nested() { + let cli = test_cli(); + let completions = get_completions(&cli, &["drive".to_string(), "f".to_string()]); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0], "files:Files resource"); + } + + #[test] + fn test_get_completions_flags_with_values() { + let cli = test_cli(); + // Completing after a flag that takes a value + let completions = get_completions( + &cli, + &[ + "drive".to_string(), + "--api-version".to_string(), + "v3".to_string(), + "f".to_string(), + ], + ); + assert_eq!(completions[0], "files:Files resource"); + } + + #[test] + fn test_get_completions_flags_with_equals() { + let cli = test_cli(); + let completions = get_completions( + &cli, + &[ + "drive".to_string(), + "--api-version=v3".to_string(), + "f".to_string(), + ], + ); + assert_eq!(completions[0], "files:Files resource"); + } + + #[test] + fn test_get_completions_flag_value() { + let cli = test_cli(); + // Input: gws drive --format j -> should suggest json + let completions = get_completions( + &cli, + &["drive".to_string(), "--format".to_string(), "j".to_string()], + ); + assert!(completions.iter().any(|c| c.starts_with("json:"))); + } + + #[test] + fn test_get_completions_flag_names() { + let cli = test_cli(); + let completions = get_completions(&cli, &["drive".to_string(), "--f".to_string()]); + assert!(completions.iter().any(|c| c.starts_with("--format:"))); + } +} diff --git a/src/main.rs b/src/main.rs index bd72c642..53274ae7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod auth; pub(crate) mod auth_commands; mod client; mod commands; +mod completion; pub(crate) mod credential_store; mod discovery; mod error; @@ -131,6 +132,29 @@ async fn run() -> Result<(), GwsError> { return generate_skills::handle_generate_skills(&gen_args).await; } + // Handle the `completion` command + if first_arg == "completion" { + let cli = build_static_cli(); + match cli.try_get_matches_from(&args) { + Ok(matches) => { + if let Some(("completion", sub_matches)) = matches.subcommand() { + let shell = sub_matches.get_one::("shell").unwrap(); + return completion::print_bridge_script(shell); + } + unreachable!("Clap parsing succeeded for 'completion' but subcommand not found."); + } + Err(e) => { + e.exit(); + } + } + } + + // Handle the hidden `__complete` command for dynamic shell completion + if first_arg == "__complete" { + let complete_args: Vec = args.iter().skip(2).cloned().collect(); + return completion::handle_complete_command(complete_args, build_static_cli).await; + } + // Handle the `auth` command if first_arg == "auth" { let auth_args: Vec = args.iter().skip(2).cloned().collect(); @@ -414,6 +438,65 @@ fn resolve_method_from_matches<'a>( ))) } +fn build_static_cli() -> clap::Command { + use clap::{Arg, Command}; + + let mut cmd = Command::new("gws") + .version(env!("CARGO_PKG_VERSION")) + .about("Google Workspace CLI") + .subcommand( + Command::new("auth") + .about("Manage authentication") + .subcommand(Command::new("login").about("Log in and save credentials")) + .subcommand(Command::new("logout").about("Log out and clear credentials")) + .subcommand(Command::new("setup").about("Run guided setup for GCP project")) + .subcommand(Command::new("status").about("Show current auth status")) + .subcommand(Command::new("export").about("Export credentials to stdout")), + ) + .subcommand( + Command::new("schema") + .about("Introspect API method schemas") + .arg( + Arg::new("path") + .required(true) + .help("API path (e.g. drive.files.list)"), + ) + .arg( + Arg::new("resolve-refs") + .long("resolve-refs") + .action(clap::ArgAction::SetTrue), + ), + ) + .subcommand( + Command::new("generate-skills") + .about("Generate SKILL.md files for AI agents") + .arg(Arg::new("output-dir").long("output-dir").num_args(1)), + ) + .subcommand( + Command::new("completion") + .about("Generate shell completion scripts") + .arg(Arg::new("shell").required(true).value_parser([ + "bash", + "zsh", + "fish", + "powershell", + "elvish", + ])), + ); + + // Add service aliases as subcommands so they show up in top-level completion + for entry in services::SERVICES { + for alias in entry.aliases { + if cmd.get_subcommands().any(|s| s.get_name() == *alias) { + continue; + } + cmd = cmd.subcommand(Command::new(*alias).about(entry.description)); + } + } + + cmd +} + fn print_usage() { println!("gws — Google Workspace CLI"); println!(); From a44743cecc2ff44bf2fc5128aa7b7440eef3229a Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 16:38:59 +0530 Subject: [PATCH 2/5] fix(completion): handle flags as last argument and remove redundant clap_complete dependency --- Cargo.lock | 10 ---------- Cargo.toml | 1 - src/completion.rs | 41 +++++++++++++++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb88917c..876fb557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,15 +301,6 @@ dependencies = [ "strsim", ] -[[package]] -name = "clap_complete" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb" -dependencies = [ - "clap", -] - [[package]] name = "clap_derive" version = "4.6.0" @@ -905,7 +896,6 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "clap_complete", "crossterm", "derive_builder", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 33396289..24bc253b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,6 @@ path = "src/main.rs" aes-gcm = "0.10" anyhow = "1" clap = { version = "4", features = ["derive", "string"] } -clap_complete = "4" dirs = "5" dotenvy = "0.15" hostname = "0.4" diff --git a/src/completion.rs b/src/completion.rs index 9ad1feae..dffc2a16 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -149,11 +149,7 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { // Walk the command tree using the provided args while let Some(arg) = it.next() { - if it.peek().is_none() { - // This is the last argument, we'll use it for filtering - last_arg = Some(arg); - break; - } + let is_last = it.peek().is_none(); if arg.starts_with('-') && arg != "-" && arg != "--" { // This is a flag. We need to check if it takes a value and consume it @@ -189,21 +185,41 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { // If it's a flag that takes a value and isn't in `--key=value` form, // we need to consume the next argument as its value. if def.get_action().takes_values() && !arg.contains('=') { + if is_last { + // The flag is the last argument and it expects a value. + active_flag = Some(def); + last_arg = Some(""); + break; + } if let Some(val) = it.next() { if it.peek().is_none() { // This value is the last arg, so we are completing it! active_flag = Some(def); - last_arg = Some(val); + last_arg = Some(val.as_str()); break; } } else { - // The flag was the last arg? (Should be handled by peek().is_none() above) + // The flag was the last arg, but it expects a value. + // We should complete for the value. + active_flag = Some(def); + last_arg = Some(""); + break; } } } + if is_last { + last_arg = Some(arg.as_str()); + break; + } continue; } + if is_last { + // This is the last argument, we'll use it for filtering + last_arg = Some(arg.as_str()); + break; + } + if let Some(subcmd) = current_cmd.find_subcommand(arg) { current_cmd = subcmd; } else { @@ -212,7 +228,7 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { } } - let filter = last_arg.map(|s| s.as_str()).unwrap_or(""); + let filter = last_arg.unwrap_or(""); // If we are completing a flag value, try to suggest allowed values if let Some(flag) = active_flag { @@ -417,6 +433,15 @@ mod tests { assert!(completions.iter().any(|c| c.starts_with("json:"))); } + #[test] + fn test_get_completions_flag_last_arg() { + let cli = test_cli(); + // Input: gws drive --format -> should suggest json, table + let completions = get_completions(&cli, &["drive".to_string(), "--format".to_string()]); + assert!(completions.iter().any(|c| c.starts_with("json:"))); + assert!(completions.iter().any(|c| c.starts_with("table:"))); + } + #[test] fn test_get_completions_flag_names() { let cli = test_cli(); From 1ab9d526dc62c46e4aba0409761ccfcd77cf743f Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 16:45:04 +0530 Subject: [PATCH 3/5] fix(completion): robust powershell parsing and correct colon splitting in all shells --- src/completion.rs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index dffc2a16..722aabab 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -31,6 +31,7 @@ _gws_complete() {{ local filtered_completions="" while IFS= read -r line; do + # Extract the completion part (everything before the first colon) filtered_completions+="${{line%%:*}} " done <<< "$completions" @@ -71,8 +72,8 @@ function _gws_complete set -l cmd (commandline -opc) set -e cmd[1] # remove 'gws' - # Fish expects 'name\tDescription' - gws __complete $cmd | string replace -r ':(.*)' '\t$1' + # Fish expects 'name\tDescription'. Replace only the first colon. + gws __complete $cmd | string replace -r '^([^:]+):(.*)' '$1\t$2' end complete -c gws -f -a "(_gws_complete)" @@ -82,11 +83,21 @@ complete -c gws -f -a "(_gws_complete)" "powershell" => { println!( r#" -Register-ArgumentCompleter -CommandName gws -ScriptBlock {{ - param($commandName, $wordToComplete, $cursorPosition) - $args = $wordToComplete.Split(" ") - gws __complete $args | ForEach-Object {{ - $parts = $_.Split(":") +Register-ArgumentCompleter -NativeCommandName gws -ScriptBlock {{ + param($wordToComplete, $commandAst, $cursorPosition) + + # Get arguments from the AST, skipping the command itself ('gws'). + $arguments = $commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object {{ $_.Extent.Text }} + + # If the last character on the command line is a space, it means we are completing a new, empty argument. + if ($commandAst.Extent.Text.EndsWith(" ")) {{ + $arguments += "" + }} + + # Pass arguments to the completion command. + gws __complete $arguments | ForEach-Object {{ + # Split only on the first colon to keep the rest of the description intact. + $parts = $_.Split(":", 2) $name = $parts[0] $desc = if ($parts.Length -gt 1) {{ $parts[1] }} else {{ "" }} [System.Management.Automation.CompletionResult]::new($name, $name, 'ParameterValue', $desc) @@ -102,7 +113,7 @@ set edit:completion:arg-completer[gws] = {{ |@args| # remove 'gws' set args = $args[1..] gws __complete $@args | each {{ |line| - local parts = [(str:split : $line)] + local parts = [(str:split : $line &max=2)] local name = $parts[0] local desc = '' if (> (count $parts) 1) {{ From 9879a05e97a23f67ef009d7b9a521bf80c009ee6 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 16:52:08 +0530 Subject: [PATCH 4/5] fix(completion): robust bash completion with space handling --- src/completion.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 722aabab..8b19b3a6 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -29,13 +29,16 @@ _gws_complete() {{ local completions completions=$(gws __complete "${{args[@]}}") - local filtered_completions="" + local -a suggestions while IFS= read -r line; do - # Extract the completion part (everything before the first colon) - filtered_completions+="${{line%%:*}} " + local name="${{line%%:*}}" + # Filter by current word + if [[ "$name" == "$word"* ]]; then + suggestions+=("$name") + fi done <<< "$completions" - COMPREPLY=( $(compgen -W "$filtered_completions" -- "$word") ) + COMPREPLY=("${{suggestions[@]}}") }} complete -F _gws_complete gws "# From 4b53399c5304bd8fed72a5faaec46c3b025ae20a Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Mon, 16 Mar 2026 17:02:26 +0530 Subject: [PATCH 5/5] refactor(completion): remove dead code, sanitize descriptions, and filter duplicate flags --- src/completion.rs | 114 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 79 insertions(+), 35 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index 8b19b3a6..1fc411ea 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -141,6 +141,9 @@ set edit:completion:arg-completer[gws] = {{ |@args| /// Recursively find the subcommand matching the current arguments and print available next tokens. pub fn handle_dynamic_completion(cli: &Command, args: &[String]) { let completions = get_completions(cli, args); + // If we have no completions and we are at the top level with no args, + // it might be because get_completions expects at least one arg to suggest anything. + // (Actually get_completions on an empty list should work) for completion in completions { println!("{}", completion); } @@ -151,6 +154,9 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { let mut it = args.iter().peekable(); let mut completions = Vec::new(); + // Track seen flags to avoid duplicates + let mut seen_flags = std::collections::HashSet::new(); + // Skip 'gws' if present if let Some(&first) = it.peek() { if first == "gws" { @@ -161,62 +167,66 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { let mut last_arg = None; let mut active_flag: Option<&clap::Arg> = None; - // Walk the command tree using the provided args + // Walking the command tree... while let Some(arg) = it.next() { let is_last = it.peek().is_none(); if arg.starts_with('-') && arg != "-" && arg != "--" { // This is a flag. We need to check if it takes a value and consume it // to avoid misinterpreting it as a subcommand. - let arg_def = if arg.starts_with("--") { - let name = arg.split('=').next().unwrap()[2..].to_string(); - current_cmd + let mut arg_def = None; + let mut has_equals = false; + + if arg.starts_with("--") { + let parts: Vec<&str> = arg[2..].splitn(2, '=').collect(); + let name = parts[0]; + has_equals = parts.len() > 1; + arg_def = current_cmd .get_arguments() - .find(|a| a.get_long() == Some(&name)) - } else { + .find(|a| a.get_long() == Some(name)); + if let Some(def) = arg_def { + if let Some(long) = def.get_long() { + seen_flags.insert(long.to_string()); + } + } + } else if arg.len() > 1 { // Short flag - handle combined flags like -vF let chars: Vec = arg[1..].chars().collect(); - let mut found = None; for (i, &c) in chars.iter().enumerate() { if let Some(def) = current_cmd .get_arguments() .find(|a| a.get_short() == Some(c)) { + if let Some(short) = def.get_short() { + seen_flags.insert(short.to_string()); + } if def.get_action().takes_values() { // Only the last flag in a group can take a value if i == chars.len() - 1 { - found = Some(def); + arg_def = Some(def); } break; } - found = Some(def); } } - found - }; + } if let Some(def) = arg_def { // If it's a flag that takes a value and isn't in `--key=value` form, // we need to consume the next argument as its value. - if def.get_action().takes_values() && !arg.contains('=') { + if def.get_action().takes_values() && !has_equals { if is_last { // The flag is the last argument and it expects a value. active_flag = Some(def); last_arg = Some(""); break; } - if let Some(val) = it.next() { - if it.peek().is_none() { - // This value is the last arg, so we are completing it! - active_flag = Some(def); - last_arg = Some(val.as_str()); - break; - } - } else { - // The flag was the last arg, but it expects a value. - // We should complete for the value. + // Since is_last is false, it.peek() is Some, so it.next() is guaranteed to be Some. + let val = it.next().unwrap(); + if it.peek().is_none() { + // This value is the last arg, so we are completing it! active_flag = Some(def); - last_arg = Some(""); + last_arg = Some(val.as_str()); break; } } @@ -250,11 +260,11 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { if let Some(pv) = val_parser.possible_values() { for val in pv { if val.get_name().starts_with(filter) { - completions.push(format!( - "{}:{}", - val.get_name(), - val.get_help().map(|h| h.to_string()).unwrap_or_default() - )); + let help = val + .get_help() + .map(|h| h.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); + completions.push(format!("{}:{}", val.get_name(), help)); } } } @@ -265,8 +275,15 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { if let Some(arg_filter) = filter.strip_prefix("--") { for arg in current_cmd.get_arguments() { if let Some(long) = arg.get_long() { - if long.starts_with(arg_filter) { - let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); + let can_repeat = matches!( + arg.get_action(), + clap::ArgAction::Append | clap::ArgAction::Count + ); + if (!seen_flags.contains(long) || can_repeat) && long.starts_with(arg_filter) { + let help = arg + .get_help() + .map(|h| h.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); completions.push(format!("--{}:{}", long, help)); } } @@ -275,8 +292,18 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { } else if let Some(arg_filter) = filter.strip_prefix('-') { for arg in current_cmd.get_arguments() { if let Some(short) = arg.get_short() { - if short.to_string().starts_with(arg_filter) { - let help = arg.get_help().map(|h| h.to_string()).unwrap_or_default(); + let short_str = short.to_string(); + let can_repeat = matches!( + arg.get_action(), + clap::ArgAction::Append | clap::ArgAction::Count + ); + if (!seen_flags.contains(&short_str) || can_repeat) + && short_str.starts_with(arg_filter) + { + let help = arg + .get_help() + .map(|h| h.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); completions.push(format!("-{}:{}", short, help)); } } @@ -288,10 +315,12 @@ fn get_completions(cli: &Command, args: &[String]) -> Vec { for subcmd in current_cmd.get_subcommands() { if !subcmd.is_hide_set() { let name = subcmd.get_name(); - if name.starts_with(filter) { + // Don't suggest the current filter if it's exactly the subcommand name + // (the shell usually handles this, but it doesn't hurt) + if name.starts_with(filter) && name != filter { let about = subcmd .get_about() - .map(|a| a.to_string()) + .map(|a| a.to_string().replace('\n', " ").trim().to_string()) .unwrap_or_default(); completions.push(format!("{}:{}", name, about)); } @@ -374,7 +403,7 @@ mod tests { .long("api-version") .action(clap::ArgAction::Set), ) - .arg(Arg::new("format").long("format").value_parser([ + .arg(Arg::new("format").long("format").short('F').value_parser([ PossibleValue::new("json").help("JSON format"), PossibleValue::new("table").help("Table format"), ])), @@ -462,4 +491,19 @@ mod tests { let completions = get_completions(&cli, &["drive".to_string(), "--f".to_string()]); assert!(completions.iter().any(|c| c.starts_with("--format:"))); } + + #[test] + fn test_get_completions_skip_seen_flags() { + let cli = test_cli(); + // Once --format is used, it should not be suggested again + let completions = get_completions( + &cli, + &["drive".to_string(), "--format".into(), "json".into(), "--".into()], + ); + assert!(!completions.iter().any(|c| c.starts_with("--format:"))); + + // Once -F is used, it should not be suggested again + let completions = get_completions(&cli, &["drive".to_string(), "-F".into(), "json".into(), "-".into()]); + assert!(!completions.iter().any(|c| c.starts_with("-F:"))); + } }