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 {