Repository: casey/just (rust)

Issue:
`[script]` annotation
Shebangs are not supported on all platforms, and a fair amount of hackery to get working. Including calling `cygpath` on windows if the path contains forward slashes. To work around this, we could add a `[script]` annotation, which would take a command and arguments. When a `[script]` recipe was executed, it would be passed as the final argument to the command.

So this recipe:

```just
[script("bash")]
foo:
  echo foo
```

Would be run like this recipe:

```just
foo:
  #!/usr/bin/env bash
  echo foo
```

Eventually, we could add a `script-interpreter` setting, and allow the command to be omitted:

```
set script-interpreter := ["bash"]

[script]
foo:
  echo foo
```

Pull request:
Add `[script]` attribute
Fixes #1479.

Patch:
diff --git a/README.md b/README.md
index 9d4f9f6c..a54a7467 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]`<sup>1.23.0</sup> | Override globally quiet recipes and always echo out the recipe. |
 | `[positional-arguments]`<sup>1.29.0</sup> | Turn on [positional arguments](#positional-arguments) for this recipe. |
 | `[private]`<sup>1.10.0</sup> | See [Private Recipes](#private-recipes). |
+| `[script(COMMAND)]`<sup>master</sup> | Execute recipe as a script interpreted by `COMMAND`. See [script recipes](#script-recipes) for more details. |
 | `[unix]`<sup>1.8.0</sup> | Enable recipe on Unixes. (Includes MacOS). |
 | `[windows]`<sup>1.8.0</sup> | 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)]` attribute<sup>master</sup> 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 bd4319ba..c1b1a7d6 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 699d1495..fb016063 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -20,6 +20,7 @@ pub(crate) enum Attribute<'src> {
   NoQuiet,
   PositionalArguments,
   Private,
+  Script(Vec<StringLiteral<'src>>),
   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<StringLiteral<'src>>,
+    arguments: Vec<StringLiteral<'src>>,
   ) -> 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 1056f37e..b2396219 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 7400b292..73c3960e 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<env::VarError>,
   },
diff --git a/src/error.rs b/src/error.rs
index 31a8bd4b..cd22311e 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<String>,
+    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 00000000..1345d902
--- /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<D>(&self, recipe: &Recipe<D>, 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 4245a15c..d4efb532 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 6b0de6c7..6218f744 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<Fragment<'src>>,
+  #[serde(skip)]
+  pub(crate) number: usize,
 }
 
 impl<'src> Line<'src> {
diff --git a/src/parser.rs b/src/parser.rs
index f337b90f..27b342da 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<Attribute<'src>>)>> {
@@ -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 01bf74b5..ece80fe1 100644
--- a/src/platform.rs
+++ b/src/platform.rs
@@ -10,13 +10,13 @@ impl PlatformInterface for Platform {
     _shebang: Shebang,
   ) -> Result<Command, OutputError> {
     // 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 2ba92470..ac95199a 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 36b79dce..2d2a6016 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 84fb3a23..98eb4937 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."),
     }
   }
 }


Test patch:
diff --git a/tests/confirm.rs b/tests/confirm.rs
index b1a2657d..3efa44f0 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 29a12ba4..013c0a27 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 072574bd..3f8fd0d0 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 ad1055a9..bc13fe19 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 00000000..95757bdc
--- /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 4f89792b..63b07a7d 100644
--- a/tests/tempdir.rs
+++ b/tests/tempdir.rs
@@ -37,7 +37,6 @@ fn test_tempdir_is_set() {
 
 
 
-
       cat just*/foo
       "
     } else {