diff --git a/README.md b/README.md
index 9d4f9f6cd5..a54a7467d2 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,7 @@ Yay, all your tests passed!
[available for most popular shells](#shell-completion-scripts).
- Recipes can be written in
- [arbitrary languages](#writing-recipes-in-other-languages), like Python or NodeJS.
+ [arbitrary languages](#shebang-recipes), like Python or NodeJS.
- `just` can be invoked from any subdirectory, not just the directory that
contains the `justfile`.
@@ -1721,6 +1721,7 @@ Recipes may be annotated with attributes that change their behavior.
| `[no-quiet]`1.23.0 | Override globally quiet recipes and always echo out the recipe. |
| `[positional-arguments]`1.29.0 | Turn on [positional arguments](#positional-arguments) for this recipe. |
| `[private]`1.10.0 | See [Private Recipes](#private-recipes). |
+| `[script(COMMAND)]`master | Execute recipe as a script interpreted by `COMMAND`. See [script recipes](#script-recipes) for more details. |
| `[unix]`1.8.0 | Enable recipe on Unixes. (Includes MacOS). |
| `[windows]`1.8.0 | Enable recipe on Windows. |
@@ -2443,7 +2444,7 @@ This has limitations, since recipe `c` is run with an entirely new invocation
of `just`: Assignments will be recalculated, dependencies might run twice, and
command line arguments will not be propagated to the child `just` process.
-### Writing Recipes in Other Languages
+### Shebang Recipes
Recipes that start with `#!` are called shebang recipes, and are executed by
saving the recipe body to a file and running it. This lets you write recipes in
@@ -2514,6 +2515,20 @@ the final argument. For example, on Windows, if a recipe starts with `#! py`,
the final command the OS runs will be something like `py
C:\Temp\PATH_TO_SAVED_RECIPE_BODY`.
+### Script Recipes
+
+Recipes with a `[script(COMMAND)]` attributemaster are run as
+scripts interpreted by `COMMAND`. This avoids some of the issues with shebang
+recipes, such as the use of `cygpath` on Windows, the need to use
+`/usr/bin/env`, and inconsistences in shebang line splitting across Unix OSs.
+
+The body of the recipe is evaluated, written to disk in the temporary
+directory, and run by passing its path as an argument to `COMMAND`.
+
+The `[script(…)]` attribute is unstable, so you'll need to use `set unstable`,
+set the `JUST_UNSTABLE` environment variable, or pass `--unstable` on the
+command line.
+
### Safer Bash Shebang Recipes
If you're writing a `bash` shebang recipe, consider adding `set -euxo
diff --git a/src/analyzer.rs b/src/analyzer.rs
index bd4319bab5..c1b1a7d63d 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -186,6 +186,18 @@ impl<'src> Analyzer<'src> {
let root = paths.get(root).unwrap();
+ let unstable_features = recipes
+ .values()
+ .flat_map(|recipe| &recipe.attributes)
+ .filter_map(|attribute| {
+ if let Attribute::Script(_) = attribute {
+ Some(UnstableFeature::ScriptAttribute)
+ } else {
+ None
+ }
+ })
+ .collect();
+
Ok(Justfile {
aliases,
assignments: self.assignments,
@@ -208,7 +220,7 @@ impl<'src> Analyzer<'src> {
settings,
source: root.into(),
unexports,
- unstable_features: BTreeSet::new(),
+ unstable_features,
warnings,
})
}
@@ -242,7 +254,7 @@ impl<'src> Analyzer<'src> {
let mut continued = false;
for line in &recipe.body {
- if !recipe.shebang && !continued {
+ if !recipe.is_script() && !continued {
if let Some(Fragment::Text { token }) = line.fragments.first() {
let text = token.lexeme();
@@ -255,7 +267,7 @@ impl<'src> Analyzer<'src> {
continued = line.is_continuation();
}
- if !recipe.shebang {
+ if !recipe.is_script() {
if let Some(attribute) = recipe
.attributes
.iter()
diff --git a/src/attribute.rs b/src/attribute.rs
index 699d1495e1..fb01606303 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -20,6 +20,7 @@ pub(crate) enum Attribute<'src> {
NoQuiet,
PositionalArguments,
Private,
+ Script(Vec>),
Unix,
Windows,
}
@@ -38,6 +39,7 @@ impl AttributeDiscriminant {
| Self::Private
| Self::Unix
| Self::Windows => 0..=0,
+ Self::Script => 1..=usize::MAX,
}
}
}
@@ -45,7 +47,7 @@ impl AttributeDiscriminant {
impl<'src> Attribute<'src> {
pub(crate) fn new(
name: Name<'src>,
- argument: Option>,
+ arguments: Vec>,
) -> CompileResult<'src, Self> {
let discriminant = name
.lexeme()
@@ -57,7 +59,7 @@ impl<'src> Attribute<'src> {
})
})?;
- let found = argument.as_ref().iter().count();
+ let found = arguments.len();
let range = discriminant.argument_range();
if !range.contains(&found) {
return Err(
@@ -71,10 +73,10 @@ impl<'src> Attribute<'src> {
}
Ok(match discriminant {
- AttributeDiscriminant::Confirm => Self::Confirm(argument),
- AttributeDiscriminant::Doc => Self::Doc(argument),
- AttributeDiscriminant::Extension => Self::Extension(argument.unwrap()),
- AttributeDiscriminant::Group => Self::Group(argument.unwrap()),
+ AttributeDiscriminant::Confirm => Self::Confirm(arguments.into_iter().next()),
+ AttributeDiscriminant::Doc => Self::Doc(arguments.into_iter().next()),
+ AttributeDiscriminant::Extension => Self::Extension(arguments.into_iter().next().unwrap()),
+ AttributeDiscriminant::Group => Self::Group(arguments.into_iter().next().unwrap()),
AttributeDiscriminant::Linux => Self::Linux,
AttributeDiscriminant::Macos => Self::Macos,
AttributeDiscriminant::NoCd => Self::NoCd,
@@ -82,6 +84,7 @@ impl<'src> Attribute<'src> {
AttributeDiscriminant::NoQuiet => Self::NoQuiet,
AttributeDiscriminant::PositionalArguments => Self::PositionalArguments,
AttributeDiscriminant::Private => Self::Private,
+ AttributeDiscriminant::Script => Self::Script(arguments),
AttributeDiscriminant::Unix => Self::Unix,
AttributeDiscriminant::Windows => Self::Windows,
})
@@ -91,11 +94,16 @@ impl<'src> Attribute<'src> {
self.into()
}
- fn argument(&self) -> Option<&StringLiteral> {
+ fn arguments(&self) -> &[StringLiteral] {
match self {
- Self::Confirm(argument) | Self::Doc(argument) => argument.as_ref(),
- Self::Extension(argument) | Self::Group(argument) => Some(argument),
- Self::Linux
+ Self::Confirm(Some(argument))
+ | Self::Doc(Some(argument))
+ | Self::Extension(argument)
+ | Self::Group(argument) => slice::from_ref(argument),
+ Self::Script(arguments) => arguments,
+ Self::Confirm(None)
+ | Self::Doc(None)
+ | Self::Linux
| Self::Macos
| Self::NoCd
| Self::NoExitMessage
@@ -103,7 +111,7 @@ impl<'src> Attribute<'src> {
| Self::PositionalArguments
| Self::Private
| Self::Unix
- | Self::Windows => None,
+ | Self::Windows => &[],
}
}
}
@@ -111,8 +119,21 @@ impl<'src> Attribute<'src> {
impl<'src> Display for Attribute<'src> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{}", self.name())?;
- if let Some(argument) = self.argument() {
- write!(f, "({argument})")?;
+
+ let arguments = self.arguments();
+
+ for (i, argument) in arguments.iter().enumerate() {
+ if i == 0 {
+ write!(f, "(")?;
+ } else {
+ write!(f, ", ")?;
+ }
+
+ write!(f, "{argument}")?;
+
+ if i + 1 == arguments.len() {
+ write!(f, ")")?;
+ }
}
Ok(())
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 1056f37e77..b2396219b8 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -238,6 +238,10 @@ impl Display for CompileError<'_> {
)
}
}
+ ShebangAndScriptAttribute { recipe } => write!(
+ f,
+ "Recipe `{recipe}` has both shebang line and `[script]` attribute"
+ ),
ShellExpansion { err } => write!(f, "Shell expansion failed: {err}"),
RequiredParameterFollowsDefaultParameter { parameter } => write!(
f,
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index 7400b2925f..73c3960e26 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -98,6 +98,9 @@ pub(crate) enum CompileErrorKind<'src> {
RequiredParameterFollowsDefaultParameter {
parameter: &'src str,
},
+ ShebangAndScriptAttribute {
+ recipe: &'src str,
+ },
ShellExpansion {
err: shellexpand::LookupError,
},
diff --git a/src/error.rs b/src/error.rs
index 31a8bd4b9a..cd22311e7b 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -136,14 +136,19 @@ pub(crate) enum Error<'src> {
RegexCompile {
source: regex::Error,
},
+ Script {
+ command: String,
+ io_error: io::Error,
+ recipe: &'src str,
+ },
Search {
search_error: SearchError,
},
Shebang {
- recipe: &'src str,
- command: String,
argument: Option,
+ command: String,
io_error: io::Error,
+ recipe: &'src str,
},
Signal {
recipe: &'src str,
@@ -413,6 +418,9 @@ impl<'src> ColorDisplay for Error<'src> {
RuntimeDirIo { io_error, path } => {
write!(f, "I/O error in runtime dir `{}`: {io_error}", path.display())?;
}
+ Script { command, io_error, recipe } => {
+ write!(f, "Recipe `{recipe}` with command `{command}` execution error: {io_error}")?;
+ }
Search { search_error } => Display::fmt(search_error, f)?,
Shebang { recipe, command, argument, io_error} => {
if let Some(argument) = argument {
diff --git a/src/executor.rs b/src/executor.rs
new file mode 100644
index 0000000000..1345d902aa
--- /dev/null
+++ b/src/executor.rs
@@ -0,0 +1,171 @@
+use super::*;
+
+pub(crate) enum Executor<'a> {
+ Command(Vec<&'a str>),
+ Shebang(Shebang<'a>),
+}
+
+impl<'a> Executor<'a> {
+ pub(crate) fn command<'src>(
+ &self,
+ path: &Path,
+ recipe: &'src str,
+ working_directory: Option<&Path>,
+ ) -> RunResult<'src, Command> {
+ match self {
+ Self::Command(args) => {
+ let mut command = Command::new(args[0]);
+
+ if let Some(working_directory) = working_directory {
+ command.current_dir(working_directory);
+ }
+
+ for arg in &args[1..] {
+ command.arg(arg);
+ }
+
+ command.arg(path);
+
+ Ok(command)
+ }
+ Self::Shebang(shebang) => {
+ // make script executable
+ Platform::set_execute_permission(path).map_err(|error| Error::TempdirIo {
+ recipe,
+ io_error: error,
+ })?;
+
+ // create command to run script
+ Platform::make_shebang_command(path, working_directory, *shebang).map_err(|output_error| {
+ Error::Cygpath {
+ recipe,
+ output_error,
+ }
+ })
+ }
+ }
+ }
+
+ pub(crate) fn script_filename(&self, recipe: &str, extension: Option<&str>) -> String {
+ let extension = extension.unwrap_or_else(|| {
+ let interpreter = match self {
+ Self::Command(args) => args[0],
+ Self::Shebang(shebang) => shebang.interpreter_filename(),
+ };
+
+ match interpreter {
+ "cmd" | "cmd.exe" => ".bat",
+ "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => ".ps1",
+ _ => "",
+ }
+ });
+
+ format!("{recipe}{extension}")
+ }
+
+ pub(crate) fn error<'src>(&self, io_error: io::Error, recipe: &'src str) -> Error<'src> {
+ match self {
+ Self::Command(args) => {
+ let mut command = String::new();
+
+ for (i, arg) in args.iter().enumerate() {
+ if i > 0 {
+ command.push(' ');
+ }
+ command.push_str(arg);
+ }
+
+ Error::Script {
+ command,
+ io_error,
+ recipe,
+ }
+ }
+ Self::Shebang(shebang) => Error::Shebang {
+ argument: shebang.argument.map(String::from),
+ command: shebang.interpreter.to_owned(),
+ io_error,
+ recipe,
+ },
+ }
+ }
+
+ // Script text for `recipe` given evaluated `lines` including blanks so line
+ // numbers in errors from generated script match justfile source lines.
+ pub(crate) fn script(&self, recipe: &Recipe, lines: &[String]) -> String {
+ let mut script = String::new();
+
+ match self {
+ Self::Shebang(shebang) => {
+ let mut n = 0;
+
+ for (i, (line, evaluated)) in recipe.body.iter().zip(lines).enumerate() {
+ if i == 0 {
+ if shebang.include_shebang_line() {
+ script.push_str(evaluated);
+ script.push('\n');
+ n += 1;
+ }
+ } else {
+ while n < line.number {
+ script.push('\n');
+ n += 1;
+ }
+
+ script.push_str(evaluated);
+ script.push('\n');
+ n += 1;
+ }
+ }
+ }
+ Self::Command(_) => {
+ let mut n = 0;
+
+ for (line, evaluated) in recipe.body.iter().zip(lines) {
+ while n < line.number {
+ script.push('\n');
+ n += 1;
+ }
+
+ script.push_str(evaluated);
+ script.push('\n');
+ n += 1;
+ }
+ }
+ }
+
+ script
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn shebang_script_filename() {
+ #[track_caller]
+ fn case(interpreter: &str, recipe: &str, extension: Option<&str>, expected: &str) {
+ assert_eq!(
+ Executor::Shebang(Shebang::new(&format!("#!{interpreter}")).unwrap())
+ .script_filename(recipe, extension),
+ expected
+ );
+ assert_eq!(
+ Executor::Command(vec![interpreter]).script_filename(recipe, extension),
+ expected
+ );
+ }
+
+ case("bar", "foo", Some(".sh"), "foo.sh");
+ case("pwsh.exe", "foo", Some(".sh"), "foo.sh");
+ case("cmd.exe", "foo", Some(".sh"), "foo.sh");
+ case("powershell", "foo", None, "foo.ps1");
+ case("pwsh", "foo", None, "foo.ps1");
+ case("powershell.exe", "foo", None, "foo.ps1");
+ case("pwsh.exe", "foo", None, "foo.ps1");
+ case("cmd", "foo", None, "foo.bat");
+ case("cmd.exe", "foo", None, "foo.bat");
+ case("bar", "foo", None, "foo");
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 4245a15c89..d4efb53273 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -29,17 +29,18 @@ pub(crate) use {
conditional_operator::ConditionalOperator, config::Config, config_error::ConfigError,
constants::constants, count::Count, delimiter::Delimiter, dependency::Dependency,
dump_format::DumpFormat, enclosure::Enclosure, error::Error, evaluator::Evaluator,
- execution_context::ExecutionContext, expression::Expression, fragment::Fragment,
- function::Function, interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler,
- item::Item, justfile::Justfile, keyed::Keyed, keyword::Keyword, lexer::Lexer, line::Line,
- list::List, load_dotenv::load_dotenv, loader::Loader, module_path::ModulePath, name::Name,
- namepath::Namepath, ordinal::Ordinal, output::output, output_error::OutputError,
- parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform,
- platform_interface::PlatformInterface, position::Position, positional::Positional, ran::Ran,
- range_ext::RangeExt, recipe::Recipe, recipe_resolver::RecipeResolver,
- recipe_signature::RecipeSignature, scope::Scope, search::Search, search_config::SearchConfig,
- search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang,
- shell::Shell, show_whitespace::ShowWhitespace, source::Source, string_kind::StringKind,
+ execution_context::ExecutionContext, executor::Executor, expression::Expression,
+ fragment::Fragment, function::Function, interrupt_guard::InterruptGuard,
+ interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, keyed::Keyed,
+ keyword::Keyword, lexer::Lexer, line::Line, list::List, load_dotenv::load_dotenv,
+ loader::Loader, module_path::ModulePath, name::Name, namepath::Namepath, ordinal::Ordinal,
+ output::output, output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind,
+ parser::Parser, platform::Platform, platform_interface::PlatformInterface, position::Position,
+ positional::Positional, ran::Ran, range_ext::RangeExt, recipe::Recipe,
+ recipe_resolver::RecipeResolver, recipe_signature::RecipeSignature, scope::Scope,
+ search::Search, search_config::SearchConfig, search_error::SearchError, set::Set,
+ setting::Setting, settings::Settings, shebang::Shebang, shell::Shell,
+ show_whitespace::ShowWhitespace, source::Source, string_kind::StringKind,
string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
unresolved_recipe::UnresolvedRecipe, unstable_feature::UnstableFeature, use_color::UseColor,
@@ -74,6 +75,7 @@ pub(crate) use {
path::{self, Path, PathBuf},
process::{self, Command, ExitStatus, Stdio},
rc::Rc,
+ slice,
str::{self, Chars},
sync::{Mutex, MutexGuard, OnceLock},
vec,
@@ -149,6 +151,7 @@ mod enclosure;
mod error;
mod evaluator;
mod execution_context;
+mod executor;
mod expression;
mod fragment;
mod function;
diff --git a/src/line.rs b/src/line.rs
index 6b0de6c7da..6218f744d4 100644
--- a/src/line.rs
+++ b/src/line.rs
@@ -5,6 +5,8 @@ use super::*;
#[serde(transparent)]
pub(crate) struct Line<'src> {
pub(crate) fragments: Vec>,
+ #[serde(skip)]
+ pub(crate) number: usize,
}
impl<'src> Line<'src> {
diff --git a/src/parser.rs b/src/parser.rs
index f337b90f39..27b342dadb 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -812,8 +812,19 @@ impl<'run, 'src> Parser<'run, 'src> {
let body = self.parse_body()?;
+ let shebang = body.first().map_or(false, Line::is_shebang);
+ let script = attributes
+ .iter()
+ .any(|attribute| matches!(attribute, Attribute::Script(_)));
+
+ if shebang && script {
+ return Err(name.error(CompileErrorKind::ShebangAndScriptAttribute {
+ recipe: name.lexeme(),
+ }));
+ }
+
Ok(Recipe {
- shebang: body.first().map_or(false, Line::is_shebang),
+ shebang: shebang || script,
attributes,
body,
dependencies,
@@ -858,13 +869,14 @@ impl<'run, 'src> Parser<'run, 'src> {
if self.accepted(Indent)? {
while !self.accepted(Dedent)? {
- let line = if self.accepted(Eol)? {
- Line {
- fragments: Vec::new(),
- }
- } else {
- let mut fragments = Vec::new();
-
+ let mut fragments = Vec::new();
+ let number = self
+ .tokens
+ .get(self.next_token)
+ .map(|token| token.line)
+ .unwrap_or_default();
+
+ if !self.accepted(Eol)? {
while !(self.accepted(Eol)? || self.next_is(Dedent)) {
if let Some(token) = self.accept(Text)? {
fragments.push(Fragment::Text { token });
@@ -877,11 +889,9 @@ impl<'run, 'src> Parser<'run, 'src> {
return Err(self.unexpected_token()?);
}
}
-
- Line { fragments }
};
- lines.push(line);
+ lines.push(Line { fragments, number });
}
}
@@ -991,7 +1001,7 @@ impl<'run, 'src> Parser<'run, 'src> {
Ok(Shell { arguments, command })
}
- /// Parse recipe attributes
+ /// Item attributes, i.e., `[macos]` or `[confirm: "warning!"]`
fn parse_attributes(
&mut self,
) -> CompileResult<'src, Option<(Token<'src>, BTreeSet>)>> {
@@ -1005,18 +1015,22 @@ impl<'run, 'src> Parser<'run, 'src> {
loop {
let name = self.parse_name()?;
- let maybe_argument = if self.accepted(Colon)? {
- let arg = self.parse_string_literal()?;
- Some(arg)
+ let mut arguments = Vec::new();
+
+ if self.accepted(Colon)? {
+ arguments.push(self.parse_string_literal()?);
} else if self.accepted(ParenL)? {
- let arg = self.parse_string_literal()?;
+ loop {
+ arguments.push(self.parse_string_literal()?);
+
+ if !self.accepted(Comma)? {
+ break;
+ }
+ }
self.expect(ParenR)?;
- Some(arg)
- } else {
- None
- };
+ }
- let attribute = Attribute::new(name, maybe_argument)?;
+ let attribute = Attribute::new(name, arguments)?;
if let Some(line) = attributes.get(&attribute) {
return Err(name.error(CompileErrorKind::DuplicateAttribute {
diff --git a/src/platform.rs b/src/platform.rs
index 01bf74b584..ece80fe133 100644
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -10,13 +10,13 @@ impl PlatformInterface for Platform {
_shebang: Shebang,
) -> Result {
// shebang scripts can be executed directly on unix
- let mut cmd = Command::new(path);
+ let mut command = Command::new(path);
if let Some(working_directory) = working_directory {
- cmd.current_dir(working_directory);
+ command.current_dir(working_directory);
}
- Ok(cmd)
+ Ok(command)
}
fn set_execute_permission(path: &Path) -> io::Result<()> {
diff --git a/src/recipe.rs b/src/recipe.rs
index 2ba924705e..ac95199a3d 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -106,6 +106,10 @@ impl<'src, D> Recipe<'src, D> {
!self.private && !self.attributes.contains(&Attribute::Private)
}
+ pub(crate) fn is_script(&self) -> bool {
+ self.shebang
+ }
+
pub(crate) fn takes_positional_arguments(&self, settings: &Settings) -> bool {
settings.positional_arguments || self.attributes.contains(&Attribute::PositionalArguments)
}
@@ -169,8 +173,8 @@ impl<'src, D> Recipe<'src, D> {
let evaluator = Evaluator::new(context, is_dependency, scope);
- if self.shebang {
- self.run_shebang(context, scope, positional, config, evaluator)
+ if self.is_script() {
+ self.run_script(context, scope, positional, config, evaluator)
} else {
self.run_linewise(context, scope, positional, config, evaluator)
}
@@ -308,7 +312,7 @@ impl<'src, D> Recipe<'src, D> {
}
}
- pub(crate) fn run_shebang<'run>(
+ pub(crate) fn run_script<'run>(
&self,
context: &ExecutionContext<'src, 'run>,
scope: &Scope<'src, 'run>,
@@ -338,13 +342,22 @@ impl<'src, D> Recipe<'src, D> {
return Ok(());
}
- let shebang_line = evaluated_lines.first().ok_or_else(|| Error::Internal {
- message: "evaluated_lines was empty".to_owned(),
- })?;
+ let executor = if let Some(Attribute::Script(args)) = self
+ .attributes
+ .iter()
+ .find(|attribute| matches!(attribute, Attribute::Script(_)))
+ {
+ Executor::Command(args.iter().map(|arg| arg.cooked.as_str()).collect())
+ } else {
+ let line = evaluated_lines
+ .first()
+ .ok_or_else(|| Error::internal("evaluated_lines was empty"))?;
- let shebang = Shebang::new(shebang_line).ok_or_else(|| Error::Internal {
- message: format!("bad shebang line: {shebang_line}"),
- })?;
+ let shebang =
+ Shebang::new(line).ok_or_else(|| Error::internal(format!("bad shebang line: {line}")))?;
+
+ Executor::Shebang(shebang)
+ };
let mut tempdir_builder = tempfile::Builder::new();
tempdir_builder.prefix("just-");
@@ -377,56 +390,21 @@ impl<'src, D> Recipe<'src, D> {
}
});
- path.push(shebang.script_filename(self.name(), extension));
+ path.push(executor.script_filename(self.name(), extension));
- {
- let mut f = fs::File::create(&path).map_err(|error| Error::TempdirIo {
- recipe: self.name(),
- io_error: error,
- })?;
- let mut text = String::new();
-
- if shebang.include_shebang_line() {
- text += &evaluated_lines[0];
- } else {
- text += "\n";
- }
-
- text += "\n";
- // add blank lines so that lines in the generated script have the same line
- // number as the corresponding lines in the justfile
- for _ in 1..(self.line_number() + 2) {
- text += "\n";
- }
- for line in &evaluated_lines[1..] {
- text += line;
- text += "\n";
- }
-
- if config.verbosity.grandiloquent() {
- eprintln!("{}", config.color.doc().stderr().paint(&text));
- }
+ let script = executor.script(self, &evaluated_lines);
- f.write_all(text.as_bytes())
- .map_err(|error| Error::TempdirIo {
- recipe: self.name(),
- io_error: error,
- })?;
+ if config.verbosity.grandiloquent() {
+ eprintln!("{}", config.color.doc().stderr().paint(&script));
}
- // make script executable
- Platform::set_execute_permission(&path).map_err(|error| Error::TempdirIo {
+ fs::write(&path, script).map_err(|error| Error::TempdirIo {
recipe: self.name(),
io_error: error,
})?;
- // create command to run script
let mut command =
- Platform::make_shebang_command(&path, self.working_directory(context.search), shebang)
- .map_err(|output_error| Error::Cygpath {
- recipe: self.name(),
- output_error,
- })?;
+ executor.command(&path, self.name(), self.working_directory(context.search))?;
if self.takes_positional_arguments(context.settings) {
command.args(positional);
@@ -451,12 +429,7 @@ impl<'src, D> Recipe<'src, D> {
}
},
),
- Err(io_error) => Err(Error::Shebang {
- recipe: self.name(),
- command: shebang.interpreter.to_owned(),
- argument: shebang.argument.map(String::from),
- io_error,
- }),
+ Err(io_error) => Err(executor.error(io_error, self.name())),
}
}
diff --git a/src/shebang.rs b/src/shebang.rs
index 36b79dce45..2d2a60164e 100644
--- a/src/shebang.rs
+++ b/src/shebang.rs
@@ -30,7 +30,7 @@ impl<'line> Shebang<'line> {
})
}
- fn interpreter_filename(&self) -> &str {
+ pub fn interpreter_filename(&self) -> &str {
self
.interpreter
.split(|c| matches!(c, '/' | '\\'))
@@ -38,16 +38,6 @@ impl<'line> Shebang<'line> {
.unwrap_or(self.interpreter)
}
- pub(crate) fn script_filename(&self, recipe: &str, extension: Option<&str>) -> String {
- let extension = extension.unwrap_or_else(|| match self.interpreter_filename() {
- "cmd" | "cmd.exe" => ".bat",
- "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => ".ps1",
- _ => "",
- });
-
- format!("{recipe}{extension}")
- }
-
pub(crate) fn include_shebang_line(&self) -> bool {
!(cfg!(windows) || matches!(self.interpreter_filename(), "cmd" | "cmd.exe"))
}
@@ -137,70 +127,6 @@ mod tests {
);
}
- #[test]
- fn powershell_script_filename() {
- assert_eq!(
- Shebang::new("#!powershell")
- .unwrap()
- .script_filename("foo", None),
- "foo.ps1"
- );
- }
-
- #[test]
- fn pwsh_script_filename() {
- assert_eq!(
- Shebang::new("#!pwsh").unwrap().script_filename("foo", None),
- "foo.ps1"
- );
- }
-
- #[test]
- fn powershell_exe_script_filename() {
- assert_eq!(
- Shebang::new("#!powershell.exe")
- .unwrap()
- .script_filename("foo", None),
- "foo.ps1"
- );
- }
-
- #[test]
- fn pwsh_exe_script_filename() {
- assert_eq!(
- Shebang::new("#!pwsh.exe")
- .unwrap()
- .script_filename("foo", None),
- "foo.ps1"
- );
- }
-
- #[test]
- fn cmd_script_filename() {
- assert_eq!(
- Shebang::new("#!cmd").unwrap().script_filename("foo", None),
- "foo.bat"
- );
- }
-
- #[test]
- fn cmd_exe_script_filename() {
- assert_eq!(
- Shebang::new("#!cmd.exe")
- .unwrap()
- .script_filename("foo", None),
- "foo.bat"
- );
- }
-
- #[test]
- fn plain_script_filename() {
- assert_eq!(
- Shebang::new("#!bar").unwrap().script_filename("foo", None),
- "foo"
- );
- }
-
#[test]
fn dont_include_shebang_line_cmd() {
assert!(!Shebang::new("#!cmd").unwrap().include_shebang_line());
@@ -222,26 +148,4 @@ mod tests {
fn include_shebang_line_other_windows() {
assert!(!Shebang::new("#!foo -c").unwrap().include_shebang_line());
}
-
- #[test]
- fn filename_with_extension() {
- assert_eq!(
- Shebang::new("#!bar")
- .unwrap()
- .script_filename("foo", Some(".sh")),
- "foo.sh"
- );
- assert_eq!(
- Shebang::new("#!pwsh.exe")
- .unwrap()
- .script_filename("foo", Some(".sh")),
- "foo.sh"
- );
- assert_eq!(
- Shebang::new("#!cmd.exe")
- .unwrap()
- .script_filename("foo", Some(".sh")),
- "foo.sh"
- );
- }
}
diff --git a/src/unstable_feature.rs b/src/unstable_feature.rs
index 84fb3a2366..98eb4937c1 100644
--- a/src/unstable_feature.rs
+++ b/src/unstable_feature.rs
@@ -3,12 +3,14 @@ use super::*;
#[derive(Copy, Clone, Debug, PartialEq, Ord, Eq, PartialOrd)]
pub(crate) enum UnstableFeature {
FormatSubcommand,
+ ScriptAttribute,
}
impl Display for UnstableFeature {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self {
Self::FormatSubcommand => write!(f, "The `--fmt` command is currently unstable."),
+ Self::ScriptAttribute => write!(f, "The `[script]` attribute is currently unstable."),
}
}
}
diff --git a/tests/confirm.rs b/tests/confirm.rs
index b1a2657df3..3efa44f02b 100644
--- a/tests/confirm.rs
+++ b/tests/confirm.rs
@@ -124,13 +124,21 @@ fn confirm_recipe_with_prompt() {
fn confirm_recipe_with_prompt_too_many_args() {
Test::new()
.justfile(
- "
- [confirm(\"This is dangerous - are you sure you want to run it?\",\"this second argument is not supported\")]
+ r#"
+ [confirm("PROMPT","EXTRA")]
requires_confirmation:
echo confirmed
- ",
+ "#,
+ )
+ .stderr(
+ r#"
+ error: Attribute `confirm` got 2 arguments but takes at most 1 argument
+ ——▶ justfile:1:2
+ │
+ 1 │ [confirm("PROMPT","EXTRA")]
+ │ ^^^^^^^
+ "#,
)
- .stderr("error: Expected ')', but found ','\n ——▶ justfile:1:64\n │\n1 │ [confirm(\"This is dangerous - are you sure you want to run it?\",\"this second argument is not supported\")]\n │ ^\n")
.stdout("")
.status(1)
.run();
diff --git a/tests/fmt.rs b/tests/fmt.rs
index 29a12ba467..013c0a27d2 100644
--- a/tests/fmt.rs
+++ b/tests/fmt.rs
@@ -1073,3 +1073,26 @@ fn exported_parameter() {
.stdout("foo +$f:\n")
.run();
}
+
+#[test]
+fn multi_argument_attribute() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script('a', 'b', 'c')]
+ foo:
+ ",
+ )
+ .arg("--dump")
+ .stdout(
+ "
+ set unstable := true
+
+ [script('a', 'b', 'c')]
+ foo:
+ ",
+ )
+ .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 072574bdf8..3f8fd0d074 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -90,6 +90,7 @@ mod readme;
mod recursion_limit;
mod regexes;
mod run;
+mod script;
mod search;
mod search_arguments;
mod shadowing_parameters;
diff --git a/tests/misc.rs b/tests/misc.rs
index ad1055a9d2..bc13fe19bf 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -1086,65 +1086,6 @@ test! {
stderr: "#!/bin/sh\necho hello\n",
}
-#[cfg(not(windows))]
-test! {
- name: shebang_line_numbers,
- justfile: r#"
- quiet:
- #!/usr/bin/env cat
-
- a
-
- b
-
-
- c
-
-
- "#,
- stdout: "
- #!/usr/bin/env cat
-
-
- a
-
- b
-
-
- c
- ",
-}
-
-#[cfg(windows)]
-test! {
- name: shebang_line_numbers,
- justfile: r#"
- quiet:
- #!/usr/bin/env cat
-
- a
-
- b
-
-
- c
-
-
- "#,
- stdout: "
-
-
-
-
- a
-
- b
-
-
- c
- ",
-}
-
test! {
name: complex_dependencies,
justfile: r#"
diff --git a/tests/script.rs b/tests/script.rs
new file mode 100644
index 0000000000..95757bdc06
--- /dev/null
+++ b/tests/script.rs
@@ -0,0 +1,300 @@
+use super::*;
+
+#[test]
+fn unstable() {
+ Test::new()
+ .justfile(
+ "
+ [script('sh', '-u')]
+ foo:
+ echo FOO
+
+ ",
+ )
+ .stderr_regex(r"error: The `\[script\]` attribute is currently unstable\..*")
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn runs_with_command() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script('cat')]
+ foo:
+ FOO
+ ",
+ )
+ .stdout(
+ "
+
+
+
+
+ FOO
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn no_arguments() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script('sh')]
+ foo:
+ echo foo
+ ",
+ )
+ .stdout("foo\n")
+ .run();
+}
+
+#[test]
+fn with_arguments() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script('sh', '-x')]
+ foo:
+ echo foo
+ ",
+ )
+ .stdout("foo\n")
+ .stderr("+ echo foo\n")
+ .run();
+}
+
+#[test]
+fn requires_argument() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script]
+ foo:
+ ",
+ )
+ .stderr(
+ "
+ error: Attribute `script` got 0 arguments but takes at least 1 argument
+ ——▶ justfile:3:2
+ │
+ 3 │ [script]
+ │ ^^^^^^
+ ",
+ )
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn not_allowed_with_shebang() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script('sh', '-u')]
+ foo:
+ #!/bin/sh
+
+ ",
+ )
+ .stderr(
+ "
+ error: Recipe `foo` has both shebang line and `[script]` attribute
+ ——▶ justfile:4:1
+ │
+ 4 │ foo:
+ │ ^^^
+ ",
+ )
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn script_line_numbers() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ [script('cat')]
+ foo:
+ FOO
+
+ BAR
+ ",
+ )
+ .stdout(
+ "
+
+
+
+
+ FOO
+
+ BAR
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn script_line_numbers_with_multi_line_recipe_signature() {
+ Test::new()
+ .justfile(
+ r"
+ set unstable
+
+ [script('cat')]
+ foo bar='baz' \
+ :
+ FOO
+
+ BAR
+
+ {{ \
+ bar \
+ }}
+
+ BAZ
+ ",
+ )
+ .stdout(
+ "
+
+
+
+
+
+ FOO
+
+ BAR
+
+ baz
+
+
+
+ BAZ
+ ",
+ )
+ .run();
+}
+
+#[cfg(not(windows))]
+#[test]
+fn shebang_line_numbers() {
+ Test::new()
+ .justfile(
+ "foo:
+ #!/usr/bin/env cat
+
+ a
+
+ b
+
+
+ c
+
+
+",
+ )
+ .stdout(
+ "#!/usr/bin/env cat
+
+
+a
+
+b
+
+
+c
+",
+ )
+ .run();
+}
+
+#[cfg(not(windows))]
+#[test]
+fn shebang_line_numbers_with_multiline_constructs() {
+ Test::new()
+ .justfile(
+ r"foo b='b'\
+ :
+ #!/usr/bin/env cat
+
+ a
+
+ {{ \
+ b \
+ }}
+
+
+ c
+
+
+",
+ )
+ .stdout(
+ "#!/usr/bin/env cat
+
+
+
+a
+
+b
+
+
+
+
+c
+",
+ )
+ .run();
+}
+
+#[cfg(windows)]
+#[test]
+fn shebang_line_numbers() {
+ Test::new()
+ .justfile(
+ "foo:
+ #!/usr/bin/env cat
+
+ a
+
+ b
+
+
+ c
+
+
+",
+ )
+ .stdout(
+ "
+
+
+
+a
+
+b
+
+
+c
+",
+ )
+ .run();
+}
diff --git a/tests/tempdir.rs b/tests/tempdir.rs
index 4f89792bfb..63b07a7de6 100644
--- a/tests/tempdir.rs
+++ b/tests/tempdir.rs
@@ -37,7 +37,6 @@ fn test_tempdir_is_set() {
-
cat just*/foo
"
} else {