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

Merge branch 'master' into feat/working-directory-attribute

This commit is contained in:
Ben Heidemann 2024-11-19 21:31:47 +00:00 committed by GitHub
commit e3c9a0c199
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 904 additions and 347 deletions

View File

@ -110,7 +110,7 @@ jobs:
shell: bash shell: bash
- name: Publish Archive - 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/') }} if: ${{ startsWith(github.ref, 'refs/tags/') }}
with: with:
draft: false draft: false
@ -120,7 +120,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Changelog - name: Publish Changelog
uses: softprops/action-gh-release@v2.0.8 uses: softprops/action-gh-release@v2.1.0
if: >- if: >-
${{ ${{
startsWith(github.ref, 'refs/tags/') startsWith(github.ref, 'refs/tags/')
@ -157,7 +157,7 @@ jobs:
shasum -a 256 * > ../SHA256SUMS shasum -a 256 * > ../SHA256SUMS
- name: Publish Checksums - name: Publish Checksums
uses: softprops/action-gh-release@v2.0.8 uses: softprops/action-gh-release@v2.1.0
with: with:
draft: false draft: false
files: SHA256SUMS files: SHA256SUMS

37
Cargo.lock generated
View File

@ -237,7 +237,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
] ]
[[package]] [[package]]
@ -329,14 +329,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "derivative" name = "derive-where"
version = "2.2.0" version = "1.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 1.0.109", "syn",
] ]
[[package]] [[package]]
@ -531,7 +531,7 @@ dependencies = [
"clap_complete", "clap_complete",
"clap_mangen", "clap_mangen",
"ctrlc", "ctrlc",
"derivative", "derive-where",
"dirs", "dirs",
"dotenvy", "dotenvy",
"edit-distance", "edit-distance",
@ -861,7 +861,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
] ]
[[package]] [[package]]
@ -930,7 +930,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
] ]
[[package]] [[package]]
@ -958,18 +958,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn 2.0.79", "syn",
]
[[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",
] ]
[[package]] [[package]]
@ -1038,7 +1027,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
] ]
[[package]] [[package]]
@ -1142,7 +1131,7 @@ dependencies = [
"once_cell", "once_cell",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -1164,7 +1153,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@ -1396,5 +1385,5 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.79", "syn",
] ]

View File

@ -26,7 +26,7 @@ clap = { version = "4.0.0", features = ["derive", "env", "wrap_help"] }
clap_complete = "4.0.0" clap_complete = "4.0.0"
clap_mangen = "0.2.20" clap_mangen = "0.2.20"
ctrlc = { version = "3.1.1", features = ["termination"] } ctrlc = { version = "3.1.1", features = ["termination"] }
derivative = "2.0.0" derive-where = "1.2.7"
dirs = "5.0.1" dirs = "5.0.1"
dotenvy = "0.15" dotenvy = "0.15"
edit-distance = "2.0.0" edit-distance = "2.0.0"

View File

@ -90,7 +90,13 @@ import : 'import' '?'? string? eol
module : 'mod' '?'? NAME 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 ')' | 'assert' '(' condition ',' expression ')'
| '/' expression | '/' expression
| value '/' expression | value '/' expression

182
README.md
View File

@ -167,7 +167,7 @@ most Windows users.)
<tr> <tr>
<td><a href=https://www.npmjs.com/>npm</a></td> <td><a href=https://www.npmjs.com/>npm</a></td>
<td><a href=https://www.npmjs.com/package/rust-just>rust-just</a></td> <td><a href=https://www.npmjs.com/package/rust-just>rust-just</a></td>
<td><code>npm install rust-just</code></td> <td><code>npm install -g rust-just</code></td>
</tr> </tr>
<tr> <tr>
<td><a href=https://pypi.org/>PyPI</a></td> <td><a href=https://pypi.org/>PyPI</a></td>
@ -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): With [extractions/setup-just](https://github.com/extractions/setup-just):
```yaml ```yaml
- uses: extractions/setup-just@v1 - uses: extractions/setup-just@v2
with: with:
just-version: 1.5.0 # optional semver specification, otherwise latest just-version: 1.5.0 # optional semver specification, otherwise latest
``` ```
@ -1290,9 +1290,11 @@ Available recipes:
test 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 ```just
tmpdir := `mktemp -d` tmpdir := `mktemp -d`
@ -1310,6 +1312,39 @@ publish:
rm -rf {{tarball}} {{tardir}} 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 #### Joining Paths
The `/` operator can be used to join two strings with a slash: The `/` operator can be used to join two strings with a slash:
@ -1543,11 +1578,11 @@ file.
and can be changed with `set shell := […]`. and can be changed with `set shell := […]`.
`command` is passed as the first argument, so if the command is `'echo $@'`, `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: `'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 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"` `requirement`, e.g., `">=0.1.0"`, returning `"true"` if so and `"false"`
otherwise. otherwise.
#### Style
- `style(name)`<sup>master</sup> - 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 Directories<sup>1.23.0</sup> ##### XDG Directories<sup>1.23.0</sup>
These functions return paths to user-specific directories for things like These functions return paths to user-specific directories for things like
@ -1842,6 +1895,30 @@ A number of constants are predefined:
| `HEX`<sup>1.27.0</sup> | `"0123456789abcdef"` | | `HEX`<sup>1.27.0</sup> | `"0123456789abcdef"` |
| `HEXLOWER`<sup>1.27.0</sup> | `"0123456789abcdef"` | | `HEXLOWER`<sup>1.27.0</sup> | `"0123456789abcdef"` |
| `HEXUPPER`<sup>1.27.0</sup> | `"0123456789ABCDEF"` | | `HEXUPPER`<sup>1.27.0</sup> | `"0123456789ABCDEF"` |
| `CLEAR`<sup>master</sup> | `"\ec"` |
| `NORMAL`<sup>master</sup> | `"\e[0m"` |
| `BOLD`<sup>master</sup> | `"\e[1m"` |
| `ITALIC`<sup>master</sup> | `"\e[3m"` |
| `UNDERLINE`<sup>master</sup> | `"\e[4m"` |
| `INVERT`<sup>master</sup> | `"\e[7m"` |
| `HIDE`<sup>master</sup> | `"\e[8m"` |
| `STRIKETHROUGH`<sup>master</sup> | `"\e[9m"` |
| `BLACK`<sup>master</sup> | `"\e[30m"` |
| `RED`<sup>master</sup> | `"\e[31m"` |
| `GREEN`<sup>master</sup> | `"\e[32m"` |
| `YELLOW`<sup>master</sup> | `"\e[33m"` |
| `BLUE`<sup>master</sup> | `"\e[34m"` |
| `MAGENTA`<sup>master</sup> | `"\e[35m"` |
| `CYAN`<sup>master</sup> | `"\e[36m"` |
| `WHITE`<sup>master</sup> | `"\e[37m"` |
| `BG_BLACK`<sup>master</sup> | `"\e[40m"` |
| `BG_RED`<sup>master</sup> | `"\e[41m"` |
| `BG_GREEN`<sup>master</sup> | `"\e[42m"` |
| `BG_YELLOW`<sup>master</sup> | `"\e[43m"` |
| `BG_BLUE`<sup>master</sup> | `"\e[44m"` |
| `BG_MAGENTA`<sup>master</sup> | `"\e[45m"` |
| `BG_CYAN`<sup>master</sup> | `"\e[46m"` |
| `BG_WHITE`<sup>master</sup> | `"\e[47m"` |
```just ```just
@foo: @foo:
@ -1853,9 +1930,29 @@ $ just foo
0123456789abcdef 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 ### 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 | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
@ -2385,8 +2482,8 @@ Testing server:unit…
./test --tests unit server ./test --tests unit server
``` ```
Default values may be arbitrary expressions, but concatenations or path joins Default values may be arbitrary expressions, but expressions containing the
must be parenthesized: `+`, `&&`, `||`, or `/` operators must be parenthesized:
```just ```just
arch := "wasm" 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 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. `/usr/bin/env`, and inconsistences in shebang line splitting across Unix OSs.
Recipes with an empty `[script]` attribute are executed with the value of Recipes with an empty `[script]` attribute are executed with the value of `set
`set script-interpreter := […]`<sup>1.33.0</sup>, defaulting to `sh -eu`. script-interpreter := […]`<sup>1.33.0</sup>, defaulting to `sh -eu`, and *not*
the value of `set shell`.
The body of the recipe is evaluated, written to disk in the temporary 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`. 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 Recipe lines are interpreted by the shell, not `just`, so it's not possible to
set `just` variables in the middle of a recipe: set `just` variables in the middle of a recipe:
```mf ```justfile
foo: foo:
x := "hello" # This doesn't work! x := "hello" # This doesn't work!
echo {{x}} echo {{x}}
@ -2890,7 +2988,7 @@ means that multi-line constructs probably won't do what you want.
For example, with the following `justfile`: For example, with the following `justfile`:
```mf ```justfile
conditional: conditional:
if true; then if true; then
echo 'True!' echo 'True!'
@ -3297,7 +3395,7 @@ One `justfile` can include the contents of another using `import` statements.
If you have the following `justfile`: If you have the following `justfile`:
```mf ```justfile
import 'foo/bar.just' import 'foo/bar.just'
a: b 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: Imports may be made optional by putting a `?` after the `import` keyword:
```mf ```just
import? 'foo/bar.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 `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: example `baz.just`, without the duplicate import of `baz.just` being an error:
```mf ```justfile
# justfile # justfile
import 'foo.just' import 'foo.just'
import 'bar.just' import 'bar.just'
``` ```
```mf ```justfile
# foo.just # foo.just
import 'baz.just' import 'baz.just'
foo: baz foo: baz
``` ```
```mf ```justfile
# bar.just # bar.just
import 'baz.just' import 'baz.just'
bar: baz 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`: If you have the following `justfile`:
```mf ```justfile
mod bar mod bar
a: a:
@ -3417,7 +3515,7 @@ the module file may have any capitalization.
Module statements may be of the form: Module statements may be of the form:
```mf ```justfile
mod foo 'PATH' mod foo 'PATH'
``` ```
@ -3441,7 +3539,7 @@ recipes.
Modules may be made optional by putting a `?` after the `mod` keyword: Modules may be made optional by putting a `?` after the `mod` keyword:
```mf ```just
mod? foo 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 mod statements with the same name, but with different source file paths, as
long as at most one source file exists: long as at most one source file exists:
```mf ```just
mod? foo 'bar.just' mod? foo 'bar.just'
mod? foo 'baz.just' mod? foo 'baz.just'
``` ```
@ -3459,7 +3557,7 @@ mod? foo 'baz.just'
Modules may be given doc comments which appear in `--list` Modules may be given doc comments which appear in `--list`
output<sup>1.30.0</sup>: output<sup>1.30.0</sup>:
```mf ```justfile
# foo is a great module! # foo is a great module!
mod foo mod foo
``` ```
@ -3601,9 +3699,9 @@ The following command will create two files, `some` and `argument.txt`:
$ just foo "some argument.txt" $ just foo "some argument.txt"
``` ```
The users shell will parse `"some argument.txt"` as a single argument, but when The user's shell will parse `"some argument.txt"` as a single argument, but
`just` replaces `touch {{argument}}` with `touch some argument.txt`, the quotes when `just` replaces `touch {{argument}}` with `touch some argument.txt`, the
are not preserved, and `touch` will receive two arguments. quotes are not preserved, and `touch` will receive two arguments.
There are a few ways to avoid this: quoting, positional arguments, and exported There are a few ways to avoid this: quoting, positional arguments, and exported
arguments. arguments.
@ -3928,6 +4026,38 @@ fetch:
Given the above `justfile`, after running `just fetch`, the recipes in Given the above `justfile`, after running `just fetch`, the recipes in
`foo.just` will be available. `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 ### Alternatives and Prior Art
There is no shortage of command runners! Some more or less similar alternatives There is no shortage of command runners! Some more or less similar alternatives

View File

@ -220,6 +220,22 @@ list:
<code>asdf install just &lt;version&gt;</code> <code>asdf install just &lt;version&gt;</code>
</td> </td>
</tr> </tr>
<tr>
<td><a href="https://packaging.python.org/tutorials/installing-packages">Various</a></td>
<td><a href="https://pypi.org">PyPI</a></td>
<td><a href="https://pypi.org/project/rust-just">rust-just</a></td>
<td>
<code>pipx install rust-just</code><br>
</td>
</tr>
<tr>
<td><a href="https://docs.npmjs.com/packages-and-modules/getting-packages-from-the-registry">Various</a></td>
<td><a href="https://www.npmjs.com">npm</a></td>
<td><a href="https://www.npmjs.com/package/rust-just">rust-just</a></td>
<td>
<code>npm install -g rust-just</code><br>
</td>
</tr>
<tr> <tr>
<td><a href="https://debian.org">Debian</a> and <a href="https://ubuntu.com">Ubuntu</a> derivatives</td> <td><a href="https://debian.org">Debian</a> and <a href="https://ubuntu.com">Ubuntu</a> derivatives</td>
<td><a href="https://mpr.makedeb.org">MPR</a></td> <td><a href="https://mpr.makedeb.org">MPR</a></td>

View File

@ -29,7 +29,7 @@ fuzz:
run: run:
cargo run cargo run
# only run tests matching PATTERN # only run tests matching `PATTERN`
[group: 'test'] [group: 'test']
filter PATTERN: filter PATTERN:
cargo test {{PATTERN}} cargo test {{PATTERN}}
@ -169,6 +169,10 @@ build-book:
mdbook build book/en mdbook build book/en
mdbook build book/zh mdbook build book/zh
[group: 'dev']
print-readme-constants-table:
cargo test constants::tests::readme_table -- --nocapture
# run all polyglot recipes # run all polyglot recipes
[group: 'demo'] [group: 'demo']
polyglot: _python _js _perl _sh _ruby polyglot: _python _js _perl _sh _ruby

View File

@ -36,12 +36,15 @@ impl<'run, 'src> Analyzer<'run, 'src> {
) -> CompileResult<'src, Justfile<'src>> { ) -> CompileResult<'src, Justfile<'src>> {
let mut definitions = HashMap::new(); let mut definitions = HashMap::new();
let mut imports = HashSet::new(); let mut imports = HashSet::new();
let mut unstable_features = BTreeSet::new();
let mut stack = Vec::new(); let mut stack = Vec::new();
let ast = asts.get(root).unwrap(); let ast = asts.get(root).unwrap();
stack.push(ast); stack.push(ast);
while let Some(ast) = stack.pop() { while let Some(ast) = stack.pop() {
unstable_features.extend(&ast.unstable_features);
for item in &ast.items { for item in &ast.items {
match item { match item {
Item::Alias(alias) => { Item::Alias(alias) => {
@ -166,8 +169,6 @@ impl<'run, 'src> Analyzer<'run, 'src> {
aliases.insert(Self::resolve_alias(&recipes, alias)?); aliases.insert(Self::resolve_alias(&recipes, alias)?);
} }
let mut unstable_features = BTreeSet::new();
for recipe in recipes.values() { for recipe in recipes.values() {
for attribute in &recipe.attributes { for attribute in &recipe.attributes {
if let Attribute::Script(_) = attribute { if let Attribute::Script(_) = attribute {

View File

@ -31,7 +31,29 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
self.stack.push(name); self.stack.push(name);
if let Some(assignment) = self.assignments.get(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); self.evaluated.insert(name);
} else { } else {
let message = format!("attempted to resolve unknown assignment `{name}`"); let message = format!("attempted to resolve unknown assignment `{name}`");
@ -51,112 +73,6 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
Ok(()) 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)] #[cfg(test)]

View File

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

View File

@ -3,6 +3,8 @@ use super::*;
/// A binding of `name` to `value` /// A binding of `name` to `value`
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct Binding<'src, V = String> { pub(crate) struct Binding<'src, V = String> {
#[serde(skip)]
pub(crate) constant: bool,
pub(crate) export: bool, pub(crate) export: bool,
#[serde(skip)] #[serde(skip)]
pub(crate) file_depth: u32, pub(crate) file_depth: u32,

View File

@ -35,7 +35,6 @@ impl Color {
Self::default() Self::default()
} }
#[cfg(test)]
pub(crate) fn always() -> Self { pub(crate) fn always() -> Self {
Self { Self {
use_color: UseColor::Always, use_color: UseColor::Always,
@ -66,6 +65,10 @@ impl Color {
self.restyle(Style::new().fg(Blue)) 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 { pub(crate) fn error(self) -> Self {
self.restyle(Style::new().fg(Red).bold()) self.restyle(Style::new().fg(Red).bold())
} }

View File

@ -39,7 +39,7 @@ impl CommandExt for Command {
} }
for binding in scope.bindings() { for binding in scope.bindings() {
if settings.export || binding.export { if binding.export || (settings.export && !binding.constant) {
self.env(binding.name.lexeme(), &binding.value); self.env(binding.name.lexeme(), &binding.value);
} }
} }

View File

@ -1,15 +1,58 @@
use super::*; use super::*;
pub(crate) fn constants() -> &'static HashMap<&'static str, &'static str> { const CONSTANTS: [(&str, &str, &str); 27] = [
static CONSTANTS: OnceLock<HashMap<&str, &str>> = OnceLock::new(); ("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(|| { pub(crate) fn constants() -> &'static HashMap<&'static str, &'static str> {
vec![ static MAP: OnceLock<HashMap<&str, &str>> = OnceLock::new();
("HEX", "0123456789abcdef"), MAP.get_or_init(|| {
("HEXLOWER", "0123456789abcdef"), CONSTANTS
("HEXUPPER", "0123456789ABCDEF"), .into_iter()
] .map(|(name, value, _version)| (name, value))
.into_iter() .collect()
.collect()
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn readme_table() {
println!("| Name | Value |");
println!("|------|-------------|");
for (name, value, version) in CONSTANTS {
println!(
"| `{name}`<sup>{version}</sup> | `\"{}\"` |",
value.replace('\x1b', "\\e")
);
}
}
}

View File

@ -32,12 +32,14 @@ impl<'src, 'run> Evaluator<'src, 'run> {
for (name, value) in overrides { for (name, value) in overrides {
if let Some(assignment) = module.assignments.get(name) { if let Some(assignment) = module.assignments.get(name) {
scope.bind( scope.bind(Binding {
assignment.export, constant: false,
assignment.name, export: assignment.export,
assignment.private, file_depth: 0,
value.clone(), name: assignment.name,
); private: assignment.private,
value: value.clone(),
});
} else { } else {
unknown_overrides.push(name.clone()); unknown_overrides.push(name.clone());
} }
@ -68,12 +70,14 @@ impl<'src, 'run> Evaluator<'src, 'run> {
if !self.scope.bound(name) { if !self.scope.bound(name) {
let value = self.evaluate_expression(&assignment.value)?; let value = self.evaluate_expression(&assignment.value)?;
self.scope.bind( self.scope.bind(Binding {
assignment.export, constant: false,
assignment.name, export: assignment.export,
assignment.private, file_depth: 0,
name: assignment.name,
private: assignment.private,
value, value,
); });
} }
Ok(self.scope.value(name).unwrap()) Ok(self.scope.value(name).unwrap())
@ -84,24 +88,31 @@ impl<'src, 'run> Evaluator<'src, 'run> {
expression: &Expression<'src>, expression: &Expression<'src>,
) -> RunResult<'src, String> { ) -> RunResult<'src, String> {
match expression { match expression {
Expression::Variable { name, .. } => { Expression::And { lhs, rhs } => {
let variable = name.lexeme(); let lhs = self.evaluate_expression(lhs)?;
if let Some(value) = self.scope.value(variable) { if lhs.is_empty() {
Ok(value.to_owned()) return Ok(String::new());
} else if let Some(assignment) = self }
.assignments self.evaluate_expression(rhs)
.and_then(|assignments| assignments.get(variable)) }
{ Expression::Assert { condition, error } => {
Ok(self.evaluate_assignment(assignment)?.to_owned()) if self.evaluate_condition(condition)? {
Ok(String::new())
} else { } else {
Err(Error::Internal { Err(Error::Assert {
message: format!("attempted to evaluate undefined variable `{variable}`"), 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 } => { Expression::Call { thunk } => {
use Thunk::*; use Thunk::*;
let result = match thunk { let result = match thunk {
Nullary { function, .. } => function(function::Context::new(self, thunk.name())), Nullary { function, .. } => function(function::Context::new(self, thunk.name())),
Unary { function, arg, .. } => { Unary { function, arg, .. } => {
@ -118,7 +129,6 @@ impl<'src, 'run> Evaluator<'src, 'run> {
Some(b) => Some(self.evaluate_expression(b)?), Some(b) => Some(self.evaluate_expression(b)?),
None => None, None => None,
}; };
function(function::Context::new(self, thunk.name()), &a, b.as_deref()) function(function::Context::new(self, thunk.name()), &a, b.as_deref())
} }
UnaryPlus { UnaryPlus {
@ -175,20 +185,11 @@ impl<'src, 'run> Evaluator<'src, 'run> {
function(function::Context::new(self, thunk.name()), &a, &b, &c) function(function::Context::new(self, thunk.name()), &a, &b, &c)
} }
}; };
result.map_err(|message| Error::FunctionCall { result.map_err(|message| Error::FunctionCall {
function: thunk.name(), function: thunk.name(),
message, 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 } => { Expression::Concatenation { lhs, rhs } => {
Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?) Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?)
} }
@ -209,12 +210,26 @@ impl<'src, 'run> Evaluator<'src, 'run> {
lhs: Some(lhs), lhs: Some(lhs),
rhs, rhs,
} => Ok(self.evaluate_expression(lhs)? + "/" + &self.evaluate_expression(rhs)?), } => Ok(self.evaluate_expression(lhs)? + "/" + &self.evaluate_expression(rhs)?),
Expression::Assert { condition, error } => { Expression::Or { lhs, rhs } => {
if self.evaluate_condition(condition)? { let lhs = self.evaluate_expression(lhs)?;
Ok(String::new()) 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 { } else {
Err(Error::Assert { Err(Error::Internal {
message: self.evaluate_expression(error)?, message: format!("attempted to evaluate undefined variable `{variable}`"),
}) })
} }
} }
@ -329,9 +344,14 @@ impl<'src, 'run> Evaluator<'src, 'run> {
rest = &rest[1..]; rest = &rest[1..];
value value
}; };
evaluator evaluator.scope.bind(Binding {
.scope constant: false,
.bind(parameter.export, parameter.name, false, value); export: parameter.export,
file_depth: 0,
name: parameter.name,
private: false,
value,
});
} }
Ok((evaluator.scope, positional)) Ok((evaluator.scope, positional))

View File

@ -8,6 +8,11 @@ use super::*;
/// The parser parses both values and expressions into `Expression`s. /// The parser parses both values and expressions into `Expression`s.
#[derive(PartialEq, Debug, Clone)] #[derive(PartialEq, Debug, Clone)]
pub(crate) enum Expression<'src> { pub(crate) enum Expression<'src> {
/// `lhs && rhs`
And {
lhs: Box<Expression<'src>>,
rhs: Box<Expression<'src>>,
},
/// `assert(condition, error)` /// `assert(condition, error)`
Assert { Assert {
condition: Condition<'src>, condition: Condition<'src>,
@ -38,6 +43,11 @@ pub(crate) enum Expression<'src> {
lhs: Option<Box<Expression<'src>>>, lhs: Option<Box<Expression<'src>>>,
rhs: Box<Expression<'src>>, rhs: Box<Expression<'src>>,
}, },
/// `lhs || rhs`
Or {
lhs: Box<Expression<'src>>,
rhs: Box<Expression<'src>>,
},
/// `"string_literal"` or `'string_literal'` /// `"string_literal"` or `'string_literal'`
StringLiteral { string_literal: StringLiteral<'src> }, StringLiteral { string_literal: StringLiteral<'src> },
/// `variable` /// `variable`
@ -53,23 +63,25 @@ impl<'src> Expression<'src> {
impl<'src> Display for Expression<'src> { impl<'src> Display for Expression<'src> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self { match self {
Self::And { lhs, rhs } => write!(f, "{lhs} && {rhs}"),
Self::Assert { condition, error } => write!(f, "assert({condition}, {error})"), Self::Assert { condition, error } => write!(f, "assert({condition}, {error})"),
Self::Backtick { token, .. } => write!(f, "{}", token.lexeme()), Self::Backtick { token, .. } => write!(f, "{}", token.lexeme()),
Self::Join { lhs: None, rhs } => write!(f, "/ {rhs}"), Self::Call { thunk } => write!(f, "{thunk}"),
Self::Join {
lhs: Some(lhs),
rhs,
} => write!(f, "{lhs} / {rhs}"),
Self::Concatenation { lhs, rhs } => write!(f, "{lhs} + {rhs}"), Self::Concatenation { lhs, rhs } => write!(f, "{lhs} + {rhs}"),
Self::Conditional { Self::Conditional {
condition, condition,
then, then,
otherwise, otherwise,
} => write!(f, "if {condition} {{ {then} }} else {{ {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::StringLiteral { string_literal } => write!(f, "{string_literal}"),
Self::Variable { name } => write!(f, "{}", name.lexeme()), 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, S: Serializer,
{ {
match self { 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 } => { Self::Assert { condition, error } => {
let mut seq: <S as Serializer>::SerializeSeq = serializer.serialize_seq(None)?; let mut seq: <S as Serializer>::SerializeSeq = serializer.serialize_seq(None)?;
seq.serialize_element("assert")?; seq.serialize_element("assert")?;
@ -101,13 +120,6 @@ impl<'src> Serialize for Expression<'src> {
seq.serialize_element(rhs)?; seq.serialize_element(rhs)?;
seq.end() 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 { Self::Conditional {
condition, condition,
then, then,
@ -121,6 +133,20 @@ impl<'src> Serialize for Expression<'src> {
seq.end() seq.end()
} }
Self::Group { contents } => contents.serialize(serializer), 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::StringLiteral { string_literal } => string_literal.serialize(serializer),
Self::Variable { name } => { Self::Variable { name } => {
let mut seq = serializer.serialize_seq(None)?; let mut seq = serializer.serialize_seq(None)?;

View File

@ -98,6 +98,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
"snakecase" => Unary(snakecase), "snakecase" => Unary(snakecase),
"source_directory" => Nullary(source_directory), "source_directory" => Nullary(source_directory),
"source_file" => Nullary(source_file), "source_file" => Nullary(source_file),
"style" => Unary(style),
"titlecase" => Unary(titlecase), "titlecase" => Unary(titlecase),
"trim" => Unary(trim), "trim" => Unary(trim),
"trim_end" => Unary(trim_end), "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 { fn titlecase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_title_case()) Ok(s.to_title_case())
} }

View File

@ -496,6 +496,7 @@ impl<'src> Lexer<'src> {
']' => self.lex_delimiter(BracketR), ']' => self.lex_delimiter(BracketR),
'`' | '"' | '\'' => self.lex_string(), '`' | '"' | '\'' => self.lex_string(),
'{' => self.lex_delimiter(BraceL), '{' => self.lex_delimiter(BraceL),
'|' => self.lex_digraph('|', '|', BarBar),
'}' => self.lex_delimiter(BraceR), '}' => self.lex_delimiter(BraceR),
_ if Self::is_identifier_start(start) => self.lex_identifier(), _ if Self::is_identifier_start(start) => self.lex_identifier(),
_ => { _ => {
@ -948,6 +949,7 @@ mod tests {
Asterisk => "*", Asterisk => "*",
At => "@", At => "@",
BangEquals => "!=", BangEquals => "!=",
BarBar => "||",
BraceL => "{", BraceL => "{",
BraceR => "}", BraceR => "}",
BracketL => "[", BracketL => "[",

View File

@ -34,7 +34,7 @@ pub(crate) use {
}, },
camino::Utf8Path, camino::Utf8Path,
clap::ValueEnum, clap::ValueEnum,
derivative::Derivative, derive_where::derive_where,
edit_distance::edit_distance, edit_distance::edit_distance,
lexiclean::Lexiclean, lexiclean::Lexiclean,
libc::EXIT_FAILURE, libc::EXIT_FAILURE,

View File

@ -88,6 +88,7 @@ impl<'src> Node<'src> for Assignment<'src> {
impl<'src> Node<'src> for Expression<'src> { impl<'src> Node<'src> for Expression<'src> {
fn tree(&self) -> Tree<'src> { fn tree(&self) -> Tree<'src> {
match self { match self {
Self::And { lhs, rhs } => Tree::atom("&&").push(lhs.tree()).push(rhs.tree()),
Self::Assert { Self::Assert {
condition: Condition { lhs, rhs, operator }, condition: Condition { lhs, rhs, operator },
error, error,
@ -96,25 +97,10 @@ impl<'src> Node<'src> for Expression<'src> {
.push(operator.to_string()) .push(operator.to_string())
.push(rhs.tree()) .push(rhs.tree())
.push(error.tree()), .push(error.tree()),
Self::Concatenation { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()), Self::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)),
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::Call { thunk } => { Self::Call { thunk } => {
use Thunk::*; use Thunk::*;
let mut tree = Tree::atom("call"); let mut tree = Tree::atom("call");
match thunk { match thunk {
Nullary { name, .. } => tree.push_mut(name.lexeme()), Nullary { name, .. } => tree.push_mut(name.lexeme()),
Unary { name, arg, .. } => { Unary { name, arg, .. } => {
@ -171,20 +157,33 @@ impl<'src> Node<'src> for Expression<'src> {
tree.push_mut(c.tree()); tree.push_mut(c.tree());
} }
} }
tree tree
} }
Self::Variable { name } => Tree::atom(name.lexeme()), Self::Concatenation { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()),
Self::StringLiteral { Self::Conditional {
string_literal: StringLiteral { cooked, .. }, condition: Condition { lhs, rhs, operator },
} => Tree::string(cooked), then,
Self::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)), 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::Group { contents } => Tree::List(vec![contents.tree()]),
Self::Join { lhs: None, rhs } => Tree::atom("/").push(rhs.tree()), Self::Join { lhs: None, rhs } => Tree::atom("/").push(rhs.tree()),
Self::Join { Self::Join {
lhs: Some(lhs), lhs: Some(lhs),
rhs, rhs,
} => Tree::atom("/").push(lhs.tree()).push(rhs.tree()), } => 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"); t.push_mut("quiet");
} }
if let Some(doc) = self.doc { if let Some(doc) = &self.doc {
t.push_mut(Tree::string(doc)); t.push_mut(Tree::string(doc));
} }

View File

@ -31,6 +31,7 @@ pub(crate) struct Parser<'run, 'src> {
next_token: usize, next_token: usize,
recursion_depth: usize, recursion_depth: usize,
tokens: &'run [Token<'src>], tokens: &'run [Token<'src>],
unstable_features: BTreeSet<UnstableFeature>,
working_directory: &'run Path, working_directory: &'run Path,
} }
@ -51,6 +52,7 @@ impl<'run, 'src> Parser<'run, 'src> {
next_token: 0, next_token: 0,
recursion_depth: 0, recursion_depth: 0,
tokens, tokens,
unstable_features: BTreeSet::new(),
working_directory, working_directory,
} }
.parse_ast() .parse_ast()
@ -442,18 +444,19 @@ impl<'run, 'src> Parser<'run, 'src> {
} }
} }
if self.next_token == self.tokens.len() { if self.next_token != self.tokens.len() {
Ok(Ast { return Err(self.internal_error(format!(
items,
warnings: Vec::new(),
working_directory: self.working_directory.into(),
})
} else {
Err(self.internal_error(format!(
"Parse completed with {} unparsed tokens", "Parse completed with {} unparsed tokens",
self.tokens.len() - self.next_token, 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` /// Parse an alias, e.g `alias name := target`
@ -497,11 +500,12 @@ impl<'run, 'src> Parser<'run, 'src> {
} }
Ok(Assignment { Ok(Assignment {
file_depth: self.file_depth, constant: false,
export, export,
file_depth: self.file_depth,
name, name,
value,
private: private || name.lexeme().starts_with('_'), private: private || name.lexeme().starts_with('_'),
value,
}) })
} }
@ -517,26 +521,17 @@ impl<'run, 'src> Parser<'run, 'src> {
self.recursion_depth += 1; self.recursion_depth += 1;
let expression = if self.accepted_keyword(Keyword::If)? { let disjunct = self.parse_disjunct()?;
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()?;
if self.accepted(Slash)? { let expression = if self.accepted(BarBar)? {
let lhs = Some(Box::new(value)); self
let rhs = self.parse_expression()?.into(); .unstable_features
Expression::Join { lhs, rhs } .insert(UnstableFeature::LogicalOperators);
} else if self.accepted(Plus)? { let lhs = disjunct.into();
let lhs = value.into(); let rhs = self.parse_expression()?.into();
let rhs = self.parse_expression()?.into(); Expression::Or { lhs, rhs }
Expression::Concatenation { lhs, rhs } } else {
} else { disjunct
value
}
}; };
self.recursion_depth -= 1; self.recursion_depth -= 1;
@ -544,6 +539,47 @@ impl<'run, 'src> Parser<'run, 'src> {
Ok(expression) 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" }` /// Parse a conditional, e.g. `if a == b { "foo" } else { "bar" }`
fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> { fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> {
let condition = self.parse_condition()?; 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 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 { Ok(Recipe {
shebang: shebang || script, shebang: shebang || script,
attributes, attributes,

View File

@ -22,7 +22,7 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> {
pub(crate) attributes: BTreeSet<Attribute<'src>>, pub(crate) attributes: BTreeSet<Attribute<'src>>,
pub(crate) body: Vec<Line<'src>>, pub(crate) body: Vec<Line<'src>>,
pub(crate) dependencies: Vec<D>, pub(crate) dependencies: Vec<D>,
pub(crate) doc: Option<&'src str>, pub(crate) doc: Option<String>,
#[serde(skip)] #[serde(skip)]
pub(crate) file_depth: u32, pub(crate) file_depth: u32,
#[serde(skip)] #[serde(skip)]
@ -473,7 +473,8 @@ impl<'src, D> Recipe<'src, D> {
return doc.as_ref().map(|s| s.cooked.as_ref()); return doc.as_ref().map(|s| s.cooked.as_ref());
} }
} }
self.doc
self.doc.as_deref()
} }
pub(crate) fn subsequents(&self) -> impl Iterator<Item = &D> { pub(crate) fn subsequents(&self) -> impl Iterator<Item = &D> {
@ -483,8 +484,14 @@ impl<'src, D> Recipe<'src, D> {
impl<'src, D: Display> ColorDisplay for Recipe<'src, D> { impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
if let Some(doc) = self.doc { if !self
writeln!(f, "# {doc}")?; .attributes
.iter()
.any(|attribute| matches!(attribute, Attribute::Doc(_)))
{
if let Some(doc) = &self.doc {
writeln!(f, "# {doc}")?;
}
} }
for attribute in &self.attributes { for attribute in &self.attributes {

View File

@ -21,9 +21,11 @@ impl<'src, 'run> Scope<'src, 'run> {
}; };
for (key, value) in constants() { for (key, value) in constants() {
root.bind( root.bind(Binding {
false, constant: true,
Name { export: false,
file_depth: 0,
name: Name {
token: Token { token: Token {
column: 0, column: 0,
kind: TokenKind::Identifier, kind: TokenKind::Identifier,
@ -34,22 +36,16 @@ impl<'src, 'run> Scope<'src, 'run> {
src: key, src: key,
}, },
}, },
false, private: false,
(*value).into(), value: (*value).into(),
); });
} }
root root
} }
pub(crate) fn bind(&mut self, export: bool, name: Name<'src>, private: bool, value: String) { pub(crate) fn bind(&mut self, binding: Binding<'src>) {
self.bindings.insert(Binding { self.bindings.insert(binding);
export,
file_depth: 0,
name,
private,
value,
});
} }
pub(crate) fn bound(&self, name: &str) -> bool { pub(crate) fn bound(&self, name: &str) -> bool {

View File

@ -2,6 +2,11 @@ use {super::*, clap_mangen::Man};
const INIT_JUSTFILE: &str = "default:\n echo 'Hello, world!'\n"; const INIT_JUSTFILE: &str = "default:\n echo 'Hello, world!'\n";
fn backtick_re() -> &'static Regex {
static BACKTICK_RE: OnceLock<Regex> = OnceLock::new();
BACKTICK_RE.get_or_init(|| Regex::new("(`.*?`)|(`[^`]*$)").unwrap())
}
#[derive(PartialEq, Clone, Debug)] #[derive(PartialEq, Clone, Debug)]
pub(crate) enum Subcommand { pub(crate) enum Subcommand {
Changelog, Changelog,
@ -413,15 +418,31 @@ impl Subcommand {
) { ) {
if let Some(doc) = doc { if let Some(doc) = doc {
if !doc.is_empty() && doc.lines().count() <= 1 { if !doc.is_empty() && doc.lines().count() <= 1 {
let color = config.color.stdout();
print!( print!(
"{:padding$}{} {}", "{:padding$}{} ",
"", "",
config.color.stdout().doc().paint("#"), color.doc().paint("#"),
config.color.stdout().doc().paint(doc),
padding = max_signature_width.saturating_sub(signature_widths[name]) + 1, 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!(); println!();
} }

View File

@ -183,6 +183,10 @@ impl Assignment {
#[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)] #[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)]
pub enum Expression { pub enum Expression {
And {
lhs: Box<Expression>,
rhs: Box<Expression>,
},
Assert { Assert {
condition: Condition, condition: Condition,
error: Box<Expression>, error: Box<Expression>,
@ -209,6 +213,10 @@ pub enum Expression {
lhs: Option<Box<Expression>>, lhs: Option<Box<Expression>>,
rhs: Box<Expression>, rhs: Box<Expression>,
}, },
Or {
lhs: Box<Expression>,
rhs: Box<Expression>,
},
String { String {
text: String, text: String,
}, },
@ -221,6 +229,10 @@ impl Expression {
fn new(expression: &full::Expression) -> Self { fn new(expression: &full::Expression) -> Self {
use full::Expression::*; use full::Expression::*;
match expression { match expression {
And { lhs, rhs } => Self::And {
lhs: Self::new(lhs).into(),
rhs: Self::new(rhs).into(),
},
Assert { Assert {
condition: full::Condition { lhs, rhs, operator }, condition: full::Condition { lhs, rhs, operator },
error, error,
@ -250,11 +262,9 @@ impl Expression {
.. ..
} => { } => {
let mut arguments = Vec::new(); let mut arguments = Vec::new();
if let Some(b) = opt_b.as_ref() { if let Some(b) = opt_b.as_ref() {
arguments.push(Self::new(b)); arguments.push(Self::new(b));
} }
arguments.push(Self::new(a)); arguments.push(Self::new(a));
Self::Call { Self::Call {
name: name.lexeme().to_owned(), name: name.lexeme().to_owned(),
@ -308,10 +318,6 @@ impl Expression {
lhs: Self::new(lhs).into(), lhs: Self::new(lhs).into(),
rhs: Self::new(rhs).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 { Conditional {
condition: full::Condition { lhs, rhs, operator }, condition: full::Condition { lhs, rhs, operator },
otherwise, otherwise,
@ -323,13 +329,21 @@ impl Expression {
rhs: Self::new(rhs).into(), rhs: Self::new(rhs).into(),
then: Self::new(then).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 { StringLiteral { string_literal } => Self::String {
text: string_literal.cooked.clone(), text: string_literal.cooked.clone(),
}, },
Variable { name, .. } => Self::Variable { Variable { name, .. } => Self::Variable {
name: name.lexeme().to_owned(), name: name.lexeme().to_owned(),
}, },
Group { contents } => Self::new(contents),
} }
} }
} }

View File

@ -1,46 +1,46 @@
use super::*; use super::*;
#[derive(Derivative)] #[derive_where(Debug, PartialEq)]
#[derivative(Debug, Clone, PartialEq = "feature_allow_slow_enum")] #[derive(Clone)]
pub(crate) enum Thunk<'src> { pub(crate) enum Thunk<'src> {
Nullary { Nullary {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context) -> FunctionResult, function: fn(function::Context) -> FunctionResult,
}, },
Unary { Unary {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str) -> FunctionResult, function: fn(function::Context, &str) -> FunctionResult,
arg: Box<Expression<'src>>, arg: Box<Expression<'src>>,
}, },
UnaryOpt { UnaryOpt {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, Option<&str>) -> FunctionResult, function: fn(function::Context, &str, Option<&str>) -> FunctionResult,
args: (Box<Expression<'src>>, Box<Option<Expression<'src>>>), args: (Box<Expression<'src>>, Box<Option<Expression<'src>>>),
}, },
UnaryPlus { UnaryPlus {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &[String]) -> FunctionResult, function: fn(function::Context, &str, &[String]) -> FunctionResult,
args: (Box<Expression<'src>>, Vec<Expression<'src>>), args: (Box<Expression<'src>>, Vec<Expression<'src>>),
}, },
Binary { Binary {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &str) -> FunctionResult, function: fn(function::Context, &str, &str) -> FunctionResult,
args: [Box<Expression<'src>>; 2], args: [Box<Expression<'src>>; 2],
}, },
BinaryPlus { BinaryPlus {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &str, &[String]) -> FunctionResult, function: fn(function::Context, &str, &str, &[String]) -> FunctionResult,
args: ([Box<Expression<'src>>; 2], Vec<Expression<'src>>), args: ([Box<Expression<'src>>; 2], Vec<Expression<'src>>),
}, },
Ternary { Ternary {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derive_where(skip(Debug, EqHashOrd))]
function: fn(function::Context, &str, &str, &str) -> FunctionResult, function: fn(function::Context, &str, &str, &str) -> FunctionResult,
args: [Box<Expression<'src>>; 3], args: [Box<Expression<'src>>; 3],
}, },

View File

@ -7,6 +7,7 @@ pub(crate) enum TokenKind {
At, At,
Backtick, Backtick,
BangEquals, BangEquals,
BarBar,
BraceL, BraceL,
BraceR, BraceR,
BracketL, BracketL,
@ -50,6 +51,7 @@ impl Display for TokenKind {
At => "'@'", At => "'@'",
Backtick => "backtick", Backtick => "backtick",
BangEquals => "'!='", BangEquals => "'!='",
BarBar => "'||'",
BraceL => "'{'", BraceL => "'{'",
BraceR => "'}'", BraceR => "'}'",
BracketL => "'['", BracketL => "'['",

View File

@ -3,6 +3,7 @@ use super::*;
#[derive(Copy, Clone, Debug, PartialEq, Ord, Eq, PartialOrd)] #[derive(Copy, Clone, Debug, PartialEq, Ord, Eq, PartialOrd)]
pub(crate) enum UnstableFeature { pub(crate) enum UnstableFeature {
FormatSubcommand, FormatSubcommand,
LogicalOperators,
ScriptAttribute, ScriptAttribute,
ScriptInterpreterSetting, ScriptInterpreterSetting,
} }
@ -11,6 +12,10 @@ impl Display for UnstableFeature {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self { match self {
Self::FormatSubcommand => write!(f, "The `--fmt` command is currently unstable."), 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::ScriptAttribute => write!(f, "The `[script]` attribute is currently unstable."),
Self::ScriptInterpreterSetting => { Self::ScriptInterpreterSetting => {
write!(f, "The `script-interpreter` setting is currently unstable.") 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>> { fn next(&mut self) -> Option<Token<'src>> {
loop { loop {
match self.stack.pop()? { 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 { Expression::Call { thunk } => match thunk {
Thunk::Nullary { .. } => {} Thunk::Nullary { .. } => {}
Thunk::Unary { arg, .. } => self.stack.push(arg), 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 { Expression::Conditional {
condition: condition:
Condition { Condition {
@ -71,10 +92,8 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
self.stack.push(rhs); self.stack.push(rhs);
self.stack.push(lhs); self.stack.push(lhs);
} }
Expression::Variable { name, .. } => return Some(name.token), Expression::Group { contents } => {
Expression::Concatenation { lhs, rhs } => { self.stack.push(contents);
self.stack.push(rhs);
self.stack.push(lhs);
} }
Expression::Join { lhs, rhs } => { Expression::Join { lhs, rhs } => {
self.stack.push(rhs); self.stack.push(rhs);
@ -82,22 +101,7 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
self.stack.push(lhs); self.stack.push(lhs);
} }
} }
Expression::Group { contents } => { Expression::Variable { name, .. } => return Some(name.token),
self.stack.push(contents);
}
Expression::Assert {
condition:
Condition {
lhs,
rhs,
operator: _,
},
error,
} => {
self.stack.push(error);
self.stack.push(rhs);
self.stack.push(lhs);
}
} }
} }
} }

View File

@ -1,3 +1,4 @@
#[track_caller]
pub(crate) fn assert_success(output: &std::process::Output) { pub(crate) fn assert_success(output: &std::process::Output) {
if !output.status.success() { if !output.status.success() {
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));

View File

@ -136,7 +136,7 @@ test! {
", ",
stdout: "", stdout: "",
stderr: " stderr: "
error: Expected '!=', '==', '=~', '+', or '/', but found identifier error: Expected '&&', '!=', '||', '==', '=~', '+', or '/', but found identifier
justfile:1:12 justfile:1:12
1 a := if '' a '' { '' } else { b } 1 a := if '' a '' { '' } else { b }

View File

@ -43,3 +43,19 @@ fn constants_can_be_redefined() {
.stdout("foo") .stdout("foo")
.run(); .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

@ -1096,3 +1096,27 @@ fn multi_argument_attribute() {
) )
.run(); .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();
}

View File

@ -1183,3 +1183,78 @@ bar:
.args(["foo", "bar"]) .args(["foo", "bar"])
.run(); .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();
}

View File

@ -125,7 +125,7 @@ fn comments_still_must_be_parsable_when_ignored() {
) )
.stderr( .stderr(
" "
error: Expected '}}', '(', '+', or '/', but found identifier error: Expected '&&', '||', '}}', '(', '+', or '/', but found identifier
justfile:4:12 justfile:4:12
4 # {{ foo bar }} 4 # {{ foo bar }}

View File

@ -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": [],
}),
);
}

View File

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

View File

@ -438,3 +438,39 @@ fn no_space_before_submodules_not_following_groups() {
) )
.run(); .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();
}

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) .status(1)
.stderr( .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 justfile:1:8
1 x := x '$JUST_TEST_VARIABLE' 1 x := x '$JUST_TEST_VARIABLE'