From 28c4e9a13c20c9ce14e7eb72a52ac2f34f058a4e Mon Sep 17 00:00:00 2001 From: Yunus <107340417+yunusey@users.noreply.github.com> Date: Sun, 27 Oct 2024 05:53:15 -0400 Subject: [PATCH 01/12] Update nix package links (#2441) --- README.md | 4 ++-- "README.\344\270\255\346\226\207.md" | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 535b2bc402..a3d78ef89b 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ most Windows users.) Nix - just + just nix-env -iA nixpkgs.just @@ -268,7 +268,7 @@ most Windows users.) NixOS Nix - just + just nix-env -iA nixos.just diff --git "a/README.\344\270\255\346\226\207.md" "b/README.\344\270\255\346\226\207.md" index 85256ece91..936fb18303 100644 --- "a/README.\344\270\255\346\226\207.md" +++ "b/README.\344\270\255\346\226\207.md" @@ -144,13 +144,13 @@ list: Various Nix - just + just nix-env -iA nixpkgs.just NixOS Nix - just + just nix-env -iA nixos.just From 4683a63adcd2f5899638c2941857d1626dc40679 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 30 Oct 2024 15:23:00 -0700 Subject: [PATCH 02/12] Allow duplicate imports (#2437) --- README.md | 28 ++++++++++++++++++++++- src/analyzer.rs | 5 ++++- src/compiler.rs | 10 +-------- src/parser.rs | 24 +++----------------- src/recipe.rs | 2 -- src/testing.rs | 11 ++------- src/unresolved_recipe.rs | 1 - tests/imports.rs | 48 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 85 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index a3d78ef89b..1f2f5409eb 100644 --- a/README.md +++ b/README.md @@ -3319,7 +3319,33 @@ Imports may be made optional by putting a `?` after the `import` keyword: import? 'foo/bar.just' ``` -Missing source files for optional imports do not produce an error. +Importing the same source file multiple times is not an errormaster. +This allows importing multiple justfiles, for example `foo.just` and +`bar.just`, which both import a third justfile containing shared recipes, for +example `baz.just`, without the duplicate import of `baz.just` being an error: + +```mf +# justfile +import 'foo.just' +import 'bar.just' +``` + +```mf +# foo.just +import 'baz.just' +foo: baz +``` + +```mf +# bar.just +import 'baz.just' +bar: baz +``` + +```just +# baz +baz: +``` ### Modules1.19.0 diff --git a/src/analyzer.rs b/src/analyzer.rs index ef6bcb2293..9a26ef0455 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -35,6 +35,7 @@ impl<'run, 'src> Analyzer<'run, 'src> { root: &Path, ) -> CompileResult<'src, Justfile<'src>> { let mut definitions = HashMap::new(); + let mut imports = HashSet::new(); let mut stack = Vec::new(); let ast = asts.get(root).unwrap(); @@ -54,7 +55,9 @@ impl<'run, 'src> Analyzer<'run, 'src> { Item::Comment(_) => (), Item::Import { absolute, .. } => { if let Some(absolute) = absolute { - stack.push(asts.get(absolute).unwrap()); + if imports.insert(absolute) { + stack.push(asts.get(absolute).unwrap()); + } } } Item::Module { diff --git a/src/compiler.rs b/src/compiler.rs index 1a555ebabb..e8d96f2edb 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -21,7 +21,6 @@ impl Compiler { let tokens = Lexer::lex(relative, src)?; let mut ast = Parser::parse( current.file_depth, - ¤t.path, ¤t.import_offsets, ¤t.namepath, &tokens, @@ -214,14 +213,7 @@ impl Compiler { #[cfg(test)] pub(crate) fn test_compile(src: &str) -> CompileResult { let tokens = Lexer::test_lex(src)?; - let ast = Parser::parse( - 0, - &PathBuf::new(), - &[], - &Namepath::default(), - &tokens, - &PathBuf::new(), - )?; + let ast = Parser::parse(0, &[], &Namepath::default(), &tokens, &PathBuf::new())?; let root = PathBuf::from("justfile"); let mut asts: HashMap = HashMap::new(); asts.insert(root.clone(), ast); diff --git a/src/parser.rs b/src/parser.rs index ea727204e5..99544acd13 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -26,7 +26,6 @@ use {super::*, TokenKind::*}; pub(crate) struct Parser<'run, 'src> { expected_tokens: BTreeSet, file_depth: u32, - file_path: &'run Path, import_offsets: Vec, module_namepath: &'run Namepath<'src>, next_token: usize, @@ -39,7 +38,6 @@ impl<'run, 'src> Parser<'run, 'src> { /// Parse `tokens` into an `Ast` pub(crate) fn parse( file_depth: u32, - file_path: &'run Path, import_offsets: &[usize], module_namepath: &'run Namepath<'src>, tokens: &'run [Token<'src>], @@ -48,7 +46,6 @@ impl<'run, 'src> Parser<'run, 'src> { Self { expected_tokens: BTreeSet::new(), file_depth, - file_path, import_offsets: import_offsets.to_vec(), module_namepath, next_token: 0, @@ -910,7 +907,6 @@ impl<'run, 'src> Parser<'run, 'src> { dependencies, doc, file_depth: self.file_depth, - file_path: self.file_path.into(), import_offsets: self.import_offsets.clone(), name, namepath: self.module_namepath.join(name), @@ -1162,15 +1158,8 @@ mod tests { fn test(text: &str, want: Tree) { let unindented = unindent(text); let tokens = Lexer::test_lex(&unindented).expect("lexing failed"); - let justfile = Parser::parse( - 0, - &PathBuf::new(), - &[], - &Namepath::default(), - &tokens, - &PathBuf::new(), - ) - .expect("parsing failed"); + let justfile = Parser::parse(0, &[], &Namepath::default(), &tokens, &PathBuf::new()) + .expect("parsing failed"); let have = justfile.tree(); if have != want { println!("parsed text: {unindented}"); @@ -1208,14 +1197,7 @@ mod tests { ) { let tokens = Lexer::test_lex(src).expect("Lexing failed in parse test..."); - match Parser::parse( - 0, - &PathBuf::new(), - &[], - &Namepath::default(), - &tokens, - &PathBuf::new(), - ) { + match Parser::parse(0, &[], &Namepath::default(), &tokens, &PathBuf::new()) { Ok(_) => panic!("Parsing unexpectedly succeeded"), Err(have) => { let want = CompileError { diff --git a/src/recipe.rs b/src/recipe.rs index bb9fe14e33..05516225a6 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -26,8 +26,6 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> { #[serde(skip)] pub(crate) file_depth: u32, #[serde(skip)] - pub(crate) file_path: PathBuf, - #[serde(skip)] pub(crate) import_offsets: Vec, pub(crate) name: Name<'src>, pub(crate) namepath: Namepath<'src>, diff --git a/src/testing.rs b/src/testing.rs index a10c398834..12069feede 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -59,15 +59,8 @@ pub(crate) fn analysis_error( ) { let tokens = Lexer::test_lex(src).expect("Lexing failed in parse test..."); - let ast = Parser::parse( - 0, - &PathBuf::new(), - &[], - &Namepath::default(), - &tokens, - &PathBuf::new(), - ) - .expect("Parsing failed in analysis test..."); + let ast = Parser::parse(0, &[], &Namepath::default(), &tokens, &PathBuf::new()) + .expect("Parsing failed in analysis test..."); let root = PathBuf::from("justfile"); let mut asts: HashMap = HashMap::new(); diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 6cbc95da91..661d75649b 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -50,7 +50,6 @@ impl<'src> UnresolvedRecipe<'src> { dependencies, doc: self.doc, file_depth: self.file_depth, - file_path: self.file_path, import_offsets: self.import_offsets, name: self.name, namepath: self.namepath, diff --git a/tests/imports.rs b/tests/imports.rs index 693a67cd3b..93b3fc75c4 100644 --- a/tests/imports.rs +++ b/tests/imports.rs @@ -360,3 +360,51 @@ fn reused_import_are_allowed() { }) .run(); } + +#[test] +fn multiply_imported_items_do_not_conflict() { + Test::new() + .justfile( + " + import 'a.just' + import 'a.just' + foo: bar + ", + ) + .write( + "a.just", + " +x := 'y' + +@bar: + echo hello +", + ) + .stdout("hello\n") + .run(); +} + +#[test] +fn nested_multiply_imported_items_do_not_conflict() { + Test::new() + .justfile( + " + import 'a.just' + import 'b.just' + foo: bar + ", + ) + .write("a.just", "import 'c.just'") + .write("b.just", "import 'c.just'") + .write( + "c.just", + " +x := 'y' + +@bar: + echo hello +", + ) + .stdout("hello\n") + .run(); +} From b063940b31ffe5e01789da4ab936b6c16e8bb073 Mon Sep 17 00:00:00 2001 From: Eric Hanchrow Date: Wed, 30 Oct 2024 15:30:23 -0700 Subject: [PATCH 03/12] =?UTF-8?q?Note=20`shell(=E2=80=A6)`=20as=20an=20alt?= =?UTF-8?q?ernative=20to=20backticks=20(#2430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 1f2f5409eb..9d3ab58396 100644 --- a/README.md +++ b/README.md @@ -2059,6 +2059,10 @@ See the [Strings](#strings) section for details on unindenting. Backticks may not start with `#!`. This syntax is reserved for a future upgrade. +The [`shell(…)` function](#external-commands) provides a more general mechanism +to invoke external commands, including the ability to execute the contents of a +variable as a command, and to pass arguments to a command. + ### Conditional Expressions `if`/`else` expressions evaluate different branches depending on if two From a71f2a53be317db56b712b9335706f2c06f5a888 Mon Sep 17 00:00:00 2001 From: Greg Shuflin Date: Wed, 30 Oct 2024 15:35:45 -0700 Subject: [PATCH 04/12] Use prettier string comparison in tests (#2435) --- tests/test.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/test.rs b/tests/test.rs index f1b73bf161..a1c65ac201 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,4 +1,7 @@ -use {super::*, pretty_assertions::assert_eq}; +use { + super::*, + pretty_assertions::{assert_eq, StrComparison}, +}; macro_rules! test { { @@ -205,6 +208,14 @@ impl Test { equal } + fn compare_string(name: &str, have: &str, want: &str) -> bool { + let equal = have == want; + if !equal { + eprintln!("Bad {name}: {}", StrComparison::new(&have, &want)); + } + equal + } + if let Some(justfile) = &self.justfile { let justfile = unindent(justfile); fs::write(self.justfile_path(), justfile).unwrap(); @@ -266,8 +277,8 @@ impl Test { } if !compare("status", output.status.code(), Some(self.status)) - | (self.stdout_regex.is_none() && !compare("stdout", output_stdout, &stdout)) - | (self.stderr_regex.is_none() && !compare("stderr", output_stderr, &stderr)) + | (self.stdout_regex.is_none() && !compare_string("stdout", output_stdout, &stdout)) + | (self.stderr_regex.is_none() && !compare_string("stderr", output_stderr, &stderr)) { panic!("Output mismatch."); } From 528c9f0e3c5b1bd9db8c2d3529ace556f9fb6bac Mon Sep 17 00:00:00 2001 From: Eric Hanchrow Date: Wed, 30 Oct 2024 15:50:47 -0700 Subject: [PATCH 05/12] Document using functions in variable assignments (#2431) --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9d3ab58396..994ec4881c 100644 --- a/README.md +++ b/README.md @@ -1292,19 +1292,19 @@ Available recipes: ### Variables and Substitution -Variables, strings, concatenation, path joining, and substitution using `{{…}}` -are supported: +Variables, strings, concatenation, path joining, substitution using `{{…}}`, and function calls are supported: ```just tmpdir := `mktemp -d` version := "0.2.7" tardir := tmpdir / "awesomesauce-" + version tarball := tardir + ".tar.gz" +config := quote(config_dir() / ".project-config") publish: rm -f {{tarball}} mkdir {{tardir}} - cp README.md *.c {{tardir}} + cp README.md *.c {{ config }} {{tardir}} tar zcvf {{tarball}} {{tardir}} scp {{tarball}} me@server.com:release/ rm -rf {{tarball}} {{tardir}} @@ -1497,8 +1497,8 @@ Done! ### Functions -`just` provides a few built-in functions that might be useful when writing -recipes. +`just` provides many built-in functions for use in expressions, including +recipe body `{{…}}` substitutions, assignments, and default parameter values. All functions ending in `_directory` can be abbreviated to `_dir`. So `home_directory()` can also be written as `home_dir()`. In addition, From 4c6368ecfc058faf80e9666bc4d5917a15c0030a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 31 Oct 2024 16:56:47 -0700 Subject: [PATCH 06/12] Add advice on printing complex strings (#2446) --- README.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 994ec4881c..ca596854fc 100644 --- a/README.md +++ b/README.md @@ -3583,9 +3583,9 @@ The following command will create two files, `some` and `argument.txt`: $ just foo "some argument.txt" ``` -The users shell will parse `"some argument.txt"` as a single argument, but when -`just` replaces `touch {{argument}}` with `touch some argument.txt`, the quotes -are not preserved, and `touch` will receive two arguments. +The user's shell will parse `"some argument.txt"` as a single argument, but +when `just` replaces `touch {{argument}}` with `touch some argument.txt`, the +quotes are not preserved, and `touch` will receive two arguments. There are a few ways to avoid this: quoting, positional arguments, and exported arguments. @@ -3910,6 +3910,38 @@ fetch: Given the above `justfile`, after running `just fetch`, the recipes in `foo.just` will be available. +### Printing Complex Strings + +`echo` can be used to print strings, but because it processes escape sequences, +like `\n`, and different implementations of `echo` recognize different escape +sequences, using `printf` is often a better choice. + +`printf` takes a C-style format string and any number of arguments, which are +interpolated into the format string. + +This can be combined with indented, triple quoted strings to emulate shell +heredocs. + +Substitution complex strings into recipe bodies with `{…}` can also lead to +trouble as it may be split by the shell into multiple arguments depending on +the presence of whitespace and quotes. Exporting complex strings as environment +variables and referring to them with `"$NAME"`, note the double quotes, can +also help. + +Putting all this together, to print a string verbatim to standard output, with +all its various escape sequences and quotes undisturbed: + +```just +export FOO := ''' + a complicated string with + some dis\tur\bi\ng escape sequences + and "quotes" of 'different' kinds +''' + +bar: + printf %s "$FOO" +``` + ### Alternatives and Prior Art There is no shortage of command runners! Some more or less similar alternatives From 7030e9cac6f1a13bd4d366fab66f0341edeb3002 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 31 Oct 2024 17:54:46 -0700 Subject: [PATCH 07/12] Add `&&` and `||` operators (#2444) --- GRAMMAR.md | 8 ++- README.md | 43 ++++++++++-- src/analyzer.rs | 5 +- src/assignment_resolver.rs | 130 +++++++------------------------------ src/ast.rs | 1 + src/evaluator.rs | 65 +++++++++++-------- src/expression.rs | 54 +++++++++++---- src/lexer.rs | 2 + src/node.rs | 43 ++++++------ src/parser.rs | 81 ++++++++++++++++------- src/summary.rs | 28 ++++++-- src/token_kind.rs | 2 + src/unstable_feature.rs | 5 ++ src/variables.rs | 46 +++++++------ tests/assert_success.rs | 1 + tests/conditional.rs | 2 +- tests/ignore_comments.rs | 2 +- tests/lib.rs | 1 + tests/logical_operators.rs | 83 +++++++++++++++++++++++ tests/shell_expansion.rs | 2 +- 20 files changed, 373 insertions(+), 231 deletions(-) create mode 100644 tests/logical_operators.rs diff --git a/GRAMMAR.md b/GRAMMAR.md index e5847f5098..00721f15af 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -90,7 +90,13 @@ import : 'import' '?'? string? eol module : 'mod' '?'? NAME string? eol -expression : 'if' condition '{' expression '}' 'else' '{' expression '}' +expression : disjunct || expression + | disjunct + +disjunct : conjunct && disjunct + | conjunct + +conjunct : 'if' condition '{' expression '}' 'else' '{' expression '}' | 'assert' '(' condition ',' expression ')' | '/' expression | value '/' expression diff --git a/README.md b/README.md index ca596854fc..bccf9a1357 100644 --- a/README.md +++ b/README.md @@ -1290,9 +1290,11 @@ Available recipes: test ``` -### Variables and Substitution +### Expressions and Substitutions -Variables, strings, concatenation, path joining, substitution using `{{…}}`, and function calls are supported: +Various operators and function calls are supported in expressions, which may be +used in assignments, default recipe arguments, and inside recipe body `{{…}}` +substitutions. ```just tmpdir := `mktemp -d` @@ -1310,6 +1312,39 @@ publish: rm -rf {{tarball}} {{tardir}} ``` +#### Concatenation + +The `+` operator returns the left-hand argument concatenated with the +right-hand argument: + +```just +foobar := 'foo' + 'bar' +``` + +#### Logical Operators + +The logical operators `&&` and `||` can be used to coalesce string +valuesmaster, similar to Python's `and` and `or`. These operators +consider the empty string `''` to be false, and all other strings to be true. + +These operators are currently unstable. + +The `&&` operator returns the empty string if the left-hand argument is the +empty string, otherwise it returns the right-hand argument: + +```mf +foo := '' && 'goodbye' # '' +bar := 'hello' && 'goodbye' # 'goodbye' +``` + +The `||` operator returns the left-hand argument if it is non-empty, otherwise +it returns the right-hand argument: + +```mf +foo := '' || 'goodbye' # 'goodbye' +bar := 'hello' || 'goodbye' # 'hello' +``` + #### Joining Paths The `/` operator can be used to join two strings with a slash: @@ -2367,8 +2402,8 @@ Testing server:unit… ./test --tests unit server ``` -Default values may be arbitrary expressions, but concatenations or path joins -must be parenthesized: +Default values may be arbitrary expressions, but expressions containing the +`+`, `&&`, `||`, or `/` operators must be parenthesized: ```just arch := "wasm" diff --git a/src/analyzer.rs b/src/analyzer.rs index 9a26ef0455..0929294c5d 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -36,12 +36,15 @@ impl<'run, 'src> Analyzer<'run, 'src> { ) -> CompileResult<'src, Justfile<'src>> { let mut definitions = HashMap::new(); let mut imports = HashSet::new(); + let mut unstable_features = BTreeSet::new(); let mut stack = Vec::new(); let ast = asts.get(root).unwrap(); stack.push(ast); while let Some(ast) = stack.pop() { + unstable_features.extend(&ast.unstable_features); + for item in &ast.items { match item { Item::Alias(alias) => { @@ -166,8 +169,6 @@ impl<'run, 'src> Analyzer<'run, 'src> { aliases.insert(Self::resolve_alias(&recipes, alias)?); } - let mut unstable_features = BTreeSet::new(); - for recipe in recipes.values() { for attribute in &recipe.attributes { if let Attribute::Script(_) = attribute { diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs index 53863fc9c5..511464d4eb 100644 --- a/src/assignment_resolver.rs +++ b/src/assignment_resolver.rs @@ -31,7 +31,29 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> { self.stack.push(name); if let Some(assignment) = self.assignments.get(name) { - self.resolve_expression(&assignment.value)?; + for variable in assignment.value.variables() { + let name = variable.lexeme(); + + if self.evaluated.contains(name) || constants().contains_key(name) { + continue; + } + + if self.stack.contains(&name) { + self.stack.push(name); + return Err( + self.assignments[name] + .name + .error(CircularVariableDependency { + variable: name, + circle: self.stack.clone(), + }), + ); + } else if self.assignments.contains_key(name) { + self.resolve_assignment(name)?; + } else { + return Err(variable.error(UndefinedVariable { variable: name })); + } + } self.evaluated.insert(name); } else { let message = format!("attempted to resolve unknown assignment `{name}`"); @@ -51,112 +73,6 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> { Ok(()) } - - fn resolve_expression(&mut self, expression: &Expression<'src>) -> CompileResult<'src> { - match expression { - Expression::Assert { - condition: Condition { - lhs, - rhs, - operator: _, - }, - error, - } => { - self.resolve_expression(lhs)?; - self.resolve_expression(rhs)?; - self.resolve_expression(error) - } - Expression::Call { thunk } => match thunk { - Thunk::Nullary { .. } => Ok(()), - Thunk::Unary { arg, .. } => self.resolve_expression(arg), - Thunk::UnaryOpt { args: (a, b), .. } => { - self.resolve_expression(a)?; - if let Some(b) = b.as_ref() { - self.resolve_expression(b)?; - } - Ok(()) - } - Thunk::UnaryPlus { - args: (a, rest), .. - } => { - self.resolve_expression(a)?; - for arg in rest { - self.resolve_expression(arg)?; - } - Ok(()) - } - Thunk::Binary { args: [a, b], .. } => { - self.resolve_expression(a)?; - self.resolve_expression(b) - } - Thunk::BinaryPlus { - args: ([a, b], rest), - .. - } => { - self.resolve_expression(a)?; - self.resolve_expression(b)?; - for arg in rest { - self.resolve_expression(arg)?; - } - Ok(()) - } - Thunk::Ternary { - args: [a, b, c], .. - } => { - self.resolve_expression(a)?; - self.resolve_expression(b)?; - self.resolve_expression(c) - } - }, - Expression::Concatenation { lhs, rhs } => { - self.resolve_expression(lhs)?; - self.resolve_expression(rhs) - } - Expression::Conditional { - condition: Condition { - lhs, - rhs, - operator: _, - }, - then, - otherwise, - .. - } => { - self.resolve_expression(lhs)?; - self.resolve_expression(rhs)?; - self.resolve_expression(then)?; - self.resolve_expression(otherwise) - } - Expression::Group { contents } => self.resolve_expression(contents), - Expression::Join { lhs, rhs } => { - if let Some(lhs) = lhs { - self.resolve_expression(lhs)?; - } - self.resolve_expression(rhs) - } - Expression::StringLiteral { .. } | Expression::Backtick { .. } => Ok(()), - Expression::Variable { name } => { - let variable = name.lexeme(); - if self.evaluated.contains(variable) || constants().contains_key(variable) { - Ok(()) - } else if self.stack.contains(&variable) { - self.stack.push(variable); - Err( - self.assignments[variable] - .name - .error(CircularVariableDependency { - variable, - circle: self.stack.clone(), - }), - ) - } else if self.assignments.contains_key(variable) { - self.resolve_assignment(variable) - } else { - Err(name.token.error(UndefinedVariable { variable })) - } - } - } - } } #[cfg(test)] diff --git a/src/ast.rs b/src/ast.rs index f9dd10c9f3..1ad7a8aa22 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -6,6 +6,7 @@ use super::*; #[derive(Debug, Clone)] pub(crate) struct Ast<'src> { pub(crate) items: Vec>, + pub(crate) unstable_features: BTreeSet, pub(crate) warnings: Vec, pub(crate) working_directory: PathBuf, } diff --git a/src/evaluator.rs b/src/evaluator.rs index 4ed00036d1..d48cece87e 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -84,24 +84,31 @@ impl<'src, 'run> Evaluator<'src, 'run> { expression: &Expression<'src>, ) -> RunResult<'src, String> { match expression { - Expression::Variable { name, .. } => { - let variable = name.lexeme(); - if let Some(value) = self.scope.value(variable) { - Ok(value.to_owned()) - } else if let Some(assignment) = self - .assignments - .and_then(|assignments| assignments.get(variable)) - { - Ok(self.evaluate_assignment(assignment)?.to_owned()) + Expression::And { lhs, rhs } => { + let lhs = self.evaluate_expression(lhs)?; + if lhs.is_empty() { + return Ok(String::new()); + } + self.evaluate_expression(rhs) + } + Expression::Assert { condition, error } => { + if self.evaluate_condition(condition)? { + Ok(String::new()) } else { - Err(Error::Internal { - message: format!("attempted to evaluate undefined variable `{variable}`"), + Err(Error::Assert { + message: self.evaluate_expression(error)?, }) } } + Expression::Backtick { contents, token } => { + if self.context.config.dry_run { + Ok(format!("`{contents}`")) + } else { + Ok(self.run_backtick(contents, token)?) + } + } Expression::Call { thunk } => { use Thunk::*; - let result = match thunk { Nullary { function, .. } => function(function::Context::new(self, thunk.name())), Unary { function, arg, .. } => { @@ -118,7 +125,6 @@ impl<'src, 'run> Evaluator<'src, 'run> { Some(b) => Some(self.evaluate_expression(b)?), None => None, }; - function(function::Context::new(self, thunk.name()), &a, b.as_deref()) } UnaryPlus { @@ -175,20 +181,11 @@ impl<'src, 'run> Evaluator<'src, 'run> { function(function::Context::new(self, thunk.name()), &a, &b, &c) } }; - result.map_err(|message| Error::FunctionCall { function: thunk.name(), message, }) } - Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()), - Expression::Backtick { contents, token } => { - if self.context.config.dry_run { - Ok(format!("`{contents}`")) - } else { - Ok(self.run_backtick(contents, token)?) - } - } Expression::Concatenation { lhs, rhs } => { Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?) } @@ -209,12 +206,26 @@ impl<'src, 'run> Evaluator<'src, 'run> { lhs: Some(lhs), rhs, } => Ok(self.evaluate_expression(lhs)? + "/" + &self.evaluate_expression(rhs)?), - Expression::Assert { condition, error } => { - if self.evaluate_condition(condition)? { - Ok(String::new()) + Expression::Or { lhs, rhs } => { + let lhs = self.evaluate_expression(lhs)?; + if !lhs.is_empty() { + return Ok(lhs); + } + self.evaluate_expression(rhs) + } + Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()), + Expression::Variable { name, .. } => { + let variable = name.lexeme(); + if let Some(value) = self.scope.value(variable) { + Ok(value.to_owned()) + } else if let Some(assignment) = self + .assignments + .and_then(|assignments| assignments.get(variable)) + { + Ok(self.evaluate_assignment(assignment)?.to_owned()) } else { - Err(Error::Assert { - message: self.evaluate_expression(error)?, + Err(Error::Internal { + message: format!("attempted to evaluate undefined variable `{variable}`"), }) } } diff --git a/src/expression.rs b/src/expression.rs index c1e4b5e015..3d5c339211 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -8,6 +8,11 @@ use super::*; /// The parser parses both values and expressions into `Expression`s. #[derive(PartialEq, Debug, Clone)] pub(crate) enum Expression<'src> { + /// `lhs && rhs` + And { + lhs: Box>, + rhs: Box>, + }, /// `assert(condition, error)` Assert { condition: Condition<'src>, @@ -38,6 +43,11 @@ pub(crate) enum Expression<'src> { lhs: Option>>, rhs: Box>, }, + /// `lhs || rhs` + Or { + lhs: Box>, + rhs: Box>, + }, /// `"string_literal"` or `'string_literal'` StringLiteral { string_literal: StringLiteral<'src> }, /// `variable` @@ -53,23 +63,25 @@ impl<'src> Expression<'src> { impl<'src> Display for Expression<'src> { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { + Self::And { lhs, rhs } => write!(f, "{lhs} && {rhs}"), Self::Assert { condition, error } => write!(f, "assert({condition}, {error})"), Self::Backtick { token, .. } => write!(f, "{}", token.lexeme()), - Self::Join { lhs: None, rhs } => write!(f, "/ {rhs}"), - Self::Join { - lhs: Some(lhs), - rhs, - } => write!(f, "{lhs} / {rhs}"), + Self::Call { thunk } => write!(f, "{thunk}"), Self::Concatenation { lhs, rhs } => write!(f, "{lhs} + {rhs}"), Self::Conditional { condition, then, otherwise, } => write!(f, "if {condition} {{ {then} }} else {{ {otherwise} }}"), + Self::Group { contents } => write!(f, "({contents})"), + Self::Join { lhs: None, rhs } => write!(f, "/ {rhs}"), + Self::Join { + lhs: Some(lhs), + rhs, + } => write!(f, "{lhs} / {rhs}"), + Self::Or { lhs, rhs } => write!(f, "{lhs} || {rhs}"), Self::StringLiteral { string_literal } => write!(f, "{string_literal}"), Self::Variable { name } => write!(f, "{}", name.lexeme()), - Self::Call { thunk } => write!(f, "{thunk}"), - Self::Group { contents } => write!(f, "({contents})"), } } } @@ -80,6 +92,13 @@ impl<'src> Serialize for Expression<'src> { S: Serializer, { match self { + Self::And { lhs, rhs } => { + let mut seq = serializer.serialize_seq(None)?; + seq.serialize_element("and")?; + seq.serialize_element(lhs)?; + seq.serialize_element(rhs)?; + seq.end() + } Self::Assert { condition, error } => { let mut seq: ::SerializeSeq = serializer.serialize_seq(None)?; seq.serialize_element("assert")?; @@ -101,13 +120,6 @@ impl<'src> Serialize for Expression<'src> { seq.serialize_element(rhs)?; seq.end() } - Self::Join { lhs, rhs } => { - let mut seq = serializer.serialize_seq(None)?; - seq.serialize_element("join")?; - seq.serialize_element(lhs)?; - seq.serialize_element(rhs)?; - seq.end() - } Self::Conditional { condition, then, @@ -121,6 +133,20 @@ impl<'src> Serialize for Expression<'src> { seq.end() } Self::Group { contents } => contents.serialize(serializer), + Self::Join { lhs, rhs } => { + let mut seq = serializer.serialize_seq(None)?; + seq.serialize_element("join")?; + seq.serialize_element(lhs)?; + seq.serialize_element(rhs)?; + seq.end() + } + Self::Or { lhs, rhs } => { + let mut seq = serializer.serialize_seq(None)?; + seq.serialize_element("or")?; + seq.serialize_element(lhs)?; + seq.serialize_element(rhs)?; + seq.end() + } Self::StringLiteral { string_literal } => string_literal.serialize(serializer), Self::Variable { name } => { let mut seq = serializer.serialize_seq(None)?; diff --git a/src/lexer.rs b/src/lexer.rs index 4d28a4460f..2c56db9dcf 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -496,6 +496,7 @@ impl<'src> Lexer<'src> { ']' => self.lex_delimiter(BracketR), '`' | '"' | '\'' => self.lex_string(), '{' => self.lex_delimiter(BraceL), + '|' => self.lex_digraph('|', '|', BarBar), '}' => self.lex_delimiter(BraceR), _ if Self::is_identifier_start(start) => self.lex_identifier(), _ => { @@ -948,6 +949,7 @@ mod tests { Asterisk => "*", At => "@", BangEquals => "!=", + BarBar => "||", BraceL => "{", BraceR => "}", BracketL => "[", diff --git a/src/node.rs b/src/node.rs index 3ccf862d57..f8788c4dc1 100644 --- a/src/node.rs +++ b/src/node.rs @@ -88,6 +88,7 @@ impl<'src> Node<'src> for Assignment<'src> { impl<'src> Node<'src> for Expression<'src> { fn tree(&self) -> Tree<'src> { match self { + Self::And { lhs, rhs } => Tree::atom("&&").push(lhs.tree()).push(rhs.tree()), Self::Assert { condition: Condition { lhs, rhs, operator }, error, @@ -96,25 +97,10 @@ impl<'src> Node<'src> for Expression<'src> { .push(operator.to_string()) .push(rhs.tree()) .push(error.tree()), - Self::Concatenation { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()), - Self::Conditional { - condition: Condition { lhs, rhs, operator }, - then, - otherwise, - } => { - let mut tree = Tree::atom(Keyword::If.lexeme()); - tree.push_mut(lhs.tree()); - tree.push_mut(operator.to_string()); - tree.push_mut(rhs.tree()); - tree.push_mut(then.tree()); - tree.push_mut(otherwise.tree()); - tree - } + Self::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)), Self::Call { thunk } => { use Thunk::*; - let mut tree = Tree::atom("call"); - match thunk { Nullary { name, .. } => tree.push_mut(name.lexeme()), Unary { name, arg, .. } => { @@ -171,20 +157,33 @@ impl<'src> Node<'src> for Expression<'src> { tree.push_mut(c.tree()); } } - tree } - Self::Variable { name } => Tree::atom(name.lexeme()), - Self::StringLiteral { - string_literal: StringLiteral { cooked, .. }, - } => Tree::string(cooked), - Self::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)), + Self::Concatenation { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()), + Self::Conditional { + condition: Condition { lhs, rhs, operator }, + then, + otherwise, + } => { + let mut tree = Tree::atom(Keyword::If.lexeme()); + tree.push_mut(lhs.tree()); + tree.push_mut(operator.to_string()); + tree.push_mut(rhs.tree()); + tree.push_mut(then.tree()); + tree.push_mut(otherwise.tree()); + tree + } Self::Group { contents } => Tree::List(vec![contents.tree()]), Self::Join { lhs: None, rhs } => Tree::atom("/").push(rhs.tree()), Self::Join { lhs: Some(lhs), rhs, } => Tree::atom("/").push(lhs.tree()).push(rhs.tree()), + Self::Or { lhs, rhs } => Tree::atom("||").push(lhs.tree()).push(rhs.tree()), + Self::StringLiteral { + string_literal: StringLiteral { cooked, .. }, + } => Tree::string(cooked), + Self::Variable { name } => Tree::atom(name.lexeme()), } } } diff --git a/src/parser.rs b/src/parser.rs index 99544acd13..e03ab7833f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -31,6 +31,7 @@ pub(crate) struct Parser<'run, 'src> { next_token: usize, recursion_depth: usize, tokens: &'run [Token<'src>], + unstable_features: BTreeSet, working_directory: &'run Path, } @@ -51,6 +52,7 @@ impl<'run, 'src> Parser<'run, 'src> { next_token: 0, recursion_depth: 0, tokens, + unstable_features: BTreeSet::new(), working_directory, } .parse_ast() @@ -442,18 +444,19 @@ impl<'run, 'src> Parser<'run, 'src> { } } - if self.next_token == self.tokens.len() { - Ok(Ast { - items, - warnings: Vec::new(), - working_directory: self.working_directory.into(), - }) - } else { - Err(self.internal_error(format!( + if self.next_token != self.tokens.len() { + return Err(self.internal_error(format!( "Parse completed with {} unparsed tokens", self.tokens.len() - self.next_token, - ))?) + ))?); } + + Ok(Ast { + items, + unstable_features: self.unstable_features, + warnings: Vec::new(), + working_directory: self.working_directory.into(), + }) } /// Parse an alias, e.g `alias name := target` @@ -517,31 +520,63 @@ impl<'run, 'src> Parser<'run, 'src> { self.recursion_depth += 1; - let expression = if self.accepted_keyword(Keyword::If)? { - self.parse_conditional()? + let disjunct = self.parse_disjunct()?; + + let expression = if self.accepted(BarBar)? { + self + .unstable_features + .insert(UnstableFeature::LogicalOperators); + let lhs = disjunct.into(); + let rhs = self.parse_expression()?.into(); + Expression::Or { lhs, rhs } + } else { + disjunct + }; + + self.recursion_depth -= 1; + + Ok(expression) + } + + fn parse_disjunct(&mut self) -> CompileResult<'src, Expression<'src>> { + let conjunct = self.parse_conjunct()?; + + let disjunct = if self.accepted(AmpersandAmpersand)? { + self + .unstable_features + .insert(UnstableFeature::LogicalOperators); + let lhs = conjunct.into(); + let rhs = self.parse_disjunct()?.into(); + Expression::And { lhs, rhs } + } else { + conjunct + }; + + Ok(disjunct) + } + + fn parse_conjunct(&mut self) -> CompileResult<'src, Expression<'src>> { + if self.accepted_keyword(Keyword::If)? { + self.parse_conditional() } else if self.accepted(Slash)? { let lhs = None; - let rhs = self.parse_expression()?.into(); - Expression::Join { lhs, rhs } + let rhs = self.parse_conjunct()?.into(); + Ok(Expression::Join { lhs, rhs }) } else { let value = self.parse_value()?; if self.accepted(Slash)? { let lhs = Some(Box::new(value)); - let rhs = self.parse_expression()?.into(); - Expression::Join { lhs, rhs } + let rhs = self.parse_conjunct()?.into(); + Ok(Expression::Join { lhs, rhs }) } else if self.accepted(Plus)? { let lhs = value.into(); - let rhs = self.parse_expression()?.into(); - Expression::Concatenation { lhs, rhs } + let rhs = self.parse_conjunct()?.into(); + Ok(Expression::Concatenation { lhs, rhs }) } else { - value + Ok(value) } - }; - - self.recursion_depth -= 1; - - Ok(expression) + } } /// Parse a conditional, e.g. `if a == b { "foo" } else { "bar" }` diff --git a/src/summary.rs b/src/summary.rs index ee3a8d1155..76483d63ec 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -183,6 +183,10 @@ impl Assignment { #[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)] pub enum Expression { + And { + lhs: Box, + rhs: Box, + }, Assert { condition: Condition, error: Box, @@ -209,6 +213,10 @@ pub enum Expression { lhs: Option>, rhs: Box, }, + Or { + lhs: Box, + rhs: Box, + }, String { text: String, }, @@ -221,6 +229,10 @@ impl Expression { fn new(expression: &full::Expression) -> Self { use full::Expression::*; match expression { + And { lhs, rhs } => Self::And { + lhs: Self::new(lhs).into(), + rhs: Self::new(rhs).into(), + }, Assert { condition: full::Condition { lhs, rhs, operator }, error, @@ -250,11 +262,9 @@ impl Expression { .. } => { let mut arguments = Vec::new(); - if let Some(b) = opt_b.as_ref() { arguments.push(Self::new(b)); } - arguments.push(Self::new(a)); Self::Call { name: name.lexeme().to_owned(), @@ -308,10 +318,6 @@ impl Expression { lhs: Self::new(lhs).into(), rhs: Self::new(rhs).into(), }, - Join { lhs, rhs } => Self::Join { - lhs: lhs.as_ref().map(|lhs| Self::new(lhs).into()), - rhs: Self::new(rhs).into(), - }, Conditional { condition: full::Condition { lhs, rhs, operator }, otherwise, @@ -323,13 +329,21 @@ impl Expression { rhs: Self::new(rhs).into(), then: Self::new(then).into(), }, + Group { contents } => Self::new(contents), + Join { lhs, rhs } => Self::Join { + lhs: lhs.as_ref().map(|lhs| Self::new(lhs).into()), + rhs: Self::new(rhs).into(), + }, + Or { lhs, rhs } => Self::Or { + lhs: Self::new(lhs).into(), + rhs: Self::new(rhs).into(), + }, StringLiteral { string_literal } => Self::String { text: string_literal.cooked.clone(), }, Variable { name, .. } => Self::Variable { name: name.lexeme().to_owned(), }, - Group { contents } => Self::new(contents), } } } diff --git a/src/token_kind.rs b/src/token_kind.rs index 0db15d2dfa..850afa9629 100644 --- a/src/token_kind.rs +++ b/src/token_kind.rs @@ -7,6 +7,7 @@ pub(crate) enum TokenKind { At, Backtick, BangEquals, + BarBar, BraceL, BraceR, BracketL, @@ -50,6 +51,7 @@ impl Display for TokenKind { At => "'@'", Backtick => "backtick", BangEquals => "'!='", + BarBar => "'||'", BraceL => "'{'", BraceR => "'}'", BracketL => "'['", diff --git a/src/unstable_feature.rs b/src/unstable_feature.rs index 07d99540e5..70e26fabcf 100644 --- a/src/unstable_feature.rs +++ b/src/unstable_feature.rs @@ -3,6 +3,7 @@ use super::*; #[derive(Copy, Clone, Debug, PartialEq, Ord, Eq, PartialOrd)] pub(crate) enum UnstableFeature { FormatSubcommand, + LogicalOperators, ScriptAttribute, ScriptInterpreterSetting, } @@ -11,6 +12,10 @@ impl Display for UnstableFeature { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { Self::FormatSubcommand => write!(f, "The `--fmt` command is currently unstable."), + Self::LogicalOperators => write!( + f, + "The logical operators `&&` and `||` are currently unstable." + ), Self::ScriptAttribute => write!(f, "The `[script]` attribute is currently unstable."), Self::ScriptInterpreterSetting => { write!(f, "The `script-interpreter` setting is currently unstable.") diff --git a/src/variables.rs b/src/variables.rs index 5797956366..9de1c98ce8 100644 --- a/src/variables.rs +++ b/src/variables.rs @@ -16,7 +16,24 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> { fn next(&mut self) -> Option> { loop { match self.stack.pop()? { - Expression::StringLiteral { .. } | Expression::Backtick { .. } => {} + Expression::And { lhs, rhs } | Expression::Or { lhs, rhs } => { + self.stack.push(lhs); + self.stack.push(rhs); + } + Expression::Assert { + condition: + Condition { + lhs, + rhs, + operator: _, + }, + error, + } => { + self.stack.push(error); + self.stack.push(rhs); + self.stack.push(lhs); + } + Expression::Backtick { .. } | Expression::StringLiteral { .. } => {} Expression::Call { thunk } => match thunk { Thunk::Nullary { .. } => {} Thunk::Unary { arg, .. } => self.stack.push(arg), @@ -56,6 +73,10 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> { } } }, + Expression::Concatenation { lhs, rhs } => { + self.stack.push(rhs); + self.stack.push(lhs); + } Expression::Conditional { condition: Condition { @@ -71,10 +92,8 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> { self.stack.push(rhs); self.stack.push(lhs); } - Expression::Variable { name, .. } => return Some(name.token), - Expression::Concatenation { lhs, rhs } => { - self.stack.push(rhs); - self.stack.push(lhs); + Expression::Group { contents } => { + self.stack.push(contents); } Expression::Join { lhs, rhs } => { self.stack.push(rhs); @@ -82,22 +101,7 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> { self.stack.push(lhs); } } - Expression::Group { contents } => { - self.stack.push(contents); - } - Expression::Assert { - condition: - Condition { - lhs, - rhs, - operator: _, - }, - error, - } => { - self.stack.push(error); - self.stack.push(rhs); - self.stack.push(lhs); - } + Expression::Variable { name, .. } => return Some(name.token), } } } diff --git a/tests/assert_success.rs b/tests/assert_success.rs index bcb364f880..f9202b7f2e 100644 --- a/tests/assert_success.rs +++ b/tests/assert_success.rs @@ -1,3 +1,4 @@ +#[track_caller] pub(crate) fn assert_success(output: &std::process::Output) { if !output.status.success() { eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); diff --git a/tests/conditional.rs b/tests/conditional.rs index c17392405c..4eab2f4d72 100644 --- a/tests/conditional.rs +++ b/tests/conditional.rs @@ -136,7 +136,7 @@ test! { ", stdout: "", stderr: " - error: Expected '!=', '==', '=~', '+', or '/', but found identifier + error: Expected '&&', '!=', '||', '==', '=~', '+', or '/', but found identifier ——▶ justfile:1:12 │ 1 │ a := if '' a '' { '' } else { b } diff --git a/tests/ignore_comments.rs b/tests/ignore_comments.rs index c3028a57a3..3f068227a4 100644 --- a/tests/ignore_comments.rs +++ b/tests/ignore_comments.rs @@ -125,7 +125,7 @@ fn comments_still_must_be_parsable_when_ignored() { ) .stderr( " - error: Expected '}}', '(', '+', or '/', but found identifier + error: Expected '&&', '||', '}}', '(', '+', or '/', but found identifier ——▶ justfile:4:12 │ 4 │ # {{ foo bar }} diff --git a/tests/lib.rs b/tests/lib.rs index ec71c665da..7c85460b44 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -73,6 +73,7 @@ mod invocation_directory; mod json; mod line_prefixes; mod list; +mod logical_operators; mod man; mod misc; mod modules; diff --git a/tests/logical_operators.rs b/tests/logical_operators.rs new file mode 100644 index 0000000000..5baa0f52f5 --- /dev/null +++ b/tests/logical_operators.rs @@ -0,0 +1,83 @@ +use super::*; + +#[track_caller] +fn evaluate(expression: &str, expected: &str) { + Test::new() + .justfile(format!("x := {expression}")) + .env("JUST_UNSTABLE", "1") + .args(["--evaluate", "x"]) + .stdout(expected) + .run(); +} + +#[test] +fn logical_operators_are_unstable() { + Test::new() + .justfile("x := 'foo' && 'bar'") + .args(["--evaluate", "x"]) + .stderr_regex(r"error: The logical operators `&&` and `\|\|` are currently unstable. .*") + .status(EXIT_FAILURE) + .run(); + + Test::new() + .justfile("x := 'foo' || 'bar'") + .args(["--evaluate", "x"]) + .stderr_regex(r"error: The logical operators `&&` and `\|\|` are currently unstable. .*") + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn and_returns_empty_string_if_lhs_is_empty() { + evaluate("'' && 'hello'", ""); +} + +#[test] +fn and_returns_rhs_if_lhs_is_non_empty() { + evaluate("'hello' && 'goodbye'", "goodbye"); +} + +#[test] +fn and_has_lower_precedence_than_plus() { + evaluate("'' && 'goodbye' + 'foo'", ""); + + evaluate("'foo' + 'hello' && 'goodbye'", "goodbye"); + + evaluate("'foo' + '' && 'goodbye'", "goodbye"); + + evaluate("'foo' + 'hello' && 'goodbye' + 'bar'", "goodbyebar"); +} + +#[test] +fn or_returns_rhs_if_lhs_is_empty() { + evaluate("'' || 'hello'", "hello"); +} + +#[test] +fn or_returns_lhs_if_lhs_is_non_empty() { + evaluate("'hello' || 'goodbye'", "hello"); +} + +#[test] +fn or_has_lower_precedence_than_plus() { + evaluate("'' || 'goodbye' + 'foo'", "goodbyefoo"); + + evaluate("'foo' + 'hello' || 'goodbye'", "foohello"); + + evaluate("'foo' + '' || 'goodbye'", "foo"); + + evaluate("'foo' + 'hello' || 'goodbye' + 'bar'", "foohello"); +} + +#[test] +fn and_has_higher_precedence_than_or() { + evaluate("('' && 'foo') || 'bar'", "bar"); + evaluate("'' && 'foo' || 'bar'", "bar"); + evaluate("'a' && 'b' || 'c'", "b"); +} + +#[test] +fn nesting() { + evaluate("'' || '' || '' || '' || 'foo'", "foo"); + evaluate("'foo' && 'foo' && 'foo' && 'foo' && 'bar'", "bar"); +} diff --git a/tests/shell_expansion.rs b/tests/shell_expansion.rs index 67fdc07d99..954e2a33db 100644 --- a/tests/shell_expansion.rs +++ b/tests/shell_expansion.rs @@ -25,7 +25,7 @@ fn shell_expanded_strings_must_not_have_whitespace() { .status(1) .stderr( " - error: Expected comment, end of file, end of line, '(', '+', or '/', but found string + error: Expected '&&', '||', comment, end of file, end of line, '(', '+', or '/', but found string ——▶ justfile:1:8 │ 1 │ x := x '$JUST_TEST_VARIABLE' From 8cdff483bf80579a130ccecfc4a5aa62b1a55d39 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Thu, 31 Oct 2024 18:00:27 -0700 Subject: [PATCH 08/12] Use `justfile` instead of `mf` on invalid examples in readme (#2447) --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bccf9a1357..5b596ca8d0 100644 --- a/README.md +++ b/README.md @@ -1332,7 +1332,7 @@ These operators are currently unstable. The `&&` operator returns the empty string if the left-hand argument is the empty string, otherwise it returns the right-hand argument: -```mf +```justfile foo := '' && 'goodbye' # '' bar := 'hello' && 'goodbye' # 'goodbye' ``` @@ -1340,7 +1340,7 @@ bar := 'hello' && 'goodbye' # 'goodbye' The `||` operator returns the left-hand argument if it is non-empty, otherwise it returns the right-hand argument: -```mf +```justfile foo := '' || 'goodbye' # 'goodbye' bar := 'hello' || 'goodbye' # 'hello' ``` @@ -2775,7 +2775,7 @@ pass a Windows-style path to the interpreter. Recipe lines are interpreted by the shell, not `just`, so it's not possible to set `just` variables in the middle of a recipe: -```mf +```justfile foo: x := "hello" # This doesn't work! echo {{x}} @@ -2907,7 +2907,7 @@ means that multi-line constructs probably won't do what you want. For example, with the following `justfile`: -```mf +```justfile conditional: if true; then echo 'True!' @@ -3314,7 +3314,7 @@ One `justfile` can include the contents of another using `import` statements. If you have the following `justfile`: -```mf +```justfile import 'foo/bar.just' a: b @@ -3354,7 +3354,7 @@ set, variables in parent modules override variables in imports. Imports may be made optional by putting a `?` after the `import` keyword: -```mf +```just import? 'foo/bar.just' ``` @@ -3363,19 +3363,19 @@ This allows importing multiple justfiles, for example `foo.just` and `bar.just`, which both import a third justfile containing shared recipes, for example `baz.just`, without the duplicate import of `baz.just` being an error: -```mf +```justfile # justfile import 'foo.just' import 'bar.just' ``` -```mf +```justfile # foo.just import 'baz.just' foo: baz ``` -```mf +```justfile # bar.just import 'baz.just' bar: baz @@ -3396,7 +3396,7 @@ versions, you'll need to use the `--unstable` flag, `set unstable`, or set the If you have the following `justfile`: -```mf +```justfile mod bar a: @@ -3434,7 +3434,7 @@ the module file may have any capitalization. Module statements may be of the form: -```mf +```justfile mod foo 'PATH' ``` @@ -3458,7 +3458,7 @@ recipes. Modules may be made optional by putting a `?` after the `mod` keyword: -```mf +```just mod? foo ``` @@ -3468,7 +3468,7 @@ Optional modules with no source file do not conflict, so you can have multiple mod statements with the same name, but with different source file paths, as long as at most one source file exists: -```mf +```just mod? foo 'bar.just' mod? foo 'baz.just' ``` @@ -3476,7 +3476,7 @@ mod? foo 'baz.just' Modules may be given doc comments which appear in `--list` output1.30.0: -```mf +```justfile # foo is a great module! mod foo ``` From 67034cb8b42a570e2a33aa88e6d5daf9cd309c11 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 2 Nov 2024 11:11:24 -0700 Subject: [PATCH 09/12] Don't export constants (#2449) --- src/binding.rs | 2 ++ src/command_ext.rs | 2 +- src/evaluator.rs | 37 +++++++++++++++++++++++-------------- src/parser.rs | 5 +++-- src/scope.rs | 24 ++++++++++-------------- tests/constants.rs | 16 ++++++++++++++++ 6 files changed, 55 insertions(+), 31 deletions(-) diff --git a/src/binding.rs b/src/binding.rs index 7e7890c7a1..69dcc3b056 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -3,6 +3,8 @@ use super::*; /// A binding of `name` to `value` #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct Binding<'src, V = String> { + #[serde(skip)] + pub(crate) constant: bool, pub(crate) export: bool, #[serde(skip)] pub(crate) file_depth: u32, diff --git a/src/command_ext.rs b/src/command_ext.rs index 6bd7208d3c..40d9da1600 100644 --- a/src/command_ext.rs +++ b/src/command_ext.rs @@ -39,7 +39,7 @@ impl CommandExt for Command { } for binding in scope.bindings() { - if settings.export || binding.export { + if binding.export || (settings.export && !binding.constant) { self.env(binding.name.lexeme(), &binding.value); } } diff --git a/src/evaluator.rs b/src/evaluator.rs index d48cece87e..b83c8cf70c 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -32,12 +32,14 @@ impl<'src, 'run> Evaluator<'src, 'run> { for (name, value) in overrides { if let Some(assignment) = module.assignments.get(name) { - scope.bind( - assignment.export, - assignment.name, - assignment.private, - value.clone(), - ); + scope.bind(Binding { + constant: false, + export: assignment.export, + file_depth: 0, + name: assignment.name, + private: assignment.private, + value: value.clone(), + }); } else { unknown_overrides.push(name.clone()); } @@ -68,12 +70,14 @@ impl<'src, 'run> Evaluator<'src, 'run> { if !self.scope.bound(name) { let value = self.evaluate_expression(&assignment.value)?; - self.scope.bind( - assignment.export, - assignment.name, - assignment.private, + self.scope.bind(Binding { + constant: false, + export: assignment.export, + file_depth: 0, + name: assignment.name, + private: assignment.private, value, - ); + }); } Ok(self.scope.value(name).unwrap()) @@ -340,9 +344,14 @@ impl<'src, 'run> Evaluator<'src, 'run> { rest = &rest[1..]; value }; - evaluator - .scope - .bind(parameter.export, parameter.name, false, value); + evaluator.scope.bind(Binding { + constant: false, + export: parameter.export, + file_depth: 0, + name: parameter.name, + private: false, + value, + }); } Ok((evaluator.scope, positional)) diff --git a/src/parser.rs b/src/parser.rs index e03ab7833f..00246bf3e7 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -500,11 +500,12 @@ impl<'run, 'src> Parser<'run, 'src> { } Ok(Assignment { - file_depth: self.file_depth, + constant: false, export, + file_depth: self.file_depth, name, - value, private: private || name.lexeme().starts_with('_'), + value, }) } diff --git a/src/scope.rs b/src/scope.rs index 36bbedc8aa..78d12ca06b 100644 --- a/src/scope.rs +++ b/src/scope.rs @@ -21,9 +21,11 @@ impl<'src, 'run> Scope<'src, 'run> { }; for (key, value) in constants() { - root.bind( - false, - Name { + root.bind(Binding { + constant: true, + export: false, + file_depth: 0, + name: Name { token: Token { column: 0, kind: TokenKind::Identifier, @@ -34,22 +36,16 @@ impl<'src, 'run> Scope<'src, 'run> { src: key, }, }, - false, - (*value).into(), - ); + private: false, + value: (*value).into(), + }); } root } - pub(crate) fn bind(&mut self, export: bool, name: Name<'src>, private: bool, value: String) { - self.bindings.insert(Binding { - export, - file_depth: 0, - name, - private, - value, - }); + pub(crate) fn bind(&mut self, binding: Binding<'src>) { + self.bindings.insert(binding); } pub(crate) fn bound(&self, name: &str) -> bool { diff --git a/tests/constants.rs b/tests/constants.rs index 59f8b9bd5c..c6a3a85329 100644 --- a/tests/constants.rs +++ b/tests/constants.rs @@ -43,3 +43,19 @@ fn constants_can_be_redefined() { .stdout("foo") .run(); } + +#[test] +fn constants_are_not_exported() { + Test::new() + .justfile( + " + set export + + foo: + echo $HEXUPPER + ", + ) + .stderr_regex(".*HEXUPPER: unbound variable.*") + .status(127) + .run(); +} From 225ba343f492b3bcde42c616a6368bcbac5e3364 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 10:49:24 -0800 Subject: [PATCH 10/12] Update softprops/action-gh-release (#2450) --- .github/workflows/release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 128c835001..779dfdba06 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -110,7 +110,7 @@ jobs: shell: bash - name: Publish Archive - uses: softprops/action-gh-release@v2.0.8 + uses: softprops/action-gh-release@v2.0.9 if: ${{ startsWith(github.ref, 'refs/tags/') }} with: draft: false @@ -120,7 +120,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Publish Changelog - uses: softprops/action-gh-release@v2.0.8 + uses: softprops/action-gh-release@v2.0.9 if: >- ${{ startsWith(github.ref, 'refs/tags/') @@ -157,7 +157,7 @@ jobs: shasum -a 256 * > ../SHA256SUMS - name: Publish Checksums - uses: softprops/action-gh-release@v2.0.8 + uses: softprops/action-gh-release@v2.0.9 with: draft: false files: SHA256SUMS From 7eea77278543e93a04b46f751a98d60f30822df9 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 6 Nov 2024 01:49:04 -0800 Subject: [PATCH 11/12] Fix shell function example in readme (#2454) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b596ca8d0..a0a126b77f 100644 --- a/README.md +++ b/README.md @@ -1578,11 +1578,11 @@ file. and can be changed with `set shell := […]`. `command` is passed as the first argument, so if the command is `'echo $@'`, - the full command line, with the default shell command `shell -cu` and `args` + the full command line, with the default shell command `sh -cu` and `args` `'foo'` and `'bar'` will be: ``` - 'shell' '-cu' 'echo $@' 'echo $@' 'foo' 'bar' + 'sh' '-cu' 'echo $@' 'echo $@' 'foo' 'bar' ``` This is so that `$@` works as expected, and `$1` refers to the first From a93b0bf3890de6c48d46b40e2cbcc73c97e3bd41 Mon Sep 17 00:00:00 2001 From: Julian Berman Date: Wed, 6 Nov 2024 17:53:51 -0500 Subject: [PATCH 12/12] Update setup-just version in README (#2456) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a0a126b77f..6ec0e0f613 100644 --- a/README.md +++ b/README.md @@ -401,7 +401,7 @@ Using package managers pre-installed on GitHub Actions runners on MacOS with With [extractions/setup-just](https://github.com/extractions/setup-just): ```yaml -- uses: extractions/setup-just@v1 +- uses: extractions/setup-just@v2 with: just-version: 1.5.0 # optional semver specification, otherwise latest ```