Repository: casey/just
Pull request #572 (2020-01-15 04:34:45)

Add command to generate shell completion scripts
- [x] test that `--completions` is exclusive with other command
- [x] test that bash and BASH are both accepted
- [x] check in generated completions in `extra` or `completions`
- [x] document the `--completions` command in readme


Patch:
diff --git a/README.adoc b/README.adoc
index 1ccb63e6..11cce049 100644
--- a/README.adoc
+++ b/README.adoc
@@ -878,6 +878,16 @@ Tools that pair nicely with `just` include:
 
 For lightning-fast command running, put `alias j=just` in your shell's configuration file.
 
+=== Shell Completion Scripts
+
+Shell completion scripts for Bash, Zsh, Fish, PowerShell, and Elvish are available in the link:completions[] directory. Please refer to your shell's documentation for how to install them.
+
+The `just` binary can also generate the same completion scripts at runtime, using the `--completions` command:
+
+```sh
+$ just --completions zsh > just.zsh
+```
+
 === Syntax Highlighting
 
 `justfile` syntax is close enough to `make` that you may want to tell your editor to use make syntax highlighting for just.
diff --git a/completions/just.bash b/completions/just.bash
new file mode 100644
index 00000000..578eb8b6
--- /dev/null
+++ b/completions/just.bash
@@ -0,0 +1,85 @@
+_just() {
+    local i cur prev opts cmds
+    COMPREPLY=()
+    cur="${COMP_WORDS[COMP_CWORD]}"
+    prev="${COMP_WORDS[COMP_CWORD-1]}"
+    cmd=""
+    opts=""
+
+    for i in ${COMP_WORDS[@]}
+    do
+        case "${i}" in
+            just)
+                cmd="just"
+                ;;
+            
+            *)
+                ;;
+        esac
+    done
+
+    case "${cmd}" in
+        just)
+            opts=" -q -v -e -l -h -V -f -d -s  --dry-run --highlight --no-highlight --quiet --clear-shell-args --verbose --dump --edit --evaluate --init --list --summary --help --version --color --justfile --set --shell --shell-arg --working-directory --completions --show  <ARGUMENTS>... "
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                
+                --color)
+                    COMPREPLY=($(compgen -W "auto always never" -- "${cur}"))
+                    return 0
+                    ;;
+                --justfile)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                    -f)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                --set)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                --shell)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                --shell-arg)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                --working-directory)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                    -d)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                --completions)
+                    COMPREPLY=($(compgen -W "zsh bash fish powershell elvish" -- "${cur}"))
+                    return 0
+                    ;;
+                --show)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                    -s)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        
+    esac
+}
+
+complete -F _just -o bashdefault -o default just
diff --git a/completions/just.elvish b/completions/just.elvish
new file mode 100644
index 00000000..68089cfb
--- /dev/null
+++ b/completions/just.elvish
@@ -0,0 +1,52 @@
+
+edit:completion:arg-completer[just] = [@words]{
+    fn spaces [n]{
+        repeat $n ' ' | joins ''
+    }
+    fn cand [text desc]{
+        edit:complex-candidate $text &display-suffix=' '(spaces (- 14 (wcswidth $text)))$desc
+    }
+    command = 'just'
+    for word $words[1:-1] {
+        if (has-prefix $word '-') {
+            break
+        }
+        command = $command';'$word
+    }
+    completions = [
+        &'just'= {
+            cand --color 'Print colorful output'
+            cand -f 'Use <JUSTFILE> as justfile.'
+            cand --justfile 'Use <JUSTFILE> as justfile.'
+            cand --set 'Override <VARIABLE> with <VALUE>'
+            cand --shell 'Invoke <SHELL> to run recipes'
+            cand --shell-arg 'Invoke shell with <SHELL-ARG> as an argument'
+            cand -d 'Use <WORKING-DIRECTORY> as working directory. --justfile must also be set'
+            cand --working-directory 'Use <WORKING-DIRECTORY> as working directory. --justfile must also be set'
+            cand --completions 'Print shell completion script for <SHELL>'
+            cand -s 'Show information about <RECIPE>'
+            cand --show 'Show information about <RECIPE>'
+            cand --dry-run 'Print what just would do without doing it'
+            cand --highlight 'Highlight echoed recipe lines in bold'
+            cand --no-highlight 'Don''t highlight echoed recipe lines in bold'
+            cand -q 'Suppress all output'
+            cand --quiet 'Suppress all output'
+            cand --clear-shell-args 'Clear shell arguments'
+            cand -v 'Use verbose output'
+            cand --verbose 'Use verbose output'
+            cand --dump 'Print entire justfile'
+            cand -e 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
+            cand --edit 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
+            cand --evaluate 'Print evaluated variables'
+            cand --init 'Initialize new justfile in project root'
+            cand -l 'List available recipes and their arguments'
+            cand --list 'List available recipes and their arguments'
+            cand --summary 'List names of available recipes'
+            cand -h 'Print help information'
+            cand --help 'Print help information'
+            cand -V 'Print version information'
+            cand --version 'Print version information'
+        }
+    ]
+    $completions[$command]
+}
diff --git a/completions/just.fish b/completions/just.fish
new file mode 100644
index 00000000..67548ce5
--- /dev/null
+++ b/completions/just.fish
@@ -0,0 +1,22 @@
+complete -c just -n "__fish_use_subcommand" -l color -d 'Print colorful output' -r -f -a "auto always never"
+complete -c just -n "__fish_use_subcommand" -s f -l justfile -d 'Use <JUSTFILE> as justfile.'
+complete -c just -n "__fish_use_subcommand" -l set -d 'Override <VARIABLE> with <VALUE>'
+complete -c just -n "__fish_use_subcommand" -l shell -d 'Invoke <SHELL> to run recipes'
+complete -c just -n "__fish_use_subcommand" -l shell-arg -d 'Invoke shell with <SHELL-ARG> as an argument'
+complete -c just -n "__fish_use_subcommand" -s d -l working-directory -d 'Use <WORKING-DIRECTORY> as working directory. --justfile must also be set'
+complete -c just -n "__fish_use_subcommand" -l completions -d 'Print shell completion script for <SHELL>' -r -f -a "zsh bash fish powershell elvish"
+complete -c just -n "__fish_use_subcommand" -s s -l show -d 'Show information about <RECIPE>'
+complete -c just -n "__fish_use_subcommand" -l dry-run -d 'Print what just would do without doing it'
+complete -c just -n "__fish_use_subcommand" -l highlight -d 'Highlight echoed recipe lines in bold'
+complete -c just -n "__fish_use_subcommand" -l no-highlight -d 'Don\'t highlight echoed recipe lines in bold'
+complete -c just -n "__fish_use_subcommand" -s q -l quiet -d 'Suppress all output'
+complete -c just -n "__fish_use_subcommand" -l clear-shell-args -d 'Clear shell arguments'
+complete -c just -n "__fish_use_subcommand" -s v -l verbose -d 'Use verbose output'
+complete -c just -n "__fish_use_subcommand" -l dump -d 'Print entire justfile'
+complete -c just -n "__fish_use_subcommand" -s e -l edit -d 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
+complete -c just -n "__fish_use_subcommand" -l evaluate -d 'Print evaluated variables'
+complete -c just -n "__fish_use_subcommand" -l init -d 'Initialize new justfile in project root'
+complete -c just -n "__fish_use_subcommand" -s l -l list -d 'List available recipes and their arguments'
+complete -c just -n "__fish_use_subcommand" -l summary -d 'List names of available recipes'
+complete -c just -n "__fish_use_subcommand" -s h -l help -d 'Print help information'
+complete -c just -n "__fish_use_subcommand" -s V -l version -d 'Print version information'
diff --git a/completions/just.powershell b/completions/just.powershell
new file mode 100644
index 00000000..92f9f8c0
--- /dev/null
+++ b/completions/just.powershell
@@ -0,0 +1,60 @@
+
+using namespace System.Management.Automation
+using namespace System.Management.Automation.Language
+
+Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
+    param($wordToComplete, $commandAst, $cursorPosition)
+
+    $commandElements = $commandAst.CommandElements
+    $command = @(
+        'just'
+        for ($i = 1; $i -lt $commandElements.Count; $i++) {
+            $element = $commandElements[$i]
+            if ($element -isnot [StringConstantExpressionAst] -or
+                $element.StringConstantType -ne [StringConstantType]::BareWord -or
+                $element.Value.StartsWith('-')) {
+                break
+        }
+        $element.Value
+    }) -join ';'
+
+    $completions = @(switch ($command) {
+        'just' {
+            [CompletionResult]::new('--color', 'color', [CompletionResultType]::ParameterName, 'Print colorful output')
+            [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Use <JUSTFILE> as justfile.')
+            [CompletionResult]::new('--justfile', 'justfile', [CompletionResultType]::ParameterName, 'Use <JUSTFILE> as justfile.')
+            [CompletionResult]::new('--set', 'set', [CompletionResultType]::ParameterName, 'Override <VARIABLE> with <VALUE>')
+            [CompletionResult]::new('--shell', 'shell', [CompletionResultType]::ParameterName, 'Invoke <SHELL> to run recipes')
+            [CompletionResult]::new('--shell-arg', 'shell-arg', [CompletionResultType]::ParameterName, 'Invoke shell with <SHELL-ARG> as an argument')
+            [CompletionResult]::new('-d', 'd', [CompletionResultType]::ParameterName, 'Use <WORKING-DIRECTORY> as working directory. --justfile must also be set')
+            [CompletionResult]::new('--working-directory', 'working-directory', [CompletionResultType]::ParameterName, 'Use <WORKING-DIRECTORY> as working directory. --justfile must also be set')
+            [CompletionResult]::new('--completions', 'completions', [CompletionResultType]::ParameterName, 'Print shell completion script for <SHELL>')
+            [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Show information about <RECIPE>')
+            [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show information about <RECIPE>')
+            [CompletionResult]::new('--dry-run', 'dry-run', [CompletionResultType]::ParameterName, 'Print what just would do without doing it')
+            [CompletionResult]::new('--highlight', 'highlight', [CompletionResultType]::ParameterName, 'Highlight echoed recipe lines in bold')
+            [CompletionResult]::new('--no-highlight', 'no-highlight', [CompletionResultType]::ParameterName, 'Don''t highlight echoed recipe lines in bold')
+            [CompletionResult]::new('-q', 'q', [CompletionResultType]::ParameterName, 'Suppress all output')
+            [CompletionResult]::new('--quiet', 'quiet', [CompletionResultType]::ParameterName, 'Suppress all output')
+            [CompletionResult]::new('--clear-shell-args', 'clear-shell-args', [CompletionResultType]::ParameterName, 'Clear shell arguments')
+            [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Use verbose output')
+            [CompletionResult]::new('--verbose', 'verbose', [CompletionResultType]::ParameterName, 'Use verbose output')
+            [CompletionResult]::new('--dump', 'dump', [CompletionResultType]::ParameterName, 'Print entire justfile')
+            [CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`')
+            [CompletionResult]::new('--edit', 'edit', [CompletionResultType]::ParameterName, 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`')
+            [CompletionResult]::new('--evaluate', 'evaluate', [CompletionResultType]::ParameterName, 'Print evaluated variables')
+            [CompletionResult]::new('--init', 'init', [CompletionResultType]::ParameterName, 'Initialize new justfile in project root')
+            [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'List available recipes and their arguments')
+            [CompletionResult]::new('--list', 'list', [CompletionResultType]::ParameterName, 'List available recipes and their arguments')
+            [CompletionResult]::new('--summary', 'summary', [CompletionResultType]::ParameterName, 'List names of available recipes')
+            [CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information')
+            [CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information')
+            [CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information')
+            [CompletionResult]::new('--version', 'version', [CompletionResultType]::ParameterName, 'Print version information')
+            break
+        }
+    })
+
+    $completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
+        Sort-Object -Property ListItemText
+}
diff --git a/completions/just.zsh b/completions/just.zsh
new file mode 100644
index 00000000..7ed88b97
--- /dev/null
+++ b/completions/just.zsh
@@ -0,0 +1,62 @@
+#compdef just
+
+autoload -U is-at-least
+
+_just() {
+    typeset -A opt_args
+    typeset -a _arguments_options
+    local ret=1
+
+    if is-at-least 5.2; then
+        _arguments_options=(-s -S -C)
+    else
+        _arguments_options=(-s -C)
+    fi
+
+    local context curcontext="$curcontext" state line
+    _arguments "${_arguments_options[@]}" \
+'--color=[Print colorful output]: :(auto always never)' \
+'-f+[Use <JUSTFILE> as justfile.]' \
+'--justfile=[Use <JUSTFILE> as justfile.]' \
+'*--set=[Override <VARIABLE> with <VALUE>]' \
+'--shell=[Invoke <SHELL> to run recipes]' \
+'*--shell-arg=[Invoke shell with <SHELL-ARG> as an argument]' \
+'-d+[Use <WORKING-DIRECTORY> as working directory. --justfile must also be set]' \
+'--working-directory=[Use <WORKING-DIRECTORY> as working directory. --justfile must also be set]' \
+'--completions=[Print shell completion script for <SHELL>]: :(zsh bash fish powershell elvish)' \
+'-s+[Show information about <RECIPE>]' \
+'--show=[Show information about <RECIPE>]' \
+'(-q --quiet)--dry-run[Print what just would do without doing it]' \
+'--highlight[Highlight echoed recipe lines in bold]' \
+'--no-highlight[Don'\''t highlight echoed recipe lines in bold]' \
+'(--dry-run)-q[Suppress all output]' \
+'(--dry-run)--quiet[Suppress all output]' \
+'--clear-shell-args[Clear shell arguments]' \
+'*-v[Use verbose output]' \
+'*--verbose[Use verbose output]' \
+'--dump[Print entire justfile]' \
+'-e[Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`]' \
+'--edit[Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`]' \
+'--evaluate[Print evaluated variables]' \
+'--init[Initialize new justfile in project root]' \
+'-l[List available recipes and their arguments]' \
+'--list[List available recipes and their arguments]' \
+'--summary[List names of available recipes]' \
+'-h[Print help information]' \
+'--help[Print help information]' \
+'-V[Print version information]' \
+'--version[Print version information]' \
+'::ARGUMENTS -- Overrides and recipe(s) to run, defaulting to the first recipe in the justfile:_files' \
+&& ret=0
+    
+}
+
+(( $+functions[_just_commands] )) ||
+_just_commands() {
+    local commands; commands=(
+        
+    )
+    _describe -t commands 'just commands' commands "$@"
+}
+
+_just "$@"
\ No newline at end of file
diff --git a/src/config.rs b/src/config.rs
index 3510e4a8..0803bea9 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches};
+use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches, ArgSettings};
 use unicode_width::UnicodeWidthStr;
 
 pub(crate) const DEFAULT_SHELL: &str = "sh";
@@ -30,9 +30,10 @@ mod cmd {
   pub(crate) const LIST: &str = "LIST";
   pub(crate) const SHOW: &str = "SHOW";
   pub(crate) const SUMMARY: &str = "SUMMARY";
+  pub(crate) const COMPLETIONS: &str = "COMPLETIONS";
 
-  pub(crate) const ALL: &[&str] = &[DUMP, EDIT, INIT, EVALUATE, LIST, SHOW, SUMMARY];
-  pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, INIT, LIST, SHOW, SUMMARY];
+  pub(crate) const ALL: &[&str] = &[COMPLETIONS, DUMP, EDIT, INIT, EVALUATE, LIST, SHOW, SUMMARY];
+  pub(crate) const ARGLESS: &[&str] = &[COMPLETIONS, DUMP, EDIT, INIT, LIST, SHOW, SUMMARY];
 }
 
 mod arg {
@@ -156,6 +157,15 @@ impl Config {
           .multiple(true)
           .help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
       )
+      .arg(
+        Arg::with_name(cmd::COMPLETIONS)
+          .long("completions")
+          .takes_value(true)
+          .value_name("SHELL")
+          .possible_values(&clap::Shell::variants())
+          .set(ArgSettings::CaseInsensitive)
+          .help("Print shell completion script for <SHELL>"),
+      )
       .arg(
         Arg::with_name(cmd::DUMP)
           .long("dump")
@@ -311,7 +321,11 @@ impl Config {
       }
     }
 
-    let subcommand = if matches.is_present(cmd::EDIT) {
+    let subcommand = if let Some(shell) = matches.value_of(cmd::COMPLETIONS) {
+      Subcommand::Completions {
+        shell: shell.to_owned(),
+      }
+    } else if matches.is_present(cmd::EDIT) {
       Subcommand::Edit
     } else if matches.is_present(cmd::SUMMARY) {
       Subcommand::Summary
@@ -402,6 +416,7 @@ impl Config {
 
     match &self.subcommand {
       Dump => self.dump(justfile),
+      Completions { shell } => Self::completions(&shell),
       Evaluate { overrides } => self.run(justfile, &search, overrides, &Vec::new()),
       Run {
         arguments,
@@ -414,6 +429,16 @@ impl Config {
     }
   }
 
+  fn completions(shell: &str) -> Result<(), i32> {
+    let shell = shell
+      .parse::<clap::Shell>()
+      .expect("Invalid value for clap::Shell");
+
+    Self::app().gen_completions_to(env!("CARGO_PKG_NAME"), shell, &mut io::stdout());
+
+    Ok(())
+  }
+
   fn dump(&self, justfile: Justfile) -> Result<(), i32> {
     println!("{}", justfile);
     Ok(())
@@ -648,6 +673,9 @@ OPTIONS:
         --color <COLOR>
             Print colorful output [default: auto]  [possible values: auto, always, never]
 
+        --completions <SHELL>
+            Print shell completion script for <SHELL> [possible values: zsh, bash, fish, powershell, elvish]
+
     -f, --justfile <JUSTFILE>                      Use <JUSTFILE> as justfile.
         --set <VARIABLE> <VALUE>                   Override <VARIABLE> with <VALUE>
         --shell <SHELL>                            Invoke <SHELL> to run recipes [default: sh]
@@ -988,6 +1016,23 @@ ARGS:
     },
   }
 
+  test! {
+    name: subcommand_completions,
+    args: ["--completions", "bash"],
+    subcommand: Subcommand::Completions{shell: "bash".to_owned()},
+  }
+
+  test! {
+    name: subcommand_completions_uppercase,
+    args: ["--completions", "BASH"],
+    subcommand: Subcommand::Completions{shell: "BASH".to_owned()},
+  }
+
+  error! {
+    name: subcommand_completions_invalid,
+    args: ["--completions", "monstersh"],
+  }
+
   test! {
     name: subcommand_dump,
     args: ["--dump"],
@@ -1236,6 +1281,16 @@ ARGS:
     error: ConfigError::SearchDirConflict,
   }
 
+  error! {
+    name: completions_arguments,
+    args: ["--completions", "zsh", "foo"],
+    error: ConfigError::SubcommandArguments { subcommand, arguments },
+    check: {
+      assert_eq!(subcommand, "--completions");
+      assert_eq!(arguments, &["foo"]);
+    },
+  }
+
   error! {
     name: list_arguments,
     args: ["--list", "bar"],
diff --git a/src/search.rs b/src/search.rs
index 1625cabb..70c7e6bf 100644
--- a/src/search.rs
+++ b/src/search.rs
@@ -130,6 +130,7 @@ impl Search {
           }
         }
       }
+
       if candidates.len() == 1 {
         return Ok(candidates.pop().unwrap());
       } else if candidates.len() > 1 {
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 9147ccef..664842e3 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -2,6 +2,9 @@ use crate::common::*;
 
 #[derive(PartialEq, Clone, Debug)]
 pub(crate) enum Subcommand {
+  Completions {
+    shell: String,
+  },
   Dump,
   Edit,
   Evaluate {


Test patch:
diff --git a/tests/completions.rs b/tests/completions.rs
new file mode 100644
index 00000000..0121e1d5
--- /dev/null
+++ b/tests/completions.rs
@@ -0,0 +1,18 @@
+use std::process::Command;
+
+use executable_path::executable_path;
+
+#[test]
+fn output() {
+  let output = Command::new(executable_path("just"))
+    .arg("--completions")
+    .arg("bash")
+    .output()
+    .unwrap();
+
+  assert!(output.status.success());
+
+  let text = String::from_utf8_lossy(&output.stdout);
+
+  assert!(text.starts_with("_just() {"));
+}