diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 128c8350..323f6f00 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.1.0
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.1.0
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.1.0
with:
draft: false
files: SHA256SUMS
diff --git a/Cargo.lock b/Cargo.lock
index 80a7276d..e96dd65b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -237,7 +237,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
]
[[package]]
@@ -329,14 +329,14 @@ dependencies = [
]
[[package]]
-name = "derivative"
-version = "2.2.0"
+name = "derive-where"
+version = "1.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25"
dependencies = [
"proc-macro2",
"quote",
- "syn 1.0.109",
+ "syn",
]
[[package]]
@@ -531,7 +531,7 @@ dependencies = [
"clap_complete",
"clap_mangen",
"ctrlc",
- "derivative",
+ "derive-where",
"dirs",
"dotenvy",
"edit-distance",
@@ -861,7 +861,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
]
[[package]]
@@ -930,7 +930,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
]
[[package]]
@@ -958,18 +958,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
- "syn 2.0.79",
-]
-
-[[package]]
-name = "syn"
-version = "1.0.109"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
+ "syn",
]
[[package]]
@@ -1038,7 +1027,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
]
[[package]]
@@ -1142,7 +1131,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
"wasm-bindgen-shared",
]
@@ -1164,7 +1153,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -1396,5 +1385,5 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.79",
+ "syn",
]
diff --git a/Cargo.toml b/Cargo.toml
index f74367d0..c579e2f8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,7 +26,7 @@ clap = { version = "4.0.0", features = ["derive", "env", "wrap_help"] }
clap_complete = "4.0.0"
clap_mangen = "0.2.20"
ctrlc = { version = "3.1.1", features = ["termination"] }
-derivative = "2.0.0"
+derive-where = "1.2.7"
dirs = "5.0.1"
dotenvy = "0.15"
edit-distance = "2.0.0"
diff --git a/GRAMMAR.md b/GRAMMAR.md
index e5847f50..00721f15 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 6427ed9a..bccd7e3a 100644
--- a/README.md
+++ b/README.md
@@ -167,7 +167,7 @@ most Windows users.)
npm
rust-just
- npm install rust-just
+ npm install -g rust-just
PyPI
@@ -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
```
@@ -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:
+
+```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:
@@ -1543,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
@@ -1816,6 +1851,24 @@ for details.
`requirement`, e.g., `">=0.1.0"`, returning `"true"` if so and `"false"`
otherwise.
+#### Style
+
+- `style(name)`master - Return a named terminal display attribute
+ escape sequence used by `just`. Unlike terminal display attribute escape
+ sequence constants, which contain standard colors and styles, `style(name)`
+ returns an escape sequence used by `just` itself, and can be used to make
+ recipe output match `just`'s own output.
+
+ Recognized values for `name` are `'command'`, for echoed recipe lines,
+ `error`, and `warning`.
+
+ For example, to style an error message:
+
+ ```just
+ scary:
+ @echo '{{ style("error") }}OH NO{{ NORMAL }}'
+ ```
+
##### XDG Directories1.23.0
These functions return paths to user-specific directories for things like
@@ -1842,6 +1895,30 @@ A number of constants are predefined:
| `HEX`1.27.0 | `"0123456789abcdef"` |
| `HEXLOWER`1.27.0 | `"0123456789abcdef"` |
| `HEXUPPER`1.27.0 | `"0123456789ABCDEF"` |
+| `CLEAR`master | `"\ec"` |
+| `NORMAL`master | `"\e[0m"` |
+| `BOLD`master | `"\e[1m"` |
+| `ITALIC`master | `"\e[3m"` |
+| `UNDERLINE`master | `"\e[4m"` |
+| `INVERT`master | `"\e[7m"` |
+| `HIDE`master | `"\e[8m"` |
+| `STRIKETHROUGH`master | `"\e[9m"` |
+| `BLACK`master | `"\e[30m"` |
+| `RED`master | `"\e[31m"` |
+| `GREEN`master | `"\e[32m"` |
+| `YELLOW`master | `"\e[33m"` |
+| `BLUE`master | `"\e[34m"` |
+| `MAGENTA`master | `"\e[35m"` |
+| `CYAN`master | `"\e[36m"` |
+| `WHITE`master | `"\e[37m"` |
+| `BG_BLACK`master | `"\e[40m"` |
+| `BG_RED`master | `"\e[41m"` |
+| `BG_GREEN`master | `"\e[42m"` |
+| `BG_YELLOW`master | `"\e[43m"` |
+| `BG_BLUE`master | `"\e[44m"` |
+| `BG_MAGENTA`master | `"\e[45m"` |
+| `BG_CYAN`master | `"\e[46m"` |
+| `BG_WHITE`master | `"\e[47m"` |
```just
@foo:
@@ -1853,9 +1930,29 @@ $ just foo
0123456789abcdef
```
+Constants starting with `\e` are
+[ANSI escape sequences](https://en.wikipedia.org/wiki/ANSI_escape_code).
+
+`CLEAR` clears the screen, similar to the `clear` command. The rest are of the
+form `\e[Nm`, where `N` is an integer, and set terminal display attributes.
+
+Terminal display attribute escape sequences can be combined, for example text
+weight `BOLD`, text style `STRIKETHROUGH`, foreground color `CYAN`, and
+background color `BG_BLUE`. They should be followed by `NORMAL`, to reset the
+terminal back to normal.
+
+Escape sequences should be quoted, since `[` is treated as a special character
+by some shells.
+
+```just
+@foo:
+ echo '{{BOLD + STRIKETHROUGH + CYAN + BG_BLUE}}Hi!{{NORMAL}}'
+```
+
### Attributes
-Recipes, `mod` statements, and aliases may be annotated with attributes that change their behavior.
+Recipes, `mod` statements, and aliases may be annotated with attributes that
+change their behavior.
| Name | Type | Description |
|------|------|-------------|
@@ -2385,8 +2482,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"
@@ -2694,8 +2791,9 @@ scripts interpreted by `COMMAND`. This avoids some of the issues with shebang
recipes, such as the use of `cygpath` on Windows, the need to use
`/usr/bin/env`, and inconsistences in shebang line splitting across Unix OSs.
-Recipes with an empty `[script]` attribute are executed with the value of
-`set script-interpreter := […]`1.33.0 , defaulting to `sh -eu`.
+Recipes with an empty `[script]` attribute are executed with the value of `set
+script-interpreter := […]`1.33.0 , defaulting to `sh -eu`, and *not*
+the value of `set shell`.
The body of the recipe is evaluated, written to disk in the temporary
directory, and run by passing its path as an argument to `COMMAND`.
@@ -2758,7 +2856,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}}
@@ -2890,7 +2988,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!'
@@ -3297,7 +3395,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
@@ -3337,7 +3435,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'
```
@@ -3346,19 +3444,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
@@ -3379,7 +3477,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:
@@ -3417,7 +3515,7 @@ the module file may have any capitalization.
Module statements may be of the form:
-```mf
+```justfile
mod foo 'PATH'
```
@@ -3441,7 +3539,7 @@ recipes.
Modules may be made optional by putting a `?` after the `mod` keyword:
-```mf
+```just
mod? foo
```
@@ -3451,7 +3549,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'
```
@@ -3459,7 +3557,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
```
@@ -3601,9 +3699,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.
@@ -3928,6 +4026,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
diff --git a/README.中文.md b/README.中文.md
index 936fb183..f586f78e 100644
--- a/README.中文.md
+++ b/README.中文.md
@@ -220,6 +220,22 @@ list:
asdf install just <version>
+
+ Various
+ PyPI
+ rust-just
+
+ pipx install rust-just
+
+
+
+ Various
+ npm
+ rust-just
+
+ npm install -g rust-just
+
+
Debian and Ubuntu derivatives
MPR
diff --git a/justfile b/justfile
index d0ff455c..e975d506 100755
--- a/justfile
+++ b/justfile
@@ -29,7 +29,7 @@ fuzz:
run:
cargo run
-# only run tests matching PATTERN
+# only run tests matching `PATTERN`
[group: 'test']
filter PATTERN:
cargo test {{PATTERN}}
@@ -169,6 +169,10 @@ build-book:
mdbook build book/en
mdbook build book/zh
+[group: 'dev']
+print-readme-constants-table:
+ cargo test constants::tests::readme_table -- --nocapture
+
# run all polyglot recipes
[group: 'demo']
polyglot: _python _js _perl _sh _ruby
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 9a26ef04..0929294c 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 53863fc9..511464d4 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 f9dd10c9..1ad7a8aa 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/binding.rs b/src/binding.rs
index 7e7890c7..69dcc3b0 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/color.rs b/src/color.rs
index ccdf2185..7742597b 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -35,7 +35,6 @@ impl Color {
Self::default()
}
- #[cfg(test)]
pub(crate) fn always() -> Self {
Self {
use_color: UseColor::Always,
@@ -66,6 +65,10 @@ impl Color {
self.restyle(Style::new().fg(Blue))
}
+ pub(crate) fn doc_backtick(self) -> Self {
+ self.restyle(Style::new().fg(Cyan))
+ }
+
pub(crate) fn error(self) -> Self {
self.restyle(Style::new().fg(Red).bold())
}
diff --git a/src/command_ext.rs b/src/command_ext.rs
index 6bd7208d..40d9da16 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/constants.rs b/src/constants.rs
index e9007ea7..5dd17681 100644
--- a/src/constants.rs
+++ b/src/constants.rs
@@ -1,15 +1,58 @@
use super::*;
-pub(crate) fn constants() -> &'static HashMap<&'static str, &'static str> {
- static CONSTANTS: OnceLock> = OnceLock::new();
+const CONSTANTS: [(&str, &str, &str); 27] = [
+ ("HEX", "0123456789abcdef", "1.27.0"),
+ ("HEXLOWER", "0123456789abcdef", "1.27.0"),
+ ("HEXUPPER", "0123456789ABCDEF", "1.27.0"),
+ ("CLEAR", "\x1bc", "master"),
+ ("NORMAL", "\x1b[0m", "master"),
+ ("BOLD", "\x1b[1m", "master"),
+ ("ITALIC", "\x1b[3m", "master"),
+ ("UNDERLINE", "\x1b[4m", "master"),
+ ("INVERT", "\x1b[7m", "master"),
+ ("HIDE", "\x1b[8m", "master"),
+ ("STRIKETHROUGH", "\x1b[9m", "master"),
+ ("BLACK", "\x1b[30m", "master"),
+ ("RED", "\x1b[31m", "master"),
+ ("GREEN", "\x1b[32m", "master"),
+ ("YELLOW", "\x1b[33m", "master"),
+ ("BLUE", "\x1b[34m", "master"),
+ ("MAGENTA", "\x1b[35m", "master"),
+ ("CYAN", "\x1b[36m", "master"),
+ ("WHITE", "\x1b[37m", "master"),
+ ("BG_BLACK", "\x1b[40m", "master"),
+ ("BG_RED", "\x1b[41m", "master"),
+ ("BG_GREEN", "\x1b[42m", "master"),
+ ("BG_YELLOW", "\x1b[43m", "master"),
+ ("BG_BLUE", "\x1b[44m", "master"),
+ ("BG_MAGENTA", "\x1b[45m", "master"),
+ ("BG_CYAN", "\x1b[46m", "master"),
+ ("BG_WHITE", "\x1b[47m", "master"),
+];
- CONSTANTS.get_or_init(|| {
- vec![
- ("HEX", "0123456789abcdef"),
- ("HEXLOWER", "0123456789abcdef"),
- ("HEXUPPER", "0123456789ABCDEF"),
- ]
- .into_iter()
- .collect()
+pub(crate) fn constants() -> &'static HashMap<&'static str, &'static str> {
+ static MAP: OnceLock> = OnceLock::new();
+ MAP.get_or_init(|| {
+ CONSTANTS
+ .into_iter()
+ .map(|(name, value, _version)| (name, value))
+ .collect()
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn readme_table() {
+ println!("| Name | Value |");
+ println!("|------|-------------|");
+ for (name, value, version) in CONSTANTS {
+ println!(
+ "| `{name}`{version} | `\"{}\"` |",
+ value.replace('\x1b', "\\e")
+ );
+ }
+ }
+}
diff --git a/src/evaluator.rs b/src/evaluator.rs
index 4ed00036..b83c8cf7 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())
@@ -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))
diff --git a/src/expression.rs b/src/expression.rs
index c1e4b5e0..3d5c3392 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/function.rs b/src/function.rs
index a714a8d0..abeae943 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -98,6 +98,7 @@ pub(crate) fn get(name: &str) -> Option {
"snakecase" => Unary(snakecase),
"source_directory" => Nullary(source_directory),
"source_file" => Nullary(source_file),
+ "style" => Unary(style),
"titlecase" => Unary(titlecase),
"trim" => Unary(trim),
"trim_end" => Unary(trim_end),
@@ -623,6 +624,20 @@ fn source_file(context: Context) -> FunctionResult {
})
}
+fn style(context: Context, s: &str) -> FunctionResult {
+ match s {
+ "command" => Ok(
+ Color::always()
+ .command(context.evaluator.context.config.command_color)
+ .prefix()
+ .to_string(),
+ ),
+ "error" => Ok(Color::always().error().prefix().to_string()),
+ "warning" => Ok(Color::always().warning().prefix().to_string()),
+ _ => Err(format!("unknown style: `{s}`")),
+ }
+}
+
fn titlecase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_title_case())
}
diff --git a/src/lexer.rs b/src/lexer.rs
index 4d28a446..2c56db9d 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/lib.rs b/src/lib.rs
index 75e3332b..799cc378 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -34,7 +34,7 @@ pub(crate) use {
},
camino::Utf8Path,
clap::ValueEnum,
- derivative::Derivative,
+ derive_where::derive_where,
edit_distance::edit_distance,
lexiclean::Lexiclean,
libc::EXIT_FAILURE,
diff --git a/src/node.rs b/src/node.rs
index 3ccf862d..6bfb042a 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()),
}
}
}
@@ -198,7 +197,7 @@ impl<'src> Node<'src> for UnresolvedRecipe<'src> {
t.push_mut("quiet");
}
- if let Some(doc) = self.doc {
+ if let Some(doc) = &self.doc {
t.push_mut(Tree::string(doc));
}
diff --git a/src/parser.rs b/src/parser.rs
index 1223153a..44b8d49a 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`
@@ -497,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,
})
}
@@ -517,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 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 }
- } else {
- value
- }
+ 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;
@@ -544,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()?;
@@ -900,6 +936,14 @@ impl<'run, 'src> Parser<'run, 'src> {
let private = name.lexeme().starts_with('_') || attributes.contains(&Attribute::Private);
+ let mut doc = doc.map(ToOwned::to_owned);
+
+ for attribute in &attributes {
+ if let Attribute::Doc(attribute_doc) = attribute {
+ doc = attribute_doc.as_ref().map(|doc| doc.cooked.clone());
+ }
+ }
+
Ok(Recipe {
shebang: shebang || script,
attributes,
diff --git a/src/recipe.rs b/src/recipe.rs
index d9b81cfa..88cdf339 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -22,7 +22,7 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> {
pub(crate) attributes: BTreeSet>,
pub(crate) body: Vec>,
pub(crate) dependencies: Vec,
- pub(crate) doc: Option<&'src str>,
+ pub(crate) doc: Option,
#[serde(skip)]
pub(crate) file_depth: u32,
#[serde(skip)]
@@ -473,7 +473,8 @@ impl<'src, D> Recipe<'src, D> {
return doc.as_ref().map(|s| s.cooked.as_ref());
}
}
- self.doc
+
+ self.doc.as_deref()
}
pub(crate) fn subsequents(&self) -> impl Iterator- {
@@ -483,8 +484,14 @@ impl<'src, D> Recipe<'src, D> {
impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
- if let Some(doc) = self.doc {
- writeln!(f, "# {doc}")?;
+ if !self
+ .attributes
+ .iter()
+ .any(|attribute| matches!(attribute, Attribute::Doc(_)))
+ {
+ if let Some(doc) = &self.doc {
+ writeln!(f, "# {doc}")?;
+ }
}
for attribute in &self.attributes {
diff --git a/src/scope.rs b/src/scope.rs
index 36bbedc8..78d12ca0 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/src/subcommand.rs b/src/subcommand.rs
index b1f13592..40795496 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -2,6 +2,11 @@ use {super::*, clap_mangen::Man};
const INIT_JUSTFILE: &str = "default:\n echo 'Hello, world!'\n";
+fn backtick_re() -> &'static Regex {
+ static BACKTICK_RE: OnceLock
= OnceLock::new();
+ BACKTICK_RE.get_or_init(|| Regex::new("(`.*?`)|(`[^`]*$)").unwrap())
+}
+
#[derive(PartialEq, Clone, Debug)]
pub(crate) enum Subcommand {
Changelog,
@@ -413,15 +418,31 @@ impl Subcommand {
) {
if let Some(doc) = doc {
if !doc.is_empty() && doc.lines().count() <= 1 {
+ let color = config.color.stdout();
print!(
- "{:padding$}{} {}",
+ "{:padding$}{} ",
"",
- config.color.stdout().doc().paint("#"),
- config.color.stdout().doc().paint(doc),
+ color.doc().paint("#"),
padding = max_signature_width.saturating_sub(signature_widths[name]) + 1,
);
+
+ let mut end = 0;
+ for backtick in backtick_re().find_iter(doc) {
+ let prefix = &doc[end..backtick.start()];
+ if !prefix.is_empty() {
+ print!("{}", color.doc().paint(prefix));
+ }
+ print!("{}", color.doc_backtick().paint(backtick.as_str()));
+ end = backtick.end();
+ }
+
+ let suffix = &doc[end..];
+ if !suffix.is_empty() {
+ print!("{}", color.doc().paint(suffix));
+ }
}
}
+
println!();
}
diff --git a/src/summary.rs b/src/summary.rs
index ee3a8d11..76483d63 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/thunk.rs b/src/thunk.rs
index 82668998..8c7ddfa8 100644
--- a/src/thunk.rs
+++ b/src/thunk.rs
@@ -1,46 +1,46 @@
use super::*;
-#[derive(Derivative)]
-#[derivative(Debug, Clone, PartialEq = "feature_allow_slow_enum")]
+#[derive_where(Debug, PartialEq)]
+#[derive(Clone)]
pub(crate) enum Thunk<'src> {
Nullary {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context) -> FunctionResult,
},
Unary {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str) -> FunctionResult,
arg: Box>,
},
UnaryOpt {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, Option<&str>) -> FunctionResult,
args: (Box>, Box>>),
},
UnaryPlus {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &[String]) -> FunctionResult,
args: (Box>, Vec>),
},
Binary {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &str) -> FunctionResult,
args: [Box>; 2],
},
BinaryPlus {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &str, &[String]) -> FunctionResult,
args: ([Box>; 2], Vec>),
},
Ternary {
name: Name<'src>,
- #[derivative(Debug = "ignore", PartialEq = "ignore")]
+ #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &str, &str) -> FunctionResult,
args: [Box>; 3],
},
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 0db15d2d..850afa96 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 07d99540..70e26fab 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 57979563..9de1c98c 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 bcb364f8..f9202b7f 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 c1739240..4eab2f4d 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/constants.rs b/tests/constants.rs
index 59f8b9bd..c6a3a853 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();
+}
diff --git a/tests/fmt.rs b/tests/fmt.rs
index 013c0a27..110b6e59 100644
--- a/tests/fmt.rs
+++ b/tests/fmt.rs
@@ -1096,3 +1096,27 @@ fn multi_argument_attribute() {
)
.run();
}
+
+#[test]
+fn doc_attribute_suppresses_comment() {
+ Test::new()
+ .justfile(
+ "
+ set unstable
+
+ # COMMENT
+ [doc('ATTRIBUTE')]
+ foo:
+ ",
+ )
+ .arg("--dump")
+ .stdout(
+ "
+ set unstable := true
+
+ [doc('ATTRIBUTE')]
+ foo:
+ ",
+ )
+ .run();
+}
diff --git a/tests/functions.rs b/tests/functions.rs
index 76964b74..d68b3946 100644
--- a/tests/functions.rs
+++ b/tests/functions.rs
@@ -1183,3 +1183,78 @@ bar:
.args(["foo", "bar"])
.run();
}
+
+#[test]
+fn style_command_default() {
+ Test::new()
+ .justfile(
+ r#"
+ foo:
+ @echo '{{ style("command") }}foo{{NORMAL}}'
+ "#,
+ )
+ .stdout("\x1b[1mfoo\x1b[0m\n")
+ .run();
+}
+
+#[test]
+fn style_command_non_default() {
+ Test::new()
+ .justfile(
+ r#"
+ foo:
+ @echo '{{ style("command") }}foo{{NORMAL}}'
+ "#,
+ )
+ .args(["--command-color", "red"])
+ .stdout("\x1b[1;31mfoo\x1b[0m\n")
+ .run();
+}
+
+#[test]
+fn style_error() {
+ Test::new()
+ .justfile(
+ r#"
+ foo:
+ @echo '{{ style("error") }}foo{{NORMAL}}'
+ "#,
+ )
+ .stdout("\x1b[1;31mfoo\x1b[0m\n")
+ .run();
+}
+
+#[test]
+fn style_warning() {
+ Test::new()
+ .justfile(
+ r#"
+ foo:
+ @echo '{{ style("warning") }}foo{{NORMAL}}'
+ "#,
+ )
+ .stdout("\x1b[1;33mfoo\x1b[0m\n")
+ .run();
+}
+
+#[test]
+fn style_unknown() {
+ Test::new()
+ .justfile(
+ r#"
+ foo:
+ @echo '{{ style("hippo") }}foo{{NORMAL}}'
+ "#,
+ )
+ .stderr(
+ r#"
+ error: Call to function `style` failed: unknown style: `hippo`
+ ——▶ justfile:2:13
+ │
+ 2 │ @echo '{{ style("hippo") }}foo{{NORMAL}}'
+ │ ^^^^^
+ "#,
+ )
+ .status(EXIT_FAILURE)
+ .run();
+}
diff --git a/tests/ignore_comments.rs b/tests/ignore_comments.rs
index c3028a57..3f068227 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/json.rs b/tests/json.rs
index 3819c040..d3ea7d56 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -1429,3 +1429,58 @@ fn recipes_with_private_attribute_are_private() {
}),
);
}
+
+#[test]
+fn doc_attribute_overrides_comment() {
+ case(
+ "
+ # COMMENT
+ [doc('ATTRIBUTE')]
+ foo:
+ ",
+ json!({
+ "aliases": {},
+ "assignments": {},
+ "first": "foo",
+ "doc": null,
+ "groups": [],
+ "modules": {},
+ "recipes": {
+ "foo": {
+ "attributes": [{"doc": "ATTRIBUTE"}],
+ "body": [],
+ "dependencies": [],
+ "doc": "ATTRIBUTE",
+ "name": "foo",
+ "namepath": "foo",
+ "parameters": [],
+ "priors": 0,
+ "private": false,
+ "quiet": false,
+ "shebang": false,
+ }
+ },
+ "settings": {
+ "allow_duplicate_recipes": false,
+ "allow_duplicate_variables": false,
+ "dotenv_filename": null,
+ "dotenv_load": false,
+ "dotenv_path": null,
+ "dotenv_required": false,
+ "export": false,
+ "fallback": false,
+ "ignore_comments": false,
+ "positional_arguments": false,
+ "quiet": false,
+ "shell": null,
+ "tempdir" : null,
+ "unstable": false,
+ "windows_powershell": false,
+ "windows_shell": null,
+ "working_directory" : null,
+ },
+ "unexports": [],
+ "warnings": [],
+ }),
+ );
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index ec71c665..7c85460b 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/list.rs b/tests/list.rs
index db7b669e..53a1c737 100644
--- a/tests/list.rs
+++ b/tests/list.rs
@@ -438,3 +438,39 @@ fn no_space_before_submodules_not_following_groups() {
)
.run();
}
+
+#[test]
+fn backticks_highlighted() {
+ Test::new()
+ .justfile(
+ "
+ # Comment `` `with backticks` and trailing text
+ recipe:
+ ",
+ )
+ .args(["--list", "--color=always"])
+ .stdout(
+ "
+ Available recipes:
+ recipe \u{1b}[34m#\u{1b}[0m \u{1b}[34mComment \u{1b}[0m\u{1b}[36m``\u{1b}[0m\u{1b}[34m \u{1b}[0m\u{1b}[36m`with backticks`\u{1b}[0m\u{1b}[34m and trailing text\u{1b}[0m
+ ")
+ .run();
+}
+
+#[test]
+fn unclosed_backticks() {
+ Test::new()
+ .justfile(
+ "
+ # Comment `with unclosed backick
+ recipe:
+ ",
+ )
+ .args(["--list", "--color=always"])
+ .stdout(
+ "
+ Available recipes:
+ recipe \u{1b}[34m#\u{1b}[0m \u{1b}[34mComment \u{1b}[0m\u{1b}[36m`with unclosed backick\u{1b}[0m
+ ")
+ .run();
+}
diff --git a/tests/logical_operators.rs b/tests/logical_operators.rs
new file mode 100644
index 00000000..5baa0f52
--- /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 67fdc07d..954e2a33 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'