From 8b7640b633a5b85415b56c1983bad2d351f868cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Karabulut?= Date: Wed, 26 Oct 2022 02:32:36 +0300 Subject: [PATCH] Add [no-exit-message] recipe annotation (#1354) When a recipe wraps cli tool and the tool exits with a non-zero code, just adds its own extra exit error message along with the messages from the tool. Introduce the `[no-exit-message]` attribute to suppress this additional message. --- GRAMMAR.md | 4 +- README.md | 29 +++++++++ src/attribute.rs | 13 ++++ src/compile_error.rs | 3 + src/compile_error_kind.rs | 3 + src/error.rs | 12 ++++ src/justfile.rs | 10 +++- src/lib.rs | 27 +++++---- src/parser.rs | 48 ++++++++++++++- src/recipe.rs | 5 ++ src/run.rs | 2 +- src/unresolved_recipe.rs | 1 + tests/byte_order_mark.rs | 4 +- tests/json.rs | 63 ++++++++++++++++++++ tests/lib.rs | 1 + tests/no_exit_message.rs | 122 ++++++++++++++++++++++++++++++++++++++ 16 files changed, 326 insertions(+), 21 deletions(-) create mode 100644 src/attribute.rs create mode 100644 tests/no_exit_message.rs diff --git a/GRAMMAR.md b/GRAMMAR.md index 98b0fbd..c72f1c1 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -94,7 +94,9 @@ string : STRING sequence : expression ',' sequence | expression ','? -recipe : '@'? NAME parameter* variadic? ':' dependency* body? +recipe : attribute? '@'? NAME parameter* variadic? ':' dependency* body? + +attribute : '[' NAME ']' eol parameter : '$'? NAME | '$'? NAME '=' value diff --git a/README.md b/README.md index 098c454..8ff70a2 100644 --- a/README.md +++ b/README.md @@ -1987,6 +1987,35 @@ echo 'Bar!' Bar! ``` +`just` normally prints error messages when a recipe line fails. These error +messages can be suppressed using the `[no-exit-message]` attribute. You may find +this especially useful with a recipe that recipe wraps a tool: + +```make +git *args: + @git {{args}} +``` + +```sh +$ just git status +fatal: not a git repository (or any of the parent directories): .git +error: Recipe `git` failed on line 2 with exit code 128 +``` + +Add the attribute to suppress the exit error message when the tool exits with a +non-zero code: + +```make +[no-exit-message] +git *args: + @git {{args}} +``` + +```sh +$ just git status +fatal: not a git repository (or any of the parent directories): .git +``` + ### Selecting Recipes to Run With an Interactive Chooser The `--choose` subcommand makes `just` invoke a chooser to select which recipes to run. Choosers should read lines containing recipe names from standard input and print one or more of those names separated by spaces to standard output. diff --git a/src/attribute.rs b/src/attribute.rs new file mode 100644 index 0000000..1256b54 --- /dev/null +++ b/src/attribute.rs @@ -0,0 +1,13 @@ +use super::*; + +#[derive(EnumString)] +#[strum(serialize_all = "kebab_case")] +pub(crate) enum Attribute { + NoExitMessage, +} + +impl Attribute { + pub(crate) fn from_name(name: Name) -> Option { + name.lexeme().parse().ok() + } +} diff --git a/src/compile_error.rs b/src/compile_error.rs index 0756878..1709985 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -247,6 +247,9 @@ impl Display for CompileError<'_> { UnknownAliasTarget { alias, target } => { write!(f, "Alias `{}` has an unknown target `{}`", alias, target)?; } + UnknownAttribute { attribute } => { + write!(f, "Unknown attribute `{}`", attribute)?; + } UnknownDependency { recipe, unknown } => { write!( f, diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 29f508e..3d63f4a 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -98,6 +98,9 @@ pub(crate) enum CompileErrorKind<'src> { alias: &'src str, target: &'src str, }, + UnknownAttribute { + attribute: &'src str, + }, UnknownDependency { recipe: &'src str, unknown: &'src str, diff --git a/src/error.rs b/src/error.rs index e28a231..682f00d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -35,6 +35,7 @@ pub(crate) enum Error<'src> { recipe: &'src str, line_number: Option, code: i32, + suppress_message: bool, }, CommandInvoke { binary: OsString, @@ -167,6 +168,16 @@ impl<'src> Error<'src> { message: message.into(), } } + + pub(crate) fn suppress_message(&self) -> bool { + matches!( + self, + Error::Code { + suppress_message: true, + .. + } + ) + } } impl<'src> From> for Error<'src> { @@ -323,6 +334,7 @@ impl<'src> ColorDisplay for Error<'src> { recipe, line_number, code, + .. } => { if let Some(n) = line_number { write!( diff --git a/src/justfile.rs b/src/justfile.rs index ae90df8..77c02b9 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -472,11 +472,13 @@ mod tests { recipe, line_number, code, + suppress_message, }, check: { assert_eq!(recipe, "a"); assert_eq!(code, 200); assert_eq!(line_number, None); + assert!(!suppress_message); } } @@ -491,11 +493,13 @@ mod tests { recipe, line_number, code, + suppress_message, }, check: { assert_eq!(recipe, "fail"); assert_eq!(code, 100); assert_eq!(line_number, Some(2)); + assert!(!suppress_message); } } @@ -510,11 +514,13 @@ mod tests { recipe, line_number, code, + suppress_message, }, check: { assert_eq!(recipe, "a"); assert_eq!(code, 150); assert_eq!(line_number, Some(2)); + assert!(!suppress_message); } } @@ -664,13 +670,15 @@ mod tests { "#, args: ["--quiet", "wut"], error: Code { - line_number, recipe, + line_number, + suppress_message, .. }, check: { assert_eq!(recipe, "wut"); assert_eq!(line_number, Some(7)); + assert!(!suppress_message); } } diff --git a/src/lib.rs b/src/lib.rs index 41ab113..b4b2835 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,19 +16,19 @@ pub(crate) use { crate::{ alias::Alias, analyzer::Analyzer, assignment::Assignment, - assignment_resolver::AssignmentResolver, ast::Ast, binding::Binding, color::Color, - color_display::ColorDisplay, command_ext::CommandExt, compile_error::CompileError, - compile_error_kind::CompileErrorKind, conditional_operator::ConditionalOperator, - config::Config, config_error::ConfigError, count::Count, delimiter::Delimiter, - dependency::Dependency, dump_format::DumpFormat, enclosure::Enclosure, error::Error, - evaluator::Evaluator, expression::Expression, fragment::Fragment, function::Function, - function_context::FunctionContext, 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, name::Name, ordinal::Ordinal, output::output, output_error::OutputError, - parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform, - platform_interface::PlatformInterface, position::Position, positional::Positional, - range_ext::RangeExt, recipe::Recipe, recipe_context::RecipeContext, + assignment_resolver::AssignmentResolver, ast::Ast, attribute::Attribute, binding::Binding, + color::Color, color_display::ColorDisplay, command_ext::CommandExt, + compile_error::CompileError, compile_error_kind::CompileErrorKind, + conditional_operator::ConditionalOperator, config::Config, config_error::ConfigError, + count::Count, delimiter::Delimiter, dependency::Dependency, dump_format::DumpFormat, + enclosure::Enclosure, error::Error, evaluator::Evaluator, expression::Expression, + fragment::Fragment, function::Function, function_context::FunctionContext, + 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, name::Name, ordinal::Ordinal, output::output, + output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, + platform::Platform, platform_interface::PlatformInterface, position::Position, + positional::Positional, range_ext::RangeExt, recipe::Recipe, recipe_context::RecipeContext, recipe_resolver::RecipeResolver, scope::Scope, search::Search, search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang, shell::Shell, show_whitespace::ShowWhitespace, string_kind::StringKind, @@ -116,6 +116,7 @@ mod analyzer; mod assignment; mod assignment_resolver; mod ast; +mod attribute; mod binding; mod color; mod color_display; diff --git a/src/parser.rs b/src/parser.rs index 44c06c0..0f8f81e 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -345,13 +345,20 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { items.push(Item::Assignment(self.parse_assignment(false)?)); } else { let doc = pop_doc_comment(&mut items, eol_since_last_comment); - items.push(Item::Recipe(self.parse_recipe(doc, false)?)); + items.push(Item::Recipe(self.parse_recipe(doc, false, false)?)); } } } } else if self.accepted(At)? { let doc = pop_doc_comment(&mut items, eol_since_last_comment); - items.push(Item::Recipe(self.parse_recipe(doc, true)?)); + items.push(Item::Recipe(self.parse_recipe(doc, true, false)?)); + } else if self.accepted(BracketL)? { + let Attribute::NoExitMessage = self.parse_attribute_name()?; + self.expect(BracketR)?; + self.expect_eol()?; + let quiet = self.accepted(At)?; + let doc = pop_doc_comment(&mut items, eol_since_last_comment); + items.push(Item::Recipe(self.parse_recipe(doc, quiet, true)?)); } else { return Err(self.unexpected_token()?); } @@ -595,6 +602,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { &mut self, doc: Option<&'src str>, quiet: bool, + suppress_exit_error_messages: bool, ) -> CompileResult<'src, UnresolvedRecipe<'src>> { let name = self.parse_name()?; @@ -658,6 +666,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { parameters: positional.into_iter().chain(variadic).collect(), private: name.lexeme().starts_with('_'), shebang: body.first().map_or(false, Line::is_shebang), + suppress_exit_error_messages, priors, body, dependencies, @@ -816,6 +825,16 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { Ok(Shell { arguments, command }) } + + /// Parse a recipe attribute name + fn parse_attribute_name(&mut self) -> CompileResult<'src, Attribute> { + let name = self.parse_name()?; + Attribute::from_name(name).ok_or_else(|| { + name.error(CompileErrorKind::UnknownAttribute { + attribute: name.lexeme(), + }) + }) + } } #[cfg(test)] @@ -1966,7 +1985,7 @@ mod tests { column: 0, width: 1, kind: UnexpectedToken { - expected: vec![At, Comment, Eof, Eol, Identifier], + expected: vec![At, BracketL, Comment, Eof, Eol, Identifier], found: BraceL, }, } @@ -2144,6 +2163,29 @@ mod tests { }, } + error! { + name: empty_attribute, + input: "[]\nsome_recipe:\n @exit 3", + offset: 1, + line: 0, + column: 1, + width: 1, + kind: UnexpectedToken { + expected: vec![Identifier], + found: BracketR, + }, + } + + error! { + name: unknown_attribute, + input: "[unknown]\nsome_recipe:\n @exit 3", + offset: 1, + line: 0, + column: 1, + width: 7, + kind: UnknownAttribute { attribute: "unknown" }, + } + error! { name: set_unknown, input: "set shall := []", diff --git a/src/recipe.rs b/src/recipe.rs index 698a49d..aa69839 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -30,6 +30,7 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> { pub(crate) private: bool, pub(crate) quiet: bool, pub(crate) shebang: bool, + pub(crate) suppress_exit_error_messages: bool, } impl<'src, D> Recipe<'src, D> { @@ -191,10 +192,12 @@ impl<'src, D> Recipe<'src, D> { Ok(exit_status) => { if let Some(code) = exit_status.code() { if code != 0 && !infallible_command { + let suppress_message = self.suppress_exit_error_messages; return Err(Error::Code { recipe: self.name(), line_number: Some(line_number), code, + suppress_message, }); } } else { @@ -322,10 +325,12 @@ impl<'src, D> Recipe<'src, D> { if code == 0 { Ok(()) } else { + let suppress_message = self.suppress_exit_error_messages; Err(Error::Code { recipe: self.name(), line_number: None, code, + suppress_message, }) } }, diff --git a/src/run.rs b/src/run.rs index 69477cb..9989d80 100644 --- a/src/run.rs +++ b/src/run.rs @@ -29,7 +29,7 @@ pub fn run() -> Result<(), i32> { config .and_then(|config| config.run(&loader)) .map_err(|error| { - if !verbosity.quiet() { + if !verbosity.quiet() && !error.suppress_message() { eprintln!("{}", error.color_display(color.stderr())); } error.code().unwrap_or(EXIT_FAILURE) diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 81f689b..faa1717 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -53,6 +53,7 @@ impl<'src> UnresolvedRecipe<'src> { quiet: self.quiet, shebang: self.shebang, priors: self.priors, + suppress_exit_error_messages: self.suppress_exit_error_messages, dependencies, }) } diff --git a/tests/byte_order_mark.rs b/tests/byte_order_mark.rs index 906f49d..58184a7 100644 --- a/tests/byte_order_mark.rs +++ b/tests/byte_order_mark.rs @@ -26,7 +26,7 @@ fn non_leading_byte_order_mark_produces_error() { ) .stderr( " - error: Expected \'@\', comment, end of file, end of line, or identifier, but found byte order mark + error: Expected \'@\', \'[\', comment, end of file, end of line, or identifier, but found byte order mark | 3 | \u{feff} | ^ @@ -41,7 +41,7 @@ fn dont_mention_byte_order_mark_in_errors() { .justfile("{") .stderr( " - error: Expected '@', comment, end of file, end of line, or identifier, but found '{' + error: Expected '@', '[', comment, end of file, end of line, or identifier, but found '{' | 1 | { | ^ diff --git a/tests/json.rs b/tests/json.rs index d70ea6e..399cc1f 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -36,6 +36,7 @@ fn alias() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -111,6 +112,7 @@ fn body() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -154,6 +156,7 @@ fn dependencies() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "foo": { "body": [], @@ -165,6 +168,7 @@ fn dependencies() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -238,6 +242,7 @@ fn dependency_argument() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "foo": { "body": [], @@ -256,6 +261,7 @@ fn dependency_argument() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -311,6 +317,7 @@ fn duplicate_recipes() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -348,6 +355,7 @@ fn doc_comment() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -417,6 +425,7 @@ fn parameters() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "b": { "body": [], @@ -435,6 +444,7 @@ fn parameters() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "c": { "body": [], @@ -453,6 +463,7 @@ fn parameters() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "d": { "body": [], @@ -471,6 +482,7 @@ fn parameters() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "e": { "body": [], @@ -489,6 +501,7 @@ fn parameters() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "f": { "body": [], @@ -507,6 +520,7 @@ fn parameters() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, }, "settings": { @@ -548,6 +562,7 @@ fn priors() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, }, "b": { "body": [], @@ -566,6 +581,7 @@ fn priors() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, "parameters": [], "priors": 1, }, @@ -578,6 +594,7 @@ fn priors() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, "parameters": [], "priors": 0, }, @@ -617,6 +634,7 @@ fn private() { "private": true, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -654,6 +672,7 @@ fn quiet() { "private": false, "quiet": true, "shebang": false, + "suppress_exit_error_messages": false, } }, "settings": { @@ -711,6 +730,7 @@ fn settings() { "private": false, "quiet": false, "shebang": true, + "suppress_exit_error_messages": false, } }, "settings": { @@ -754,6 +774,7 @@ fn shebang() { "private": false, "quiet": false, "shebang": true, + "suppress_exit_error_messages": false, } }, "settings": { @@ -791,6 +812,48 @@ fn simple() { "private": false, "quiet": false, "shebang": false, + "suppress_exit_error_messages": false, + } + }, + "settings": { + "allow_duplicate_recipes": false, + "dotenv_load": null, + "export": false, + "fallback": false, + "ignore_comments": false, + "positional_arguments": false, + "shell": null, + "windows_powershell": false, + "windows_shell": null, + }, + "warnings": [], + }), + ); +} + +#[test] +fn attribute() { + test( + " + [no-exit-message] + foo: + ", + json!({ + "aliases": {}, + "assignments": {}, + "first": "foo", + "recipes": { + "foo": { + "body": [], + "dependencies": [], + "doc": null, + "name": "foo", + "parameters": [], + "priors": 0, + "private": false, + "quiet": false, + "shebang": false, + "suppress_exit_error_messages": true, } }, "settings": { diff --git a/tests/lib.rs b/tests/lib.rs index 3e6f9d0..3eb692b 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -59,6 +59,7 @@ mod json; mod line_prefixes; mod misc; mod multibyte_char; +mod no_exit_message; mod parser; mod positional_arguments; mod quiet; diff --git a/tests/no_exit_message.rs b/tests/no_exit_message.rs new file mode 100644 index 0000000..0752302 --- /dev/null +++ b/tests/no_exit_message.rs @@ -0,0 +1,122 @@ +use libc::EXIT_FAILURE; + +test! { + name: recipe_exit_message_suppressed, + justfile: r#" +# This is a doc comment +[no-exit-message] +hello: + @echo "Hello, World!" + @exit 100 +"#, + stdout: "Hello, World!\n", + stderr: "", + status: 100, +} + +test! { + name: silent_recipe_exit_message_suppressed, + justfile: r#" +# This is a doc comment +[no-exit-message] +@hello: + echo "Hello, World!" + exit 100 +"#, + stdout: "Hello, World!\n", + stderr: "", + status: 100, +} + +test! { + name: recipe_has_doc_comment, + justfile: r#" +# This is a doc comment +[no-exit-message] +hello: + @exit 100 +"#, + args: ("--list"), + stdout: " + Available recipes: + hello # This is a doc comment + ", +} + +test! { + name: unknown_attribute, + justfile: r#" +# This is a doc comment +[unknown-attribute] +hello: + @exit 100 +"#, + stderr: r#" +error: Unknown attribute `unknown-attribute` + | +2 | [unknown-attribute] + | ^^^^^^^^^^^^^^^^^ +"#, + status: EXIT_FAILURE, +} + +test! { + name: empty_attribute, + justfile: r#" +# This is a doc comment +[] +hello: + @exit 100 +"#, + stderr: r#" +error: Expected identifier, but found ']' + | +2 | [] + | ^ +"#, + status: EXIT_FAILURE, +} + +test! { + name: unattached_attribute_before_comment, + justfile: r#" +[no-exit-message] +# This is a doc comment +hello: + @exit 100 +"#, + stderr: r#" +error: Expected '@' or identifier, but found comment + | +2 | # This is a doc comment + | ^^^^^^^^^^^^^^^^^^^^^^^ +"#, + + status: EXIT_FAILURE, +} + +test! { + name: unattached_attribute_before_empty_line, + justfile: r#" +[no-exit-message] + +hello: + @exit 100 +"#, + stderr: "error: Expected '@' or identifier, but found end of line\n |\n2 | \n | ^\n", + status: EXIT_FAILURE, +} + +test! { + name: shebang_exit_message_suppressed, + justfile: r#" +[no-exit-message] +hello: + #!/usr/bin/env bash + echo 'Hello, World!' + exit 100 +"#, + stdout: "Hello, World!\n", + stderr: "", + status: 100, +}