1
1
mirror of https://github.com/casey/just.git synced 2024-11-22 10:26:26 +03:00

Compare commits

...

11 Commits

Author SHA1 Message Date
0xzhzh
bbc9d0cfde
Merge 0c6f5e8376 into 225ba343f4 2024-11-04 10:50:23 -08:00
dependabot[bot]
225ba343f4
Update softprops/action-gh-release (#2450) 2024-11-04 10:49:24 -08:00
Casey Rodarmor
67034cb8b4
Don't export constants (#2449) 2024-11-02 18:11:24 +00:00
Casey Rodarmor
8cdff483bf
Use justfile instead of mf on invalid examples in readme (#2447) 2024-11-01 01:00:27 +00:00
Casey Rodarmor
7030e9cac6
Add && and || operators (#2444) 2024-11-01 00:54:46 +00:00
Casey Rodarmor
4c6368ecfc
Add advice on printing complex strings (#2446) 2024-10-31 23:56:47 +00:00
Eric Hanchrow
528c9f0e3c
Document using functions in variable assignments (#2431) 2024-10-30 22:50:47 +00:00
Greg Shuflin
a71f2a53be
Use prettier string comparison in tests (#2435) 2024-10-30 22:35:45 +00:00
Eric Hanchrow
b063940b31
Note shell(…) as an alternative to backticks (#2430) 2024-10-30 22:30:23 +00:00
Casey Rodarmor
4683a63adc
Allow duplicate imports (#2437) 2024-10-30 22:23:00 +00:00
Yunus
28c4e9a13c
Update nix package links (#2441) 2024-10-27 09:53:15 +00:00
32 changed files with 591 additions and 337 deletions

View File

@ -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

View File

@ -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

143
README.md
View File

@ -161,7 +161,7 @@ most Windows users.)
</tr>
<tr>
<td><a href=https://nixos.org/nix/>Nix</a></td>
<td><a href=https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/just/default.nix>just</a></td>
<td><a href=https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/ju/just/package.nix>just</a></td>
<td><code>nix-env -iA nixpkgs.just</code></td>
</tr>
<tr>
@ -268,7 +268,7 @@ most Windows users.)
<tr>
<td><a href=https://nixos.org/nixos/>NixOS</a></td>
<td><a href=https://nixos.org/nix/>Nix</a></td>
<td><a href=https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/just/default.nix>just</a></td>
<td><a href=https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/ju/just/package.nix>just</a></td>
<td><code>nix-env -iA nixos.just</code></td>
</tr>
<tr>
@ -1290,26 +1290,61 @@ Available recipes:
test
```
### Variables and Substitution
### Expressions and Substitutions
Variables, strings, concatenation, path joining, and substitution using `{{…}}`
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`
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}}
```
#### 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
values<sup>master</sup>, 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:
```justfile
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:
```justfile
foo := '' || 'goodbye' # 'goodbye'
bar := 'hello' || 'goodbye' # 'hello'
```
#### Joining Paths
The `/` operator can be used to join two strings with a slash:
@ -1497,8 +1532,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,
@ -2075,6 +2110,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
@ -2379,8 +2418,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"
@ -2752,7 +2791,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}}
@ -2884,7 +2923,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!'
@ -3291,7 +3330,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
@ -3331,11 +3370,37 @@ 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'
```
Missing source files for optional imports do not produce an error.
Importing the same source file multiple times is not an error<sup>master</sup>.
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:
```justfile
# justfile
import 'foo.just'
import 'bar.just'
```
```justfile
# foo.just
import 'baz.just'
foo: baz
```
```justfile
# bar.just
import 'baz.just'
bar: baz
```
```just
# baz
baz:
```
### Modules<sup>1.19.0</sup>
@ -3347,7 +3412,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:
@ -3385,7 +3450,7 @@ the module file may have any capitalization.
Module statements may be of the form:
```mf
```justfile
mod foo 'PATH'
```
@ -3409,7 +3474,7 @@ recipes.
Modules may be made optional by putting a `?` after the `mod` keyword:
```mf
```just
mod? foo
```
@ -3419,7 +3484,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'
```
@ -3427,7 +3492,7 @@ mod? foo 'baz.just'
Modules may be given doc comments which appear in `--list`
output<sup>1.30.0</sup>:
```mf
```justfile
# foo is a great module!
mod foo
```
@ -3569,9 +3634,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.
@ -3896,6 +3961,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

View File

@ -144,13 +144,13 @@ list:
<tr>
<td><a href="https://nixos.org/download.html#download-nix">Various</a></td>
<td><a href="https://nixos.org/nix/">Nix</a></td>
<td><a href="https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/just/default.nix">just</a></td>
<td><a href="https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/ju/just/package.nix">just</a></td>
<td><code>nix-env -iA nixpkgs.just</code></td>
</tr>
<tr>
<td><a href="https://nixos.org/nixos/">NixOS</a></td>
<td><a href="https://nixos.org/nix/">Nix</a></td>
<td><a href="https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/tools/just/default.nix">just</a></td>
<td><a href="https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/ju/just/package.nix">just</a></td>
<td><code>nix-env -iA nixos.just</code></td>
</tr>
<tr>

View File

@ -35,12 +35,16 @@ impl<'run, 'src> Analyzer<'run, 'src> {
root: &Path,
) -> 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) => {
@ -54,9 +58,11 @@ impl<'run, 'src> Analyzer<'run, 'src> {
Item::Comment(_) => (),
Item::Import { absolute, .. } => {
if let Some(absolute) = absolute {
if imports.insert(absolute) {
stack.push(asts.get(absolute).unwrap());
}
}
}
Item::Module {
absolute,
name,
@ -163,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 {

View File

@ -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)]

View File

@ -6,6 +6,7 @@ use super::*;
#[derive(Debug, Clone)]
pub(crate) struct Ast<'src> {
pub(crate) items: Vec<Item<'src>>,
pub(crate) unstable_features: BTreeSet<UnstableFeature>,
pub(crate) warnings: Vec<Warning>,
pub(crate) working_directory: PathBuf,
}

View File

@ -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,

View File

@ -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);
}
}

View File

@ -21,7 +21,6 @@ impl Compiler {
let tokens = Lexer::lex(relative, src)?;
let mut ast = Parser::parse(
current.file_depth,
&current.path,
&current.import_offsets,
&current.namepath,
&tokens,
@ -214,14 +213,7 @@ impl Compiler {
#[cfg(test)]
pub(crate) fn test_compile(src: &str) -> CompileResult<Justfile> {
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<PathBuf, Ast> = HashMap::new();
asts.insert(root.clone(), ast);

View File

@ -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())
@ -84,24 +88,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 +129,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 +185,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 +210,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}`"),
})
}
}
@ -329,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))

View File

@ -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<Expression<'src>>,
rhs: Box<Expression<'src>>,
},
/// `assert(condition, error)`
Assert {
condition: Condition<'src>,
@ -38,6 +43,11 @@ pub(crate) enum Expression<'src> {
lhs: Option<Box<Expression<'src>>>,
rhs: Box<Expression<'src>>,
},
/// `lhs || rhs`
Or {
lhs: Box<Expression<'src>>,
rhs: Box<Expression<'src>>,
},
/// `"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: <S as Serializer>::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)?;

View File

@ -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 => "[",

View File

@ -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()),
}
}
}

View File

@ -26,12 +26,12 @@ use {super::*, TokenKind::*};
pub(crate) struct Parser<'run, 'src> {
expected_tokens: BTreeSet<TokenKind>,
file_depth: u32,
file_path: &'run Path,
import_offsets: Vec<usize>,
module_namepath: &'run Namepath<'src>,
next_token: usize,
recursion_depth: usize,
tokens: &'run [Token<'src>],
unstable_features: BTreeSet<UnstableFeature>,
working_directory: &'run Path,
}
@ -39,7 +39,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,12 +47,12 @@ 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,
recursion_depth: 0,
tokens,
unstable_features: BTreeSet::new(),
working_directory,
}
.parse_ast()
@ -445,18 +444,19 @@ impl<'run, 'src> Parser<'run, 'src> {
}
}
if self.next_token == self.tokens.len() {
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(),
})
} else {
Err(self.internal_error(format!(
"Parse completed with {} unparsed tokens",
self.tokens.len() - self.next_token,
))?)
}
}
/// Parse an alias, e.g `alias name := target`
@ -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,
})
}
@ -520,26 +521,17 @@ impl<'run, 'src> Parser<'run, 'src> {
self.recursion_depth += 1;
let expression = 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 }
} else {
let value = self.parse_value()?;
let disjunct = self.parse_disjunct()?;
if self.accepted(Slash)? {
let lhs = Some(Box::new(value));
let expression = if self.accepted(BarBar)? {
self
.unstable_features
.insert(UnstableFeature::LogicalOperators);
let lhs = disjunct.into();
let rhs = self.parse_expression()?.into();
Expression::Join { lhs, rhs }
} else if self.accepted(Plus)? {
let lhs = value.into();
let rhs = self.parse_expression()?.into();
Expression::Concatenation { lhs, rhs }
Expression::Or { lhs, rhs }
} else {
value
}
disjunct
};
self.recursion_depth -= 1;
@ -547,6 +539,47 @@ impl<'run, 'src> Parser<'run, 'src> {
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_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_conjunct()?.into();
Ok(Expression::Join { lhs, rhs })
} else if self.accepted(Plus)? {
let lhs = value.into();
let rhs = self.parse_conjunct()?.into();
Ok(Expression::Concatenation { lhs, rhs })
} else {
Ok(value)
}
}
}
/// Parse a conditional, e.g. `if a == b { "foo" } else { "bar" }`
fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> {
let condition = self.parse_condition()?;
@ -910,7 +943,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,14 +1194,7 @@ 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(),
)
let justfile = Parser::parse(0, &[], &Namepath::default(), &tokens, &PathBuf::new())
.expect("parsing failed");
let have = justfile.tree();
if have != want {
@ -1208,14 +1233,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 {

View File

@ -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<usize>,
pub(crate) name: Name<'src>,
pub(crate) namepath: Namepath<'src>,

View File

@ -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 {

View File

@ -183,6 +183,10 @@ impl Assignment {
#[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)]
pub enum Expression {
And {
lhs: Box<Expression>,
rhs: Box<Expression>,
},
Assert {
condition: Condition,
error: Box<Expression>,
@ -209,6 +213,10 @@ pub enum Expression {
lhs: Option<Box<Expression>>,
rhs: Box<Expression>,
},
Or {
lhs: Box<Expression>,
rhs: Box<Expression>,
},
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),
}
}
}

View File

@ -59,14 +59,7 @@ 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(),
)
let ast = Parser::parse(0, &[], &Namepath::default(), &tokens, &PathBuf::new())
.expect("Parsing failed in analysis test...");
let root = PathBuf::from("justfile");

View File

@ -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 => "'['",

View File

@ -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,

View File

@ -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.")

View File

@ -16,7 +16,24 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
fn next(&mut self) -> Option<Token<'src>> {
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),
}
}
}

View File

@ -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));

View File

@ -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 }

View File

@ -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();
}

View File

@ -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 }}

View File

@ -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();
}

View File

@ -73,6 +73,7 @@ mod invocation_directory;
mod json;
mod line_prefixes;
mod list;
mod logical_operators;
mod man;
mod misc;
mod modules;

View File

@ -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");
}

View File

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

View File

@ -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.");
}