Repository: casey/just
Pull request #2047 (2024-05-16 21:00:51)

Support shell()
This PR add support for the `shell()`, which expands to the output of running its given command. 

Example:
```just
set shell := ["fish", "-c"]
v := shell("
set p $argv
for i in $p
	echo "$i"
end
",  "Nightmares", "on", "Elms", "Street")

_:
  @echo "{{ v }}"
```
output:
```console
Nightmares
on
Elms
Street
```

casey: This seems really nice! What do you think about getting rid of the `name` argument for now? Since a lot of uses of this function won't use it, it seems unfortunate to force users to pass an additional dummy argument if they want to pass arguments. I'd like to enable it in the future with a dedicated named argument syntax, i.e., `shell(CMD, name=NAME, ARGS)`, which is generally useful and can be done in a future PR.
casey: Also, this should be documented in the readme.
gyreas: Alright. I clean stuff up, I think I have a solution to remove that inconvenience. Thanks
casey: I think this should be refactored to use the same code as `Evaluator::run_backtick`. `run_backtick` does a few additional things, like setting the working directory, exporting environment variables, and the destination of stderr.

To make this easier, I just landed #2048, which gets rid of `FunctionContext` and instead just passes the `Evaluator`, since all fields on `FunctionContext` are just taken from `Evaluator` anyways.

The signature of `run_backtick` can be changed to something like:

```
fn run_command(&self, command: &str, args: &[String], token: Option<&Token<'src>>) -> RunResult<'src, String> {
```

Which produces an `Error::Backtick` when `token` is present, and an `Error::FunctionCall` when it isn't.

Patch:
diff --git a/README.md b/README.md
index 97b7d54c..fd0e7db0 100644
--- a/README.md
+++ b/README.md
@@ -1340,6 +1340,33 @@ that work on various operating systems. For an example, see
 [cross-platform.just](https://github.com/casey/just/blob/master/examples/cross-platform.just)
 file.
 
+#### External Commands
+
+- `shell(command, args...)` returns the standard output of shell script
+  `command` with zero or more positional arguments `args`. The shell used to
+  interpret `command` is the same shell that is used to evaluate recipe lines,
+  and can be changed with `set shell := […]`.
+
+```just
+# arguments can be variables
+file := '/sys/class/power_supply/BAT0/status'
+bat0stat := shell('cat $1', file)
+
+# commands can be variables
+command := 'wc -l $1'
+output := shell(command, 'main.c')
+
+# note that arguments must be used
+empty := shell('echo', 'foo')
+full := shell('echo $1', 'foo')
+```
+
+```just
+# using python as the shell
+set shell := ["python3", "-c"]
+olleh := shell('import sys; print(sys.argv[1][::-1]))', 'hello')
+```
+
 #### Environment Variables
 
 - `env_var(key)` — Retrieves the environment variable with name `key`, aborting
diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs
index 04a89367..53863fc9 100644
--- a/src/assignment_resolver.rs
+++ b/src/assignment_resolver.rs
@@ -76,6 +76,15 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
           }
           Ok(())
         }
+        Thunk::UnaryPlus {
+          args: (a, rest), ..
+        } => {
+          self.resolve_expression(a)?;
+          for arg in rest {
+            self.resolve_expression(arg)?;
+          }
+          Ok(())
+        }
         Thunk::Binary { args: [a, b], .. } => {
           self.resolve_expression(a)?;
           self.resolve_expression(b)
diff --git a/src/evaluator.rs b/src/evaluator.rs
index 0ac58198..1b0f9460 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -102,6 +102,22 @@ impl<'src, 'run> Evaluator<'src, 'run> {
               message,
             })
           }
+          UnaryPlus {
+            name,
+            function,
+            args: (a, rest),
+            ..
+          } => {
+            let a = self.evaluate_expression(a)?;
+            let mut rest_evaluated = Vec::new();
+            for arg in rest {
+              rest_evaluated.push(self.evaluate_expression(arg)?);
+            }
+            function(self, &a, &rest_evaluated).map_err(|message| Error::FunctionCall {
+              function: *name,
+              message,
+            })
+          }
           Binary {
             name,
             function,
@@ -127,7 +143,6 @@ impl<'src, 'run> Evaluator<'src, 'run> {
             for arg in rest {
               rest_evaluated.push(self.evaluate_expression(arg)?);
             }
-
             function(self, &a, &b, &rest_evaluated).map_err(|message| Error::FunctionCall {
               function: *name,
               message,
@@ -203,28 +218,27 @@ impl<'src, 'run> Evaluator<'src, 'run> {
   }
 
   fn run_backtick(&self, raw: &str, token: &Token<'src>) -> RunResult<'src, String> {
-    let mut cmd = self.settings.shell_command(self.config);
-
-    cmd.arg(raw);
+    self
+      .run_command(raw, &[])
+      .map_err(|output_error| Error::Backtick {
+        token: *token,
+        output_error,
+      })
+  }
 
+  pub(crate) fn run_command(&self, command: &str, args: &[String]) -> Result<String, OutputError> {
+    let mut cmd = self.settings.shell_command(self.config);
+    cmd.arg(command);
+    cmd.args(args);
     cmd.current_dir(&self.search.working_directory);
-
     cmd.export(self.settings, self.dotenv, &self.scope);
-
     cmd.stdin(Stdio::inherit());
-
     cmd.stderr(if self.config.verbosity.quiet() {
       Stdio::null()
     } else {
       Stdio::inherit()
     });
-
-    InterruptHandler::guard(|| {
-      output(cmd).map_err(|output_error| Error::Backtick {
-        token: *token,
-        output_error,
-      })
-    })
+    InterruptHandler::guard(|| output(cmd))
   }
 
   pub(crate) fn evaluate_line(
diff --git a/src/function.rs b/src/function.rs
index 1b657d1c..0133d9ef 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -14,6 +14,7 @@ pub(crate) enum Function {
   Nullary(fn(&Evaluator) -> Result<String, String>),
   Unary(fn(&Evaluator, &str) -> Result<String, String>),
   UnaryOpt(fn(&Evaluator, &str, Option<&str>) -> Result<String, String>),
+  UnaryPlus(fn(&Evaluator, &str, &[String]) -> Result<String, String>),
   Binary(fn(&Evaluator, &str, &str) -> Result<String, String>),
   BinaryPlus(fn(&Evaluator, &str, &str, &[String]) -> Result<String, String>),
   Ternary(fn(&Evaluator, &str, &str, &str) -> Result<String, String>),
@@ -67,6 +68,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
     "semver_matches" => Binary(semver_matches),
     "sha256" => Unary(sha256),
     "sha256_file" => Unary(sha256_file),
+    "shell" => UnaryPlus(shell),
     "shoutykebabcase" => Unary(shoutykebabcase),
     "shoutysnakecase" => Unary(shoutysnakecase),
     "snakecase" => Unary(snakecase),
@@ -93,6 +95,7 @@ impl Function {
       Nullary(_) => 0..0,
       Unary(_) => 1..1,
       UnaryOpt(_) => 1..2,
+      UnaryPlus(_) => 1..usize::MAX,
       Binary(_) => 2..2,
       BinaryPlus(_) => 2..usize::MAX,
       Ternary(_) => 3..3,
@@ -456,6 +459,12 @@ fn sha256_file(evaluator: &Evaluator, path: &str) -> Result<String, String> {
   Ok(format!("{hash:x}"))
 }
 
+fn shell(evaluator: &Evaluator, command: &str, args: &[String]) -> Result<String, String> {
+  evaluator
+    .run_command(command, args)
+    .map_err(|output_error| output_error.to_string())
+}
+
 fn shoutykebabcase(_evaluator: &Evaluator, s: &str) -> Result<String, String> {
   Ok(s.to_shouty_kebab_case())
 }
diff --git a/src/node.rs b/src/node.rs
index 34b48b57..e18c9fbb 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -125,6 +125,17 @@ impl<'src> Node<'src> for Expression<'src> {
               tree.push_mut(b.tree());
             }
           }
+          UnaryPlus {
+            name,
+            args: (a, rest),
+            ..
+          } => {
+            tree.push_mut(name.lexeme());
+            tree.push_mut(a.tree());
+            for arg in rest {
+              tree.push_mut(arg.tree());
+            }
+          }
           Binary {
             name, args: [a, b], ..
           } => {
diff --git a/src/summary.rs b/src/summary.rs
index be3a1c9a..472feb04 100644
--- a/src/summary.rs
+++ b/src/summary.rs
@@ -261,6 +261,20 @@ impl Expression {
             arguments,
           }
         }
+        full::Thunk::UnaryPlus {
+          name,
+          args: (a, rest),
+          ..
+        } => {
+          let mut arguments = vec![Expression::new(a)];
+          for arg in rest {
+            arguments.push(Expression::new(arg));
+          }
+          Expression::Call {
+            name: name.lexeme().to_owned(),
+            arguments,
+          }
+        }
         full::Thunk::Binary {
           name, args: [a, b], ..
         } => Self::Call {
diff --git a/src/thunk.rs b/src/thunk.rs
index da25d00b..87d66a3d 100644
--- a/src/thunk.rs
+++ b/src/thunk.rs
@@ -20,6 +20,12 @@ pub(crate) enum Thunk<'src> {
     function: fn(&Evaluator, &str, Option<&str>) -> Result<String, String>,
     args: (Box<Expression<'src>>, Box<Option<Expression<'src>>>),
   },
+  UnaryPlus {
+    name: Name<'src>,
+    #[derivative(Debug = "ignore", PartialEq = "ignore")]
+    function: fn(&Evaluator, &str, &[String]) -> Result<String, String>,
+    args: (Box<Expression<'src>>, Vec<Expression<'src>>),
+  },
   Binary {
     name: Name<'src>,
     #[derivative(Debug = "ignore", PartialEq = "ignore")]
@@ -46,6 +52,7 @@ impl<'src> Thunk<'src> {
       Self::Nullary { name, .. }
       | Self::Unary { name, .. }
       | Self::UnaryOpt { name, .. }
+      | Self::UnaryPlus { name, .. }
       | Self::Binary { name, .. }
       | Self::BinaryPlus { name, .. }
       | Self::Ternary { name, .. } => name,
@@ -79,6 +86,15 @@ impl<'src> Thunk<'src> {
             name,
           })
         }
+        (Function::UnaryPlus(function), 1..=usize::MAX) => {
+          let rest = arguments.drain(1..).collect();
+          let a = Box::new(arguments.pop().unwrap());
+          Ok(Thunk::UnaryPlus {
+            function,
+            args: (a, rest),
+            name,
+          })
+        }
         (Function::Binary(function), 2) => {
           let b = arguments.pop().unwrap().into();
           let a = arguments.pop().unwrap().into();
@@ -133,6 +149,17 @@ impl Display for Thunk<'_> {
           write!(f, "{}({a})", name.lexeme())
         }
       }
+      UnaryPlus {
+        name,
+        args: (a, rest),
+        ..
+      } => {
+        write!(f, "{}({a}", name.lexeme())?;
+        for arg in rest {
+          write!(f, ", {arg}")?;
+        }
+        write!(f, ")")
+      }
       Binary {
         name, args: [a, b], ..
       } => write!(f, "{}({a}, {b})", name.lexeme()),
@@ -175,6 +202,14 @@ impl<'src> Serialize for Thunk<'src> {
           seq.serialize_element(b)?;
         }
       }
+      Self::UnaryPlus {
+        args: (a, rest), ..
+      } => {
+        seq.serialize_element(a)?;
+        for arg in rest {
+          seq.serialize_element(arg)?;
+        }
+      }
       Self::Binary { args, .. } => {
         for arg in args {
           seq.serialize_element(arg)?;
diff --git a/src/variables.rs b/src/variables.rs
index b4591341..57979563 100644
--- a/src/variables.rs
+++ b/src/variables.rs
@@ -28,6 +28,14 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
               self.stack.push(b);
             }
           }
+          Thunk::UnaryPlus {
+            args: (a, rest), ..
+          } => {
+            let first: &[&Expression] = &[a];
+            for arg in first.iter().copied().chain(rest).rev() {
+              self.stack.push(arg);
+            }
+          }
           Thunk::Binary { args, .. } => {
             for arg in args.iter().rev() {
               self.stack.push(arg);


Test patch:
diff --git a/tests/functions.rs b/tests/functions.rs
index 614761a0..c2d718c7 100644
--- a/tests/functions.rs
+++ b/tests/functions.rs
@@ -759,6 +759,47 @@ fn just_pid() {
   assert_eq!(stdout.parse::<u32>().unwrap(), pid);
 }
 
+#[test]
+fn shell_no_argument() {
+  Test::new()
+    .justfile("var := shell()")
+    .args(["--evaluate"])
+    .stderr(
+      "
+      error: Function `shell` called with 0 arguments but takes 1 or more
+       ——▶ justfile:1:8
+        │
+      1 │ var := shell()
+        │        ^^^^^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn shell_minimal() {
+  assert_eval_eq("shell('echo $0 $1', 'justice', 'legs')", "justice legs");
+}
+
+#[test]
+fn shell_error() {
+  Test::new()
+    .justfile("var := shell('exit 1')")
+    .args(["--evaluate"])
+    .stderr(
+      "
+      error: Call to function `shell` failed: Process exited with status code 1
+       ——▶ justfile:1:8
+        │
+      1 │ var := shell('exit 1')
+        │        ^^^^^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
+}
+
 #[test]
 fn blake3() {
   Test::new()