diff --git a/.github/scripts/generate_formula.sh b/.github/scripts/generate_formula.sh index d917e50..52f46b8 100755 --- a/.github/scripts/generate_formula.sh +++ b/.github/scripts/generate_formula.sh @@ -25,6 +25,7 @@ class Why < Formula def install system "cargo", "install", "--locked", *std_cargo_args + generate_completions_from_executable(bin/"why", "--completion") end test do diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b31e1d2..bd76f4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,3 +71,6 @@ jobs: - name: Build Nix Package run: nix build + + - name: Run Nix Checks + run: nix flake check diff --git a/README.md b/README.md index 9ab9429..a925ff8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,25 @@ Simply run `why` followed by the command name. why ``` +### Shell Completion + +`why` can print completion scripts for bash, zsh, and fish: + +```bash +why --completion bash +why --completion zsh +why --completion fish +``` + +The generated completion delegates `why ` and `why l` to the shell's +native command completion instead of invoking `why` on every tab press. + +For one-off bash usage: + +```bash +source <(why --completion bash) +``` + ## Examples ### 1. Version Managers (e.g., Mise, Volta) diff --git a/completions/_why b/completions/_why new file mode 100644 index 0000000..8bc731e --- /dev/null +++ b/completions/_why @@ -0,0 +1,11 @@ +#compdef why + +_why() { + _arguments \ + '--completion[print a shell completion script]:shell:(bash zsh fish)' \ + '--help[show help]' \ + '--version[show version]' \ + '1:command:_path_commands' +} + +_why "$@" diff --git a/completions/why.bash b/completions/why.bash new file mode 100644 index 0000000..5f8b92f --- /dev/null +++ b/completions/why.bash @@ -0,0 +1,24 @@ +_why() { + local cur prev + COMPREPLY=() + + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + if [[ "$prev" == "--completion" ]]; then + COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur")) + return 0 + fi + + if [[ "$cur" == --* ]]; then + COMPREPLY=($(compgen -W "--completion --help --version" -- "$cur")) + return 0 + fi + + if (( COMP_CWORD == 1 )); then + COMPREPLY=($(compgen -c -- "$cur")) + return 0 + fi +} + +complete -F _why why diff --git a/completions/why.fish b/completions/why.fish new file mode 100644 index 0000000..e38330d --- /dev/null +++ b/completions/why.fish @@ -0,0 +1,18 @@ +function __why_needs_command + set -l tokens (commandline -opc) + + for token in $tokens + switch $token + case --completion --help --version + return 1 + end + end + + test (count $tokens) -eq 1 +end + +complete -c why -f +complete -c why -l completion -x -a "bash zsh fish" -d "Print a shell completion script" +complete -c why -l help -d "Show help" +complete -c why -l version -d "Show version" +complete -c why -n "__why_needs_command" -a "(__fish_complete_command)" -d "Command" diff --git a/flake.lock b/flake.lock index cd56566..be3e6f9 100644 --- a/flake.lock +++ b/flake.lock @@ -18,6 +18,27 @@ "type": "github" } }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1779726825, + "narHash": "sha256-RUkMrREjKDQrA+dA9+xZviGAxM5W1aVdyOr/bSYpHrE=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "b179bde238977f7d4454fc770b1a727eaf55111c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-26.05", + "repo": "home-manager", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1778443072, @@ -37,6 +58,7 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "home-manager": "home-manager", "nixpkgs": "nixpkgs" } }, diff --git a/flake.nix b/flake.nix index 8390693..ad4bd32 100644 --- a/flake.nix +++ b/flake.nix @@ -3,11 +3,20 @@ inputs = { flake-utils.url = "github:numtide/flake-utils"; + home-manager = { + url = "github:nix-community/home-manager/release-26.05"; + inputs.nixpkgs.follows = "nixpkgs"; + }; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; }; outputs = - { self, flake-utils, nixpkgs }: + { + self, + flake-utils, + home-manager, + nixpkgs, + }: flake-utils.lib.eachDefaultSystem ( system: let @@ -16,6 +25,21 @@ }; why-cli = pkgs.callPackage ./nix/package.nix { }; + homeManagerCompletionConfig = home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [ + self.homeManagerModules.default + { + home.username = "why-e2e"; + home.homeDirectory = "/tmp/why-e2e-home"; + home.stateVersion = "26.05"; + + programs.why.enable = true; + programs.zsh.enable = true; + programs.fish.enable = true; + } + ]; + }; in { packages = { @@ -28,6 +52,10 @@ program = "${why-cli}/bin/why"; meta.description = "Run why"; }; + + checks.home-manager-completions = pkgs.callPackage ./tests/e2e/home-manager/completions.nix { + homePath = homeManagerCompletionConfig.config.home.path; + }; } ) // { diff --git a/nix/package.nix b/nix/package.nix index 9c1ac24..33628c5 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,5 +1,6 @@ { lib, + installShellFiles, rustPlatform, }: @@ -10,6 +11,17 @@ rustPlatform.buildRustPackage { src = lib.cleanSource ../.; cargoLock.lockFile = ../Cargo.lock; + nativeBuildInputs = [ + installShellFiles + ]; + + postInstall = '' + installShellCompletion --cmd why \ + --bash completions/why.bash \ + --fish completions/why.fish \ + --zsh completions/_why + ''; + meta = { description = "Tells you why a command is installed on your system"; homepage = "https://github.com/akriaueno/why-cli"; diff --git a/src/main.rs b/src/main.rs index 696780f..7a41e08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::Path; use std::process::{Command, ExitCode}; -use clap::Parser; +use clap::{Parser, ValueEnum, ValueHint}; use crate::core::{DirEntryKind, ExecResult, WhyCtx, why_core}; @@ -16,8 +16,20 @@ use crate::core::{DirEntryKind, ExecResult, WhyCtx, why_core}; about = "Identify why a command is installed on your system" )] struct Args { + /// Print a shell completion script. + #[arg(long, value_enum, value_name = "SHELL", conflicts_with = "command")] + completion: Option, + /// The command to investigate, for example 'node' or 'ls'. - command: String, + #[arg(value_hint = ValueHint::CommandName, required_unless_present = "completion")] + command: Option, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +enum CompletionShell { + Bash, + Zsh, + Fish, } struct DefaultCtx; @@ -126,9 +138,18 @@ impl WhyCtx for DefaultCtx { fn main() -> ExitCode { let args = Args::parse(); + if let Some(shell) = args.completion { + print!("{}", completion_script(shell)); + return ExitCode::SUCCESS; + } + + let Some(command) = args.command else { + return ExitCode::from(2); + }; + let ctx = DefaultCtx; - match why_core(&args.command, &ctx) { + match why_core(&command, &ctx) { Ok(result) => { if !result.hint.is_empty() { println!("{}", result.hint); @@ -145,3 +166,45 @@ fn main() -> ExitCode { } } } + +fn completion_script(shell: CompletionShell) -> &'static str { + match shell { + CompletionShell::Bash => BASH_COMPLETION, + CompletionShell::Zsh => ZSH_COMPLETION, + CompletionShell::Fish => FISH_COMPLETION, + } +} + +const BASH_COMPLETION: &str = include_str!("../completions/why.bash"); +const ZSH_COMPLETION: &str = include_str!("../completions/_why"); +const FISH_COMPLETION: &str = include_str!("../completions/why.fish"); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_completion_without_command() { + let args = Args::try_parse_from(["why", "--completion", "bash"]).unwrap(); + + assert_eq!(args.completion, Some(CompletionShell::Bash)); + assert_eq!(args.command, None); + } + + #[test] + fn requires_command_without_completion() { + assert!(Args::try_parse_from(["why"]).is_err()); + } + + #[test] + fn completion_conflicts_with_command() { + assert!(Args::try_parse_from(["why", "--completion", "bash", "ls"]).is_err()); + } + + #[test] + fn completion_scripts_use_shell_native_command_completion() { + assert!(completion_script(CompletionShell::Bash).contains("compgen -c")); + assert!(completion_script(CompletionShell::Zsh).contains("_path_commands")); + assert!(completion_script(CompletionShell::Fish).contains("__fish_complete_command")); + } +} diff --git a/tests/e2e/home-manager/completions.nix b/tests/e2e/home-manager/completions.nix new file mode 100644 index 0000000..482e48f --- /dev/null +++ b/tests/e2e/home-manager/completions.nix @@ -0,0 +1,62 @@ +{ + bash, + homePath, + runCommand, +}: + +runCommand "why-home-manager-completions-e2e" + { + nativeBuildInputs = [ + bash + homePath + ]; + } + '' + set -eu + + test -x "${homePath}/bin/why" + test -x "${homePath}/bin/zsh" + test -x "${homePath}/bin/fish" + + test -f "${homePath}/share/bash-completion/completions/why.bash" + test -f "${homePath}/share/zsh/site-functions/_why" + test -f "${homePath}/share/fish/vendor_completions.d/why.fish" + + "${homePath}/bin/why" --version | grep -E '^why [0-9]+\.[0-9]+\.[0-9]+' + + BASH_COMPLETION_FILE="${homePath}/share/bash-completion/completions/why.bash" \ + PATH="${homePath}/bin:$PATH" \ + bash --noprofile --norc > "$TMPDIR/bash.out" <<'BASH' + set -euo pipefail + source "$BASH_COMPLETION_FILE" + COMP_WORDS=(why l) + COMP_CWORD=1 + _why + printf '%s\n' "''${COMPREPLY[@]}" +BASH + grep -Fx "ls" "$TMPDIR/bash.out" + + ZSH_COMPLETION_DIR="${homePath}/share/zsh/site-functions" \ + PATH="${homePath}/bin:$PATH" \ + zsh -f > "$TMPDIR/zsh.out" <<'ZSH' + set -e + fpath=("$ZSH_COMPLETION_DIR" $fpath) + autoload -Uz compinit + compinit -D + test "$_comps[why]" = "_why" + autoload -Uz _why + autoload +X _why + functions _why +ZSH + grep -F "_path_commands" "$TMPDIR/zsh.out" + + FISH_COMPLETION_FILE="${homePath}/share/fish/vendor_completions.d/why.fish" \ + PATH="${homePath}/bin:$PATH" \ + fish --no-config > "$TMPDIR/fish.out" <<'FISH' + source "$FISH_COMPLETION_FILE" + complete -C "why l" +FISH + cut -f1 "$TMPDIR/fish.out" | grep -Fx "ls" + + touch "$out" + ''