Add shell() function for running external commands (#2047)

This commit is contained in:
Saheed Adeleye 2024-05-20 01:24:27 +01:00 committed by GitHub
parent 198b37c020
commit c6612de760
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 182 additions and 14 deletions

View File

@ -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

View File

@ -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)

View File

@ -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<String, OutputError> {
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(

View File

@ -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())
}

View File

@ -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], ..
} => {

View File

@ -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 {

View File

@ -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)?;

View File

@ -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);

View File

@ -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()