From c6612de7601d16cdd4f88d0fd502f4a978518562 Mon Sep 17 00:00:00 2001 From: Saheed Adeleye <126640223+gyreas@users.noreply.github.com> Date: Mon, 20 May 2024 01:24:27 +0100 Subject: [PATCH] Add shell() function for running external commands (#2047) --- README.md | 27 ++++++++++++++++++++++++ src/assignment_resolver.rs | 9 ++++++++ src/evaluator.rs | 42 +++++++++++++++++++++++++------------- src/function.rs | 9 ++++++++ src/node.rs | 11 ++++++++++ src/summary.rs | 14 +++++++++++++ src/thunk.rs | 35 +++++++++++++++++++++++++++++++ src/variables.rs | 8 ++++++++ tests/functions.rs | 41 +++++++++++++++++++++++++++++++++++++ 9 files changed, 182 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 97b7d54..fd0e7db 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 04a8936..53863fc 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 0ac5819..1b0f946 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> { + 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 { let mut cmd = self.settings.shell_command(self.config); - - cmd.arg(raw); - + 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 1b657d1..0133d9e 100644 --- a/src/function.rs +++ b/src/function.rs @@ -14,6 +14,7 @@ pub(crate) enum Function { Nullary(fn(&Evaluator) -> Result), Unary(fn(&Evaluator, &str) -> Result), UnaryOpt(fn(&Evaluator, &str, Option<&str>) -> Result), + UnaryPlus(fn(&Evaluator, &str, &[String]) -> Result), Binary(fn(&Evaluator, &str, &str) -> Result), BinaryPlus(fn(&Evaluator, &str, &str, &[String]) -> Result), Ternary(fn(&Evaluator, &str, &str, &str) -> Result), @@ -67,6 +68,7 @@ pub(crate) fn get(name: &str) -> Option { "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 { Ok(format!("{hash:x}")) } +fn shell(evaluator: &Evaluator, command: &str, args: &[String]) -> Result { + evaluator + .run_command(command, args) + .map_err(|output_error| output_error.to_string()) +} + fn shoutykebabcase(_evaluator: &Evaluator, s: &str) -> Result { Ok(s.to_shouty_kebab_case()) } diff --git a/src/node.rs b/src/node.rs index 34b48b5..e18c9fb 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 be3a1c9..472feb0 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 da25d00..87d66a3 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, args: (Box>, Box>>), }, + UnaryPlus { + name: Name<'src>, + #[derivative(Debug = "ignore", PartialEq = "ignore")] + function: fn(&Evaluator, &str, &[String]) -> Result, + args: (Box>, Vec>), + }, 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 b459134..5797956 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); diff --git a/tests/functions.rs b/tests/functions.rs index 614761a..c2d718c 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -759,6 +759,47 @@ fn just_pid() { assert_eq!(stdout.parse::().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()