diff --git a/README.md b/README.md index d070567c37..4862b69b19 100644 --- a/README.md +++ b/README.md @@ -188,10 +188,10 @@ You can also set the shell using command-line arguments. For example, to use Pow Gentoo Linux Portage - dm9pZCAq/sys-devel/just + guru/sys-devel/just - eselect repository enable dm9pZCAq
- emerge --sync dm9pZCAq
+ eselect repository enable guru
+ emerge --sync guru
emerge sys-devel/just @@ -1321,7 +1321,7 @@ Can be used with paths that are relative to the current directory, because `[no-cd]` prevents `just` from changing the current directory when executing `commit`. -### Requiring Confirmation for Recipesmaster +#### Requiring Confirmation for Recipesmaster `just` normally executes all recipes unless there is an error. The `[confirm]` attribute allows recipes require confirmation in the terminal prior to running. @@ -2076,6 +2076,64 @@ while: done ``` +#### Outside Recipe Bodies + +Parenthesized expressions can span multiple lines: + +```just +abc := ('a' + + 'b' + + 'c') + +abc2 := ( + 'a' + + 'b' + + 'c' +) + +foo param=('foo' + + 'bar' + ): + echo {{param}} + +bar: (foo + 'Foo' + ) + echo 'Bar!' +``` + +Lines ending with a backslash continue on to the next line as if the lines were joined by whitespace1.15.0: + +```just +a := 'foo' + \ + 'bar' + +foo param1 \ + param2='foo' \ + *varparam='': dep1 \ + (dep2 'foo') + echo {{param1}} {{param2}} {{varparam}} + +dep1: \ + # this comment is not part of the recipe body + echo 'dep1' + +dep2 \ + param: + echo 'Dependency with parameter {{param}}' +``` + +Backslash line continuations can also be used in interpolations. The line following the backslash must start with the same indentation as the recipe body, although additional indentation is accepted. + +```just +recipe: + echo '{{ \ + "This interpolation " + \ + "has a lot of text." \ + }}' + echo 'back to recipe body' +``` + ### Command Line Options `just` supports a number of useful command line options for listing, dumping, and debugging recipes and variables: diff --git "a/README.\344\270\255\346\226\207.md" "b/README.\344\270\255\346\226\207.md" index 9b167d1625..7be3d0ccfa 100644 --- "a/README.\344\270\255\346\226\207.md" +++ "b/README.\344\270\255\346\226\207.md" @@ -186,10 +186,10 @@ list: Gentoo Linux Portage - dm9pZCAq/sys-devel/just + guru/sys-devel/just - eselect repository enable dm9pZCAq
- emerge --sync dm9pZCAq
+ eselect repository enable guru
+ emerge --sync guru
emerge sys-devel/just diff --git a/justfile b/justfile index 37b442bc4b..0944c49136 100755 --- a/justfile +++ b/justfile @@ -182,8 +182,8 @@ build-book: mdbook build book/en mdbook build book/zh -convert-integration-test test: - cargo expand --test integration {{test}} | \ +convert-integration-test TEST: + cargo expand --test integration {{ TEST }} | \ sed \ -E \ -e 's/#\[cfg\(test\)\]/#\[test\]/' \ diff --git a/src/config.rs b/src/config.rs index 9e8b7b9256..b22e9190f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,8 @@ use { }; // These three strings should be kept in sync: -pub(crate) const CHOOSER_DEFAULT: &str = "fzf --multi --preview 'just --show {}'"; +pub(crate) const CHOOSER_DEFAULT: &str = + "fzf --multi --preview 'just --unstable --color always --show {}'"; pub(crate) const CHOOSER_ENVIRONMENT_KEY: &str = "JUST_CHOOSER"; pub(crate) const CHOOSE_HELP: &str = "Select one or more recipes to run using a binary. If \ `--chooser` is not passed the chooser defaults to the value \ diff --git a/src/evaluator.rs b/src/evaluator.rs index ac70f6b29b..a7ef67fdc9 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -255,6 +255,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { config: &'run Config, dotenv: &'run BTreeMap, parameters: &[Parameter<'src>], + opts: BTreeMap, &str>, arguments: &[&str], scope: &'run Scope<'src, 'run>, settings: &'run Settings, @@ -271,6 +272,10 @@ impl<'src, 'run> Evaluator<'src, 'run> { let mut scope = scope.child(); + for (name, value) in opts { + scope.bind(false, name, value.into()); + } + let mut positional = Vec::new(); let mut rest = arguments; diff --git a/src/justfile.rs b/src/justfile.rs index 45b327d5c4..d75deb46bf 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -215,8 +215,25 @@ impl<'src> Justfile<'src> { while let Some((argument, mut tail)) = rest.split_first() { if let Some(recipe) = self.get_recipe(argument) { + let mut opts = BTreeMap::new(); + + for opt in &recipe.opts { + if let Some(arg) = tail.first() { + if opt.accepts(arg) { + if let Some(value) = tail.get(1) { + opts.insert(opt.variable, *value); + } else { + panic!("opt with no value"); + } + tail = &tail[2..]; + continue; + } + } + panic!("opt not found"); + } + if recipe.parameters.is_empty() { - grouped.push((recipe, &[][..])); + grouped.push((recipe, opts, &[][..])); } else { let argument_range = recipe.argument_range(); let argument_count = cmp::min(tail.len(), recipe.max_arguments()); @@ -229,7 +246,7 @@ impl<'src> Justfile<'src> { max: recipe.max_arguments(), }); } - grouped.push((recipe, &tail[0..argument_count])); + grouped.push((recipe, opts, &tail[0..argument_count])); tail = &tail[argument_count..]; } } else { @@ -258,8 +275,8 @@ impl<'src> Justfile<'src> { }; let mut ran = BTreeSet::new(); - for (recipe, arguments) in grouped { - Self::run_recipe(&context, recipe, arguments, &dotenv, search, &mut ran)?; + for (recipe, opts, arguments) in grouped { + Self::run_recipe(&context, recipe, opts, arguments, &dotenv, search, &mut ran)?; } Ok(()) @@ -280,6 +297,7 @@ impl<'src> Justfile<'src> { fn run_recipe( context: &RecipeContext<'src, '_>, recipe: &Recipe<'src>, + opts: BTreeMap, &str>, arguments: &[&str], dotenv: &BTreeMap, search: &Search, @@ -304,6 +322,7 @@ impl<'src> Justfile<'src> { context.config, dotenv, &recipe.parameters, + opts, arguments, &context.scope, context.settings, @@ -324,6 +343,7 @@ impl<'src> Justfile<'src> { Self::run_recipe( context, recipe, + BTreeMap::new(), &arguments.iter().map(String::as_ref).collect::>(), dotenv, search, @@ -346,6 +366,7 @@ impl<'src> Justfile<'src> { Self::run_recipe( context, recipe, + BTreeMap::new(), &evaluated.iter().map(String::as_ref).collect::>(), dotenv, search, diff --git a/src/lexer.rs b/src/lexer.rs index 3a9a429c63..b2eb0ef7f7 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -282,11 +282,7 @@ impl<'src> Lexer<'src> { /// True if `c` can be a continuation character of an identifier fn is_identifier_continue(c: char) -> bool { - if Self::is_identifier_start(c) { - return true; - } - - matches!(c, '0'..='9' | '-') + Self::is_identifier_start(c) | matches!(c, '0'..='9' | '-') } /// Consume the text and produce a series of tokens @@ -490,6 +486,7 @@ impl<'src> Lexer<'src> { '*' => self.lex_single(Asterisk), '+' => self.lex_single(Plus), ',' => self.lex_single(Comma), + '-' => self.lex_digraph('-', '-', DashDash), '/' => self.lex_single(Slash), ':' => self.lex_colon(), '\\' => self.lex_escape(), @@ -990,6 +987,7 @@ mod tests { Colon => ":", ColonEquals => ":=", Comma => ",", + DashDash => "--", Dollar => "$", Eol => "\n", Equals => "=", @@ -2205,8 +2203,8 @@ mod tests { } error! { - name: invalid_name_start_dash, - input: "-foo", + name: invalid_name_start_caret, + input: "^foo", offset: 0, line: 0, column: 0, diff --git a/src/lib.rs b/src/lib.rs index fa88edeecd..255cda9d89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,9 +21,9 @@ pub(crate) use { 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, + load_dotenv::load_dotenv, loader::Loader, name::Name, opt::Opt, 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, @@ -145,6 +145,7 @@ mod list; mod load_dotenv; mod loader; mod name; +mod opt; mod ordinal; mod output; mod output_error; diff --git a/src/opt.rs b/src/opt.rs new file mode 100644 index 0000000000..fabcb1eaea --- /dev/null +++ b/src/opt.rs @@ -0,0 +1,34 @@ +use super::*; + +#[derive(PartialEq, Debug, Clone, Serialize)] +pub(crate) struct Opt<'src> { + pub(crate) default: Option>, + pub(crate) key: Name<'src>, + pub(crate) variable: Name<'src>, +} + +impl<'src> Opt<'src> { + pub(crate) fn accepts(&self, arg: &str) -> bool { + arg + .strip_prefix("--") + .map(|key| key == self.key.lexeme()) + .unwrap_or_default() + } +} + +impl<'src> ColorDisplay for Opt<'src> { + fn fmt(&self, f: &mut Formatter, color: Color) -> Result<(), fmt::Error> { + write!( + f, + "--{} {}", + color.annotation().paint(self.key.lexeme()), + color.parameter().paint(self.variable.lexeme()) + )?; + + if let Some(ref default) = self.default { + write!(f, "={}", color.string().paint(&default.to_string()))?; + } + + Ok(()) + } +} diff --git a/src/parser.rs b/src/parser.rs index a8a04d8092..f607a1305a 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -627,9 +627,16 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { let name = self.parse_name()?; let mut positional = Vec::new(); + let mut opts = Vec::new(); - while self.next_is(Identifier) || self.next_is(Dollar) { - positional.push(self.parse_parameter(ParameterKind::Singular)?); + loop { + if self.next_is(Identifier) || self.next_is(Dollar) { + positional.push(self.parse_parameter(ParameterKind::Singular)?); + } else if self.next_is(DashDash) { + opts.push(self.parse_opt()?); + } else { + break; + } } let kind = if self.accepted(Plus)? { @@ -687,11 +694,12 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { private: name.lexeme().starts_with('_'), shebang: body.first().map_or(false, Line::is_shebang), attributes, - priors, body, dependencies, doc, name, + opts, + priors, quiet, }) } @@ -716,6 +724,27 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { }) } + /// Parse a recipe option --foo foo + fn parse_opt(&mut self) -> CompileResult<'src, Opt<'src>> { + self.presume(DashDash)?; + + let key = self.parse_name()?; + + let variable = self.parse_name()?; + + let default = if self.accepted(Equals)? { + Some(self.parse_value()?) + } else { + None + }; + + Ok(Opt { + default, + key, + variable, + }) + } + /// Parse the body of a recipe fn parse_body(&mut self) -> CompileResult<'src, Vec>> { let mut lines = Vec::new(); @@ -2014,7 +2043,7 @@ mod tests { column: 5, width: 1, kind: UnexpectedToken{ - expected: vec![Asterisk, Colon, Dollar, Equals, Identifier, Plus], + expected: vec![Asterisk, Colon, DashDash, Dollar, Equals, Identifier, Plus], found: Eol }, } @@ -2149,7 +2178,7 @@ mod tests { column: 8, width: 0, kind: UnexpectedToken { - expected: vec![Asterisk, Colon, Dollar, Equals, Identifier, Plus], + expected: vec![Asterisk, Colon, DashDash, Dollar, Equals, Identifier, Plus], found: Eof }, } diff --git a/src/recipe.rs b/src/recipe.rs index 4ea8bc2b62..0117a6747f 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -32,6 +32,8 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> { pub(crate) private: bool, pub(crate) quiet: bool, pub(crate) shebang: bool, + #[serde(skip)] + pub(crate) opts: Vec>, } impl<'src, D> Recipe<'src, D> { @@ -406,6 +408,10 @@ impl<'src, D: Display> ColorDisplay for Recipe<'src, D> { write!(f, "{}", self.name)?; } + for opt in &self.opts { + write!(f, " {}", opt.color_display(color))?; + } + for parameter in &self.parameters { write!(f, " {}", parameter.color_display(color))?; } diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs index 2715acc240..701bdb05ff 100644 --- a/src/recipe_resolver.rs +++ b/src/recipe_resolver.rs @@ -25,7 +25,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> { for parameter in &recipe.parameters { if let Some(expression) = ¶meter.default { for variable in expression.variables() { - resolver.resolve_variable(&variable, &[])?; + resolver.resolve_variable(&variable, &Vec::new(), &[])?; } } } @@ -33,7 +33,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> { for dependency in &recipe.dependencies { for argument in &dependency.arguments { for variable in argument.variables() { - resolver.resolve_variable(&variable, &recipe.parameters)?; + resolver.resolve_variable(&variable, &recipe.opts, &recipe.parameters)?; } } } @@ -42,7 +42,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> { for fragment in &line.fragments { if let Fragment::Interpolation { expression, .. } = fragment { for variable in expression.variables() { - resolver.resolve_variable(&variable, &recipe.parameters)?; + resolver.resolve_variable(&variable, &recipe.opts, &recipe.parameters)?; } } } @@ -55,11 +55,15 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> { fn resolve_variable( &self, variable: &Token<'src>, + opts: &Vec>, parameters: &[Parameter], ) -> CompileResult<'src, ()> { let name = variable.lexeme(); - let undefined = - !self.assignments.contains_key(name) && !parameters.iter().any(|p| p.name.lexeme() == name); + let undefined = !self.assignments.contains_key(name) + && !opts.iter().any(|opt| opt.variable.lexeme() == name) + && !parameters + .iter() + .any(|parameter| parameter.name.lexeme() == name); if undefined { return Err(variable.error(UndefinedVariable { variable: name })); diff --git a/src/token_kind.rs b/src/token_kind.rs index 87c70d5fc5..6d87db107b 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -17,6 +17,7 @@ pub(crate) enum TokenKind { ColonEquals, Comma, Comment, + DashDash, Dedent, Dollar, Eof, @@ -60,6 +61,7 @@ impl Display for TokenKind { ColonEquals => "':='", Comma => "','", Comment => "comment", + DashDash => "'--'", Dedent => "dedent", Dollar => "'$'", Eof => "end of file", diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 0a49443de8..4e37b33875 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -45,16 +45,17 @@ impl<'src> UnresolvedRecipe<'src> { .collect(); Ok(Recipe { + attributes: self.attributes, body: self.body, + dependencies, doc: self.doc, name: self.name, + opts: self.opts, parameters: self.parameters, + priors: self.priors, private: self.private, quiet: self.quiet, shebang: self.shebang, - priors: self.priors, - attributes: self.attributes, - dependencies, }) } } diff --git a/tests/choose.rs b/tests/choose.rs index ff22425721..940fab0e61 100644 --- a/tests/choose.rs +++ b/tests/choose.rs @@ -152,7 +152,7 @@ fn invoke_error_function() { ", ) .stderr_regex( - r"error: Chooser `/ -cu fzf --multi --preview 'just --show \{\}'` invocation failed: .*\n", + r"error: Chooser `/ -cu fzf --multi --preview 'just --unstable --color always --show \{\}'` invocation failed: .*\n", ) .status(EXIT_FAILURE) .shell(false) diff --git a/tests/error_messages.rs b/tests/error_messages.rs index 6e860273a5..fecce36776 100644 --- a/tests/error_messages.rs +++ b/tests/error_messages.rs @@ -62,7 +62,7 @@ fn file_path_is_indented_if_justfile_is_long() { .status(EXIT_FAILURE) .stderr( " -error: Expected '*', ':', '$', identifier, or '+', but found end of file +error: Expected '*', ':', '--', '$', identifier, or '+', but found end of file --> justfile:20:4 | 20 | foo @@ -81,7 +81,7 @@ fn file_paths_are_relative() { .status(EXIT_FAILURE) .stderr(format!( " -error: Expected '*', ':', '$', identifier, or '+', but found end of file +error: Expected '*', ':', '--', '$', identifier, or '+', but found end of file --> foo{}bar.just:1:4 | 1 | baz diff --git a/tests/lib.rs b/tests/lib.rs index 42153f51cf..4c41a0e6a4 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -67,6 +67,7 @@ mod multibyte_char; mod newline_escape; mod no_cd; mod no_exit_message; +mod opts; mod os_attributes; mod parser; mod positional_arguments; diff --git a/tests/misc.rs b/tests/misc.rs index 8733507cd1..2a7dca6cec 100644 --- a/tests/misc.rs +++ b/tests/misc.rs @@ -1319,7 +1319,7 @@ test! { justfile: "foo 'bar'", args: ("foo"), stdout: "", - stderr: "error: Expected '*', ':', '$', identifier, or '+', but found string + stderr: "error: Expected '*', ':', '--', '$', identifier, or '+', but found string --> justfile:1:5 | 1 | foo 'bar' @@ -1563,8 +1563,8 @@ test! { name: list_colors, justfile: " # comment -a B C +D='hello': - echo {{B}} {{C}} {{D}} +a --b C D E +F='hello': + echo {{C}} {{D}} {{E}} {{F}} ", args: ("--color", "always", "--list"), stdout: " @@ -1925,7 +1925,7 @@ test! { echo {{foo}} ", stderr: " - error: Expected '*', ':', '$', identifier, or '+', but found '=' + error: Expected '*', ':', '--', '$', identifier, or '+', but found '=' --> justfile:1:5 | 1 | foo = 'bar' diff --git a/tests/opts.rs b/tests/opts.rs new file mode 100644 index 0000000000..47fce57c6d --- /dev/null +++ b/tests/opts.rs @@ -0,0 +1,111 @@ +use super::*; + +// todo: +// - document +// - make unstable? +// - should opts be serialized? + +#[test] +fn opts_are_dumped() { + Test::new() + .justfile("foo --bar BAR:") + .arg("--dump") + .stdout("foo --bar BAR:\n") + .run(); +} + +#[test] +fn opts_may_take_values() { + Test::new() + .justfile( + " + foo --bar BAR: + @echo {{ BAR }} + ", + ) + .args(["foo", "--bar", "baz"]) + .stdout("baz\n") + .run(); +} + +#[test] +fn opts_may_be_parsed_after_arguments() { + // foo BAR --bar BAZ: + todo!() +} + +#[test] +fn opts_may_be_passed_after_arguments() { + // foo --bar BAZ BAR: + // 'foo bar --bar baz + todo!() +} + +#[test] +fn opts_conflict_with_arguments() { + // foo --bar BAR BAR: + todo!() +} + +#[test] +fn opts_conflict_with_each_other() { + // foo --bar FOO --bar BAR: + todo!() +} + +#[test] +fn opts_can_be_passed_to_dependencies() { + // foo: (bar --foo "hello") + todo!() +} + +#[test] +fn opts_can_be_passed_in_any_order() { + // foo --bar BAR --baz BAZ: + // + // foo --baz a --bar b + todo!() +} + +#[test] +fn opts_without_default_values_are_mandatory() { + // foo --bar BAR: + // + // foo + todo!() +} + +#[test] +fn opts_with_default_values_are_optional() { + // foo --bar BAR="bar": + // + // foo + todo!() +} + +#[test] +fn opts_with_default_values_may_be_overridden() { + // foo --bar BAR="bar": + // + // foo --bar "baz" + todo!() +} + +#[test] +fn dependency_invocations_with_same_opts_are_only_executed_once() { + // foo --bar BAR: + // + // a: b c + // b: (foo --bar "bar") + // c: (foo --bar "bar") + todo!() +} + +#[test] +fn opts_with_no_value_are_an_error() { + // foo --bar BAR: + // + // + // foo --bar + todo!() +} diff --git a/tests/slash_operator.rs b/tests/slash_operator.rs index 7991d1c742..db3e476921 100644 --- a/tests/slash_operator.rs +++ b/tests/slash_operator.rs @@ -69,7 +69,7 @@ fn default_un_parenthesized() { ) .stderr( " - error: Expected '*', ':', '$', identifier, or '+', but found '/' + error: Expected '*', ':', '--', '$', identifier, or '+', but found '/' --> justfile:1:11 | 1 | foo x='a' / 'b':