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':