From 03cd85d574828d4a115730b9a338f9c1493b9d9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:50:50 +0000 Subject: [PATCH] Add `completion` subcommand for shell completion support Add a `ccase completion ` subcommand using `clap_complete` to generate shell completion scripts for bash, elvish, fish, powershell, and zsh. - Add `clap_complete` v4 dependency - Add `completion` subcommand to the CLI (displayed last before help) - Handle completion generation in main, writing to stdout - Add integration tests for all supported shells and error cases - Document the subcommand in README Co-authored-by: salim-b <20040931+salim-b@users.noreply.github.com> --- Cargo.lock | 10 ++++++++++ Cargo.toml | 1 + README.md | 10 ++++++++++ src/app.rs | 28 +++++++++++++++++++++++++- src/lib.rs | 6 ++++-- src/main.rs | 11 +++++++++++ tests/cli.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 115 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a246b6..ea84524 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,7 @@ version = "0.5.1" dependencies = [ "assert_cmd", "clap", + "clap_complete", "convert_case", "convert_case_extras", "predicates 2.1.5", @@ -139,6 +140,15 @@ dependencies = [ "terminal_size", ] +[[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" diff --git a/Cargo.toml b/Cargo.toml index 89b9019..0c37ed8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" convert_case = "0.11" convert_case_extras = { version = "0.2", features = ["random"] } clap = { version = "^4.0", features = ["cargo", "color", "wrap_help", "derive"] } +clap_complete = "4" [dev-dependencies] predicates = "2.1.1" diff --git a/README.md b/README.md index 864aac1..ebd4380 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,16 @@ $ ccase -p sentence -d __ my-var-name My__var__name ``` +### `completion ` + +Generate shell completion scripts. Supported shells: `bash`, `elvish`, `fish`, `powershell`, `zsh`. + +```sh +$ ccase completion bash > ~/.local/share/bash-completion/completions/ccase +$ ccase completion zsh > ~/.zfunc/_ccase +$ ccase completion fish > ~/.config/fish/completions/ccase.fish +``` + # How Case Conversion Works A _case_ can be defined as a _pattern_ joined with a _delimeter_. Turning a list of words into a certain case happens in two steps. First, each word is transformed by the pattern. Then the words are joined together with the delimeter. diff --git a/src/app.rs b/src/app.rs index bb3b760..b65c1e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crate::{CaseOption, PatternOption}; use clap::builder::{EnumValueParser, StyledStr}; use clap::{crate_version, Arg, ArgAction, Command, ValueEnum}; +use clap_complete::Shell; pub fn build() -> Command { Command::new("ccase") @@ -8,12 +9,32 @@ pub fn build() -> Command { .about("Convert between string cases.\nhttps://github.com/stringcase/ccase") .arg_required_else_help(true) .args(args::all()) + .subcommand(completion_subcommand()) + .subcommand_negates_reqs(true) + .args_conflicts_with_subcommands(true) .override_usage(usage()) .max_term_width(90) .after_help(after_help()) .after_long_help(after_long_help()) } +fn completion_subcommand() -> Command { + Command::new("completion") + .about("Generate shell completion scripts") + .long_about( + "Generate shell completion scripts for the specified shell.\n\ + The output is written to stdout and can be redirected to a file \ + or sourced directly.", + ) + .display_order(900) + .arg( + Arg::new("shell") + .help("The shell to generate completions for") + .required(true) + .value_parser(EnumValueParser::::new()), + ) +} + fn usage() -> StyledStr { StyledStr::from( "\x1b[1mccase --to\x1b[0m ...\n \ @@ -62,7 +83,12 @@ fn list_patterns() -> String { let possible_value = pattern_opt.to_possible_value().unwrap(); let name = possible_value.get_name(); let underline_pattern = format!("\x1b[1m{}\x1b[0m", name); - s = format!("{}{:>25} {}\n", s, underline_pattern, pattern_opt.example()) + s = format!( + "{}{:>25} {}\n", + s, + underline_pattern, + pattern_opt.example() + ) } s } diff --git a/src/lib.rs b/src/lib.rs index 593a9cf..9a78f22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,10 @@ pub use app::build as build_app; use clap::ValueEnum; use convert_case::{Case, Casing, Pattern}; -use convert_case_extras::case::{ALTERNATING as ALTERNATING_CASE, PSEUDO_RANDOM as PSEUDO_RANDOM_CASE, RANDOM as RANDOM_CASE, TOGGLE as TOGGLE_CASE}; +use convert_case_extras::case::{ + ALTERNATING as ALTERNATING_CASE, PSEUDO_RANDOM as PSEUDO_RANDOM_CASE, RANDOM as RANDOM_CASE, + TOGGLE as TOGGLE_CASE, +}; use convert_case_extras::pattern::{ALTERNATING, PSEUDO_RANDOM, RANDOM, TOGGLE}; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] @@ -105,4 +108,3 @@ impl PatternOption { } } } - diff --git a/src/main.rs b/src/main.rs index 66ab258..31d13a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use ccase::{CaseOption, PatternOption}; use clap::ArgMatches; +use clap_complete::Shell; use convert_case::{Boundary, Converter}; use std::io::{self, IsTerminal, Read}; @@ -7,6 +8,16 @@ fn main() { let app = ccase::build_app(); let matches = app.get_matches(); + if let Some(completion_matches) = matches.subcommand_matches("completion") { + let shell = completion_matches + .get_one::("shell") + .copied() + .unwrap(); + let mut app = ccase::build_app(); + clap_complete::generate(shell, &mut app, "ccase", &mut io::stdout()); + return; + } + let inputs: Vec = match matches.get_many::("input") { Some(inputs) => inputs.cloned().collect(), None => { diff --git a/tests/cli.rs b/tests/cli.rs index 6bd6121..26e7c1e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -75,9 +75,7 @@ fn delimeter() { fn input_required_tty() { // When stdin is a TTY and no input provided, should show error. // This can only be verified manually: `ccase -t snake` - ccase(&["-t", "snake"]) - .failure() - .stderr(contains("input")); + ccase(&["-t", "snake"]).failure().stderr(contains("input")); } #[test] @@ -140,6 +138,57 @@ fn multiple_inputs() { .stdout("my_var_name\nanother_multi_word_token\n"); } +mod completion { + use super::*; + + #[test] + fn generates_bash_completions() { + ccase(&["completion", "bash"]) + .success() + .stdout(contains("_ccase")); + } + + #[test] + fn generates_zsh_completions() { + ccase(&["completion", "zsh"]) + .success() + .stdout(contains("#compdef ccase")); + } + + #[test] + fn generates_fish_completions() { + ccase(&["completion", "fish"]) + .success() + .stdout(contains("complete -c ccase")); + } + + #[test] + fn generates_powershell_completions() { + ccase(&["completion", "powershell"]) + .success() + .stdout(contains("ccase")); + } + + #[test] + fn generates_elvish_completions() { + ccase(&["completion", "elvish"]) + .success() + .stdout(contains("ccase")); + } + + #[test] + fn missing_shell_arg() { + ccase(&["completion"]).failure().stderr(contains("")); + } + + #[test] + fn invalid_shell() { + ccase(&["completion", "nushell"]) + .failure() + .stderr(contains("invalid value")); + } +} + mod stdin { use super::*;