Repository: casey/just
Pull request #2264 (2024-07-19 05:39:56)

Allow empty `[script]` attribute and add `set script-interpreter`


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index b00ebce9..b12bfc50 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -612,6 +612,7 @@ dependencies = [
  "libc",
  "log",
  "num_cpus",
+ "once_cell",
  "percent-encoding",
  "pretty_assertions",
  "rand",
diff --git a/Cargo.toml b/Cargo.toml
index 8f64e271..36d370fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ lexiclean = "0.0.1"
 libc = "0.2.0"
 log = "0.4.4"
 num_cpus = "1.15.0"
+once_cell = "1.19.0"
 percent-encoding = "2.3.1"
 rand = "0.8.5"
 regex = "1.10.4"
diff --git a/src/analyzer.rs b/src/analyzer.rs
index c1b1a7d6..cdb598d8 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -186,17 +186,20 @@ impl<'src> Analyzer<'src> {
 
     let root = paths.get(root).unwrap();
 
-    let unstable_features = recipes
-      .values()
-      .flat_map(|recipe| &recipe.attributes)
-      .filter_map(|attribute| {
+    let mut unstable_features = BTreeSet::new();
+
+    for recipe in recipes.values() {
+      for attribute in &recipe.attributes {
         if let Attribute::Script(_) = attribute {
-          Some(UnstableFeature::ScriptAttribute)
-        } else {
-          None
+          unstable_features.insert(UnstableFeature::ScriptAttribute);
+          break;
         }
-      })
-      .collect();
+      }
+    }
+
+    if settings.script_interpreter.is_some() {
+      unstable_features.insert(UnstableFeature::ScriptInterpreterSetting);
+    }
 
     Ok(Justfile {
       aliases,
diff --git a/src/attribute.rs b/src/attribute.rs
index fb016063..ebdc2127 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -20,7 +20,7 @@ pub(crate) enum Attribute<'src> {
   NoQuiet,
   PositionalArguments,
   Private,
-  Script(Vec<StringLiteral<'src>>),
+  Script(Option<Interpreter<'src>>),
   Unix,
   Windows,
 }
@@ -39,7 +39,7 @@ impl AttributeDiscriminant {
       | Self::Private
       | Self::Unix
       | Self::Windows => 0..=0,
-      Self::Script => 1..=usize::MAX,
+      Self::Script => 0..=usize::MAX,
     }
   }
 }
@@ -84,7 +84,13 @@ impl<'src> Attribute<'src> {
       AttributeDiscriminant::NoQuiet => Self::NoQuiet,
       AttributeDiscriminant::PositionalArguments => Self::PositionalArguments,
       AttributeDiscriminant::Private => Self::Private,
-      AttributeDiscriminant::Script => Self::Script(arguments),
+      AttributeDiscriminant::Script => Self::Script({
+        let mut arguments = arguments.into_iter();
+        arguments.next().map(|command| Interpreter {
+          command,
+          arguments: arguments.collect(),
+        })
+      }),
       AttributeDiscriminant::Unix => Self::Unix,
       AttributeDiscriminant::Windows => Self::Windows,
     })
@@ -93,14 +99,18 @@ impl<'src> Attribute<'src> {
   pub(crate) fn name(&self) -> &'static str {
     self.into()
   }
+}
+
+impl<'src> Display for Attribute<'src> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+    write!(f, "{}", self.name())?;
 
-  fn arguments(&self) -> &[StringLiteral] {
     match self {
       Self::Confirm(Some(argument))
       | Self::Doc(Some(argument))
       | Self::Extension(argument)
-      | Self::Group(argument) => slice::from_ref(argument),
-      Self::Script(arguments) => arguments,
+      | Self::Group(argument) => write!(f, "({argument})")?,
+      Self::Script(Some(shell)) => write!(f, "({shell})")?,
       Self::Confirm(None)
       | Self::Doc(None)
       | Self::Linux
@@ -110,30 +120,9 @@ impl<'src> Attribute<'src> {
       | Self::NoQuiet
       | Self::PositionalArguments
       | Self::Private
+      | Self::Script(None)
       | Self::Unix
-      | Self::Windows => &[],
-    }
-  }
-}
-
-impl<'src> Display for Attribute<'src> {
-  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-    write!(f, "{}", self.name())?;
-
-    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, ")")?;
-      }
+      | Self::Windows => {}
     }
 
     Ok(())
diff --git a/src/executor.rs b/src/executor.rs
index 1345d902..6c31aacf 100644
--- a/src/executor.rs
+++ b/src/executor.rs
@@ -1,7 +1,7 @@
 use super::*;
 
 pub(crate) enum Executor<'a> {
-  Command(Vec<&'a str>),
+  Command(&'a Interpreter<'a>),
   Shebang(Shebang<'a>),
 }
 
@@ -13,15 +13,15 @@ impl<'a> Executor<'a> {
     working_directory: Option<&Path>,
   ) -> RunResult<'src, Command> {
     match self {
-      Self::Command(args) => {
-        let mut command = Command::new(args[0]);
+      Self::Command(interpreter) => {
+        let mut command = Command::new(&interpreter.command.cooked);
 
         if let Some(working_directory) = working_directory {
           command.current_dir(working_directory);
         }
 
-        for arg in &args[1..] {
-          command.arg(arg);
+        for arg in &interpreter.arguments {
+          command.arg(&arg.cooked);
         }
 
         command.arg(path);
@@ -49,7 +49,7 @@ impl<'a> Executor<'a> {
   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::Command(interpreter) => &interpreter.command.cooked,
         Self::Shebang(shebang) => shebang.interpreter_filename(),
       };
 
@@ -65,14 +65,12 @@ impl<'a> Executor<'a> {
 
   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();
+      Self::Command(Interpreter { command, arguments }) => {
+        let mut command = command.cooked.clone();
 
-        for (i, arg) in args.iter().enumerate() {
-          if i > 0 {
-            command.push(' ');
-          }
-          command.push_str(arg);
+        for arg in arguments {
+          command.push(' ');
+          command.push_str(&arg.cooked);
         }
 
         Error::Script {
@@ -152,7 +150,11 @@ mod tests {
         expected
       );
       assert_eq!(
-        Executor::Command(vec![interpreter]).script_filename(recipe, extension),
+        Executor::Command(&Interpreter {
+          command: StringLiteral::from_raw(interpreter),
+          arguments: Vec::new()
+        })
+        .script_filename(recipe, extension),
         expected
       );
     }
diff --git a/src/interpreter.rs b/src/interpreter.rs
new file mode 100644
index 00000000..eaf6c2ad
--- /dev/null
+++ b/src/interpreter.rs
@@ -0,0 +1,29 @@
+use super::*;
+
+#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
+pub(crate) struct Interpreter<'src> {
+  pub(crate) arguments: Vec<StringLiteral<'src>>,
+  pub(crate) command: StringLiteral<'src>,
+}
+
+impl<'src> Interpreter<'src> {
+  pub(crate) fn default_script_interpreter() -> &'static Interpreter<'static> {
+    static INSTANCE: Lazy<Interpreter<'static>> = Lazy::new(|| Interpreter {
+      arguments: vec![StringLiteral::from_raw("-eu")],
+      command: StringLiteral::from_raw("sh"),
+    });
+    &INSTANCE
+  }
+}
+
+impl<'src> Display for Interpreter<'src> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+    write!(f, "{}", self.command)?;
+
+    for argument in &self.arguments {
+      write!(f, ", {argument}")?;
+    }
+
+    Ok(())
+  }
+}
diff --git a/src/keyword.rs b/src/keyword.rs
index 7f8b5b3f..1461c0c9 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -21,6 +21,7 @@ pub(crate) enum Keyword {
   Mod,
   PositionalArguments,
   Quiet,
+  ScriptInterpreter,
   Set,
   Shell,
   Tempdir,
diff --git a/src/lib.rs b/src/lib.rs
index d4efb532..713c72de 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -30,21 +30,22 @@ pub(crate) use {
     constants::constants, count::Count, delimiter::Delimiter, dependency::Dependency,
     dump_format::DumpFormat, enclosure::Enclosure, error::Error, evaluator::Evaluator,
     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,
-    variables::Variables, verbosity::Verbosity, warning::Warning,
+    fragment::Fragment, function::Function, interpreter::Interpreter,
+    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,
+    show_whitespace::ShowWhitespace, source::Source, string_delimiter::StringDelimiter,
+    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, variables::Variables,
+    verbosity::Verbosity, warning::Warning,
   },
   camino::Utf8Path,
   clap::ValueEnum,
@@ -53,6 +54,7 @@ pub(crate) use {
   lexiclean::Lexiclean,
   libc::EXIT_FAILURE,
   log::{info, warn},
+  once_cell::sync::Lazy,
   regex::Regex,
   serde::{
     ser::{SerializeMap, SerializeSeq},
@@ -75,7 +77,6 @@ pub(crate) use {
     path::{self, Path, PathBuf},
     process::{self, Command, ExitStatus, Stdio},
     rc::Rc,
-    slice,
     str::{self, Chars},
     sync::{Mutex, MutexGuard, OnceLock},
     vec,
@@ -155,6 +156,7 @@ mod executor;
 mod expression;
 mod fragment;
 mod function;
+mod interpreter;
 mod interrupt_guard;
 mod interrupt_handler;
 mod item;
@@ -193,9 +195,9 @@ mod set;
 mod setting;
 mod settings;
 mod shebang;
-mod shell;
 mod show_whitespace;
 mod source;
+mod string_delimiter;
 mod string_kind;
 mod string_literal;
 mod subcommand;
diff --git a/src/node.rs b/src/node.rs
index 1de9bdcb..bd5585df 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -299,15 +299,16 @@ impl<'src> Node<'src> for Set<'src> {
       | Setting::IgnoreComments(value) => {
         set.push_mut(value.to_string());
       }
-      Setting::Shell(Shell { command, arguments })
-      | Setting::WindowsShell(Shell { command, arguments }) => {
+      Setting::ScriptInterpreter(Interpreter { command, arguments })
+      | Setting::Shell(Interpreter { command, arguments })
+      | Setting::WindowsShell(Interpreter { command, arguments }) => {
         set.push_mut(Tree::string(&command.cooked));
         for argument in arguments {
           set.push_mut(Tree::string(&argument.cooked));
         }
       }
       Setting::DotenvFilename(value) | Setting::DotenvPath(value) | Setting::Tempdir(value) => {
-        set.push_mut(Tree::string(value));
+        set.push_mut(Tree::string(&value.cooked));
       }
     }
 
diff --git a/src/parser.rs b/src/parser.rs
index 27b342da..d9985a32 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -961,11 +961,12 @@ impl<'run, 'src> Parser<'run, 'src> {
     self.expect(ColonEquals)?;
 
     let set_value = match keyword {
-      Keyword::DotenvFilename => Some(Setting::DotenvFilename(self.parse_string_literal()?.cooked)),
-      Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_string_literal()?.cooked)),
-      Keyword::Shell => Some(Setting::Shell(self.parse_shell()?)),
-      Keyword::Tempdir => Some(Setting::Tempdir(self.parse_string_literal()?.cooked)),
-      Keyword::WindowsShell => Some(Setting::WindowsShell(self.parse_shell()?)),
+      Keyword::DotenvFilename => Some(Setting::DotenvFilename(self.parse_string_literal()?)),
+      Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_string_literal()?)),
+      Keyword::ScriptInterpreter => Some(Setting::ScriptInterpreter(self.parse_interpreter()?)),
+      Keyword::Shell => Some(Setting::Shell(self.parse_interpreter()?)),
+      Keyword::Tempdir => Some(Setting::Tempdir(self.parse_string_literal()?)),
+      Keyword::WindowsShell => Some(Setting::WindowsShell(self.parse_interpreter()?)),
       _ => None,
     };
 
@@ -978,8 +979,8 @@ impl<'run, 'src> Parser<'run, 'src> {
     }))
   }
 
-  /// Parse a shell setting value
-  fn parse_shell(&mut self) -> CompileResult<'src, Shell<'src>> {
+  /// Parse interpreter setting value, i.e., `['sh', '-eu']`
+  fn parse_interpreter(&mut self) -> CompileResult<'src, Interpreter<'src>> {
     self.expect(BracketL)?;
 
     let command = self.parse_string_literal()?;
@@ -998,7 +999,7 @@ impl<'run, 'src> Parser<'run, 'src> {
 
     self.expect(BracketR)?;
 
-    Ok(Shell { arguments, command })
+    Ok(Interpreter { arguments, command })
   }
 
   /// Item attributes, i.e., `[macos]` or `[confirm: "warning!"]`
diff --git a/src/recipe.rs b/src/recipe.rs
index ac95199a..7ae9641a 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -342,12 +342,17 @@ impl<'src, D> Recipe<'src, D> {
       return Ok(());
     }
 
-    let executor = if let Some(Attribute::Script(args)) = self
+    let executor = if let Some(Attribute::Script(interpreter)) = self
       .attributes
       .iter()
       .find(|attribute| matches!(attribute, Attribute::Script(_)))
     {
-      Executor::Command(args.iter().map(|arg| arg.cooked.as_str()).collect())
+      Executor::Command(
+        interpreter
+          .as_ref()
+          .or(context.settings.script_interpreter.as_ref())
+          .unwrap_or_else(|| Interpreter::default_script_interpreter()),
+      )
     } else {
       let line = evaluated_lines
         .first()
diff --git a/src/setting.rs b/src/setting.rs
index f19dab47..342cd878 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -4,20 +4,21 @@ use super::*;
 pub(crate) enum Setting<'src> {
   AllowDuplicateRecipes(bool),
   AllowDuplicateVariables(bool),
-  DotenvFilename(String),
+  DotenvFilename(StringLiteral<'src>),
   DotenvLoad(bool),
-  DotenvPath(String),
+  DotenvPath(StringLiteral<'src>),
   DotenvRequired(bool),
   Export(bool),
   Fallback(bool),
   IgnoreComments(bool),
   PositionalArguments(bool),
   Quiet(bool),
-  Shell(Shell<'src>),
-  Tempdir(String),
+  ScriptInterpreter(Interpreter<'src>),
+  Shell(Interpreter<'src>),
+  Tempdir(StringLiteral<'src>),
   Unstable(bool),
   WindowsPowerShell(bool),
-  WindowsShell(Shell<'src>),
+  WindowsShell(Interpreter<'src>),
 }
 
 impl<'src> Display for Setting<'src> {
@@ -34,9 +35,11 @@ impl<'src> Display for Setting<'src> {
       | Self::Quiet(value)
       | Self::Unstable(value)
       | Self::WindowsPowerShell(value) => write!(f, "{value}"),
-      Self::Shell(shell) | Self::WindowsShell(shell) => write!(f, "{shell}"),
+      Self::ScriptInterpreter(shell) | Self::Shell(shell) | Self::WindowsShell(shell) => {
+        write!(f, "[{shell}]")
+      }
       Self::DotenvFilename(value) | Self::DotenvPath(value) | Self::Tempdir(value) => {
-        write!(f, "{value:?}")
+        write!(f, "{value}")
       }
     }
   }
diff --git a/src/settings.rs b/src/settings.rs
index 338f2cc7..7ea64dba 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -18,11 +18,13 @@ pub(crate) struct Settings<'src> {
   pub(crate) ignore_comments: bool,
   pub(crate) positional_arguments: bool,
   pub(crate) quiet: bool,
-  pub(crate) shell: Option<Shell<'src>>,
+  #[serde(skip)]
+  pub(crate) script_interpreter: Option<Interpreter<'src>>,
+  pub(crate) shell: Option<Interpreter<'src>>,
   pub(crate) tempdir: Option<String>,
   pub(crate) unstable: bool,
   pub(crate) windows_powershell: bool,
-  pub(crate) windows_shell: Option<Shell<'src>>,
+  pub(crate) windows_shell: Option<Interpreter<'src>>,
 }
 
 impl<'src> Settings<'src> {
@@ -38,13 +40,13 @@ impl<'src> Settings<'src> {
           settings.allow_duplicate_variables = allow_duplicate_variables;
         }
         Setting::DotenvFilename(filename) => {
-          settings.dotenv_filename = Some(filename);
+          settings.dotenv_filename = Some(filename.cooked);
         }
         Setting::DotenvLoad(dotenv_load) => {
           settings.dotenv_load = dotenv_load;
         }
         Setting::DotenvPath(path) => {
-          settings.dotenv_path = Some(PathBuf::from(path));
+          settings.dotenv_path = Some(PathBuf::from(path.cooked));
         }
         Setting::DotenvRequired(dotenv_required) => {
           settings.dotenv_required = dotenv_required;
@@ -64,6 +66,9 @@ impl<'src> Settings<'src> {
         Setting::Quiet(quiet) => {
           settings.quiet = quiet;
         }
+        Setting::ScriptInterpreter(script_interpreter) => {
+          settings.script_interpreter = Some(script_interpreter);
+        }
         Setting::Shell(shell) => {
           settings.shell = Some(shell);
         }
@@ -77,7 +82,7 @@ impl<'src> Settings<'src> {
           settings.windows_shell = Some(windows_shell);
         }
         Setting::Tempdir(tempdir) => {
-          settings.tempdir = Some(tempdir);
+          settings.tempdir = Some(tempdir.cooked);
         }
       }
     }
@@ -204,7 +209,7 @@ mod tests {
   #[test]
   fn shell_cooked() {
     let settings = Settings {
-      shell: Some(Shell {
+      shell: Some(Interpreter {
         command: StringLiteral {
           kind: StringKind::from_token_start("\"").unwrap(),
           raw: "asdf.exe",
diff --git a/src/shell.rs b/src/shell.rs
deleted file mode 100644
index f637a7e7..00000000
--- a/src/shell.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-use super::*;
-
-#[derive(Debug, Clone, PartialEq, Serialize)]
-pub(crate) struct Shell<'src> {
-  pub(crate) arguments: Vec<StringLiteral<'src>>,
-  pub(crate) command: StringLiteral<'src>,
-}
-
-impl<'src> Display for Shell<'src> {
-  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-    write!(f, "[{}", self.command)?;
-
-    for argument in &self.arguments {
-      write!(f, ", {argument}")?;
-    }
-
-    write!(f, "]")
-  }
-}
diff --git a/src/string_delimiter.rs b/src/string_delimiter.rs
new file mode 100644
index 00000000..99dd32b5
--- /dev/null
+++ b/src/string_delimiter.rs
@@ -0,0 +1,6 @@
+#[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)]
+pub(crate) enum StringDelimiter {
+  Backtick,
+  QuoteDouble,
+  QuoteSingle,
+}
diff --git a/src/string_kind.rs b/src/string_kind.rs
index 06b530f3..a03692f9 100644
--- a/src/string_kind.rs
+++ b/src/string_kind.rs
@@ -2,15 +2,8 @@ use super::*;
 
 #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)]
 pub(crate) struct StringKind {
-  delimiter: StringDelimiter,
-  indented: bool,
-}
-
-#[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)]
-enum StringDelimiter {
-  Backtick,
-  QuoteDouble,
-  QuoteSingle,
+  pub(crate) delimiter: StringDelimiter,
+  pub(crate) indented: bool,
 }
 
 impl StringKind {
diff --git a/src/string_literal.rs b/src/string_literal.rs
index 05b9c28f..2ca411cc 100644
--- a/src/string_literal.rs
+++ b/src/string_literal.rs
@@ -8,7 +8,21 @@ pub(crate) struct StringLiteral<'src> {
   pub(crate) raw: &'src str,
 }
 
-impl Display for StringLiteral<'_> {
+impl<'src> StringLiteral<'src> {
+  pub(crate) fn from_raw(raw: &'src str) -> Self {
+    Self {
+      cooked: raw.into(),
+      expand: false,
+      kind: StringKind {
+        delimiter: StringDelimiter::QuoteSingle,
+        indented: false,
+      },
+      raw,
+    }
+  }
+}
+
+impl<'src> Display for StringLiteral<'src> {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     if self.expand {
       write!(f, "x")?;
diff --git a/src/unstable_feature.rs b/src/unstable_feature.rs
index 98eb4937..07d99540 100644
--- a/src/unstable_feature.rs
+++ b/src/unstable_feature.rs
@@ -4,6 +4,7 @@ use super::*;
 pub(crate) enum UnstableFeature {
   FormatSubcommand,
   ScriptAttribute,
+  ScriptInterpreterSetting,
 }
 
 impl Display for UnstableFeature {
@@ -11,6 +12,9 @@ impl Display for UnstableFeature {
     match self {
       Self::FormatSubcommand => write!(f, "The `--fmt` command is currently unstable."),
       Self::ScriptAttribute => write!(f, "The `[script]` attribute is currently unstable."),
+      Self::ScriptInterpreterSetting => {
+        write!(f, "The `script-interpreter` setting is currently unstable.")
+      }
     }
   }
 }


Test patch:
diff --git a/tests/script.rs b/tests/script.rs
index 95757bdc..0a293165 100644
--- a/tests/script.rs
+++ b/tests/script.rs
@@ -8,7 +8,6 @@ fn unstable() {
         [script('sh', '-u')]
         foo:
           echo FOO
-
       ",
     )
     .stderr_regex(r"error: The `\[script\]` attribute is currently unstable\..*")
@@ -16,6 +15,15 @@ fn unstable() {
     .run();
 }
 
+#[test]
+fn script_interpreter_setting_is_unstable() {
+  Test::new()
+    .justfile("set script-interpreter := ['sh']")
+    .status(EXIT_FAILURE)
+    .stderr_regex(r"error: The `script-interpreter` setting is currently unstable\..*")
+    .run();
+}
+
 #[test]
 fn runs_with_command() {
   Test::new()
@@ -73,30 +81,6 @@ fn with_arguments() {
     .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()
@@ -298,3 +282,53 @@ c
     )
     .run();
 }
+
+#[test]
+fn no_arguments_with_default_script_interpreter() {
+  Test::new()
+    .justfile(
+      "
+        set unstable
+
+        [script]
+        foo:
+          case $- in
+            *e*) echo '-e is set';;
+          esac
+
+          case $- in
+            *u*) echo '-u is set';;
+          esac
+      ",
+    )
+    .stdout(
+      "
+        -e is set
+        -u is set
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn no_arguments_with_non_default_script_interpreter() {
+  Test::new()
+    .justfile(
+      "
+        set unstable
+
+        set script-interpreter := ['sh']
+
+        [script]
+        foo:
+          case $- in
+            *e*) echo '-e is set';;
+          esac
+
+          case $- in
+            *u*) echo '-u is set';;
+          esac
+      ",
+    )
+    .run();
+}