diff --git a/src/justfile.rs b/src/justfile.rs index 08a8bd2..709a29e 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -302,7 +302,7 @@ impl<'src> Justfile<'src> { let mut evaluator = Evaluator::recipe_evaluator(context.config, dotenv, &scope, context.settings, search); - for Dependency { recipe, arguments } in &recipe.dependencies { + for Dependency { recipe, arguments } in recipe.dependencies.iter().take(recipe.priors) { let mut invocation = vec![recipe.name().to_owned()]; for argument in arguments { @@ -321,6 +321,27 @@ impl<'src> Justfile<'src> { recipe.run(context, dotenv, scope.child(), search, &positional)?; + { + let mut ran = BTreeSet::new(); + + for Dependency { recipe, arguments } in recipe.dependencies.iter().skip(recipe.priors) { + let mut evaluated = Vec::new(); + + for argument in arguments { + evaluated.push(evaluator.evaluate_expression(argument)?); + } + + self.run_recipe( + context, + recipe, + &evaluated.iter().map(String::as_ref).collect::>(), + dotenv, + search, + &mut ran, + )?; + } + } + let mut invocation = vec![recipe.name().to_owned()]; for argument in arguments.iter().cloned() { invocation.push(argument.to_owned()); diff --git a/src/lexer.rs b/src/lexer.rs index d6a4de1..04527d3 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -479,7 +479,8 @@ impl<'src> Lexer<'src> { /// Lex token beginning with `start` outside of a recipe body fn lex_normal(&mut self, start: char) -> CompilationResult<'src, ()> { match start { - '!' => self.lex_bang(), + '&' => self.lex_digraph('&', '&', AmpersandAmpersand), + '!' => self.lex_digraph('!', '=', BangEquals), '*' => self.lex_single(Asterisk), '$' => self.lex_single(Dollar), '@' => self.lex_single(At), @@ -679,25 +680,30 @@ impl<'src> Lexer<'src> { !self.open_delimiters.is_empty() } - /// Lex a token starting with '!' - fn lex_bang(&mut self) -> CompilationResult<'src, ()> { - self.presume('!')?; + /// Lex a two-character digraph + fn lex_digraph( + &mut self, + left: char, + right: char, + token: TokenKind, + ) -> CompilationResult<'src, ()> { + self.presume(left)?; - if self.accepted('=')? { - self.token(BangEquals); + if self.accepted(right)? { + self.token(token); Ok(()) } else { // Emit an unspecified token to consume the current character, self.token(Unspecified); if self.at_eof() { - return Err(self.error(UnexpectedEndOfToken { expected: '=' })); + return Err(self.error(UnexpectedEndOfToken { expected: right })); } // …and advance past another character, self.advance()?; // …so that the error we produce highlights the unexpected character. - Err(self.error(UnexpectedCharacter { expected: '=' })) + Err(self.error(UnexpectedCharacter { expected: right })) } } @@ -919,6 +925,7 @@ mod tests { fn default_lexeme(kind: TokenKind) -> &'static str { match kind { // Fixed lexemes + AmpersandAmpersand => "&&", Asterisk => "*", At => "@", BangEquals => "!=", @@ -1048,6 +1055,12 @@ mod tests { tokens: (StringToken:"\"\"\"hello\ngoodbye\"\"\""), } + test! { + name: ampersand_ampersand, + text: "&&", + tokens: (AmpersandAmpersand), + } + test! { name: equals, text: "=", @@ -2109,16 +2122,6 @@ mod tests { kind: UnpairedCarriageReturn, } - error! { - name: unknown_start_of_token_ampersand, - input: " \r\n&", - offset: 3, - line: 1, - column: 0, - width: 1, - kind: UnknownStartOfToken, - } - error! { name: unknown_start_of_token_tilde, input: "~", @@ -2257,6 +2260,29 @@ mod tests { }, } + error! { + name: ampersand_eof, + input: "&", + offset: 1, + line: 0, + column: 1, + width: 0, + kind: UnexpectedEndOfToken { + expected: '&', + }, + } + error! { + name: ampersand_unexpected, + input: "&%", + offset: 1, + line: 0, + column: 1, + width: 1, + kind: UnexpectedCharacter { + expected: '&', + }, + } + #[test] fn presume_error() { assert_matches!( diff --git a/src/node.rs b/src/node.rs index ee66667..897da9a 100644 --- a/src/node.rs +++ b/src/node.rs @@ -145,18 +145,29 @@ impl<'src> Node<'src> for UnresolvedRecipe<'src> { if !self.dependencies.is_empty() { let mut dependencies = Tree::atom("deps"); + let mut subsequents = Tree::atom("sups"); - for dependency in &self.dependencies { + for (i, dependency) in self.dependencies.iter().enumerate() { let mut d = Tree::atom(dependency.recipe.lexeme()); for argument in &dependency.arguments { d.push_mut(argument.tree()); } - dependencies.push_mut(d); + if i < self.priors { + dependencies.push_mut(d); + } else { + subsequents.push_mut(d); + } } - t.push_mut(dependencies); + if let Tree::List(_) = dependencies { + t.push_mut(dependencies); + } + + if let Tree::List(_) = subsequents { + t.push_mut(subsequents); + } } if !self.body.is_empty() { diff --git a/src/parser.rs b/src/parser.rs index 0453177..66d62cc 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -627,19 +627,36 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { dependencies.push(dependency); } + let priors = dependencies.len(); + + if self.accepted(AmpersandAmpersand)? { + let mut subsequents = Vec::new(); + + while let Some(subsequent) = self.accept_dependency()? { + subsequents.push(subsequent); + } + + if subsequents.is_empty() { + return Err(self.unexpected_token()?); + } + + dependencies.append(&mut subsequents); + } + self.expect_eol()?; let body = self.parse_body()?; Ok(Recipe { + parameters: positional.into_iter().chain(variadic).collect(), private: name.lexeme().starts_with('_'), shebang: body.first().map(Line::is_shebang).unwrap_or(false), - parameters: positional.into_iter().chain(variadic).collect(), + priors, + body, + dependencies, doc, name, quiet, - dependencies, - body, }) } @@ -1102,6 +1119,12 @@ mod tests { tree: (justfile (recipe foo (deps (bar (+ 'a' 'b') (+ 'c' 'd'))))), } + test! { + name: recipe_subsequent, + text: "foo: && bar", + tree: (justfile (recipe foo (sups bar))), + } + test! { name: recipe_line_single, text: "foo:\n bar", @@ -1885,10 +1908,13 @@ mod tests { name: missing_eol, input: "a b c: z =", offset: 9, - line: 0, - column: 9, - width: 1, - kind: UnexpectedToken{expected: vec![Comment, Eof, Eol, Identifier, ParenL], found: Equals}, + line: 0, + column: 9, + width: 1, + kind: UnexpectedToken{ + expected: vec![AmpersandAmpersand, Comment, Eof, Eol, Identifier, ParenL], + found: Equals + }, } error! { diff --git a/src/recipe.rs b/src/recipe.rs index c7b2d71..bc3832a 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -25,14 +25,15 @@ fn error_from_signal( /// A recipe, e.g. `foo: bar baz` #[derive(PartialEq, Debug, Clone)] pub(crate) struct Recipe<'src, D = Dependency<'src>> { + pub(crate) body: Vec>, pub(crate) dependencies: Vec, pub(crate) doc: Option<&'src str>, - pub(crate) body: Vec>, pub(crate) name: Name<'src>, pub(crate) parameters: Vec>, pub(crate) private: bool, pub(crate) quiet: bool, pub(crate) shebang: bool, + pub(crate) priors: usize, } impl<'src, D> Recipe<'src, D> { @@ -330,7 +331,12 @@ impl<'src, D: Display> Display for Recipe<'src, D> { write!(f, " {}", parameter)?; } write!(f, ":")?; - for dependency in &self.dependencies { + + for (i, dependency) in self.dependencies.iter().enumerate() { + if i == self.priors { + write!(f, " &&")?; + } + write!(f, " {}", dependency)?; } diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs index 27e134c..0c5e6cb 100644 --- a/src/recipe_resolver.rs +++ b/src/recipe_resolver.rs @@ -113,9 +113,10 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> { } } + stack.pop(); + let resolved = Rc::new(recipe.resolve(dependencies)?); self.resolved_recipes.insert(Rc::clone(&resolved)); - stack.pop(); Ok(resolved) } } diff --git a/src/token_kind.rs b/src/token_kind.rs index a437762..d76c8f1 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -2,6 +2,7 @@ use crate::common::*; #[derive(Debug, PartialEq, Clone, Copy, Ord, PartialOrd, Eq)] pub(crate) enum TokenKind { + AmpersandAmpersand, Asterisk, At, Backtick, @@ -37,6 +38,7 @@ impl Display for TokenKind { fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { use TokenKind::*; write!(f, "{}", match *self { + AmpersandAmpersand => "'&&'", Asterisk => "'*'", At => "'@'", Backtick => "backtick", diff --git a/src/tree.rs b/src/tree.rs index fd13822..cc45f2f 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -42,6 +42,12 @@ macro_rules! tree { $crate::tree::Tree::atom("*") }; + { + && + } => { + $crate::tree::Tree::atom("&&") + }; + { == } => { diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 575c77a..125eb02 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -7,7 +7,14 @@ impl<'src> UnresolvedRecipe<'src> { self, resolved: Vec>>, ) -> CompilationResult<'src, Recipe<'src>> { - assert_eq!(self.dependencies.len(), resolved.len()); + assert_eq!( + self.dependencies.len(), + resolved.len(), + "UnresolvedRecipe::resolve: dependency count not equal to resolved count: {} != {}", + self.dependencies.len(), + resolved.len() + ); + for (unresolved, resolved) in self.dependencies.iter().zip(&resolved) { assert_eq!(unresolved.recipe.lexeme(), resolved.name.lexeme()); if !resolved @@ -36,13 +43,14 @@ impl<'src> UnresolvedRecipe<'src> { .collect(); Ok(Recipe { - doc: self.doc, body: self.body, + doc: self.doc, name: self.name, parameters: self.parameters, private: self.private, quiet: self.quiet, shebang: self.shebang, + priors: self.priors, dependencies, }) } diff --git a/tests/fmt.rs b/tests/fmt.rs index a051117..392bca2 100644 --- a/tests/fmt.rs +++ b/tests/fmt.rs @@ -944,3 +944,18 @@ test! { echo foo ", } + +test! { + name: subsequent, + justfile: " + bar: + foo: && bar + echo foo", + args: ("--dump"), + stdout: " + bar: + + foo: && bar + echo foo + ", +} diff --git a/tests/lib.rs b/tests/lib.rs index a9e60db..46aacfb 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -3,11 +3,9 @@ mod test; mod assert_stdout; mod assert_success; -mod common; -mod tempdir; - mod choose; mod command; +mod common; mod completions; mod conditional; mod delimiters; @@ -31,4 +29,6 @@ mod shebang; mod shell; mod string; mod sublime_syntax; +mod subsequents; +mod tempdir; mod working_directory; diff --git a/tests/misc.rs b/tests/misc.rs index 9ced506..cc4b75d 100644 --- a/tests/misc.rs +++ b/tests/misc.rs @@ -1336,7 +1336,7 @@ test! { justfile: "foo: 'bar'", args: ("foo"), stdout: "", - stderr: "error: Expected comment, end of file, end of line, \ + stderr: "error: Expected '&&', comment, end of file, end of line, \ identifier, or '(', but found string | 1 | foo: 'bar' diff --git a/tests/subsequents.rs b/tests/subsequents.rs new file mode 100644 index 0000000..36230b9 --- /dev/null +++ b/tests/subsequents.rs @@ -0,0 +1,151 @@ +use crate::common::*; + +test! { + name: success, + justfile: " + foo: && bar + echo foo + + bar: + echo bar + ", + stdout: " + foo + bar + ", + stderr: " + echo foo + echo bar + ", +} + +test! { + name: failure, + justfile: " + foo: && bar + echo foo + false + + bar: + echo bar + ", + stdout: " + foo + ", + stderr: " + echo foo + false + error: Recipe `foo` failed on line 3 with exit code 1 + ", + status: EXIT_FAILURE, +} + +test! { + name: circular_dependency, + justfile: " + foo: && foo + ", + stderr: " + error: Recipe `foo` depends on itself + | + 1 | foo: && foo + | ^^^ + ", + status: EXIT_FAILURE, +} + +test! { + name: unknown, + justfile: " + foo: && bar + ", + stderr: " + error: Recipe `foo` has unknown dependency `bar` + | + 1 | foo: && bar + | ^^^ + ", + status: EXIT_FAILURE, +} + +test! { + name: unknown_argument, + justfile: " + bar x: + + foo: && (bar y) + ", + stderr: " + error: Variable `y` not defined + | + 3 | foo: && (bar y) + | ^ + ", + status: EXIT_FAILURE, +} + +test! { + name: argument, + justfile: " + foo: && (bar 'hello') + + bar x: + echo {{ x }} + ", + stdout: " + hello + ", + stderr: " + echo hello + ", +} + +test! { + name: duplicate_subsequents_dont_run, + justfile: " + a: && b c + echo a + + b: d + echo b + + c: d + echo c + + d: + echo d + ", + stdout: " + a + d + b + c + ", + stderr: " + echo a + echo d + echo b + echo c + ", +} + +test! { + name: subsequents_run_even_if_already_ran_as_prior, + justfile: " + a: b && b + echo a + + b: + echo b + ", + stdout: " + b + a + b + ", + stderr: " + echo b + echo a + echo b + ", +}