Repository: casey/just
Pull request #2140 (2024-06-08 21:52:30)

Allow printing nu completion script with `just --completions nushell`


Patch:
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 4e58e6f3..7905090c 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -77,7 +77,8 @@ jobs:
       run: |
         set -euxo pipefail
         cargo build
-        for shell in bash elvish fish powershell zsh; do
+        mkdir -p completions
+        for shell in bash elvish fish nu powershell zsh; do
           ./target/debug/just --completions $shell > completions/just.$shell
         done
         mkdir -p man
diff --git a/Cargo.lock b/Cargo.lock
index dd55d16e..86be2a78 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -226,6 +226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
 dependencies = [
  "clap_builder",
+ "clap_derive",
 ]
 
 [[package]]
@@ -250,6 +251,18 @@ dependencies = [
  "clap 4.5.4",
 ]
 
+[[package]]
+name = "clap_derive"
+version = "4.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.66",
+]
+
 [[package]]
 name = "clap_lex"
 version = "0.7.0"
diff --git a/Cargo.toml b/Cargo.toml
index b5538750..f497cc82 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,7 +22,7 @@ ansi_term = "0.12.0"
 blake3 = { version = "1.5.0", features = ["rayon", "mmap"] }
 camino = "1.0.4"
 chrono = "0.4.38"
-clap = { version = "4.0.0", features = ["env", "wrap_help"] }
+clap = { version = "4.0.0", features = ["derive", "env", "wrap_help"] }
 clap_complete = "4.0.0"
 clap_mangen = "0.2.20"
 ctrlc = { version = "3.1.1", features = ["termination"] }
diff --git a/completions/just.nu b/completions/just.nu
deleted file mode 100644
index 82b72665..00000000
--- a/completions/just.nu
+++ /dev/null
@@ -1,8 +0,0 @@
-def "nu-complete just" [] {
-    (^just --dump --unstable --dump-format json | from json).recipes | transpose recipe data | flatten | where {|row| $row.private == false } | select recipe doc parameters | rename value description
-}
-
-# Just: A Command Runner
-export extern "just" [
-    ...recipe: string@"nu-complete just", # Recipe(s) to run, may be with argument(s)
-]
diff --git a/justfile b/justfile
index e711cf94..d7f67252 100755
--- a/justfile
+++ b/justfile
@@ -165,9 +165,6 @@ watch-readme:
   just render-readme
   fswatch -ro README.adoc | xargs -n1 -I{} just render-readme
 
-update-completions:
-  ./bin/update-completions
-
 test-completions:
   ./tests/completions/just.bash
 
diff --git a/src/completions.rs b/src/completions.rs
index 23f2902f..193700d9 100644
--- a/src/completions.rs
+++ b/src/completions.rs
@@ -1,4 +1,99 @@
-pub(crate) const FISH_RECIPE_COMPLETIONS: &str = r#"function __fish_just_complete_recipes
+use {super::*, clap::ValueEnum};
+
+#[derive(ValueEnum, Debug, Clone, Copy, PartialEq)]
+pub(crate) enum Shell {
+  Bash,
+  Elvish,
+  Fish,
+  #[value(alias = "nu")]
+  Nushell,
+  Powershell,
+  Zsh,
+}
+
+impl Shell {
+  pub(crate) fn script(self) -> RunResult<'static, String> {
+    match self {
+      Self::Bash => completions::clap(clap_complete::Shell::Bash),
+      Self::Elvish => completions::clap(clap_complete::Shell::Elvish),
+      Self::Fish => completions::clap(clap_complete::Shell::Fish),
+      Self::Nushell => Ok(completions::NUSHELL_COMPLETION_SCRIPT.into()),
+      Self::Powershell => completions::clap(clap_complete::Shell::PowerShell),
+      Self::Zsh => completions::clap(clap_complete::Shell::Zsh),
+    }
+  }
+}
+
+fn clap(shell: clap_complete::Shell) -> RunResult<'static, String> {
+  fn replace(haystack: &mut String, needle: &str, replacement: &str) -> RunResult<'static, ()> {
+    if let Some(index) = haystack.find(needle) {
+      haystack.replace_range(index..index + needle.len(), replacement);
+      Ok(())
+    } else {
+      Err(Error::internal(format!(
+        "Failed to find text:\n{needle}\n…in completion script:\n{haystack}"
+      )))
+    }
+  }
+
+  let mut script = {
+    let mut tempfile = tempfile().map_err(|io_error| Error::TempfileIo { io_error })?;
+
+    clap_complete::generate(
+      shell,
+      &mut crate::config::Config::app(),
+      env!("CARGO_PKG_NAME"),
+      &mut tempfile,
+    );
+
+    tempfile
+      .rewind()
+      .map_err(|io_error| Error::TempfileIo { io_error })?;
+
+    let mut buffer = String::new();
+
+    tempfile
+      .read_to_string(&mut buffer)
+      .map_err(|io_error| Error::TempfileIo { io_error })?;
+
+    buffer
+  };
+
+  match shell {
+    clap_complete::Shell::Bash => {
+      for (needle, replacement) in completions::BASH_COMPLETION_REPLACEMENTS {
+        replace(&mut script, needle, replacement)?;
+      }
+    }
+    clap_complete::Shell::Fish => {
+      script.insert_str(0, completions::FISH_RECIPE_COMPLETIONS);
+    }
+    clap_complete::Shell::PowerShell => {
+      for (needle, replacement) in completions::POWERSHELL_COMPLETION_REPLACEMENTS {
+        replace(&mut script, needle, replacement)?;
+      }
+    }
+    clap_complete::Shell::Zsh => {
+      for (needle, replacement) in completions::ZSH_COMPLETION_REPLACEMENTS {
+        replace(&mut script, needle, replacement)?;
+      }
+    }
+    _ => {}
+  }
+
+  Ok(script.trim().into())
+}
+
+const NUSHELL_COMPLETION_SCRIPT: &str = r#"def "nu-complete just" [] {
+    (^just --dump --unstable --dump-format json | from json).recipes | transpose recipe data | flatten | where {|row| $row.private == false } | select recipe doc parameters | rename value description
+}
+
+# Just: A Command Runner
+export extern "just" [
+    ...recipe: string@"nu-complete just", # Recipe(s) to run, may be with argument(s)
+]"#;
+
+const FISH_RECIPE_COMPLETIONS: &str = r#"function __fish_just_complete_recipes
         just --list 2> /dev/null | tail -n +2 | awk '{
         command = $1;
         args = $0;
@@ -37,7 +132,7 @@ complete -c just -a '(__fish_just_complete_recipes)'
 # autogenerated completions
 "#;
 
-pub(crate) const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[
+const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[
   (
     r#"    _arguments "${_arguments_options[@]}" \"#,
     r"    local common=(",
@@ -151,7 +246,7 @@ _just "$@""#,
   ),
 ];
 
-pub(crate) const POWERSHELL_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[(
+const POWERSHELL_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[(
   r#"$completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
         Sort-Object -Property ListItemText"#,
   r#"function Get-JustFileRecipes([string[]]$CommandElements) {
@@ -178,7 +273,7 @@ pub(crate) const POWERSHELL_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[(
         Sort-Object -Property ListItemText"#,
 )];
 
-pub(crate) const BASH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[
+const BASH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[
   (
     r#"            if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
                 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
diff --git a/src/config.rs b/src/config.rs
index 6c19c700..d2056d5d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -381,10 +381,9 @@ impl Config {
       .arg(
         Arg::new(cmd::COMPLETIONS)
           .long("completions")
-          .action(ArgAction::Append)
-          .num_args(1..)
+          .action(ArgAction::Set)
           .value_name("SHELL")
-          .value_parser(value_parser!(clap_complete::Shell))
+          .value_parser(value_parser!(completions::Shell))
           .ignore_case(true)
           .help("Print shell completion script for <SHELL>"),
       )
@@ -686,7 +685,7 @@ impl Config {
         arguments,
         overrides,
       }
-    } else if let Some(&shell) = matches.get_one::<clap_complete::Shell>(cmd::COMPLETIONS) {
+    } else if let Some(&shell) = matches.get_one::<completions::Shell>(cmd::COMPLETIONS) {
       Subcommand::Completions { shell }
     } else if matches.get_flag(cmd::EDIT) {
       Subcommand::Edit
@@ -1255,13 +1254,13 @@ mod tests {
   test! {
     name: subcommand_completions,
     args: ["--completions", "bash"],
-    subcommand: Subcommand::Completions{ shell: clap_complete::Shell::Bash },
+    subcommand: Subcommand::Completions{ shell: completions::Shell::Bash },
   }
 
   test! {
     name: subcommand_completions_uppercase,
     args: ["--completions", "BASH"],
-    subcommand: Subcommand::Completions{ shell: clap_complete::Shell::Bash },
+    subcommand: Subcommand::Completions{ shell: completions::Shell::Bash },
   }
 
   error! {
@@ -1544,15 +1543,30 @@ mod tests {
   }
 
   error_matches! {
-    name: completions_arguments,
-    args: ["--completions", "zsh", "foo"],
+    name: completions_argument,
+    args: ["--completions", "foo"],
     error: error,
     check: {
       assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue);
       assert_eq!(error.context().collect::<Vec<_>>(), vec![
-        (ContextKind::InvalidArg, &ContextValue::String("--completions <SHELL>...".into())),
-        (ContextKind::InvalidValue, &ContextValue::String("foo".into())),
-        (ContextKind::ValidValue, &ContextValue::Strings(["bash".into(), "elvish".into(), "fish".into(), "powershell".into(), "zsh".into()].into())),
+        (
+          ContextKind::InvalidArg,
+          &ContextValue::String("--completions <SHELL>".into())),
+        (
+          ContextKind::InvalidValue,
+          &ContextValue::String("foo".into()),
+        ),
+        (
+          ContextKind::ValidValue,
+          &ContextValue::Strings([
+            "bash".into(),
+            "elvish".into(),
+            "fish".into(),
+            "nushell".into(),
+            "powershell".into(),
+            "zsh".into()].into()
+          ),
+        ),
       ]);
     },
   }
diff --git a/src/lib.rs b/src/lib.rs
index b0faaf02..a315a841 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -39,6 +39,18 @@ pub(crate) use {
     unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,
     verbosity::Verbosity, warning::Warning,
   },
+  camino::Utf8Path,
+  derivative::Derivative,
+  edit_distance::edit_distance,
+  lexiclean::Lexiclean,
+  libc::EXIT_FAILURE,
+  log::{info, warn},
+  regex::Regex,
+  serde::{
+    ser::{SerializeMap, SerializeSeq},
+    Serialize, Serializer,
+  },
+  snafu::{ResultExt, Snafu},
   std::{
     borrow::Cow,
     cmp,
@@ -47,7 +59,7 @@ pub(crate) use {
     ffi::OsString,
     fmt::{self, Debug, Display, Formatter},
     fs,
-    io::{self, Write},
+    io::{self, Read, Seek, Write},
     iter::{self, FromIterator},
     mem,
     ops::Deref,
@@ -59,23 +71,10 @@ pub(crate) use {
     sync::{Mutex, MutexGuard, OnceLock},
     vec,
   },
-  {
-    camino::Utf8Path,
-    derivative::Derivative,
-    edit_distance::edit_distance,
-    lexiclean::Lexiclean,
-    libc::EXIT_FAILURE,
-    log::{info, warn},
-    regex::Regex,
-    serde::{
-      ser::{SerializeMap, SerializeSeq},
-      Serialize, Serializer,
-    },
-    snafu::{ResultExt, Snafu},
-    strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr},
-    typed_arena::Arena,
-    unicode_width::{UnicodeWidthChar, UnicodeWidthStr},
-  },
+  strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr},
+  tempfile::tempfile,
+  typed_arena::Arena,
+  unicode_width::{UnicodeWidthChar, UnicodeWidthStr},
 };
 
 #[cfg(test)]
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 77658ac2..e1433154 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -1,9 +1,4 @@
-use {
-  super::*,
-  clap_mangen::Man,
-  std::io::{Read, Seek},
-  tempfile::tempfile,
-};
+use {super::*, clap_mangen::Man};
 
 const INIT_JUSTFILE: &str = "default:\n    echo 'Hello, world!'\n";
 
@@ -20,7 +15,7 @@ pub(crate) enum Subcommand {
     overrides: BTreeMap<String, String>,
   },
   Completions {
-    shell: clap_complete::Shell,
+    shell: completions::Shell,
   },
   Dump,
   Edit,
@@ -296,68 +291,8 @@ impl Subcommand {
     justfile.run(config, search, overrides, &recipes)
   }
 
-  fn completions(shell: clap_complete::Shell) -> RunResult<'static, ()> {
-    use clap_complete::Shell;
-
-    fn replace(haystack: &mut String, needle: &str, replacement: &str) -> RunResult<'static, ()> {
-      if let Some(index) = haystack.find(needle) {
-        haystack.replace_range(index..index + needle.len(), replacement);
-        Ok(())
-      } else {
-        Err(Error::internal(format!(
-          "Failed to find text:\n{needle}\n…in completion script:\n{haystack}"
-        )))
-      }
-    }
-
-    let mut script = {
-      let mut tempfile = tempfile().map_err(|io_error| Error::TempfileIo { io_error })?;
-
-      clap_complete::generate(
-        shell,
-        &mut crate::config::Config::app(),
-        env!("CARGO_PKG_NAME"),
-        &mut tempfile,
-      );
-
-      tempfile
-        .rewind()
-        .map_err(|io_error| Error::TempfileIo { io_error })?;
-
-      let mut buffer = String::new();
-
-      tempfile
-        .read_to_string(&mut buffer)
-        .map_err(|io_error| Error::TempfileIo { io_error })?;
-
-      buffer
-    };
-
-    match shell {
-      Shell::Bash => {
-        for (needle, replacement) in completions::BASH_COMPLETION_REPLACEMENTS {
-          replace(&mut script, needle, replacement)?;
-        }
-      }
-      Shell::Fish => {
-        script.insert_str(0, completions::FISH_RECIPE_COMPLETIONS);
-      }
-      Shell::PowerShell => {
-        for (needle, replacement) in completions::POWERSHELL_COMPLETION_REPLACEMENTS {
-          replace(&mut script, needle, replacement)?;
-        }
-      }
-
-      Shell::Zsh => {
-        for (needle, replacement) in completions::ZSH_COMPLETION_REPLACEMENTS {
-          replace(&mut script, needle, replacement)?;
-        }
-      }
-      _ => {}
-    }
-
-    println!("{}", script.trim());
-
+  fn completions(shell: completions::Shell) -> RunResult<'static, ()> {
+    println!("{}", shell.script()?);
     Ok(())
   }
 


Test patch:
diff --git a/tests/completions.rs b/tests/completions.rs
index 96d35caf..9de2787c 100644
--- a/tests/completions.rs
+++ b/tests/completions.rs
@@ -28,7 +28,7 @@ fn bash() {
 
 #[test]
 fn replacements() {
-  for shell in ["bash", "elvish", "fish", "powershell", "zsh"] {
+  for shell in ["bash", "elvish", "fish", "nushell", "powershell", "zsh"] {
     let output = Command::new(executable_path("just"))
       .args(["--completions", shell])
       .output()