From fdc9245c5d5023daeb8be1c920e8908444a31ae8 Mon Sep 17 00:00:00 2001 From: Ben Heidemann Date: Wed, 27 Nov 2024 23:52:44 +0000 Subject: [PATCH] Add `[working-directory]` recipe attribute (#2438) --- README.md | 25 ++++++++++++- src/attribute.rs | 9 ++++- src/compile_error.rs | 4 ++ src/compile_error_kind.rs | 3 ++ src/parser.rs | 16 ++++++++ src/recipe.rs | 16 ++++++-- tests/test.rs | 22 ++++++++++- tests/working_directory.rs | 77 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0af86582dc..27e3307a50 100644 --- a/README.md +++ b/README.md @@ -884,8 +884,8 @@ $ just bar /subdir ``` -You can override working directory with `set working-directory := '…'`, whose value -is relative to the default working directory. +You can override the working directory for all recipes with +`set working-directory := '…'`: ```just set working-directory := 'bar' @@ -901,6 +901,26 @@ $ just foo /home/bob/bar ``` +You can override the working directory for a specific recipe with the +`working-directory` attributemaster: + +```just +[working-directory: 'bar'] +@foo: + pwd +``` + +```console +$ pwd +/home/bob +$ just foo +/home/bob/bar +``` + +The argument to the `working-directory` setting or `working-directory` +attribute may be absolute or relative. If it is relative it is interpreted +relative to the default working directory. + ### Aliases Aliases allow recipes to be invoked on the command line with alternative names: @@ -1972,6 +1992,7 @@ change their behavior. | `[script(COMMAND)]`1.32.0 | recipe | Execute recipe as a script interpreted by `COMMAND`. See [script recipes](#script-recipes) for more details. | | `[unix]`1.8.0 | recipe | Enable recipe on Unixes. (Includes MacOS). | | `[windows]`1.8.0 | recipe | Enable recipe on Windows. | +| `[working-directory(PATH)]`master | recipe | Set recipe working directory. `PATH` may be relative or absolute. If relative, it is interpreted relative to the default working directory. | A recipe can have multiple attributes, either on multiple lines: diff --git a/src/attribute.rs b/src/attribute.rs index e6e9f52a40..b2710d3dbd 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -23,13 +23,14 @@ pub(crate) enum Attribute<'src> { Script(Option>), Unix, Windows, + WorkingDirectory(StringLiteral<'src>), } impl AttributeDiscriminant { fn argument_range(self) -> RangeInclusive { match self { Self::Confirm | Self::Doc => 0..=1, - Self::Group | Self::Extension => 1..=1, + Self::Group | Self::Extension | Self::WorkingDirectory => 1..=1, Self::Linux | Self::Macos | Self::NoCd @@ -93,6 +94,9 @@ impl<'src> Attribute<'src> { }), AttributeDiscriminant::Unix => Self::Unix, AttributeDiscriminant::Windows => Self::Windows, + AttributeDiscriminant::WorkingDirectory => { + Self::WorkingDirectory(arguments.into_iter().next().unwrap()) + } }) } @@ -117,7 +121,8 @@ impl<'src> Display for Attribute<'src> { Self::Confirm(Some(argument)) | Self::Doc(Some(argument)) | Self::Extension(argument) - | Self::Group(argument) => write!(f, "({argument})")?, + | Self::Group(argument) + | Self::WorkingDirectory(argument) => write!(f, "({argument})")?, Self::Script(Some(shell)) => write!(f, "({shell})")?, Self::Confirm(None) | Self::Doc(None) diff --git a/src/compile_error.rs b/src/compile_error.rs index 8b4ec8ded6..7fa2e0a3cb 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -203,6 +203,10 @@ impl Display for CompileError<'_> { consist of tabs or spaces, but not both", ShowWhitespace(whitespace) ), + NoCdAndWorkingDirectoryAttribute { recipe } => write!( + f, + "Recipe `{recipe}` has both `[no-cd]` and `[working-directory]` attributes" + ), ParameterFollowsVariadicParameter { parameter } => { write!(f, "Parameter `{parameter}` follows variadic parameter") } diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 99943fc76b..bc013b9025 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -87,6 +87,9 @@ pub(crate) enum CompileErrorKind<'src> { MixedLeadingWhitespace { whitespace: &'src str, }, + NoCdAndWorkingDirectoryAttribute { + recipe: &'src str, + }, ParameterFollowsVariadicParameter { parameter: &'src str, }, diff --git a/src/parser.rs b/src/parser.rs index b5f2ed5f7d..32a5575159 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -934,6 +934,22 @@ impl<'run, 'src> Parser<'run, 'src> { })); } + let working_directory = attributes + .iter() + .any(|attribute| matches!(attribute, Attribute::WorkingDirectory(_))); + + if working_directory { + for attribute in &attributes { + if let Attribute::NoCd = attribute { + return Err( + name.error(CompileErrorKind::NoCdAndWorkingDirectoryAttribute { + recipe: name.lexeme(), + }), + ); + } + } + } + let private = name.lexeme().starts_with('_') || attributes.contains(&Attribute::Private); let mut doc = doc.map(ToOwned::to_owned); diff --git a/src/recipe.rs b/src/recipe.rs index 0b6cdacfb0..b07de478dc 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -131,11 +131,19 @@ impl<'src, D> Recipe<'src, D> { } fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option { - if self.change_directory() { - Some(context.working_directory()) - } else { - None + if !self.change_directory() { + return None; } + + let working_directory = context.working_directory(); + + for attribute in &self.attributes { + if let Attribute::WorkingDirectory(dir) = attribute { + return Some(working_directory.join(&dir.cooked)); + } + } + + Some(working_directory) } fn no_quiet(&self) -> bool { diff --git a/tests/test.rs b/tests/test.rs index a1c65ac201..41c0db5786 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -48,6 +48,7 @@ pub(crate) struct Test { pub(crate) args: Vec, pub(crate) current_dir: PathBuf, pub(crate) env: BTreeMap, + pub(crate) expected_files: BTreeMap>, pub(crate) justfile: Option, pub(crate) shell: bool, pub(crate) status: i32, @@ -71,6 +72,7 @@ impl Test { args: Vec::new(), current_dir: PathBuf::new(), env: BTreeMap::new(), + expected_files: BTreeMap::new(), justfile: Some(String::new()), shell: true, status: EXIT_SUCCESS, @@ -98,7 +100,7 @@ impl Test { } pub(crate) fn create_dir(self, path: impl AsRef) -> Self { - fs::create_dir_all(self.tempdir.path().join(path.as_ref())).unwrap(); + fs::create_dir_all(self.tempdir.path().join(path)).unwrap(); self } @@ -195,6 +197,14 @@ impl Test { fs::write(path, content).unwrap(); self } + + pub(crate) fn expect_file(mut self, path: impl AsRef, content: impl AsRef<[u8]>) -> Self { + let path = path.as_ref(); + self + .expected_files + .insert(path.into(), content.as_ref().into()); + self + } } impl Test { @@ -283,6 +293,16 @@ impl Test { panic!("Output mismatch."); } + for (path, expected) in &self.expected_files { + let actual = fs::read(self.tempdir.path().join(path)).unwrap(); + assert_eq!( + actual, + expected.as_slice(), + "mismatch for expected file at path {}", + path.display(), + ); + } + if self.test_round_trip && self.status == EXIT_SUCCESS { self.round_trip(); } diff --git a/tests/working_directory.rs b/tests/working_directory.rs index 3396b73eb7..bfae635f5c 100644 --- a/tests/working_directory.rs +++ b/tests/working_directory.rs @@ -331,3 +331,80 @@ file := shell('cat file.txt') .stdout("FILE\n") .run(); } + +#[test] +fn attribute_duplicate() { + Test::new() + .justfile( + " + [working-directory('bar')] + [working-directory('baz')] + foo: + ", + ) + .stderr( + "error: Recipe attribute `working-directory` first used on line 1 is duplicated on line 2 + ——▶ justfile:2:2 + │ +2 │ [working-directory('baz')] + │ ^^^^^^^^^^^^^^^^^ +", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn attribute() { + Test::new() + .justfile( + " + [working-directory('foo')] + @qux: + echo baz > bar + ", + ) + .create_dir("foo") + .expect_file("foo/bar", "baz\n") + .run(); +} + +#[test] +fn attribute_with_nocd_is_forbidden() { + Test::new() + .justfile( + " + [working-directory('foo')] + [no-cd] + bar: + ", + ) + .stderr( + " + error: Recipe `bar` has both `[no-cd]` and `[working-directory]` attributes + ——▶ justfile:3:1 + │ + 3 │ bar: + │ ^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn setting_and_attribute() { + Test::new() + .justfile( + " + set working-directory := 'foo' + + [working-directory('bar')] + @baz: + echo bob > fred + ", + ) + .create_dir("foo/bar") + .expect_file("foo/bar/fred", "bob\n") + .run(); +}