From a3693f3e8f2561f3c810e96c95ee1a5cd8e40554 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Mon, 15 Jul 2024 13:08:28 -0700 Subject: [PATCH] Add `[extension: 'EXT']` attribute to set shebang recipe script file extension (#2256) --- README.md | 1 + src/analyzer.rs | 14 +++++++++++ src/attribute.rs | 35 +++++++++++++-------------- src/recipe.rs | 11 ++++++++- src/shebang.rs | 59 +++++++++++++++++++++++++++++++++++---------- tests/attributes.rs | 37 ++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 17bbfad..9d4f9f6 100644 --- a/README.md +++ b/README.md @@ -1712,6 +1712,7 @@ Recipes may be annotated with attributes that change their behavior. | `[confirm]`1.17.0 | Require confirmation prior to executing recipe. | | `[confirm('PROMPT')]`1.23.0 | Require confirmation prior to executing recipe with a custom prompt. | | `[doc('DOC')]`1.27.0 | Set recipe's [documentation comment](#documentation-comments) to `DOC`. | +| `[extension('EXT')]`master | Set shebang recipe script's file extension to `EXT`. `EXT` should include a period if one is desired. | | `[group('NAME')]`1.27.0 | Put recipe in [recipe group](#recipe-groups) `NAME`. | | `[linux]`1.8.0 | Enable recipe on Linux. | | `[macos]`1.8.0 | Enable recipe on MacOS. | diff --git a/src/analyzer.rs b/src/analyzer.rs index 1f1cece..bd4319b 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -255,6 +255,20 @@ impl<'src> Analyzer<'src> { continued = line.is_continuation(); } + if !recipe.shebang { + if let Some(attribute) = recipe + .attributes + .iter() + .find(|attribute| matches!(attribute, Attribute::Extension(_))) + { + return Err(recipe.name.error(InvalidAttribute { + item_kind: "Recipe", + item_name: recipe.name.lexeme(), + attribute: attribute.clone(), + })); + } + } + Ok(()) } diff --git a/src/attribute.rs b/src/attribute.rs index 5176364..699d149 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -11,6 +11,7 @@ use super::*; pub(crate) enum Attribute<'src> { Confirm(Option>), Doc(Option>), + Extension(StringLiteral<'src>), Group(StringLiteral<'src>), Linux, Macos, @@ -27,7 +28,7 @@ impl AttributeDiscriminant { fn argument_range(self) -> RangeInclusive { match self { Self::Confirm | Self::Doc => 0..=1, - Self::Group => 1..=1, + Self::Group | Self::Extension => 1..=1, Self::Linux | Self::Macos | Self::NoCd @@ -46,8 +47,6 @@ impl<'src> Attribute<'src> { name: Name<'src>, argument: Option>, ) -> CompileResult<'src, Self> { - use AttributeDiscriminant::*; - let discriminant = name .lexeme() .parse::() @@ -72,18 +71,19 @@ impl<'src> Attribute<'src> { } Ok(match discriminant { - Confirm => Self::Confirm(argument), - Doc => Self::Doc(argument), - Group => Self::Group(argument.unwrap()), - Linux => Self::Linux, - Macos => Self::Macos, - NoCd => Self::NoCd, - NoExitMessage => Self::NoExitMessage, - NoQuiet => Self::NoQuiet, - PositionalArguments => Self::PositionalArguments, - Private => Self::Private, - Unix => Self::Unix, - Windows => Self::Windows, + AttributeDiscriminant::Confirm => Self::Confirm(argument), + AttributeDiscriminant::Doc => Self::Doc(argument), + AttributeDiscriminant::Extension => Self::Extension(argument.unwrap()), + AttributeDiscriminant::Group => Self::Group(argument.unwrap()), + AttributeDiscriminant::Linux => Self::Linux, + AttributeDiscriminant::Macos => Self::Macos, + AttributeDiscriminant::NoCd => Self::NoCd, + AttributeDiscriminant::NoExitMessage => Self::NoExitMessage, + AttributeDiscriminant::NoQuiet => Self::NoQuiet, + AttributeDiscriminant::PositionalArguments => Self::PositionalArguments, + AttributeDiscriminant::Private => Self::Private, + AttributeDiscriminant::Unix => Self::Unix, + AttributeDiscriminant::Windows => Self::Windows, }) } @@ -93,9 +93,8 @@ impl<'src> Attribute<'src> { fn argument(&self) -> Option<&StringLiteral> { match self { - Self::Confirm(prompt) => prompt.as_ref(), - Self::Doc(doc) => doc.as_ref(), - Self::Group(group) => Some(group), + Self::Confirm(argument) | Self::Doc(argument) => argument.as_ref(), + Self::Extension(argument) | Self::Group(argument) => Some(argument), Self::Linux | Self::Macos | Self::NoCd diff --git a/src/recipe.rs b/src/recipe.rs index a41ce93..2ba9247 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -368,7 +368,16 @@ impl<'src, D> Recipe<'src, D> { io_error: error, })?; let mut path = tempdir.path().to_path_buf(); - path.push(shebang.script_filename(self.name())); + + let extension = self.attributes.iter().find_map(|attribute| { + if let Attribute::Extension(extension) = attribute { + Some(extension.cooked.as_str()) + } else { + None + } + }); + + path.push(shebang.script_filename(self.name(), extension)); { let mut f = fs::File::create(&path).map_err(|error| Error::TempdirIo { diff --git a/src/shebang.rs b/src/shebang.rs index 067963f..36b79dc 100644 --- a/src/shebang.rs +++ b/src/shebang.rs @@ -38,12 +38,14 @@ impl<'line> Shebang<'line> { .unwrap_or(self.interpreter) } - pub(crate) fn script_filename(&self, recipe: &str) -> String { - match self.interpreter_filename() { - "cmd" | "cmd.exe" => format!("{recipe}.bat"), - "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => format!("{recipe}.ps1"), - _ => recipe.to_owned(), - } + 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 { @@ -138,7 +140,9 @@ mod tests { #[test] fn powershell_script_filename() { assert_eq!( - Shebang::new("#!powershell").unwrap().script_filename("foo"), + Shebang::new("#!powershell") + .unwrap() + .script_filename("foo", None), "foo.ps1" ); } @@ -146,7 +150,7 @@ mod tests { #[test] fn pwsh_script_filename() { assert_eq!( - Shebang::new("#!pwsh").unwrap().script_filename("foo"), + Shebang::new("#!pwsh").unwrap().script_filename("foo", None), "foo.ps1" ); } @@ -156,7 +160,7 @@ mod tests { assert_eq!( Shebang::new("#!powershell.exe") .unwrap() - .script_filename("foo"), + .script_filename("foo", None), "foo.ps1" ); } @@ -164,7 +168,9 @@ mod tests { #[test] fn pwsh_exe_script_filename() { assert_eq!( - Shebang::new("#!pwsh.exe").unwrap().script_filename("foo"), + Shebang::new("#!pwsh.exe") + .unwrap() + .script_filename("foo", None), "foo.ps1" ); } @@ -172,7 +178,7 @@ mod tests { #[test] fn cmd_script_filename() { assert_eq!( - Shebang::new("#!cmd").unwrap().script_filename("foo"), + Shebang::new("#!cmd").unwrap().script_filename("foo", None), "foo.bat" ); } @@ -180,14 +186,19 @@ mod tests { #[test] fn cmd_exe_script_filename() { assert_eq!( - Shebang::new("#!cmd.exe").unwrap().script_filename("foo"), + 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"), "foo"); + assert_eq!( + Shebang::new("#!bar").unwrap().script_filename("foo", None), + "foo" + ); } #[test] @@ -211,4 +222,26 @@ 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/tests/attributes.rs b/tests/attributes.rs index a604777..65c4d5a 100644 --- a/tests/attributes.rs +++ b/tests/attributes.rs @@ -193,3 +193,40 @@ fn doc_multiline() { ) .run(); } + +#[test] +fn extension() { + Test::new() + .justfile( + " + [extension: '.txt'] + baz: + #!/bin/sh + echo $0 + ", + ) + .stdout_regex(r"*baz\.txt\n") + .run(); +} + +#[test] +fn extension_on_linewise_error() { + Test::new() + .justfile( + " + [extension: '.txt'] + baz: + ", + ) + .stderr( + " + error: Recipe `baz` has invalid attribute `extension` + ——▶ justfile:2:1 + │ + 2 │ baz: + │ ^^^ +", + ) + .status(EXIT_FAILURE) + .run(); +}