1
0
mirror of https://github.com/casey/just synced 2024-07-01 07:24:45 +00:00

Compare commits

...

28 Commits

Author SHA1 Message Date
Marcin Drzymala
95d51ab933 WIP: Add JustfileKind to Search: Path or Stdin 2024-06-26 01:36:11 +02:00
Marcin Drzymala
2964a62542 Add WithStdin variants to SearchConfig 2024-06-26 01:36:11 +02:00
Casey Rodarmor
570d3058cf
Link to modules when first introduced in readme (#2193) 2024-06-25 21:59:42 +00:00
dependabot[bot]
c900b6f478
Update softprops/action-gh-release (#2183) 2024-06-24 12:42:39 -07:00
Casey Rodarmor
af86a471e2
Don't analyze comments when ignore-comments is set (#2180) 2024-06-21 20:39:34 +00:00
Casey Rodarmor
e4564f45a3
Don't exit process in run() on argument parse error (#2176) 2024-06-20 03:57:46 +00:00
Casey Rodarmor
aa43a664ee
Document remote justfile workaround (#2175) 2024-06-19 17:18:03 -07:00
Casey Rodarmor
553adc1004
Document library interface (#2174) 2024-06-19 23:38:02 +00:00
Casey Rodarmor
e572b93d84
Allow passing command-line arguments into run() (#2173) 2024-06-19 23:25:36 +00:00
Ryan McGuire
fcac7ee768
Ignore env_logger initialization errors (#2170) 2024-06-19 07:50:37 +00:00
Blair Noctis
71b72c4a53
Remove dependency on cradle (#2169) 2024-06-18 02:42:16 +00:00
Casey Rodarmor
0e8f660d6d
Add datetime() and datetime_utc() functions (#2167) 2024-06-14 22:48:34 -07:00
Casey Rodarmor
1c3c1dd3c0
Add note to readme about quoting paths on Windows (#2166) 2024-06-15 05:32:07 +00:00
Casey Rodarmor
197e1002d0
List recipes by group in group justfile order with just --list --unsorted (#2164) 2024-06-15 03:04:47 +00:00
Casey Rodarmor
4a59769faa
Add missing changelog credits (#2163) 2024-06-14 23:58:31 +00:00
Casey Rodarmor
bf6ec6bf16
Credit myself in changelog (#2162) 2024-06-14 23:42:14 +00:00
Casey Rodarmor
1547af08b5
Allow setting more command-line options with environment variables (#2161) 2024-06-14 23:11:22 +00:00
Casey Rodarmor
b05a75d168
List groups in source order with just --groups --unsorted (#2160) 2024-06-14 20:35:03 +00:00
Casey Rodarmor
5f91b37c82
Release 1.29.1 (#2159)
- Bump version: 1.29.0 → 1.29.1
- Update changelog
- Update changelog contributor credits
2024-06-14 12:44:52 -07:00
Casey Rodarmor
dd9792571b
Fix unexport syntax conflicts (#2158) 2024-06-14 19:39:34 +00:00
Casey Rodarmor
e6c37aacd1
Release 1.29.0 (#2155)
- Bump version: 1.28.0 → 1.29.0
- Update changelog
- Update changelog contributor credits
- Update dependencies
- Update version references in readme
- Fix zsh completion script
2024-06-14 02:57:12 +00:00
Casey Rodarmor
18ec9796b9
Improve argument parsing and error handling for submodules (#2154) 2024-06-14 02:41:45 +00:00
Casey Rodarmor
e1b17fe9cf
Document shell expanded string defaults (#2153) 2024-06-13 21:41:19 +00:00
Casey Rodarmor
4b5ba8f6f5
Load environment file from dotenv-path relative to working directory (#2152) 2024-06-13 20:21:00 +00:00
Casey Rodarmor
1ce7a05bef
Add [positional-arguments] attribute (#2151) 2024-06-13 19:35:14 +00:00
Casey Rodarmor
637023e86f
Test bare bash path in shebang on windows (#2144) 2024-06-13 19:19:22 +00:00
Ruben Nicolaides
4f16428bcb
Use --justfile in Fish shell completions (#2148) 2024-06-12 18:38:37 +00:00
Casey Rodarmor
8778972014
Test shell not found error messages (#2145) 2024-06-11 13:10:32 -07:00
58 changed files with 2090 additions and 1016 deletions

View File

@ -95,7 +95,7 @@ jobs:
shell: bash shell: bash
- name: Publish Archive - name: Publish Archive
uses: softprops/action-gh-release@v2.0.5 uses: softprops/action-gh-release@v2.0.6
if: ${{ startsWith(github.ref, 'refs/tags/') }} if: ${{ startsWith(github.ref, 'refs/tags/') }}
with: with:
draft: false draft: false
@ -105,7 +105,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.5 uses: softprops/action-gh-release@v2.0.6
if: >- if: >-
${{ ${{
startsWith(github.ref, 'refs/tags/') startsWith(github.ref, 'refs/tags/')

File diff suppressed because it is too large Load Diff

68
Cargo.lock generated
View File

@ -174,9 +174,9 @@ checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.0.98" version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41c270e7540d725e65ac7f1b212ac8ce349719624d7bcff99f8e2e488e8cf03f" checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
@ -221,9 +221,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.4" version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -231,9 +231,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.2" version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -244,18 +244,18 @@ dependencies = [
[[package]] [[package]]
name = "clap_complete" name = "clap_complete"
version = "4.5.2" version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd79504325bf38b10165b02e89b4347300f855f273c4cb30c4a3209e6583275e" checksum = "d2020fa13af48afc65a9a87335bda648309ab3d154cd03c7ff95b378c7ed39c4"
dependencies = [ dependencies = [
"clap 4.5.4", "clap 4.5.7",
] ]
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.4" version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@ -265,17 +265,17 @@ dependencies = [
[[package]] [[package]]
name = "clap_lex" name = "clap_lex"
version = "0.7.0" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70"
[[package]] [[package]]
name = "clap_mangen" name = "clap_mangen"
version = "0.2.20" version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1dd95b5ebb5c1c54581dd6346f3ed6a79a3eef95dd372fc2ac13d535535300e" checksum = "74b70fc13e60c0e1d490dc50eb73a749be6d81f4ef03783df1d9b7b0c62bc937"
dependencies = [ dependencies = [
"clap 4.5.4", "clap 4.5.7",
"roff", "roff",
] ]
@ -306,15 +306,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cradle"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7096122c1023d53de7298f322590170540ad3eba46bbc2750b495f098c27c09a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.5" version = "0.8.5"
@ -600,16 +591,15 @@ dependencies = [
[[package]] [[package]]
name = "just" name = "just"
version = "1.28.0" version = "1.29.1"
dependencies = [ dependencies = [
"ansi_term", "ansi_term",
"blake3", "blake3",
"camino", "camino",
"chrono", "chrono",
"clap 4.5.4", "clap 4.5.7",
"clap_complete", "clap_complete",
"clap_mangen", "clap_mangen",
"cradle",
"ctrlc", "ctrlc",
"derivative", "derivative",
"dirs", "dirs",
@ -685,9 +675,9 @@ checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.7.2" version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]] [[package]]
name = "memmap2" name = "memmap2"
@ -898,13 +888,13 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.10.4" version = "1.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-automata 0.4.6", "regex-automata 0.4.7",
"regex-syntax", "regex-syntax",
] ]
@ -916,9 +906,9 @@ checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.6" version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -927,9 +917,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]] [[package]]
name = "roff" name = "roff"
@ -1244,9 +1234,9 @@ dependencies = [
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.1" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "just" name = "just"
version = "1.28.0" version = "1.29.1"
authors = ["Casey Rodarmor <casey@rodarmor.com>"] authors = ["Casey Rodarmor <casey@rodarmor.com>"]
autotests = false autotests = false
categories = ["command-line-utilities", "development-tools"] categories = ["command-line-utilities", "development-tools"]
@ -54,7 +54,6 @@ unicode-width = "0.1.0"
uuid = { version = "1.0.0", features = ["v4"] } uuid = { version = "1.0.0", features = ["v4"] }
[dev-dependencies] [dev-dependencies]
cradle = "0.2.0"
executable-path = "1.0.0" executable-path = "1.0.0"
pretty_assertions = "1.0.0" pretty_assertions = "1.0.0"
temptree = "0.2.0" temptree = "0.2.0"

View File

@ -656,8 +656,8 @@ Available recipes:
lint lint
``` ```
Recipes in submodules can be listed with `just --list PATH`, where `PATH` is a Recipes in [submodules](#modules1190) can be listed with `just --list PATH`,
space- or `::`-separated module path: where `PATH` is a space- or `::`-separated module path:
``` ```
$ cat justfile $ cat justfile
@ -996,6 +996,20 @@ $ just test foo "bar baz"
- bar baz - bar baz
``` ```
Positional arguments may also be turned on on a per-recipe basis with the
`[positional-arguments]` attribute<sup>1.29.0</sup>:
```just
[positional-arguments]
@foo bar:
echo $0
echo $1
```
Note that PowerShell does not handle positional arguments in the same way as
other shells, so turning on positional arguments will likely break recipes that
use PowerShell.
#### Shell #### Shell
The `shell` setting controls the command used to invoke recipe lines and The `shell` setting controls the command used to invoke recipe lines and
@ -1300,6 +1314,7 @@ foobar := x'~/$FOO/${BAR}'
|------|-------------| |------|-------------|
| `$VAR` | value of environment variable `VAR` | | `$VAR` | value of environment variable `VAR` |
| `${VAR}` | value of environment variable `VAR` | | `${VAR}` | value of environment variable `VAR` |
| `${VAR:-DEFAULT}` | value of environment variable `VAR`, or `DEFAULT` if `VAR` is not set |
| Leading `~` | path to current user's home directory | | Leading `~` | path to current user's home directory |
| Leading `~USER` | path to `USER`'s home directory | | Leading `~USER` | path to `USER`'s home directory |
@ -1627,6 +1642,16 @@ which will halt execution.
characters. For example, `choose('64', HEX)` will generate a random characters. For example, `choose('64', HEX)` will generate a random
64-character lowercase hex string. 64-character lowercase hex string.
#### Datetime
- `datetime(format)`<sup>master</sup> - Return local time with `format`.
- `datetime_utc(format)`<sup>master</sup> - Return UTC time with `format`.
The arguments to `datetime` and `datetime_utc` are `strftime`-style format
strings, see the
[`chrono` library docs](https://docs.rs/chrono/latest/chrono/format/strftime/index.html)
for details.
#### Semantic Versions #### Semantic Versions
- `semver_matches(version, requirement)`<sup>1.16.0</sup> - Check whether a - `semver_matches(version, requirement)`<sup>1.16.0</sup> - Check whether a
@ -1686,6 +1711,7 @@ Recipes may be annotated with attributes that change their behavior.
| `[no-cd]`<sup>1.9.0</sup> | Don't change directory before executing recipe. | | `[no-cd]`<sup>1.9.0</sup> | Don't change directory before executing recipe. |
| `[no-exit-message]`<sup>1.7.0</sup> | Don't print an error message if recipe fails. | | `[no-exit-message]`<sup>1.7.0</sup> | Don't print an error message if recipe fails. |
| `[no-quiet]`<sup>1.23.0</sup> | Override globally quiet recipes and always echo out the recipe. | | `[no-quiet]`<sup>1.23.0</sup> | Override globally quiet recipes and always echo out the recipe. |
| `[positional-arguments]`<sup>1.29.0</sup> | Turn on [positional arguments](#positional-arguments) for this recipe. |
| `[private]`<sup>1.10.0</sup> | See [Private Recipes](#private-recipes). | | `[private]`<sup>1.10.0</sup> | See [Private Recipes](#private-recipes). |
| `[unix]`<sup>1.8.0</sup> | Enable recipe on Unixes. (Includes MacOS). | | `[unix]`<sup>1.8.0</sup> | Enable recipe on Unixes. (Includes MacOS). |
| `[windows]`<sup>1.8.0</sup> | Enable recipe on Windows. | | `[windows]`<sup>1.8.0</sup> | Enable recipe on Windows. |
@ -1845,6 +1871,8 @@ Recipe groups:
rust recipes rust recipes
``` ```
Use `just --groups --unsorted` to print groups in their justfile order.
### Command Evaluation Using Backticks ### Command Evaluation Using Backticks
Backticks can be used to store the result of commands: Backticks can be used to store the result of commands:
@ -2059,7 +2087,7 @@ a $A $B=`echo $A`:
When [export](#export) is set, all `just` variables are exported as environment When [export](#export) is set, all `just` variables are exported as environment
variables. variables.
#### Unexporting Environment Variables<sup>master</sup> #### Unexporting Environment Variables<sup>1.29.0</sup>
Environment variables can be unexported with the `unexport keyword`: Environment variables can be unexported with the `unexport keyword`:
@ -3117,7 +3145,7 @@ import? 'foo/bar.just'
Missing source files for optional imports do not produce an error. Missing source files for optional imports do not produce an error.
### Modules <sup>1.19.0</sup> ### Modules<sup>1.19.0</sup>
A `justfile` can declare modules using `mod` statements. `mod` statements are A `justfile` can declare modules using `mod` statements. `mod` statements are
currently unstable, so you'll need to use the `--unstable` flag, or set the currently unstable, so you'll need to use the `--unstable` flag, or set the
@ -3362,9 +3390,9 @@ foo argument:
touch "$1" touch "$1"
``` ```
This defeats `just`'s ability to catch typos, for example if you type `$2`, but This defeats `just`'s ability to catch typos, for example if you type `$2`
works for all possible values of `argument`, including those with double instead of `$1`, but works for all possible values of `argument`, including
quotes. those with double quotes.
#### Exported Arguments #### Exported Arguments
@ -3629,6 +3657,33 @@ Node.js `package.json` files:
export PATH := "./node_modules/.bin:" + env_var('PATH') export PATH := "./node_modules/.bin:" + env_var('PATH')
``` ```
### Paths on Windows
On Windows, functions that return paths will return `\`-separated paths. When
not using PowerShell or `cmd.exe` these paths should be quoted to prevent the
`\`s from being intepreted as character escapes:
```just
ls:
echo '{{absolute_path(".")}}'
```
### Remote Justfiles
If you wish to include a `mod` or `import` source file in many `justfiles`
without needing to duplicate it, you can use an optional `mod` or `import`,
along with a recipe to fetch the module source:
```just
import? 'foo.just'
fetch:
curl https://raw.githubusercontent.com/casey/just/master/justfile > foo.just
```
Given the above `justfile`, after running `just fetch`, the recipes in
`foo.just` will be available.
### 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

@ -30,13 +30,9 @@ fn main() {
.replace_all( .replace_all(
&fs::read_to_string("CHANGELOG.md").unwrap(), &fs::read_to_string("CHANGELOG.md").unwrap(),
|captures: &Captures| { |captures: &Captures| {
let pr = captures[1].parse().unwrap(); let pr = captures[1].parse().unwrap();
match author(pr).as_str() { let contributor = author(pr);
"casey" => format!("([#{pr}](https://github.com/casey/just/pull/{pr}))"), format!("([#{pr}](https://github.com/casey/just/pull/{pr}) by [{contributor}](https://github.com/{contributor}))")
contributor => {
format!("([#{pr}](https://github.com/casey/just/pull/{pr}) by [{contributor}](https://github.com/{contributor}))")
}
}
}, },
), ),
) )

View File

@ -149,7 +149,7 @@ impl<'src> Analyzer<'src> {
} }
} }
let recipes = RecipeResolver::resolve_recipes(recipe_table, &self.assignments)?; let recipes = RecipeResolver::resolve_recipes(&self.assignments, &settings, recipe_table)?;
let mut aliases = Table::new(); let mut aliases = Table::new();
while let Some(alias) = self.aliases.pop() { while let Some(alias) = self.aliases.pop() {

403
src/argument_parser.rs Normal file
View File

@ -0,0 +1,403 @@
use super::*;
#[allow(clippy::doc_markdown)]
/// The argument parser is responsible for grouping positional arguments into
/// argument groups, which consist of a path to a recipe and its arguments.
///
/// Argument parsing is substantially complicated by the fact that recipe paths
/// can be given on the command line as multiple arguments, i.e., "foo" "bar"
/// baz", or as a single "::"-separated argument.
///
/// Error messages produced by the argument parser should use the format of the
/// recipe path as passed on the command line.
///
/// Additionally, if a recipe is specified with a "::"-separated path, extra
/// components of that path after a valid recipe must not be used as arguments,
/// whereas arguments after multiple argument path may be used as arguments. As
/// an example, `foo bar baz` may refer to recipe `foo::bar` with argument
/// `baz`, but `foo::bar::baz` is an error, since `bar` is a recipe, not a
/// module.
pub(crate) struct ArgumentParser<'src: 'run, 'run> {
arguments: &'run [&'run str],
next: usize,
root: &'run Justfile<'src>,
}
#[derive(Debug, PartialEq)]
pub(crate) struct ArgumentGroup<'run> {
pub(crate) arguments: Vec<&'run str>,
pub(crate) path: Vec<String>,
}
impl<'src: 'run, 'run> ArgumentParser<'src, 'run> {
pub(crate) fn parse_arguments(
root: &'run Justfile<'src>,
arguments: &'run [&'run str],
) -> RunResult<'src, Vec<ArgumentGroup<'run>>> {
let mut groups = Vec::new();
let mut invocation_parser = Self {
arguments,
next: 0,
root,
};
loop {
groups.push(invocation_parser.parse_group()?);
if invocation_parser.next == arguments.len() {
break;
}
}
Ok(groups)
}
fn parse_group(&mut self) -> RunResult<'src, ArgumentGroup<'run>> {
let (recipe, path) = if let Some(next) = self.next() {
if next.contains(':') {
let module_path =
ModulePath::try_from([next].as_slice()).map_err(|()| Error::UnknownRecipe {
recipe: next.into(),
suggestion: None,
})?;
let (recipe, path, _) = self.resolve_recipe(true, &module_path.path)?;
self.next += 1;
(recipe, path)
} else {
let (recipe, path, consumed) = self.resolve_recipe(false, self.rest())?;
self.next += consumed;
(recipe, path)
}
} else {
let (recipe, path, consumed) = self.resolve_recipe(false, self.rest())?;
assert_eq!(consumed, 0);
(recipe, path)
};
let rest = self.rest();
let argument_range = recipe.argument_range();
let argument_count = cmp::min(rest.len(), recipe.max_arguments());
if !argument_range.range_contains(&argument_count) {
return Err(Error::ArgumentCountMismatch {
recipe: recipe.name(),
parameters: recipe.parameters.clone(),
found: rest.len(),
min: recipe.min_arguments(),
max: recipe.max_arguments(),
});
}
let arguments = rest[..argument_count].to_vec();
self.next += argument_count;
Ok(ArgumentGroup { arguments, path })
}
fn resolve_recipe(
&self,
module_path: bool,
args: &[impl AsRef<str>],
) -> RunResult<'src, (&'run Recipe<'src>, Vec<String>, usize)> {
let mut current = self.root;
let mut path = Vec::new();
for (i, arg) in args.iter().enumerate() {
let arg = arg.as_ref();
path.push(arg.to_string());
if let Some(module) = current.modules.get(arg) {
current = module;
} else if let Some(recipe) = current.get_recipe(arg) {
if module_path && i + 1 < args.len() {
return Err(Error::ExpectedSubmoduleButFoundRecipe {
path: if module_path {
path.join("::")
} else {
path.join(" ")
},
});
}
return Ok((recipe, path, i + 1));
} else {
if module_path && i + 1 < args.len() {
return Err(Error::UnknownSubmodule {
path: path.join("::"),
});
}
return Err(Error::UnknownRecipe {
recipe: if module_path {
path.join("::")
} else {
path.join(" ")
},
suggestion: current.suggest_recipe(arg),
});
}
}
if let Some(recipe) = &current.default {
recipe.check_can_be_default_recipe()?;
path.push(recipe.name().into());
Ok((recipe, path, args.len()))
} else if current.recipes.is_empty() {
Err(Error::NoRecipes)
} else {
Err(Error::NoDefaultRecipe)
}
}
fn next(&self) -> Option<&'run str> {
self.arguments.get(self.next).copied()
}
fn rest(&self) -> &[&'run str] {
&self.arguments[self.next..]
}
}
#[cfg(test)]
mod tests {
use {super::*, tempfile::TempDir};
trait TempDirExt {
fn write(&self, path: &str, content: &str);
}
impl TempDirExt for TempDir {
fn write(&self, path: &str, content: &str) {
let path = self.path().join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
}
#[test]
fn single_no_arguments() {
let justfile = testing::compile("foo:");
assert_eq!(
ArgumentParser::parse_arguments(&justfile, &["foo"]).unwrap(),
vec![ArgumentGroup {
path: vec!["foo".into()],
arguments: Vec::new()
}],
);
}
#[test]
fn single_with_argument() {
let justfile = testing::compile("foo bar:");
assert_eq!(
ArgumentParser::parse_arguments(&justfile, &["foo", "baz"]).unwrap(),
vec![ArgumentGroup {
path: vec!["foo".into()],
arguments: vec!["baz"],
}],
);
}
#[test]
fn single_argument_count_mismatch() {
let justfile = testing::compile("foo bar:");
assert_matches!(
ArgumentParser::parse_arguments(&justfile, &["foo"]).unwrap_err(),
Error::ArgumentCountMismatch {
recipe: "foo",
found: 0,
min: 1,
max: 1,
..
},
);
}
#[test]
fn single_unknown() {
let justfile = testing::compile("foo:");
assert_matches!(
ArgumentParser::parse_arguments(&justfile, &["bar"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "bar",
);
}
#[test]
fn multiple_unknown() {
let justfile = testing::compile("foo:");
assert_matches!(
ArgumentParser::parse_arguments(&justfile, &["bar", "baz"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "bar",
);
}
#[test]
fn recipe_in_submodule() {
let loader = Loader::new();
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("justfile");
fs::write(&path, "mod foo").unwrap();
fs::create_dir(tempdir.path().join("foo")).unwrap();
fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
let compilation = Compiler::compile(true, &loader, &path).unwrap();
assert_eq!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "bar"]).unwrap(),
vec![ArgumentGroup {
path: vec!["foo".into(), "bar".into()],
arguments: Vec::new()
}],
);
}
#[test]
fn recipe_in_submodule_unknown() {
let loader = Loader::new();
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("justfile");
fs::write(&path, "mod foo").unwrap();
fs::create_dir(tempdir.path().join("foo")).unwrap();
fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
let compilation = Compiler::compile(true, &loader, &path).unwrap();
assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "zzz"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "foo zzz",
);
}
#[test]
fn recipe_in_submodule_path_unknown() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "mod foo");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo::zzz"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "foo::zzz",
);
}
#[test]
fn module_path_not_consumed() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "mod foo");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &["foo::bar::baz"]).unwrap_err(),
Error::ExpectedSubmoduleButFoundRecipe {
path,
} if path == "foo::bar",
);
}
#[test]
fn no_recipes() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "");
let loader = Loader::new();
let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
Error::NoRecipes,
);
}
#[test]
fn default_recipe_requires_arguments() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "foo bar:");
let loader = Loader::new();
let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
Error::DefaultRecipeRequiresArguments {
recipe: "foo",
min_arguments: 1,
},
);
}
#[test]
fn no_default_recipe() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "import 'foo.just'");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
assert_matches!(
ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
Error::NoDefaultRecipe,
);
}
#[test]
fn complex_grouping() {
let justfile = testing::compile(
"
FOO A B='blarg':
echo foo: {{A}} {{B}}
BAR X:
echo bar: {{X}}
BAZ +Z:
echo baz: {{Z}}
",
);
assert_eq!(
ArgumentParser::parse_arguments(
&justfile,
&["BAR", "0", "FOO", "1", "2", "BAZ", "3", "4", "5"]
)
.unwrap(),
vec![
ArgumentGroup {
path: vec!["BAR".into()],
arguments: vec!["0"],
},
ArgumentGroup {
path: vec!["FOO".into()],
arguments: vec!["1", "2"],
},
ArgumentGroup {
path: vec!["BAZ".into()],
arguments: vec!["3", "4", "5"],
},
],
);
}
}

View File

@ -4,7 +4,7 @@ use super::*;
pub(crate) type Assignment<'src> = Binding<'src, Expression<'src>>; pub(crate) type Assignment<'src> = Binding<'src, Expression<'src>>;
impl<'src> Display for Assignment<'src> { impl<'src> Display for Assignment<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
if self.export { if self.export {
write!(f, "export ")?; write!(f, "export ")?;
} }

View File

@ -12,7 +12,7 @@ pub(crate) struct Ast<'src> {
} }
impl<'src> Display for Ast<'src> { impl<'src> Display for Ast<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let mut iter = self.items.iter().peekable(); let mut iter = self.items.iter().peekable();
while let Some(item) = iter.next() { while let Some(item) = iter.next() {

View File

@ -17,6 +17,7 @@ pub(crate) enum Attribute<'src> {
NoCd, NoCd,
NoExitMessage, NoExitMessage,
NoQuiet, NoQuiet,
PositionalArguments,
Private, Private,
Unix, Unix,
Windows, Windows,
@ -32,6 +33,7 @@ impl AttributeDiscriminant {
| Self::NoCd | Self::NoCd
| Self::NoExitMessage | Self::NoExitMessage
| Self::NoQuiet | Self::NoQuiet
| Self::PositionalArguments
| Self::Private | Self::Private
| Self::Unix | Self::Unix
| Self::Windows => 0..=0, | Self::Windows => 0..=0,
@ -78,6 +80,7 @@ impl<'src> Attribute<'src> {
NoCd => Self::NoCd, NoCd => Self::NoCd,
NoExitMessage => Self::NoExitMessage, NoExitMessage => Self::NoExitMessage,
NoQuiet => Self::NoQuiet, NoQuiet => Self::NoQuiet,
PositionalArguments => Self::PositionalArguments,
Private => Self::Private, Private => Self::Private,
Unix => Self::Unix, Unix => Self::Unix,
Windows => Self::Windows, Windows => Self::Windows,
@ -98,6 +101,7 @@ impl<'src> Attribute<'src> {
| Self::NoCd | Self::NoCd
| Self::NoExitMessage | Self::NoExitMessage
| Self::NoQuiet | Self::NoQuiet
| Self::PositionalArguments
| Self::Private | Self::Private
| Self::Unix | Self::Unix
| Self::Windows => None, | Self::Windows => None,
@ -106,7 +110,7 @@ impl<'src> Attribute<'src> {
} }
impl<'src> Display for Attribute<'src> { impl<'src> Display for Attribute<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{}", self.name())?; write!(f, "{}", self.name())?;
if let Some(argument) = self.argument() { if let Some(argument) = self.argument() {
write!(f, "({argument})")?; write!(f, "({argument})")?;

View File

@ -28,7 +28,7 @@ fn capitalize(s: &str) -> String {
} }
impl Display for CompileError<'_> { impl Display for CompileError<'_> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
use CompileErrorKind::*; use CompileErrorKind::*;
match &*self.kind { match &*self.kind {

View File

@ -94,6 +94,9 @@ export extern "just" [
]"#; ]"#;
const FISH_RECIPE_COMPLETIONS: &str = r#"function __fish_just_complete_recipes const FISH_RECIPE_COMPLETIONS: &str = r#"function __fish_just_complete_recipes
if string match -rq '(-f|--justfile)\s*=?(?<justfile>[^\s]+)' -- (string split -- ' -- ' (commandline -pc))[1]
set -fx JUST_JUSTFILE "$justfile"
end
just --list 2> /dev/null | tail -n +2 | awk '{ just --list 2> /dev/null | tail -n +2 | awk '{
command = $1; command = $1;
args = $0; args = $0;
@ -134,7 +137,7 @@ complete -c just -a '(__fish_just_complete_recipes)'
const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[ const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[
( (
r#" _arguments "${_arguments_options[@]}" \"#, r#" _arguments "${_arguments_options[@]}" : \"#,
r" local common=(", r" local common=(",
), ),
( (

View File

@ -8,7 +8,7 @@ pub(crate) struct Condition<'src> {
} }
impl<'src> Display for Condition<'src> { impl<'src> Display for Condition<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{} {} {}", self.lhs, self.operator, self.rhs) write!(f, "{} {} {}", self.lhs, self.operator, self.rhs)
} }
} }

View File

@ -7,10 +7,6 @@ use {
}, },
}; };
const CHOOSE_HELP: &str = "Select one or more recipes to run using a binary chooser. \
If `--chooser` is not passed the chooser defaults to the \
value of $JUST_CHOOSER, falling back to `fzf`";
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub(crate) struct Config { pub(crate) struct Config {
pub(crate) check: bool, pub(crate) check: bool,
@ -154,17 +150,20 @@ impl Config {
.trailing_var_arg(true) .trailing_var_arg(true)
.styles( .styles(
Styles::styled() Styles::styled()
.header(AnsiColor::Yellow.on_default()) .header(AnsiColor::Yellow.on_default())
.usage(AnsiColor::Yellow.on_default()) .literal(AnsiColor::Green.on_default())
.literal(AnsiColor::Green.on_default()) .placeholder(AnsiColor::Green.on_default())
.placeholder(AnsiColor::Green.on_default()) .usage(AnsiColor::Yellow.on_default()),
) )
.arg( .arg(
Arg::new(arg::CHECK) Arg::new(arg::CHECK)
.long("check") .long("check")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.requires(cmd::FORMAT) .requires(cmd::FORMAT)
.help("Run `--fmt` in 'check' mode. Exits with 0 if justfile is formatted correctly. Exits with 1 and prints a diff if formatting is required."), .help(
"Run `--fmt` in 'check' mode. Exits with 0 if justfile is formatted correctly. \
Exits with 1 and prints a diff if formatting is required.",
),
) )
.arg( .arg(
Arg::new(arg::CHOOSER) Arg::new(arg::CHOOSER)
@ -173,6 +172,13 @@ impl Config {
.action(ArgAction::Set) .action(ArgAction::Set)
.help("Override binary invoked by `--choose`"), .help("Override binary invoked by `--choose`"),
) )
.arg(
Arg::new(arg::CLEAR_SHELL_ARGS)
.long("clear-shell-args")
.action(ArgAction::SetTrue)
.overrides_with(arg::SHELL_ARG)
.help("Clear shell arguments"),
)
.arg( .arg(
Arg::new(arg::COLOR) Arg::new(arg::COLOR)
.long("color") .long("color")
@ -190,7 +196,21 @@ impl Config {
.value_parser(PossibleValuesParser::new(arg::COMMAND_COLOR_VALUES)) .value_parser(PossibleValuesParser::new(arg::COMMAND_COLOR_VALUES))
.help("Echo recipe lines in <COMMAND-COLOR>"), .help("Echo recipe lines in <COMMAND-COLOR>"),
) )
.arg(Arg::new(arg::YES).long("yes").action(ArgAction::SetTrue).help("Automatically confirm all recipes.")) .arg(
Arg::new(arg::DOTENV_FILENAME)
.long("dotenv-filename")
.action(ArgAction::Set)
.help("Search for environment file named <DOTENV-FILENAME> instead of `.env`")
.conflicts_with(arg::DOTENV_PATH),
)
.arg(
Arg::new(arg::DOTENV_PATH)
.short('E')
.long("dotenv-path")
.action(ArgAction::Set)
.value_parser(value_parser!(PathBuf))
.help("Load <DOTENV-PATH> as environment file instead of searching for one"),
)
.arg( .arg(
Arg::new(arg::DRY_RUN) Arg::new(arg::DRY_RUN)
.short('n') .short('n')
@ -203,66 +223,30 @@ impl Config {
.arg( .arg(
Arg::new(arg::DUMP_FORMAT) Arg::new(arg::DUMP_FORMAT)
.long("dump-format") .long("dump-format")
.env("JUST_DUMP_FORMAT")
.action(ArgAction::Set) .action(ArgAction::Set)
.value_parser(PossibleValuesParser::new(arg::DUMP_FORMAT_VALUES)) .value_parser(PossibleValuesParser::new(arg::DUMP_FORMAT_VALUES))
.default_value(arg::DUMP_FORMAT_JUST) .default_value(arg::DUMP_FORMAT_JUST)
.value_name("FORMAT") .value_name("FORMAT")
.help("Dump justfile as <FORMAT>"), .help("Dump justfile as <FORMAT>"),
) )
.arg(
Arg::new(arg::GLOBAL_JUSTFILE)
.action(ArgAction::SetTrue)
.long("global-justfile")
.short('g')
.conflicts_with(arg::JUSTFILE)
.conflicts_with(arg::WORKING_DIRECTORY)
.help("Use global justfile"),
)
.arg( .arg(
Arg::new(arg::HIGHLIGHT) Arg::new(arg::HIGHLIGHT)
.long("highlight") .long("highlight")
.env("JUST_HIGHLIGHT")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Highlight echoed recipe lines in bold") .help("Highlight echoed recipe lines in bold")
.overrides_with(arg::NO_HIGHLIGHT), .overrides_with(arg::NO_HIGHLIGHT),
) )
.arg(
Arg::new(arg::LIST_HEADING)
.long("list-heading")
.help("Print <TEXT> before list")
.value_name("TEXT")
.action(ArgAction::Set),
)
.arg(
Arg::new(arg::LIST_PREFIX)
.long("list-prefix")
.help("Print <TEXT> before each list item")
.value_name("TEXT")
.action(ArgAction::Set),
)
.arg(
Arg::new(arg::LIST_SUBMODULES)
.long("list-submodules")
.help("List recipes in submodules")
.action(ArgAction::SetTrue)
.env("JUST_LIST_SUBMODULES"),
)
.arg(
Arg::new(arg::NO_ALIASES)
.long("no-aliases")
.action(ArgAction::SetTrue)
.help("Don't show aliases in list"),
)
.arg (
Arg::new(arg::NO_DEPS)
.long("no-deps")
.alias("no-dependencies")
.action(ArgAction::SetTrue)
.help("Don't run recipe dependencies")
)
.arg(
Arg::new(arg::NO_DOTENV)
.long("no-dotenv")
.action(ArgAction::SetTrue)
.help("Don't load `.env` file"),
)
.arg(
Arg::new(arg::NO_HIGHLIGHT)
.long("no-highlight")
.action(ArgAction::SetTrue)
.help("Don't highlight echoed recipe lines in bold")
.overrides_with(arg::HIGHLIGHT),
)
.arg( .arg(
Arg::new(arg::JUSTFILE) Arg::new(arg::JUSTFILE)
.short('f') .short('f')
@ -272,6 +256,60 @@ impl Config {
.value_parser(value_parser!(PathBuf)) .value_parser(value_parser!(PathBuf))
.help("Use <JUSTFILE> as justfile"), .help("Use <JUSTFILE> as justfile"),
) )
.arg(
Arg::new(arg::LIST_HEADING)
.long("list-heading")
.env("JUST_LIST_HEADING")
.help("Print <TEXT> before list")
.value_name("TEXT")
.action(ArgAction::Set),
)
.arg(
Arg::new(arg::LIST_PREFIX)
.long("list-prefix")
.env("JUST_LIST_PREFIX")
.help("Print <TEXT> before each list item")
.value_name("TEXT")
.action(ArgAction::Set),
)
.arg(
Arg::new(arg::LIST_SUBMODULES)
.long("list-submodules")
.env("JUST_LIST_SUBMODULES")
.help("List recipes in submodules")
.action(ArgAction::SetTrue)
.env("JUST_LIST_SUBMODULES"),
)
.arg(
Arg::new(arg::NO_ALIASES)
.long("no-aliases")
.env("JUST_NO_ALIASES")
.action(ArgAction::SetTrue)
.help("Don't show aliases in list"),
)
.arg(
Arg::new(arg::NO_DEPS)
.long("no-deps")
.env("JUST_NO_DEPS")
.alias("no-dependencies")
.action(ArgAction::SetTrue)
.help("Don't run recipe dependencies"),
)
.arg(
Arg::new(arg::NO_DOTENV)
.long("no-dotenv")
.env("JUST_NO_DOTENV")
.action(ArgAction::SetTrue)
.help("Don't load `.env` file"),
)
.arg(
Arg::new(arg::NO_HIGHLIGHT)
.long("no-highlight")
.env("JUST_NO_HIGHLIGHT")
.action(ArgAction::SetTrue)
.help("Don't highlight echoed recipe lines in bold")
.overrides_with(arg::HIGHLIGHT),
)
.arg( .arg(
Arg::new(arg::QUIET) Arg::new(arg::QUIET)
.short('q') .short('q')
@ -311,15 +349,24 @@ impl Config {
.help("Invoke <COMMAND> with the shell used to run recipe lines and backticks"), .help("Invoke <COMMAND> with the shell used to run recipe lines and backticks"),
) )
.arg( .arg(
Arg::new(arg::CLEAR_SHELL_ARGS) Arg::new(arg::TIMESTAMP)
.long("clear-shell-args")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.overrides_with(arg::SHELL_ARG) .long("timestamp")
.help("Clear shell arguments"), .env("JUST_TIMESTAMP")
.help("Print recipe command timestamps"),
)
.arg(
Arg::new(arg::TIMESTAMP_FORMAT)
.action(ArgAction::Set)
.long("timestamp-format")
.env("JUST_TIMESTAMP_FORMAT")
.default_value("%H:%M:%S")
.help("Timestamp format string"),
) )
.arg( .arg(
Arg::new(arg::UNSORTED) Arg::new(arg::UNSORTED)
.long("unsorted") .long("unsorted")
.env("JUST_UNSORTED")
.short('u') .short('u')
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Return list and summary entries in source order"), .help("Return list and summary entries in source order"),
@ -350,13 +397,28 @@ impl Config {
.help("Use <WORKING-DIRECTORY> as working directory. --justfile must also be set") .help("Use <WORKING-DIRECTORY> as working directory. --justfile must also be set")
.requires(arg::JUSTFILE), .requires(arg::JUSTFILE),
) )
.arg(
Arg::new(arg::YES)
.long("yes")
.env("JUST_YES")
.action(ArgAction::SetTrue)
.help("Automatically confirm all recipes."),
)
.arg( .arg(
Arg::new(cmd::CHANGELOG) Arg::new(cmd::CHANGELOG)
.long("changelog") .long("changelog")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Print changelog"), .help("Print changelog"),
) )
.arg(Arg::new(cmd::CHOOSE).long("choose").action(ArgAction::SetTrue).help(CHOOSE_HELP)) .arg(
Arg::new(cmd::CHOOSE)
.long("choose")
.action(ArgAction::SetTrue)
.help(
"Select one or more recipes to run using a binary chooser. If `--chooser` is not \
passed the chooser defaults to the value of $JUST_CHOOSER, falling back to `fzf`",
),
)
.arg( .arg(
Arg::new(cmd::COMMAND) Arg::new(cmd::COMMAND)
.long("command") .long("command")
@ -395,6 +457,7 @@ impl Config {
.arg( .arg(
Arg::new(cmd::EVALUATE) Arg::new(cmd::EVALUATE)
.long("evaluate") .long("evaluate")
.alias("eval")
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help( .help(
"Evaluate and print all variables. If a variable name is given as an argument, only \ "Evaluate and print all variables. If a variable name is given as an argument, only \
@ -408,6 +471,12 @@ impl Config {
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("Format and overwrite justfile"), .help("Format and overwrite justfile"),
) )
.arg(
Arg::new(cmd::GROUPS)
.long("groups")
.action(ArgAction::SetTrue)
.help("List recipe groups"),
)
.arg( .arg(
Arg::new(cmd::INIT) Arg::new(cmd::INIT)
.long("init") .long("init")
@ -425,12 +494,6 @@ impl Config {
.conflicts_with(arg::ARGUMENTS) .conflicts_with(arg::ARGUMENTS)
.help("List available recipes"), .help("List available recipes"),
) )
.arg(
Arg::new(cmd::GROUPS)
.long("groups")
.action(ArgAction::SetTrue)
.help("List recipe groups")
)
.arg( .arg(
Arg::new(cmd::MAN) Arg::new(cmd::MAN)
.long("man") .long("man")
@ -459,21 +522,6 @@ impl Config {
.action(ArgAction::SetTrue) .action(ArgAction::SetTrue)
.help("List names of variables"), .help("List names of variables"),
) )
.arg(
Arg::new(arg::DOTENV_FILENAME)
.long("dotenv-filename")
.action(ArgAction::Set)
.help("Search for environment file named <DOTENV-FILENAME> instead of `.env`")
.conflicts_with(arg::DOTENV_PATH),
)
.arg(
Arg::new(arg::DOTENV_PATH)
.short('E')
.long("dotenv-path")
.action(ArgAction::Set)
.value_parser(value_parser!(PathBuf))
.help("Load <DOTENV-PATH> as environment file instead of searching for one")
)
.group(ArgGroup::new("SUBCOMMAND").args(cmd::ALL)) .group(ArgGroup::new("SUBCOMMAND").args(cmd::ALL))
.arg( .arg(
Arg::new(arg::ARGUMENTS) Arg::new(arg::ARGUMENTS)
@ -481,30 +529,6 @@ impl Config {
.action(ArgAction::Append) .action(ArgAction::Append)
.help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"), .help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
) )
.arg(
Arg::new(arg::GLOBAL_JUSTFILE)
.action(ArgAction::SetTrue)
.long("global-justfile")
.short('g')
.conflicts_with(arg::JUSTFILE)
.conflicts_with(arg::WORKING_DIRECTORY)
.help("Use global justfile")
)
.arg(
Arg::new(arg::TIMESTAMP)
.action(ArgAction::SetTrue)
.long("timestamp")
.env("JUST_TIMESTAMP")
.help("Print recipe command timestamps")
)
.arg(
Arg::new(arg::TIMESTAMP_FORMAT)
.action(ArgAction::Set)
.long("timestamp-format")
.env("JUST_TIMESTAMP_FORMAT")
.default_value("%H:%M:%S")
.help("Timestamp format string")
)
} }
fn color_from_matches(matches: &ArgMatches) -> ConfigResult<Color> { fn color_from_matches(matches: &ArgMatches) -> ConfigResult<Color> {
@ -611,17 +635,6 @@ impl Config {
} }
pub(crate) fn from_matches(matches: &ArgMatches) -> ConfigResult<Self> { pub(crate) fn from_matches(matches: &ArgMatches) -> ConfigResult<Self> {
let invocation_directory = env::current_dir().context(config_error::CurrentDirContext)?;
let verbosity = if matches.get_flag(arg::QUIET) {
Verbosity::Quiet
} else {
Verbosity::from_flag_occurrences(matches.get_count(arg::VERBOSE))
};
let color = Self::color_from_matches(matches)?;
let command_color = Self::command_color_from_matches(matches)?;
let mut overrides = BTreeMap::new(); let mut overrides = BTreeMap::new();
if let Some(mut values) = matches.get_many::<String>(arg::SET) { if let Some(mut values) = matches.get_many::<String>(arg::SET) {
while let (Some(k), Some(v)) = (values.next(), values.next()) { while let (Some(k), Some(v)) = (values.next(), values.next()) {
@ -684,28 +697,10 @@ impl Config {
} }
} else if let Some(&shell) = matches.get_one::<completions::Shell>(cmd::COMPLETIONS) { } else if let Some(&shell) = matches.get_one::<completions::Shell>(cmd::COMPLETIONS) {
Subcommand::Completions { shell } Subcommand::Completions { shell }
} else if matches.get_flag(cmd::EDIT) {
Subcommand::Edit
} else if matches.get_flag(cmd::SUMMARY) {
Subcommand::Summary
} else if matches.get_flag(cmd::DUMP) { } else if matches.get_flag(cmd::DUMP) {
Subcommand::Dump Subcommand::Dump
} else if matches.get_flag(cmd::FORMAT) { } else if matches.get_flag(cmd::EDIT) {
Subcommand::Format Subcommand::Edit
} else if matches.get_flag(cmd::INIT) {
Subcommand::Init
} else if let Some(path) = matches.get_many::<String>(cmd::LIST) {
Subcommand::List {
path: Self::parse_module_path(path)?,
}
} else if matches.get_flag(cmd::GROUPS) {
Subcommand::Groups
} else if matches.get_flag(cmd::MAN) {
Subcommand::Man
} else if let Some(path) = matches.get_many::<String>(cmd::SHOW) {
Subcommand::Show {
path: Self::parse_module_path(path)?,
}
} else if matches.get_flag(cmd::EVALUATE) { } else if matches.get_flag(cmd::EVALUATE) {
if positional.arguments.len() > 1 { if positional.arguments.len() > 1 {
return Err(ConfigError::SubcommandArguments { return Err(ConfigError::SubcommandArguments {
@ -722,6 +717,24 @@ impl Config {
variable: positional.arguments.into_iter().next(), variable: positional.arguments.into_iter().next(),
overrides, overrides,
} }
} else if matches.get_flag(cmd::FORMAT) {
Subcommand::Format
} else if matches.get_flag(cmd::GROUPS) {
Subcommand::Groups
} else if matches.get_flag(cmd::INIT) {
Subcommand::Init
} else if let Some(path) = matches.get_many::<String>(cmd::LIST) {
Subcommand::List {
path: Self::parse_module_path(path)?,
}
} else if matches.get_flag(cmd::MAN) {
Subcommand::Man
} else if let Some(path) = matches.get_many::<String>(cmd::SHOW) {
Subcommand::Show {
path: Self::parse_module_path(path)?,
}
} else if matches.get_flag(cmd::SUMMARY) {
Subcommand::Summary
} else if matches.get_flag(cmd::VARIABLES) { } else if matches.get_flag(cmd::VARIABLES) {
Subcommand::Variables Subcommand::Variables
} else { } else {
@ -731,20 +744,10 @@ impl Config {
} }
}; };
let shell_args = if matches.get_flag(arg::CLEAR_SHELL_ARGS) {
Some(Vec::new())
} else {
matches
.get_many::<String>(arg::SHELL_ARG)
.map(|s| s.map(Into::into).collect())
};
let unstable = matches.get_flag(arg::UNSTABLE);
Ok(Self { Ok(Self {
check: matches.get_flag(arg::CHECK), check: matches.get_flag(arg::CHECK),
color, color: Self::color_from_matches(matches)?,
command_color, command_color: Self::command_color_from_matches(matches)?,
dotenv_filename: matches dotenv_filename: matches
.get_one::<String>(arg::DOTENV_FILENAME) .get_one::<String>(arg::DOTENV_FILENAME)
.map(Into::into), .map(Into::into),
@ -752,7 +755,7 @@ impl Config {
dry_run: matches.get_flag(arg::DRY_RUN), dry_run: matches.get_flag(arg::DRY_RUN),
dump_format: Self::dump_format_from_matches(matches)?, dump_format: Self::dump_format_from_matches(matches)?,
highlight: !matches.get_flag(arg::NO_HIGHLIGHT), highlight: !matches.get_flag(arg::NO_HIGHLIGHT),
invocation_directory, invocation_directory: env::current_dir().context(config_error::CurrentDirContext)?,
list_heading: matches list_heading: matches
.get_one::<String>(arg::LIST_HEADING) .get_one::<String>(arg::LIST_HEADING)
.map_or_else(|| "Available recipes:\n".into(), Into::into), .map_or_else(|| "Available recipes:\n".into(), Into::into),
@ -765,7 +768,13 @@ impl Config {
no_dependencies: matches.get_flag(arg::NO_DEPS), no_dependencies: matches.get_flag(arg::NO_DEPS),
search_config, search_config,
shell: matches.get_one::<String>(arg::SHELL).map(Into::into), shell: matches.get_one::<String>(arg::SHELL).map(Into::into),
shell_args, shell_args: if matches.get_flag(arg::CLEAR_SHELL_ARGS) {
Some(Vec::new())
} else {
matches
.get_many::<String>(arg::SHELL_ARG)
.map(|s| s.map(Into::into).collect())
},
shell_command: matches.get_flag(arg::SHELL_COMMAND), shell_command: matches.get_flag(arg::SHELL_COMMAND),
subcommand, subcommand,
timestamp: matches.get_flag(arg::TIMESTAMP), timestamp: matches.get_flag(arg::TIMESTAMP),
@ -774,13 +783,17 @@ impl Config {
.unwrap() .unwrap()
.into(), .into(),
unsorted: matches.get_flag(arg::UNSORTED), unsorted: matches.get_flag(arg::UNSORTED),
unstable, unstable: matches.get_flag(arg::UNSTABLE),
verbosity, verbosity: if matches.get_flag(arg::QUIET) {
Verbosity::Quiet
} else {
Verbosity::from_flag_occurrences(matches.get_count(arg::VERBOSE))
},
yes: matches.get_flag(arg::YES), yes: matches.get_flag(arg::YES),
}) })
} }
pub(crate) fn require_unstable(&self, message: &str) -> Result<(), Error<'static>> { pub(crate) fn require_unstable(&self, message: &str) -> RunResult<'static> {
if self.unstable { if self.unstable {
Ok(()) Ok(())
} else { } else {
@ -790,7 +803,7 @@ impl Config {
} }
} }
pub(crate) fn run(self, loader: &Loader) -> Result<(), Error> { pub(crate) fn run(self, loader: &Loader) -> RunResult {
if let Err(error) = InterruptHandler::install(self.verbosity) { if let Err(error) = InterruptHandler::install(self.verbosity) {
warn!("Failed to set CTRL-C handler: {error}"); warn!("Failed to set CTRL-C handler: {error}");
} }
@ -1469,6 +1482,22 @@ mod tests {
}, },
} }
test! {
name: search_config_from_working_directory_and_justfile_long_stdin,
args: ["--working-directory", "foo", "--justfile", "-"],
search_config: SearchConfig::WithStdinAndWorkingDirectory {
working_directory: PathBuf::from("foo"),
},
}
test! {
name: search_config_from_working_directory_and_justfile_short_stdin,
args: ["--working-directory", "foo", "-f", "-"],
search_config: SearchConfig::WithStdinAndWorkingDirectory {
working_directory: PathBuf::from("foo"),
},
}
test! { test! {
name: search_config_justfile_long, name: search_config_justfile_long,
args: ["--justfile", "foo"], args: ["--justfile", "foo"],
@ -1477,6 +1506,12 @@ mod tests {
}, },
} }
test! {
name: search_config_justfile_long_stdin,
args: ["--justfile", "-"],
search_config: SearchConfig::WithStdin,
}
test! { test! {
name: search_config_justfile_short, name: search_config_justfile_short,
args: ["-f", "foo"], args: ["-f", "foo"],
@ -1485,6 +1520,12 @@ mod tests {
}, },
} }
test! {
name: search_config_justfile_short_stdin,
args: ["-f", "-"],
search_config: SearchConfig::WithStdin,
}
test! { test! {
name: search_directory_parent, name: search_directory_parent,
args: ["../"], args: ["../"],

View File

@ -8,7 +8,7 @@ pub(crate) struct Dependency<'src> {
} }
impl<'src> Display for Dependency<'src> { impl<'src> Display for Dependency<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
if self.arguments.is_empty() { if self.arguments.is_empty() {
write!(f, "{}", self.recipe.name()) write!(f, "{}", self.recipe.name())
} else { } else {

View File

@ -95,6 +95,9 @@ pub(crate) enum Error<'src> {
variable: String, variable: String,
suggestion: Option<Suggestion<'src>>, suggestion: Option<Suggestion<'src>>,
}, },
ExpectedSubmoduleButFoundRecipe {
path: String,
},
FormatCheckFoundDiff, FormatCheckFoundDiff,
FunctionCall { FunctionCall {
function: Name<'src>, function: Name<'src>,
@ -162,13 +165,13 @@ pub(crate) enum Error<'src> {
line_number: Option<usize>, line_number: Option<usize>,
}, },
UnknownSubmodule { UnknownSubmodule {
path: ModulePath, path: String,
}, },
UnknownOverrides { UnknownOverrides {
overrides: Vec<String>, overrides: Vec<String>,
}, },
UnknownRecipes { UnknownRecipe {
recipes: Vec<String>, recipe: String,
suggestion: Option<Suggestion<'src>>, suggestion: Option<Suggestion<'src>>,
}, },
Unstable { Unstable {
@ -365,6 +368,9 @@ impl<'src> ColorDisplay for Error<'src> {
write!(f, "\n{suggestion}")?; write!(f, "\n{suggestion}")?;
} }
} }
ExpectedSubmoduleButFoundRecipe { path } => {
write!(f, "Expected submodule at `{path}` but found recipe.")?;
},
FormatCheckFoundDiff => { FormatCheckFoundDiff => {
write!(f, "Formatted justfile differs from original.")?; write!(f, "Formatted justfile differs from original.")?;
} }
@ -447,10 +453,8 @@ impl<'src> ColorDisplay for Error<'src> {
let overrides = List::and_ticked(overrides); let overrides = List::and_ticked(overrides);
write!(f, "{count} {overrides} overridden on the command line but not present in justfile")?; write!(f, "{count} {overrides} overridden on the command line but not present in justfile")?;
} }
UnknownRecipes { recipes, suggestion } => { UnknownRecipe { recipe, suggestion } => {
let count = Count("recipe", recipes.len()); write!(f, "Justfile does not contain recipe `{recipe}`.")?;
let recipes = List::or_ticked(recipes);
write!(f, "Justfile does not contain {count} {recipes}.")?;
if let Some(suggestion) = suggestion { if let Some(suggestion) = suggestion {
write!(f, "\n{suggestion}")?; write!(f, "\n{suggestion}")?;
} }

View File

@ -51,7 +51,7 @@ impl<'src> Expression<'src> {
} }
impl<'src> Display for Expression<'src> { impl<'src> Display for Expression<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self { match self {
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()),

View File

@ -11,13 +11,13 @@ use {
}; };
pub(crate) enum Function { pub(crate) enum Function {
Nullary(fn(Context) -> Result<String, String>), Nullary(fn(Context) -> FunctionResult),
Unary(fn(Context, &str) -> Result<String, String>), Unary(fn(Context, &str) -> FunctionResult),
UnaryOpt(fn(Context, &str, Option<&str>) -> Result<String, String>), UnaryOpt(fn(Context, &str, Option<&str>) -> FunctionResult),
UnaryPlus(fn(Context, &str, &[String]) -> Result<String, String>), UnaryPlus(fn(Context, &str, &[String]) -> FunctionResult),
Binary(fn(Context, &str, &str) -> Result<String, String>), Binary(fn(Context, &str, &str) -> FunctionResult),
BinaryPlus(fn(Context, &str, &str, &[String]) -> Result<String, String>), BinaryPlus(fn(Context, &str, &str, &[String]) -> FunctionResult),
Ternary(fn(Context, &str, &str, &str) -> Result<String, String>), Ternary(fn(Context, &str, &str, &str) -> FunctionResult),
} }
pub(crate) struct Context<'src: 'run, 'run> { pub(crate) struct Context<'src: 'run, 'run> {
@ -47,6 +47,8 @@ pub(crate) fn get(name: &str) -> Option<Function> {
"config_local_directory" => Nullary(|_| dir("local config", dirs::config_local_dir)), "config_local_directory" => Nullary(|_| dir("local config", dirs::config_local_dir)),
"data_directory" => Nullary(|_| dir("data", dirs::data_dir)), "data_directory" => Nullary(|_| dir("data", dirs::data_dir)),
"data_local_directory" => Nullary(|_| dir("local data", dirs::data_local_dir)), "data_local_directory" => Nullary(|_| dir("local data", dirs::data_local_dir)),
"datetime" => Unary(datetime),
"datetime_utc" => Unary(datetime_utc),
"encode_uri_component" => Unary(encode_uri_component), "encode_uri_component" => Unary(encode_uri_component),
"env" => UnaryOpt(env), "env" => UnaryOpt(env),
"env_var" => Unary(env_var), "env_var" => Unary(env_var),
@ -119,7 +121,7 @@ impl Function {
} }
} }
fn absolute_path(context: Context, path: &str) -> Result<String, String> { fn absolute_path(context: Context, path: &str) -> FunctionResult {
let abs_path_unchecked = context let abs_path_unchecked = context
.evaluator .evaluator
.context .context
@ -136,7 +138,7 @@ fn absolute_path(context: Context, path: &str) -> Result<String, String> {
} }
} }
fn append(_context: Context, suffix: &str, s: &str) -> Result<String, String> { fn append(_context: Context, suffix: &str, s: &str) -> FunctionResult {
Ok( Ok(
s.split_whitespace() s.split_whitespace()
.map(|s| format!("{s}{suffix}")) .map(|s| format!("{s}{suffix}"))
@ -145,15 +147,15 @@ fn append(_context: Context, suffix: &str, s: &str) -> Result<String, String> {
) )
} }
fn arch(_context: Context) -> Result<String, String> { fn arch(_context: Context) -> FunctionResult {
Ok(target::arch().to_owned()) Ok(target::arch().to_owned())
} }
fn blake3(_context: Context, s: &str) -> Result<String, String> { fn blake3(_context: Context, s: &str) -> FunctionResult {
Ok(blake3::hash(s.as_bytes()).to_string()) Ok(blake3::hash(s.as_bytes()).to_string())
} }
fn blake3_file(context: Context, path: &str) -> Result<String, String> { fn blake3_file(context: Context, path: &str) -> FunctionResult {
let path = context let path = context
.evaluator .evaluator
.context .context
@ -167,7 +169,7 @@ fn blake3_file(context: Context, path: &str) -> Result<String, String> {
Ok(hasher.finalize().to_string()) Ok(hasher.finalize().to_string())
} }
fn canonicalize(_context: Context, path: &str) -> Result<String, String> { fn canonicalize(_context: Context, path: &str) -> FunctionResult {
let canonical = let canonical =
std::fs::canonicalize(path).map_err(|err| format!("I/O error canonicalizing path: {err}"))?; std::fs::canonicalize(path).map_err(|err| format!("I/O error canonicalizing path: {err}"))?;
@ -179,7 +181,7 @@ fn canonicalize(_context: Context, path: &str) -> Result<String, String> {
}) })
} }
fn capitalize(_context: Context, s: &str) -> Result<String, String> { fn capitalize(_context: Context, s: &str) -> FunctionResult {
let mut capitalized = String::new(); let mut capitalized = String::new();
for (i, c) in s.chars().enumerate() { for (i, c) in s.chars().enumerate() {
if i == 0 { if i == 0 {
@ -191,7 +193,7 @@ fn capitalize(_context: Context, s: &str) -> Result<String, String> {
Ok(capitalized) Ok(capitalized)
} }
fn choose(_context: Context, n: &str, alphabet: &str) -> Result<String, String> { fn choose(_context: Context, n: &str, alphabet: &str) -> FunctionResult {
if alphabet.is_empty() { if alphabet.is_empty() {
return Err("empty alphabet".into()); return Err("empty alphabet".into());
} }
@ -215,11 +217,11 @@ fn choose(_context: Context, n: &str, alphabet: &str) -> Result<String, String>
Ok((0..n).map(|_| alphabet.choose(&mut rng).unwrap()).collect()) Ok((0..n).map(|_| alphabet.choose(&mut rng).unwrap()).collect())
} }
fn clean(_context: Context, path: &str) -> Result<String, String> { fn clean(_context: Context, path: &str) -> FunctionResult {
Ok(Path::new(path).lexiclean().to_str().unwrap().to_owned()) Ok(Path::new(path).lexiclean().to_str().unwrap().to_owned())
} }
fn dir(name: &'static str, f: fn() -> Option<PathBuf>) -> Result<String, String> { fn dir(name: &'static str, f: fn() -> Option<PathBuf>) -> FunctionResult {
match f() { match f() {
Some(path) => path Some(path) => path
.as_os_str() .as_os_str()
@ -235,7 +237,15 @@ fn dir(name: &'static str, f: fn() -> Option<PathBuf>) -> Result<String, String>
} }
} }
fn encode_uri_component(_context: Context, s: &str) -> Result<String, String> { fn datetime(_context: Context, format: &str) -> FunctionResult {
Ok(chrono::Local::now().format(format).to_string())
}
fn datetime_utc(_context: Context, format: &str) -> FunctionResult {
Ok(chrono::Utc::now().format(format).to_string())
}
fn encode_uri_component(_context: Context, s: &str) -> FunctionResult {
static PERCENT_ENCODE: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC static PERCENT_ENCODE: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC
.remove(b'-') .remove(b'-')
.remove(b'_') .remove(b'_')
@ -249,7 +259,7 @@ fn encode_uri_component(_context: Context, s: &str) -> Result<String, String> {
Ok(percent_encoding::utf8_percent_encode(s, &PERCENT_ENCODE).to_string()) Ok(percent_encoding::utf8_percent_encode(s, &PERCENT_ENCODE).to_string())
} }
fn env_var(context: Context, key: &str) -> Result<String, String> { fn env_var(context: Context, key: &str) -> FunctionResult {
use std::env::VarError::*; use std::env::VarError::*;
if let Some(value) = context.evaluator.context.dotenv.get(key) { if let Some(value) = context.evaluator.context.dotenv.get(key) {
@ -265,7 +275,7 @@ fn env_var(context: Context, key: &str) -> Result<String, String> {
} }
} }
fn env_var_or_default(context: Context, key: &str, default: &str) -> Result<String, String> { fn env_var_or_default(context: Context, key: &str, default: &str) -> FunctionResult {
use std::env::VarError::*; use std::env::VarError::*;
if let Some(value) = context.evaluator.context.dotenv.get(key) { if let Some(value) = context.evaluator.context.dotenv.get(key) {
@ -281,39 +291,39 @@ fn env_var_or_default(context: Context, key: &str, default: &str) -> Result<Stri
} }
} }
fn env(context: Context, key: &str, default: Option<&str>) -> Result<String, String> { fn env(context: Context, key: &str, default: Option<&str>) -> FunctionResult {
match default { match default {
Some(val) => env_var_or_default(context, key, val), Some(val) => env_var_or_default(context, key, val),
None => env_var(context, key), None => env_var(context, key),
} }
} }
fn error(_context: Context, message: &str) -> Result<String, String> { fn error(_context: Context, message: &str) -> FunctionResult {
Err(message.to_owned()) Err(message.to_owned())
} }
fn extension(_context: Context, path: &str) -> Result<String, String> { fn extension(_context: Context, path: &str) -> FunctionResult {
Utf8Path::new(path) Utf8Path::new(path)
.extension() .extension()
.map(str::to_owned) .map(str::to_owned)
.ok_or_else(|| format!("Could not extract extension from `{path}`")) .ok_or_else(|| format!("Could not extract extension from `{path}`"))
} }
fn file_name(_context: Context, path: &str) -> Result<String, String> { fn file_name(_context: Context, path: &str) -> FunctionResult {
Utf8Path::new(path) Utf8Path::new(path)
.file_name() .file_name()
.map(str::to_owned) .map(str::to_owned)
.ok_or_else(|| format!("Could not extract file name from `{path}`")) .ok_or_else(|| format!("Could not extract file name from `{path}`"))
} }
fn file_stem(_context: Context, path: &str) -> Result<String, String> { fn file_stem(_context: Context, path: &str) -> FunctionResult {
Utf8Path::new(path) Utf8Path::new(path)
.file_stem() .file_stem()
.map(str::to_owned) .map(str::to_owned)
.ok_or_else(|| format!("Could not extract file stem from `{path}`")) .ok_or_else(|| format!("Could not extract file stem from `{path}`"))
} }
fn invocation_directory(context: Context) -> Result<String, String> { fn invocation_directory(context: Context) -> FunctionResult {
Platform::convert_native_path( Platform::convert_native_path(
&context.evaluator.context.search.working_directory, &context.evaluator.context.search.working_directory,
&context.evaluator.context.config.invocation_directory, &context.evaluator.context.config.invocation_directory,
@ -321,7 +331,7 @@ fn invocation_directory(context: Context) -> Result<String, String> {
.map_err(|e| format!("Error getting shell path: {e}")) .map_err(|e| format!("Error getting shell path: {e}"))
} }
fn invocation_directory_native(context: Context) -> Result<String, String> { fn invocation_directory_native(context: Context) -> FunctionResult {
context context
.evaluator .evaluator
.context .context
@ -342,11 +352,11 @@ fn invocation_directory_native(context: Context) -> Result<String, String> {
}) })
} }
fn is_dependency(context: Context) -> Result<String, String> { fn is_dependency(context: Context) -> FunctionResult {
Ok(context.evaluator.is_dependency.to_string()) Ok(context.evaluator.is_dependency.to_string())
} }
fn prepend(_context: Context, prefix: &str, s: &str) -> Result<String, String> { fn prepend(_context: Context, prefix: &str, s: &str) -> FunctionResult {
Ok( Ok(
s.split_whitespace() s.split_whitespace()
.map(|s| format!("{prefix}{s}")) .map(|s| format!("{prefix}{s}"))
@ -355,7 +365,7 @@ fn prepend(_context: Context, prefix: &str, s: &str) -> Result<String, String> {
) )
} }
fn join(_context: Context, base: &str, with: &str, and: &[String]) -> Result<String, String> { fn join(_context: Context, base: &str, with: &str, and: &[String]) -> FunctionResult {
let mut result = Utf8Path::new(base).join(with); let mut result = Utf8Path::new(base).join(with);
for arg in and { for arg in and {
result.push(arg); result.push(arg);
@ -363,7 +373,7 @@ fn join(_context: Context, base: &str, with: &str, and: &[String]) -> Result<Str
Ok(result.to_string()) Ok(result.to_string())
} }
fn just_executable(_context: Context) -> Result<String, String> { fn just_executable(_context: Context) -> FunctionResult {
let exe_path = let exe_path =
env::current_exe().map_err(|e| format!("Error getting current executable: {e}"))?; env::current_exe().map_err(|e| format!("Error getting current executable: {e}"))?;
@ -375,11 +385,11 @@ fn just_executable(_context: Context) -> Result<String, String> {
}) })
} }
fn just_pid(_context: Context) -> Result<String, String> { fn just_pid(_context: Context) -> FunctionResult {
Ok(std::process::id().to_string()) Ok(std::process::id().to_string())
} }
fn justfile(context: Context) -> Result<String, String> { fn justfile(context: Context) -> FunctionResult {
context context
.evaluator .evaluator
.context .context
@ -395,7 +405,7 @@ fn justfile(context: Context) -> Result<String, String> {
}) })
} }
fn justfile_directory(context: Context) -> Result<String, String> { fn justfile_directory(context: Context) -> FunctionResult {
let justfile_directory = context let justfile_directory = context
.evaluator .evaluator
.context .context
@ -420,19 +430,19 @@ fn justfile_directory(context: Context) -> Result<String, String> {
}) })
} }
fn kebabcase(_context: Context, s: &str) -> Result<String, String> { fn kebabcase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_kebab_case()) Ok(s.to_kebab_case())
} }
fn lowercamelcase(_context: Context, s: &str) -> Result<String, String> { fn lowercamelcase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_lower_camel_case()) Ok(s.to_lower_camel_case())
} }
fn lowercase(_context: Context, s: &str) -> Result<String, String> { fn lowercase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_lowercase()) Ok(s.to_lowercase())
} }
fn module_directory(context: Context) -> Result<String, String> { fn module_directory(context: Context) -> FunctionResult {
context context
.evaluator .evaluator
.context .context
@ -459,7 +469,7 @@ fn module_directory(context: Context) -> Result<String, String> {
}) })
} }
fn module_file(context: Context) -> Result<String, String> { fn module_file(context: Context) -> FunctionResult {
context context
.evaluator .evaluator
.context .context
@ -478,27 +488,27 @@ fn module_file(context: Context) -> Result<String, String> {
}) })
} }
fn num_cpus(_context: Context) -> Result<String, String> { fn num_cpus(_context: Context) -> FunctionResult {
let num = num_cpus::get(); let num = num_cpus::get();
Ok(num.to_string()) Ok(num.to_string())
} }
fn os(_context: Context) -> Result<String, String> { fn os(_context: Context) -> FunctionResult {
Ok(target::os().to_owned()) Ok(target::os().to_owned())
} }
fn os_family(_context: Context) -> Result<String, String> { fn os_family(_context: Context) -> FunctionResult {
Ok(target::family().to_owned()) Ok(target::family().to_owned())
} }
fn parent_directory(_context: Context, path: &str) -> Result<String, String> { fn parent_directory(_context: Context, path: &str) -> FunctionResult {
Utf8Path::new(path) Utf8Path::new(path)
.parent() .parent()
.map(Utf8Path::to_string) .map(Utf8Path::to_string)
.ok_or_else(|| format!("Could not extract parent directory from `{path}`")) .ok_or_else(|| format!("Could not extract parent directory from `{path}`"))
} }
fn path_exists(context: Context, path: &str) -> Result<String, String> { fn path_exists(context: Context, path: &str) -> FunctionResult {
Ok( Ok(
context context
.evaluator .evaluator
@ -511,20 +521,15 @@ fn path_exists(context: Context, path: &str) -> Result<String, String> {
) )
} }
fn quote(_context: Context, s: &str) -> Result<String, String> { fn quote(_context: Context, s: &str) -> FunctionResult {
Ok(format!("'{}'", s.replace('\'', "'\\''"))) Ok(format!("'{}'", s.replace('\'', "'\\''")))
} }
fn replace(_context: Context, s: &str, from: &str, to: &str) -> Result<String, String> { fn replace(_context: Context, s: &str, from: &str, to: &str) -> FunctionResult {
Ok(s.replace(from, to)) Ok(s.replace(from, to))
} }
fn replace_regex( fn replace_regex(_context: Context, s: &str, regex: &str, replacement: &str) -> FunctionResult {
_context: Context,
s: &str,
regex: &str,
replacement: &str,
) -> Result<String, String> {
Ok( Ok(
Regex::new(regex) Regex::new(regex)
.map_err(|err| err.to_string())? .map_err(|err| err.to_string())?
@ -533,7 +538,7 @@ fn replace_regex(
) )
} }
fn sha256(_context: Context, s: &str) -> Result<String, String> { fn sha256(_context: Context, s: &str) -> FunctionResult {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(s); hasher.update(s);
@ -541,7 +546,7 @@ fn sha256(_context: Context, s: &str) -> Result<String, String> {
Ok(format!("{hash:x}")) Ok(format!("{hash:x}"))
} }
fn sha256_file(context: Context, path: &str) -> Result<String, String> { fn sha256_file(context: Context, path: &str) -> FunctionResult {
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
let path = context let path = context
.evaluator .evaluator
@ -558,7 +563,7 @@ fn sha256_file(context: Context, path: &str) -> Result<String, String> {
Ok(format!("{hash:x}")) Ok(format!("{hash:x}"))
} }
fn shell(context: Context, command: &str, args: &[String]) -> Result<String, String> { fn shell(context: Context, command: &str, args: &[String]) -> FunctionResult {
let args = iter::once(command) let args = iter::once(command)
.chain(args.iter().map(String::as_str)) .chain(args.iter().map(String::as_str))
.collect::<Vec<&str>>(); .collect::<Vec<&str>>();
@ -569,19 +574,19 @@ fn shell(context: Context, command: &str, args: &[String]) -> Result<String, Str
.map_err(|output_error| output_error.to_string()) .map_err(|output_error| output_error.to_string())
} }
fn shoutykebabcase(_context: Context, s: &str) -> Result<String, String> { fn shoutykebabcase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_shouty_kebab_case()) Ok(s.to_shouty_kebab_case())
} }
fn shoutysnakecase(_context: Context, s: &str) -> Result<String, String> { fn shoutysnakecase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_shouty_snake_case()) Ok(s.to_shouty_snake_case())
} }
fn snakecase(_context: Context, s: &str) -> Result<String, String> { fn snakecase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_snake_case()) Ok(s.to_snake_case())
} }
fn source_directory(context: Context) -> Result<String, String> { fn source_directory(context: Context) -> FunctionResult {
context context
.evaluator .evaluator
.context .context
@ -602,7 +607,7 @@ fn source_directory(context: Context) -> Result<String, String> {
}) })
} }
fn source_file(context: Context) -> Result<String, String> { fn source_file(context: Context) -> FunctionResult {
context context
.evaluator .evaluator
.context .context
@ -621,51 +626,51 @@ fn source_file(context: Context) -> Result<String, String> {
}) })
} }
fn titlecase(_context: Context, s: &str) -> Result<String, String> { fn titlecase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_title_case()) Ok(s.to_title_case())
} }
fn trim(_context: Context, s: &str) -> Result<String, String> { fn trim(_context: Context, s: &str) -> FunctionResult {
Ok(s.trim().to_owned()) Ok(s.trim().to_owned())
} }
fn trim_end(_context: Context, s: &str) -> Result<String, String> { fn trim_end(_context: Context, s: &str) -> FunctionResult {
Ok(s.trim_end().to_owned()) Ok(s.trim_end().to_owned())
} }
fn trim_end_match(_context: Context, s: &str, pat: &str) -> Result<String, String> { fn trim_end_match(_context: Context, s: &str, pat: &str) -> FunctionResult {
Ok(s.strip_suffix(pat).unwrap_or(s).to_owned()) Ok(s.strip_suffix(pat).unwrap_or(s).to_owned())
} }
fn trim_end_matches(_context: Context, s: &str, pat: &str) -> Result<String, String> { fn trim_end_matches(_context: Context, s: &str, pat: &str) -> FunctionResult {
Ok(s.trim_end_matches(pat).to_owned()) Ok(s.trim_end_matches(pat).to_owned())
} }
fn trim_start(_context: Context, s: &str) -> Result<String, String> { fn trim_start(_context: Context, s: &str) -> FunctionResult {
Ok(s.trim_start().to_owned()) Ok(s.trim_start().to_owned())
} }
fn trim_start_match(_context: Context, s: &str, pat: &str) -> Result<String, String> { fn trim_start_match(_context: Context, s: &str, pat: &str) -> FunctionResult {
Ok(s.strip_prefix(pat).unwrap_or(s).to_owned()) Ok(s.strip_prefix(pat).unwrap_or(s).to_owned())
} }
fn trim_start_matches(_context: Context, s: &str, pat: &str) -> Result<String, String> { fn trim_start_matches(_context: Context, s: &str, pat: &str) -> FunctionResult {
Ok(s.trim_start_matches(pat).to_owned()) Ok(s.trim_start_matches(pat).to_owned())
} }
fn uppercamelcase(_context: Context, s: &str) -> Result<String, String> { fn uppercamelcase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_upper_camel_case()) Ok(s.to_upper_camel_case())
} }
fn uppercase(_context: Context, s: &str) -> Result<String, String> { fn uppercase(_context: Context, s: &str) -> FunctionResult {
Ok(s.to_uppercase()) Ok(s.to_uppercase())
} }
fn uuid(_context: Context) -> Result<String, String> { fn uuid(_context: Context) -> FunctionResult {
Ok(uuid::Uuid::new_v4().to_string()) Ok(uuid::Uuid::new_v4().to_string())
} }
fn without_extension(_context: Context, path: &str) -> Result<String, String> { fn without_extension(_context: Context, path: &str) -> FunctionResult {
let parent = Utf8Path::new(path) let parent = Utf8Path::new(path)
.parent() .parent()
.ok_or_else(|| format!("Could not extract parent from `{path}`"))?; .ok_or_else(|| format!("Could not extract parent from `{path}`"))?;
@ -679,7 +684,7 @@ fn without_extension(_context: Context, path: &str) -> Result<String, String> {
/// Check whether a string processes properly as semver (e.x. "0.1.0") /// Check whether a string processes properly as semver (e.x. "0.1.0")
/// and matches a given semver requirement (e.x. ">=0.1.0") /// and matches a given semver requirement (e.x. ">=0.1.0")
fn semver_matches(_context: Context, version: &str, requirement: &str) -> Result<String, String> { fn semver_matches(_context: Context, version: &str, requirement: &str) -> FunctionResult {
Ok( Ok(
requirement requirement
.parse::<VersionReq>() .parse::<VersionReq>()

View File

@ -173,66 +173,26 @@ impl<'src> Justfile<'src> {
_ => {} _ => {}
} }
let mut remaining: Vec<&str> = if !arguments.is_empty() { let arguments = arguments.iter().map(String::as_str).collect::<Vec<&str>>();
arguments.iter().map(String::as_str).collect()
} else if let Some(recipe) = &self.default { let groups = ArgumentParser::parse_arguments(self, &arguments)?;
recipe.check_can_be_default_recipe()?;
vec![recipe.name()]
} else if self.recipes.is_empty() {
return Err(Error::NoRecipes);
} else {
return Err(Error::NoDefaultRecipe);
};
let mut missing = Vec::new();
let mut invocations = Vec::new();
let mut scopes = BTreeMap::new();
let arena: Arena<Scope> = Arena::new(); let arena: Arena<Scope> = Arena::new();
let mut invocations = Vec::<Invocation>::new();
let mut scopes = BTreeMap::new();
while let Some(first) = remaining.first().copied() { for group in &groups {
if first.contains("::") invocations.push(self.invocation(
&& !(first.starts_with(':') || first.ends_with(':') || first.contains(":::"))
{
remaining = first
.split("::")
.chain(remaining[1..].iter().copied())
.collect();
continue;
}
let rest = &remaining[1..];
if let Some((invocation, consumed)) = self.invocation(
0,
&mut Vec::new(),
&arena, &arena,
&mut scopes, &group.arguments,
config, config,
&dotenv, &dotenv,
search,
&scope, &scope,
first, &group.path,
rest, 0,
)? { &mut scopes,
remaining = rest[consumed..].to_vec(); search,
invocations.push(invocation); )?);
} else {
missing.push(first.to_string());
remaining = rest.to_vec();
}
}
if !missing.is_empty() {
let suggestion = if missing.len() == 1 {
self.suggest_recipe(missing.first().unwrap())
} else {
None
};
return Err(Error::UnknownRecipes {
recipes: missing,
suggestion,
});
} }
let mut ran = Ran::default(); let mut ran = Ran::default();
@ -278,21 +238,29 @@ impl<'src> Justfile<'src> {
fn invocation<'run>( fn invocation<'run>(
&'run self, &'run self,
depth: usize,
path: &mut Vec<&'run str>,
arena: &'run Arena<Scope<'src, 'run>>, arena: &'run Arena<Scope<'src, 'run>>,
scopes: &mut BTreeMap<Vec<&'run str>, &'run Scope<'src, 'run>>, arguments: &[&'run str],
config: &'run Config, config: &'run Config,
dotenv: &'run BTreeMap<String, String>, dotenv: &'run BTreeMap<String, String>,
search: &'run Search,
parent: &'run Scope<'src, 'run>, parent: &'run Scope<'src, 'run>,
first: &'run str, path: &'run [String],
rest: &[&'run str], position: usize,
) -> RunResult<'src, Option<(Invocation<'src, 'run>, usize)>> { scopes: &mut BTreeMap<&'run [String], &'run Scope<'src, 'run>>,
if let Some(module) = self.modules.get(first) { search: &'run Search,
path.push(first); ) -> RunResult<'src, Invocation<'src, 'run>> {
if position + 1 == path.len() {
let recipe = self.get_recipe(&path[position]).unwrap();
Ok(Invocation {
recipe,
module_source: &self.source,
arguments: arguments.into(),
settings: &self.settings,
scope: parent,
})
} else {
let module = self.modules.get(&path[position]).unwrap();
let scope = if let Some(scope) = scopes.get(path) { let scope = if let Some(scope) = scopes.get(&path[..position]) {
scope scope
} else { } else {
let scope = Evaluator::evaluate_assignments( let scope = Evaluator::evaluate_assignments(
@ -304,76 +272,21 @@ impl<'src> Justfile<'src> {
search, search,
)?; )?;
let scope = arena.alloc(scope); let scope = arena.alloc(scope);
scopes.insert(path.clone(), scope); scopes.insert(path, scope);
scopes.get(path).unwrap() scopes.get(path).unwrap()
}; };
if rest.is_empty() { module.invocation(
if let Some(recipe) = &module.default { arena,
recipe.check_can_be_default_recipe()?; arguments,
return Ok(Some(( config,
Invocation { dotenv,
settings: &module.settings, scope,
recipe, path,
arguments: Vec::new(), position + 1,
scope, scopes,
module_source: &self.source, search,
}, )
depth,
)));
}
Err(Error::NoDefaultRecipe)
} else {
module.invocation(
depth + 1,
path,
arena,
scopes,
config,
dotenv,
search,
scope,
rest[0],
&rest[1..],
)
}
} else if let Some(recipe) = self.get_recipe(first) {
if recipe.parameters.is_empty() {
Ok(Some((
Invocation {
arguments: Vec::new(),
recipe,
scope: parent,
settings: &self.settings,
module_source: &self.source,
},
depth,
)))
} else {
let argument_range = recipe.argument_range();
let argument_count = cmp::min(rest.len(), recipe.max_arguments());
if !argument_range.range_contains(&argument_count) {
return Err(Error::ArgumentCountMismatch {
recipe: recipe.name(),
parameters: recipe.parameters.clone(),
found: rest.len(),
min: recipe.min_arguments(),
max: recipe.max_arguments(),
});
}
Ok(Some((
Invocation {
arguments: rest[..argument_count].to_vec(),
recipe,
scope: parent,
settings: &self.settings,
module_source: &self.source,
},
depth + argument_count,
)))
}
} else {
Ok(None)
} }
} }
@ -452,13 +365,13 @@ impl<'src> Justfile<'src> {
modules modules
} }
pub(crate) fn public_recipes(&self, config: &Config) -> Vec<&Recipe<'src, Dependency>> { pub(crate) fn public_recipes(&self, config: &Config) -> Vec<&Recipe> {
let mut recipes = self let mut recipes = self
.recipes .recipes
.values() .values()
.map(AsRef::as_ref) .map(AsRef::as_ref)
.filter(|recipe| recipe.is_public()) .filter(|recipe| recipe.is_public())
.collect::<Vec<&Recipe<Dependency>>>(); .collect::<Vec<&Recipe>>();
if config.unsorted { if config.unsorted {
recipes.sort_by_key(|recipe| (&recipe.import_offsets, recipe.name.offset)); recipes.sort_by_key(|recipe| (&recipe.import_offsets, recipe.name.offset));
@ -467,19 +380,33 @@ impl<'src> Justfile<'src> {
recipes recipes
} }
pub(crate) fn public_groups(&self) -> BTreeSet<String> { pub(crate) fn public_groups(&self, config: &Config) -> Vec<String> {
self let mut groups = Vec::new();
.recipes
.values() for recipe in self.recipes.values() {
.map(AsRef::as_ref) if recipe.is_public() {
.filter(|recipe| recipe.is_public()) for group in recipe.groups() {
.flat_map(Recipe::groups) groups.push((&recipe.import_offsets, recipe.name.offset, group));
.collect() }
}
}
if config.unsorted {
groups.sort();
} else {
groups.sort_by(|(_, _, a), (_, _, b)| a.cmp(b));
}
let mut seen = HashSet::new();
groups.retain(|(_, _, group)| seen.insert(group.clone()));
groups.into_iter().map(|(_, _, group)| group).collect()
} }
} }
impl<'src> ColorDisplay for Justfile<'src> { impl<'src> ColorDisplay for Justfile<'src> {
fn fmt(&self, f: &mut Formatter, color: Color) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len(); let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len();
for (name, assignment) in &self.assignments { for (name, assignment) in &self.assignments {
if assignment.export { if assignment.export {
@ -523,21 +450,38 @@ mod tests {
use Error::*; use Error::*;
run_error! { run_error! {
name: unknown_recipes, name: unknown_recipe_no_suggestion,
src: "a:\nb:\nc:", src: "a:\nb:\nc:",
args: ["a", "x", "y", "z"], args: ["a", "xyz", "y", "z"],
error: UnknownRecipes { error: UnknownRecipe {
recipes, recipe,
suggestion, suggestion,
}, },
check: { check: {
assert_eq!(recipes, &["x", "y", "z"]); assert_eq!(recipe, "xyz");
assert_eq!(suggestion, None); assert_eq!(suggestion, None);
} }
} }
run_error! { run_error! {
name: unknown_recipes_show_alias_suggestion, name: unknown_recipe_with_suggestion,
src: "a:\nb:\nc:",
args: ["a", "x", "y", "z"],
error: UnknownRecipe {
recipe,
suggestion,
},
check: {
assert_eq!(recipe, "x");
assert_eq!(suggestion, Some(Suggestion {
name: "a",
target: None,
}));
}
}
run_error! {
name: unknown_recipe_show_alias_suggestion,
src: " src: "
foo: foo:
echo foo echo foo
@ -545,12 +489,12 @@ mod tests {
alias z := foo alias z := foo
", ",
args: ["zz"], args: ["zz"],
error: UnknownRecipes { error: UnknownRecipe {
recipes, recipe,
suggestion, suggestion,
}, },
check: { check: {
assert_eq!(recipes, &["zz"]); assert_eq!(recipe, "zz");
assert_eq!(suggestion, Some(Suggestion { assert_eq!(suggestion, Some(Suggestion {
name: "z", name: "z",
target: Some("foo"), target: Some("foo"),

View File

@ -13,9 +13,15 @@
overlapping_range_endpoints overlapping_range_endpoints
)] )]
//! `just` is primarily used as a command-line binary, but does provide a
//! limited public library interface.
//!
//! Please keep in mind that there are no semantic version guarantees for the
//! library interface. It may break or change at any time.
pub(crate) use { pub(crate) use {
crate::{ crate::{
alias::Alias, analyzer::Analyzer, assignment::Assignment, alias::Alias, analyzer::Analyzer, argument_parser::ArgumentParser, assignment::Assignment,
assignment_resolver::AssignmentResolver, ast::Ast, attribute::Attribute, binding::Binding, assignment_resolver::AssignmentResolver, ast::Ast, attribute::Attribute, binding::Binding,
color::Color, color_display::ColorDisplay, command_ext::CommandExt, compilation::Compilation, color::Color, color_display::ColorDisplay, command_ext::CommandExt, compilation::Compilation,
compile_error::CompileError, compile_error_kind::CompileErrorKind, compiler::Compiler, compile_error::CompileError, compile_error_kind::CompileErrorKind, compiler::Compiler,
@ -86,10 +92,11 @@ pub use crate::run::run;
#[doc(hidden)] #[doc(hidden)]
pub use unindent::unindent; pub use unindent::unindent;
pub(crate) type CompileResult<'a, T = ()> = Result<T, CompileError<'a>>; type CompileResult<'a, T = ()> = Result<T, CompileError<'a>>;
pub(crate) type ConfigResult<T> = Result<T, ConfigError>; type ConfigResult<T> = Result<T, ConfigError>;
pub(crate) type RunResult<'a, T = ()> = Result<T, Error<'a>>; type FunctionResult = Result<String, String>;
pub(crate) type SearchResult<T> = Result<T, SearchError>; type RunResult<'a, T = ()> = Result<T, Error<'a>>;
type SearchResult<T> = Result<T, SearchError>;
#[cfg(test)] #[cfg(test)]
#[macro_use] #[macro_use]
@ -113,6 +120,7 @@ pub mod summary;
mod alias; mod alias;
mod analyzer; mod analyzer;
mod argument_parser;
mod assignment; mod assignment;
mod assignment_resolver; mod assignment_resolver;
mod ast; mod ast;

View File

@ -24,8 +24,9 @@ pub(crate) fn load_dotenv(
} }
if let Some(path) = dotenv_path { if let Some(path) = dotenv_path {
let path = working_directory.join(path);
if path.is_file() { if path.is_file() {
return load_from_file(&working_directory.join(path)); return load_from_file(&path);
} }
} }

View File

@ -1,5 +1,5 @@
fn main() { fn main() {
if let Err(code) = just::run() { if let Err(code) = just::run(std::env::args_os()) {
std::process::exit(code); std::process::exit(code);
} }
} }

View File

@ -15,7 +15,7 @@ pub(crate) enum OutputError {
} }
impl Display for OutputError { impl Display for OutputError {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match *self { match *self {
Self::Code(code) => write!(f, "Process exited with status code {code}"), Self::Code(code) => write!(f, "Process exited with status code {code}"),
Self::Io(ref io_error) => write!(f, "Error executing process: {io_error}"), Self::Io(ref io_error) => write!(f, "Error executing process: {io_error}"),

View File

@ -14,7 +14,7 @@ pub(crate) struct Parameter<'src> {
} }
impl<'src> ColorDisplay for Parameter<'src> { impl<'src> ColorDisplay for Parameter<'src> {
fn fmt(&self, f: &mut Formatter, color: Color) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
if let Some(prefix) = self.kind.prefix() { if let Some(prefix) = self.kind.prefix() {
write!(f, "{}", color.annotation().paint(prefix))?; write!(f, "{}", color.annotation().paint(prefix))?;
} }

View File

@ -340,9 +340,13 @@ impl<'run, 'src> Parser<'run, 'src> {
self.presume_keyword(Keyword::Export)?; self.presume_keyword(Keyword::Export)?;
items.push(Item::Assignment(self.parse_assignment(true)?)); items.push(Item::Assignment(self.parse_assignment(true)?));
} }
Some(Keyword::Unexport) => { Some(Keyword::Unexport)
if self.next_are(&[Identifier, Identifier, Eof])
|| self.next_are(&[Identifier, Identifier, Eol]) =>
{
self.presume_keyword(Keyword::Unexport)?; self.presume_keyword(Keyword::Unexport)?;
let name = self.parse_name()?; let name = self.parse_name()?;
self.expect_eol()?;
items.push(Item::Unexport { name }); items.push(Item::Unexport { name });
} }
Some(Keyword::Import) Some(Keyword::Import)

View File

@ -19,7 +19,7 @@ impl PlatformInterface for Platform {
Ok(cmd) Ok(cmd)
} }
fn set_execute_permission(path: &Path) -> Result<(), io::Error> { fn set_execute_permission(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
// get current permissions // get current permissions
@ -38,7 +38,7 @@ impl PlatformInterface for Platform {
exit_status.signal() exit_status.signal()
} }
fn convert_native_path(_working_directory: &Path, path: &Path) -> Result<String, String> { fn convert_native_path(_working_directory: &Path, path: &Path) -> FunctionResult {
path path
.to_str() .to_str()
.map(str::to_string) .map(str::to_string)
@ -85,7 +85,7 @@ impl PlatformInterface for Platform {
Ok(cmd) Ok(cmd)
} }
fn set_execute_permission(_path: &Path) -> Result<(), io::Error> { fn set_execute_permission(_path: &Path) -> io::Result<()> {
// it is not necessary to set an execute permission on a script on windows, so // it is not necessary to set an execute permission on a script on windows, so
// this is a nop // this is a nop
Ok(()) Ok(())
@ -97,7 +97,7 @@ impl PlatformInterface for Platform {
None None
} }
fn convert_native_path(working_directory: &Path, path: &Path) -> Result<String, String> { fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult {
// Translate path from windows style to unix style // Translate path from windows style to unix style
let mut cygpath = Command::new("cygpath"); let mut cygpath = Command::new("cygpath");
cygpath.current_dir(working_directory); cygpath.current_dir(working_directory);

View File

@ -10,12 +10,12 @@ pub(crate) trait PlatformInterface {
) -> Result<Command, OutputError>; ) -> Result<Command, OutputError>;
/// Set the execute permission on the file pointed to by `path` /// Set the execute permission on the file pointed to by `path`
fn set_execute_permission(path: &Path) -> Result<(), io::Error>; fn set_execute_permission(path: &Path) -> io::Result<()>;
/// Extract the signal from a process exit status, if it was terminated by a /// Extract the signal from a process exit status, if it was terminated by a
/// signal /// signal
fn signal_from_exit_status(exit_status: ExitStatus) -> Option<i32>; fn signal_from_exit_status(exit_status: ExitStatus) -> Option<i32>;
/// Translate a path from a "native" path to a path the interpreter expects /// Translate a path from a "native" path to a path the interpreter expects
fn convert_native_path(working_directory: &Path, path: &Path) -> Result<String, String>; fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult;
} }

View File

@ -106,6 +106,10 @@ impl<'src, D> Recipe<'src, D> {
!self.private && !self.attributes.contains(&Attribute::Private) !self.private && !self.attributes.contains(&Attribute::Private)
} }
pub(crate) fn takes_positional_arguments(&self, settings: &Settings) -> bool {
settings.positional_arguments || self.attributes.contains(&Attribute::PositionalArguments)
}
pub(crate) fn change_directory(&self) -> bool { pub(crate) fn change_directory(&self) -> bool {
!self.attributes.contains(&Attribute::NoCd) !self.attributes.contains(&Attribute::NoCd)
} }
@ -263,7 +267,7 @@ impl<'src, D> Recipe<'src, D> {
cmd.arg(command); cmd.arg(command);
if context.settings.positional_arguments { if self.takes_positional_arguments(context.settings) {
cmd.arg(self.name.lexeme()); cmd.arg(self.name.lexeme());
cmd.args(positional); cmd.args(positional);
} }
@ -415,7 +419,7 @@ impl<'src, D> Recipe<'src, D> {
output_error, output_error,
})?; })?;
if context.settings.positional_arguments { if self.takes_positional_arguments(context.settings) {
command.args(positional); command.args(positional);
} }
@ -472,7 +476,7 @@ 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) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
if let Some(doc) = self.doc { if let Some(doc) = self.doc {
writeln!(f, "# {doc}")?; writeln!(f, "# {doc}")?;
} }

View File

@ -8,8 +8,9 @@ pub(crate) struct RecipeResolver<'src: 'run, 'run> {
impl<'src: 'run, 'run> RecipeResolver<'src, 'run> { impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
pub(crate) fn resolve_recipes( pub(crate) fn resolve_recipes(
unresolved_recipes: Table<'src, UnresolvedRecipe<'src>>,
assignments: &'run Table<'src, Assignment<'src>>, assignments: &'run Table<'src, Assignment<'src>>,
settings: &Settings,
unresolved_recipes: Table<'src, UnresolvedRecipe<'src>>,
) -> CompileResult<'src, Table<'src, Rc<Recipe<'src>>>> { ) -> CompileResult<'src, Table<'src, Rc<Recipe<'src>>>> {
let mut resolver = Self { let mut resolver = Self {
resolved_recipes: Table::new(), resolved_recipes: Table::new(),
@ -39,6 +40,10 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
} }
for line in &recipe.body { for line in &recipe.body {
if line.is_comment() && settings.ignore_comments {
continue;
}
for fragment in &line.fragments { for fragment in &line.fragments {
if let Fragment::Interpolation { expression, .. } = fragment { if let Fragment::Interpolation { expression, .. } = fragment {
for variable in expression.variables() { for variable in expression.variables() {

View File

@ -1,8 +1,8 @@
use super::*; use super::*;
/// Main entry point into just binary. /// Main entry point into `just`. Parse arguments from `args` and run.
#[allow(clippy::missing_errors_doc)] #[allow(clippy::missing_errors_doc)]
pub fn run() -> Result<(), i32> { pub fn run(args: impl Iterator<Item = impl Into<OsString> + Clone>) -> Result<(), i32> {
#[cfg(windows)] #[cfg(windows)]
ansi_term::enable_ansi_support().ok(); ansi_term::enable_ansi_support().ok();
@ -11,12 +11,16 @@ pub fn run() -> Result<(), i32> {
.filter("JUST_LOG") .filter("JUST_LOG")
.write_style("JUST_LOG_STYLE"), .write_style("JUST_LOG_STYLE"),
) )
.init(); .try_init()
.ok();
let app = Config::app(); let app = Config::app();
info!("Parsing command line arguments…"); info!("Parsing command line arguments…");
let matches = app.get_matches(); let matches = app.try_get_matches_from(args).map_err(|err| {
err.print().ok();
err.exit_code()
})?;
let config = Config::from_matches(&matches).map_err(Error::from); let config = Config::from_matches(&matches).map_err(Error::from);

View File

@ -1,14 +1,58 @@
use {super::*, std::path::Component}; use {
super::*,
std::{
io::{stdin, Read},
path::{Component, Display},
},
};
const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0]; const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0];
pub(crate) const JUSTFILE_NAMES: [&str; 2] = ["justfile", ".justfile"]; pub(crate) const JUSTFILE_NAMES: [&str; 2] = ["justfile", ".justfile"];
const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"]; const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
pub(crate) struct Search { pub(crate) struct Search {
pub(crate) justfile: PathBuf, pub(crate) justfile: JustfileKind,
pub(crate) working_directory: PathBuf, pub(crate) working_directory: PathBuf,
} }
#[derive(Debug, Clone)]
pub enum JustfileKind {
Path { path: PathBuf },
Stdin { data: String },
}
impl JustfileKind {
pub fn to_str(&self) -> Option<&str> {
match self {
JustfileKind::Path { path } => path.to_str(),
JustfileKind::Stdin { .. } => None,
}
}
pub fn display(&self) -> Display<'_> {
match self {
JustfileKind::Path { path } => path.display(),
JustfileKind::Stdin { .. } => Path::new("<STDIN>").display(),
}
}
pub fn parent(&self) -> Option<&Path> {
match self {
JustfileKind::Path { path } => path.parent(),
JustfileKind::Stdin { .. } => None,
}
}
}
impl PartialEq<PathBuf> for JustfileKind {
fn eq(&self, other: &PathBuf) -> bool {
match self {
JustfileKind::Path { path } => path == other,
JustfileKind::Stdin { .. } => false,
}
}
}
impl Search { impl Search {
fn global_justfile_paths() -> Vec<PathBuf> { fn global_justfile_paths() -> Vec<PathBuf> {
let mut paths = Vec::new(); let mut paths = Vec::new();
@ -33,6 +77,7 @@ impl Search {
paths paths
} }
/// Search for a Justfile
pub(crate) fn find( pub(crate) fn find(
search_config: &SearchConfig, search_config: &SearchConfig,
invocation_directory: &Path, invocation_directory: &Path,
@ -41,24 +86,29 @@ impl Search {
SearchConfig::FromInvocationDirectory => Self::find_next(invocation_directory), SearchConfig::FromInvocationDirectory => Self::find_next(invocation_directory),
SearchConfig::FromSearchDirectory { search_directory } => { SearchConfig::FromSearchDirectory { search_directory } => {
let search_directory = Self::clean(invocation_directory, search_directory); let search_directory = Self::clean(invocation_directory, search_directory);
let justfile = Self::justfile(&search_directory)?; let path = Self::justfile(&search_directory)?;
let working_directory = Self::working_directory_from_justfile(&justfile)?; let working_directory = Self::working_directory_from_justfile(&path)?;
let justfile = JustfileKind::Path { path: path.clone() };
Ok(Self { Ok(Self {
justfile, justfile,
working_directory, working_directory,
}) })
} }
SearchConfig::GlobalJustfile => Ok(Self { SearchConfig::GlobalJustfile => {
justfile: Self::global_justfile_paths() let path = Self::global_justfile_paths()
.iter() .iter()
.find(|path| path.exists()) .find(|path| path.exists())
.cloned() .cloned()
.ok_or(SearchError::GlobalJustfileNotFound)?, .ok_or(SearchError::GlobalJustfileNotFound)?;
working_directory: Self::project_root(invocation_directory)?, Ok(Self {
}), justfile: JustfileKind::Path { path },
working_directory: Self::project_root(invocation_directory)?,
})
}
SearchConfig::WithJustfile { justfile } => { SearchConfig::WithJustfile { justfile } => {
let justfile = Self::clean(invocation_directory, justfile); let path = Self::clean(invocation_directory, justfile);
let working_directory = Self::working_directory_from_justfile(&justfile)?; let justfile = JustfileKind::Path { path: path.clone() };
let working_directory = Self::working_directory_from_justfile(&path)?;
Ok(Self { Ok(Self {
justfile, justfile,
working_directory, working_directory,
@ -67,22 +117,58 @@ impl Search {
SearchConfig::WithJustfileAndWorkingDirectory { SearchConfig::WithJustfileAndWorkingDirectory {
justfile, justfile,
working_directory, working_directory,
} => Ok(Self { } => {
justfile: Self::clean(invocation_directory, justfile), let path = Self::clean(invocation_directory, justfile);
working_directory: Self::clean(invocation_directory, working_directory), let justfile = JustfileKind::Path { path };
}), Ok(Self {
justfile,
working_directory: Self::clean(invocation_directory, working_directory),
})
}
SearchConfig::WithStdin => {
let working_directory = PathBuf::from(invocation_directory);
let mut data = String::new();
if let Err(err) = stdin().read_to_string(&mut data) {
return Err(SearchError::Io {
directory: working_directory,
io_error: err,
});
}
let justfile = JustfileKind::Stdin { data };
Ok(Self {
justfile,
working_directory,
})
}
SearchConfig::WithStdinAndWorkingDirectory { working_directory } => {
let working_directory = working_directory.to_owned();
let mut data = String::new();
if let Err(err) = stdin().read_to_string(&mut data) {
return Err(SearchError::Io {
directory: working_directory,
io_error: err,
});
}
let justfile = JustfileKind::Stdin { data };
Ok(Self {
justfile,
working_directory,
})
}
} }
} }
pub(crate) fn find_next(starting_dir: &Path) -> SearchResult<Self> { pub(crate) fn find_next(starting_dir: &Path) -> SearchResult<Self> {
let justfile = Self::justfile(starting_dir)?; let path = Self::justfile(starting_dir)?;
let working_directory = Self::working_directory_from_justfile(&justfile)?; let justfile = JustfileKind::Path { path: path.clone() };
let working_directory = Self::working_directory_from_justfile(&path)?;
Ok(Self { Ok(Self {
justfile, justfile,
working_directory, working_directory,
}) })
} }
/// Search for a Justfile when running "init" subcommand
pub(crate) fn init( pub(crate) fn init(
search_config: &SearchConfig, search_config: &SearchConfig,
invocation_directory: &Path, invocation_directory: &Path,
@ -90,7 +176,8 @@ impl Search {
match search_config { match search_config {
SearchConfig::FromInvocationDirectory => { SearchConfig::FromInvocationDirectory => {
let working_directory = Self::project_root(invocation_directory)?; let working_directory = Self::project_root(invocation_directory)?;
let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME); let path = working_directory.join(DEFAULT_JUSTFILE_NAME);
let justfile = JustfileKind::Path { path };
Ok(Self { Ok(Self {
justfile, justfile,
working_directory, working_directory,
@ -99,7 +186,8 @@ impl Search {
SearchConfig::FromSearchDirectory { search_directory } => { SearchConfig::FromSearchDirectory { search_directory } => {
let search_directory = Self::clean(invocation_directory, search_directory); let search_directory = Self::clean(invocation_directory, search_directory);
let working_directory = Self::project_root(&search_directory)?; let working_directory = Self::project_root(&search_directory)?;
let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME); let path = working_directory.join(DEFAULT_JUSTFILE_NAME);
let justfile = JustfileKind::Path { path };
Ok(Self { Ok(Self {
justfile, justfile,
working_directory, working_directory,
@ -107,8 +195,9 @@ impl Search {
} }
SearchConfig::GlobalJustfile => Err(SearchError::GlobalJustfileInit), SearchConfig::GlobalJustfile => Err(SearchError::GlobalJustfileInit),
SearchConfig::WithJustfile { justfile } => { SearchConfig::WithJustfile { justfile } => {
let justfile = Self::clean(invocation_directory, justfile); let path = Self::clean(invocation_directory, justfile);
let working_directory = Self::working_directory_from_justfile(&justfile)?; let working_directory = Self::working_directory_from_justfile(&path)?;
let justfile = JustfileKind::Path { path };
Ok(Self { Ok(Self {
justfile, justfile,
working_directory, working_directory,
@ -117,14 +206,51 @@ impl Search {
SearchConfig::WithJustfileAndWorkingDirectory { SearchConfig::WithJustfileAndWorkingDirectory {
justfile, justfile,
working_directory, working_directory,
} => Ok(Self { } => {
justfile: Self::clean(invocation_directory, justfile), let path = Self::clean(invocation_directory, justfile);
working_directory: Self::clean(invocation_directory, working_directory), let justfile = JustfileKind::Path { path };
}), let working_directory = Self::clean(invocation_directory, working_directory);
Ok(Self {
justfile,
working_directory,
})
}
SearchConfig::WithStdin => {
let working_directory = Self::project_root(invocation_directory)?;
let mut data = String::new();
if let Err(err) = io::stdin().read_to_string(&mut data) {
return Err(SearchError::Io {
directory: working_directory,
io_error: err,
});
}
let justfile = JustfileKind::Stdin { data };
Ok(Self {
justfile,
working_directory,
})
}
SearchConfig::WithStdinAndWorkingDirectory { working_directory } => {
let working_directory = working_directory.to_owned();
let mut data = String::new();
if let Err(err) = io::stdin().read_to_string(&mut data) {
return Err(SearchError::Io {
directory: working_directory,
io_error: err,
});
}
let justfile = JustfileKind::Stdin { data };
Ok(Self {
justfile,
working_directory,
})
}
} }
} }
pub(crate) fn justfile(directory: &Path) -> SearchResult<PathBuf> { fn justfile(directory: &Path) -> SearchResult<PathBuf> {
for directory in directory.ancestors() { for directory in directory.ancestors() {
let mut candidates = BTreeSet::new(); let mut candidates = BTreeSet::new();

View File

@ -19,4 +19,10 @@ pub(crate) enum SearchConfig {
justfile: PathBuf, justfile: PathBuf,
working_directory: PathBuf, working_directory: PathBuf,
}, },
/// Use justfile loaded from stdin by using "-" as justfile name.
WithStdin,
/// Use justfile loaded from stdin and working directory.
WithStdinAndWorkingDirectory {
working_directory: PathBuf,
},
} }

View File

@ -13,7 +13,7 @@ impl<'src> Keyed<'src> for Set<'src> {
} }
impl<'src> Display for Set<'src> { impl<'src> Display for Set<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "set {} := {}", self.name, self.value) write!(f, "set {} := {}", self.name, self.value)
} }
} }

View File

@ -20,7 +20,7 @@ pub(crate) enum Setting<'src> {
} }
impl<'src> Display for Setting<'src> { impl<'src> Display for Setting<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self { match self {
Self::AllowDuplicateRecipes(value) Self::AllowDuplicateRecipes(value)
| Self::AllowDuplicateVariables(value) | Self::AllowDuplicateVariables(value)

View File

@ -7,7 +7,7 @@ pub(crate) struct Shell<'src> {
} }
impl<'src> Display for Shell<'src> { impl<'src> Display for Shell<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "[{}", self.command)?; write!(f, "[{}", self.command)?;
for argument in &self.arguments { for argument in &self.arguments {

View File

@ -42,11 +42,7 @@ pub(crate) enum Subcommand {
} }
impl Subcommand { impl Subcommand {
pub(crate) fn execute<'src>( pub(crate) fn execute<'src>(&self, config: &Config, loader: &'src Loader) -> RunResult<'src> {
&self,
config: &Config,
loader: &'src Loader,
) -> Result<(), Error<'src>> {
use Subcommand::*; use Subcommand::*;
match self { match self {
@ -97,7 +93,7 @@ impl Subcommand {
fn groups(config: &Config, justfile: &Justfile) { fn groups(config: &Config, justfile: &Justfile) {
println!("Recipe groups:"); println!("Recipe groups:");
for group in justfile.public_groups() { for group in justfile.public_groups(config) {
println!("{}{group}", config.list_prefix); println!("{}{group}", config.list_prefix);
} }
} }
@ -107,7 +103,7 @@ impl Subcommand {
loader: &'src Loader, loader: &'src Loader,
arguments: &[String], arguments: &[String],
overrides: &BTreeMap<String, String>, overrides: &BTreeMap<String, String>,
) -> Result<(), Error<'src>> { ) -> RunResult<'src> {
if matches!( if matches!(
config.search_config, config.search_config,
SearchConfig::FromInvocationDirectory | SearchConfig::FromSearchDirectory { .. } SearchConfig::FromInvocationDirectory | SearchConfig::FromSearchDirectory { .. }
@ -150,7 +146,7 @@ impl Subcommand {
}; };
match Self::run_inner(config, loader, arguments, overrides, &search) { match Self::run_inner(config, loader, arguments, overrides, &search) {
Err((err @ Error::UnknownRecipes { .. }, true)) => { Err((err @ Error::UnknownRecipe { .. }, true)) => {
match search.justfile.parent().unwrap().parent() { match search.justfile.parent().unwrap().parent() {
Some(parent) => { Some(parent) => {
unknown_recipes_errors.get_or_insert(err); unknown_recipes_errors.get_or_insert(err);
@ -192,7 +188,7 @@ impl Subcommand {
config: &Config, config: &Config,
loader: &'src Loader, loader: &'src Loader,
search: &Search, search: &Search,
) -> Result<Compilation<'src>, Error<'src>> { ) -> RunResult<'src, Compilation<'src>> {
let compilation = Compiler::compile(config.unstable, loader, &search.justfile)?; let compilation = Compiler::compile(config.unstable, loader, &search.justfile)?;
if config.verbosity.loud() { if config.verbosity.loud() {
@ -214,8 +210,8 @@ impl Subcommand {
search: &Search, search: &Search,
overrides: &BTreeMap<String, String>, overrides: &BTreeMap<String, String>,
chooser: Option<&str>, chooser: Option<&str>,
) -> Result<(), Error<'src>> { ) -> RunResult<'src> {
let mut recipes = Vec::<&Recipe<Dependency>>::new(); let mut recipes = Vec::<&Recipe>::new();
let mut stack = vec![justfile]; let mut stack = vec![justfile];
while let Some(module) = stack.pop() { while let Some(module) = stack.pop() {
recipes.extend( recipes.extend(
@ -304,7 +300,7 @@ impl Subcommand {
Ok(()) Ok(())
} }
fn dump(config: &Config, ast: &Ast, justfile: &Justfile) -> Result<(), Error<'static>> { fn dump(config: &Config, ast: &Ast, justfile: &Justfile) -> RunResult<'static> {
match config.dump_format { match config.dump_format {
DumpFormat::Json => { DumpFormat::Json => {
serde_json::to_writer(io::stdout(), justfile) serde_json::to_writer(io::stdout(), justfile)
@ -316,7 +312,7 @@ impl Subcommand {
Ok(()) Ok(())
} }
fn edit(search: &Search) -> Result<(), Error<'static>> { fn edit(search: &Search) -> RunResult<'static> {
let editor = env::var_os("VISUAL") let editor = env::var_os("VISUAL")
.or_else(|| env::var_os("EDITOR")) .or_else(|| env::var_os("EDITOR"))
.unwrap_or_else(|| "vim".into()); .unwrap_or_else(|| "vim".into());
@ -338,7 +334,7 @@ impl Subcommand {
Ok(()) Ok(())
} }
fn format(config: &Config, search: &Search, src: &str, ast: &Ast) -> Result<(), Error<'static>> { fn format(config: &Config, search: &Search, src: &str, ast: &Ast) -> RunResult<'static> {
config.require_unstable("The `--fmt` command is currently unstable.")?; config.require_unstable("The `--fmt` command is currently unstable.")?;
let formatted = ast.to_string(); let formatted = ast.to_string();
@ -383,7 +379,7 @@ impl Subcommand {
Ok(()) Ok(())
} }
fn init(config: &Config) -> Result<(), Error<'static>> { fn init(config: &Config) -> RunResult<'static> {
let search = Search::init(&config.search_config, &config.invocation_directory)?; let search = Search::init(&config.search_config, &config.invocation_directory)?;
if search.justfile.is_file() { if search.justfile.is_file() {
@ -403,7 +399,7 @@ impl Subcommand {
} }
} }
fn man() -> Result<(), Error<'static>> { fn man() -> RunResult<'static> {
let mut buffer = Vec::<u8>::new(); let mut buffer = Vec::<u8>::new();
Man::new(Config::app()) Man::new(Config::app())
@ -423,12 +419,14 @@ impl Subcommand {
Ok(()) Ok(())
} }
fn list(config: &Config, mut module: &Justfile, path: &ModulePath) -> Result<(), Error<'static>> { fn list(config: &Config, mut module: &Justfile, path: &ModulePath) -> RunResult<'static> {
for name in &path.path { for name in &path.path {
module = module module = module
.modules .modules
.get(name) .get(name)
.ok_or_else(|| Error::UnknownSubmodule { path: path.clone() })?; .ok_or_else(|| Error::UnknownSubmodule {
path: path.to_string(),
})?;
} }
Self::list_module(config, module, 0); Self::list_module(config, module, 0);
@ -502,7 +500,17 @@ impl Subcommand {
groups groups
}; };
for (i, (group, recipes)) in groups.iter().enumerate() { let mut ordered = module
.public_groups(config)
.into_iter()
.map(Some)
.collect::<Vec<Option<String>>>();
if groups.contains_key(&None) {
ordered.insert(0, None);
}
for (i, group) in ordered.into_iter().enumerate() {
if i > 0 { if i > 0 {
println!(); println!();
} }
@ -511,14 +519,14 @@ impl Subcommand {
if !no_groups { if !no_groups {
print!("{list_prefix}"); print!("{list_prefix}");
if let Some(group_name) = group { if let Some(group) = &group {
println!("[{group_name}]"); println!("[{group}]");
} else { } else {
println!("(no group)"); println!("(no group)");
} }
} }
for recipe in recipes { for recipe in groups.get(&group).unwrap() {
for (i, name) in iter::once(&recipe.name()) for (i, name) in iter::once(&recipe.name())
.chain(aliases.get(recipe.name()).unwrap_or(&Vec::new())) .chain(aliases.get(recipe.name()).unwrap_or(&Vec::new()))
.enumerate() .enumerate()
@ -583,12 +591,14 @@ impl Subcommand {
config: &Config, config: &Config,
mut module: &Justfile<'src>, mut module: &Justfile<'src>,
path: &ModulePath, path: &ModulePath,
) -> Result<(), Error<'src>> { ) -> RunResult<'src> {
for name in &path.path[0..path.path.len() - 1] { for name in &path.path[0..path.path.len() - 1] {
module = module module = module
.modules .modules
.get(name) .get(name)
.ok_or_else(|| Error::UnknownSubmodule { path: path.clone() })?; .ok_or_else(|| Error::UnknownSubmodule {
path: path.to_string(),
})?;
} }
let name = path.path.last().unwrap(); let name = path.path.last().unwrap();
@ -602,8 +612,8 @@ impl Subcommand {
println!("{}", recipe.color_display(config.color.stdout())); println!("{}", recipe.color_display(config.color.stdout()));
Ok(()) Ok(())
} else { } else {
Err(Error::UnknownRecipes { Err(Error::UnknownRecipe {
recipes: vec![name.to_owned()], recipe: name.to_owned(),
suggestion: module.suggest_recipe(name), suggestion: module.suggest_recipe(name),
}) })
} }

View File

@ -25,7 +25,7 @@ mod full {
}; };
} }
pub fn summary(path: &Path) -> Result<Result<Summary, String>, io::Error> { pub fn summary(path: &Path) -> io::Result<Result<Summary, String>> {
let loader = Loader::new(); let loader = Loader::new();
match Compiler::compile(false, &loader, path) { match Compiler::compile(false, &loader, path) {

View File

@ -1,4 +1,4 @@
use {super::*, pretty_assertions::assert_eq}; use {self::search::JustfileKind, super::*, pretty_assertions::assert_eq};
pub(crate) fn compile(src: &str) -> Justfile { pub(crate) fn compile(src: &str) -> Justfile {
Compiler::test_compile(src).expect("expected successful compilation") Compiler::test_compile(src).expect("expected successful compilation")
@ -17,7 +17,8 @@ pub(crate) fn config(args: &[&str]) -> Config {
pub(crate) fn search(config: &Config) -> Search { pub(crate) fn search(config: &Config) -> Search {
let working_directory = config.invocation_directory.clone(); let working_directory = config.invocation_directory.clone();
let justfile = working_directory.join("justfile"); let path = working_directory.join("justfile");
let justfile = JustfileKind::Path { path };
Search { Search {
justfile, justfile,
@ -131,7 +132,7 @@ macro_rules! run_error {
} }
macro_rules! assert_matches { macro_rules! assert_matches {
($expression:expr, $( $pattern:pat_param )|+ $( if $guard:expr )?) => { ($expression:expr, $( $pattern:pat_param )|+ $( if $guard:expr )? $(,)?) => {
match $expression { match $expression {
$( $pattern )|+ $( if $guard )? => {} $( $pattern )|+ $( if $guard )? => {}
left => panic!( left => panic!(

View File

@ -6,42 +6,42 @@ pub(crate) enum Thunk<'src> {
Nullary { Nullary {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context) -> Result<String, String>, function: fn(function::Context) -> FunctionResult,
}, },
Unary { Unary {
name: Name<'src>, name: Name<'src>,
#[derivative(Debug = "ignore", PartialEq = "ignore")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context, &str) -> Result<String, String>, 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")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context, &str, Option<&str>) -> Result<String, String>, 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")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context, &str, &[String]) -> Result<String, String>, 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")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context, &str, &str) -> Result<String, String>, 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")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context, &str, &str, &[String]) -> Result<String, String>, 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")] #[derivative(Debug = "ignore", PartialEq = "ignore")]
function: fn(function::Context, &str, &str, &str) -> Result<String, String>, function: fn(function::Context, &str, &str, &str) -> FunctionResult,
args: [Box<Expression<'src>>; 3], args: [Box<Expression<'src>>; 3],
}, },
} }

View File

@ -39,7 +39,7 @@ pub(crate) enum TokenKind {
} }
impl Display for TokenKind { impl Display for TokenKind {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
use TokenKind::*; use TokenKind::*;
write!( write!(
f, f,

View File

@ -7,7 +7,7 @@ pub(crate) struct UnresolvedDependency<'src> {
} }
impl<'src> Display for UnresolvedDependency<'src> { impl<'src> Display for UnresolvedDependency<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
if self.arguments.is_empty() { if self.arguments.is_empty() {
write!(f, "{}", self.recipe) write!(f, "{}", self.recipe)
} else { } else {

View File

@ -185,7 +185,13 @@ fn status_error() {
"exit-2": "#!/usr/bin/env bash\nexit 2\n", "exit-2": "#!/usr/bin/env bash\nexit 2\n",
}; };
("chmod", "+x", tmp.path().join("exit-2")).run(); let output = Command::new("chmod")
.arg("+x")
.arg(tmp.path().join("exit-2"))
.output()
.unwrap();
assert!(output.status.success());
let path = env::join_paths( let path = env::join_paths(
iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())), iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())),

View File

@ -33,6 +33,10 @@ fn replacements() {
.args(["--completions", shell]) .args(["--completions", shell])
.output() .output()
.unwrap(); .unwrap();
assert!(output.status.success()); assert!(
output.status.success(),
"shell completion generation for {shell} failed: {}",
output.status
);
} }
} }

27
tests/datetime.rs Normal file
View File

@ -0,0 +1,27 @@
use super::*;
#[test]
fn datetime() {
Test::new()
.justfile(
"
x := datetime('%Y-%m-%d %z')
",
)
.args(["--eval", "x"])
.stdout_regex(r"\d\d\d\d-\d\d-\d\d [+-]\d\d\d\d")
.run();
}
#[test]
fn datetime_utc() {
Test::new()
.justfile(
"
x := datetime_utc('%Y-%m-%d %Z')
",
)
.args(["--eval", "x"])
.stdout_regex(r"\d\d\d\d-\d\d-\d\d UTC")
.run();
}

View File

@ -360,6 +360,7 @@ fn no_dotenv() {
.stderr("echo DEFAULT\n") .stderr("echo DEFAULT\n")
.run(); .run();
} }
#[test] #[test]
fn dotenv_env_var_override() { fn dotenv_env_var_override() {
Test::new() Test::new()
@ -375,3 +376,21 @@ fn dotenv_env_var_override() {
.stderr("echo $DOTENV_KEY\n") .stderr("echo $DOTENV_KEY\n")
.run(); .run();
} }
#[test]
fn dotenv_path_usable_from_subdir() {
Test::new()
.justfile(
"
set dotenv-path := '.custom-env'
@echo:
echo $DOTENV_KEY
",
)
.create_dir("sub")
.current_dir("sub")
.write(".custom-env", "DOTENV_KEY=dotenv-value")
.stdout("dotenv-value\n")
.run();
}

View File

@ -64,7 +64,13 @@ fn status_error() {
"exit-2": "#!/usr/bin/env bash\nexit 2\n", "exit-2": "#!/usr/bin/env bash\nexit 2\n",
}; };
("chmod", "+x", tmp.path().join("exit-2")).run(); let output = Command::new("chmod")
.arg("+x")
.arg(tmp.path().join("exit-2"))
.output()
.unwrap();
assert!(output.status.success());
let path = env::join_paths( let path = env::join_paths(
iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())), iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())),

View File

@ -126,7 +126,13 @@ fn write_error() {
let justfile_path = test.justfile_path(); let justfile_path = test.justfile_path();
("chmod", "400", &justfile_path).run(); let output = Command::new("chmod")
.arg("400")
.arg(&justfile_path)
.output()
.unwrap();
assert!(output.status.success());
let _tempdir = test.run(); let _tempdir = test.run();

View File

@ -96,6 +96,47 @@ fn list_with_groups_unsorted() {
.run(); .run();
} }
#[test]
fn list_with_groups_unsorted_group_order() {
Test::new()
.justfile(
"
[group('y')]
[group('x')]
f:
[group('b')]
b:
[group('a')]
e:
c:
",
)
.args(["--list", "--unsorted"])
.stdout(
"
Available recipes:
(no group)
c
[x]
f
[y]
f
[b]
b
[a]
e
",
)
.run();
}
#[test] #[test]
fn list_groups() { fn list_groups() {
Test::new() Test::new()
@ -157,12 +198,89 @@ fn list_groups_with_shorthand_syntax() {
bar: bar:
", ",
) )
.args(["--groups", "--list-prefix", "..."]) .arg("--groups")
.stdout( .stdout(
" "
Recipe groups: Recipe groups:
...A A
...B B
",
)
.run();
}
#[test]
fn list_groups_unsorted() {
Test::new()
.justfile(
"
[group: 'Z']
baz:
[group: 'B']
foo:
[group: 'A', group: 'B']
bar:
",
)
.args(["--groups", "--unsorted"])
.stdout(
"
Recipe groups:
Z
B
A
",
)
.run();
}
#[test]
fn list_groups_private_unsorted() {
Test::new()
.justfile(
"
[private]
[group: 'A']
foo:
[group: 'B']
bar:
[group: 'A']
baz:
",
)
.args(["--groups", "--unsorted"])
.stdout(
"
Recipe groups:
B
A
",
)
.run();
}
#[test]
fn list_groups_private() {
Test::new()
.justfile(
"
[private]
[group: 'A']
foo:
[group: 'B']
bar:
",
)
.args(["--groups", "--unsorted"])
.stdout(
"
Recipe groups:
B
", ",
) )
.run(); .run();

View File

@ -97,3 +97,41 @@ fn dont_evaluate_comments() {
) )
.run(); .run();
} }
#[test]
fn dont_analyze_comments() {
Test::new()
.justfile(
"
set ignore-comments
some_recipe:
# {{ bar }}
",
)
.run();
}
#[test]
fn comments_still_must_be_parsable_when_ignored() {
Test::new()
.justfile(
"
set ignore-comments
some_recipe:
# {{ foo bar }}
",
)
.stderr(
"
error: Expected '}}', '(', '+', or '/', but found identifier
justfile:4:12
4 # {{ foo bar }}
^^^
",
)
.status(EXIT_FAILURE)
.run();
}

View File

@ -5,7 +5,6 @@ pub(crate) use {
tempdir::tempdir, tempdir::tempdir,
test::{assert_eval_eq, Output, Test}, test::{assert_eval_eq, Output, Test},
}, },
cradle::input::Input,
executable_path::executable_path, executable_path::executable_path,
just::unindent, just::unindent,
libc::{EXIT_FAILURE, EXIT_SUCCESS}, libc::{EXIT_FAILURE, EXIT_SUCCESS},
@ -47,6 +46,7 @@ mod completions;
mod conditional; mod conditional;
mod confirm; mod confirm;
mod constants; mod constants;
mod datetime;
mod delimiters; mod delimiters;
mod directories; mod directories;
mod dotenv; mod dotenv;
@ -106,6 +106,8 @@ mod timestamps;
mod undefined_variables; mod undefined_variables;
mod unexport; mod unexport;
mod unstable; mod unstable;
#[cfg(windows)]
mod windows;
#[cfg(target_family = "windows")] #[cfg(target_family = "windows")]
mod windows_shell; mod windows_shell;
mod working_directory; mod working_directory;

View File

@ -652,7 +652,7 @@ test! {
justfile: "hello:", justfile: "hello:",
args: ("foo", "bar"), args: ("foo", "bar"),
stdout: "", stdout: "",
stderr: "error: Justfile does not contain recipes `foo` or `bar`.\n", stderr: "error: Justfile does not contain recipe `foo`.\n",
status: EXIT_FAILURE, status: EXIT_FAILURE,
} }

View File

@ -115,7 +115,7 @@ fn missing_recipe_after_invalid_path() {
.test_round_trip(false) .test_round_trip(false)
.arg(":foo::foo") .arg(":foo::foo")
.arg("bar") .arg("bar")
.stderr("error: Justfile does not contain recipes `:foo::foo` or `bar`.\n") .stderr("error: Justfile does not contain recipe `:foo::foo`.\n")
.status(EXIT_FAILURE) .status(EXIT_FAILURE)
.run(); .run();
} }
@ -690,3 +690,94 @@ fn recipes_with_same_name_are_both_run() {
.stdout("MODULE\nROOT\n") .stdout("MODULE\nROOT\n")
.run(); .run();
} }
#[test]
fn submodule_recipe_not_found_error_message() {
Test::new()
.args(["--unstable", "foo::bar"])
.stderr("error: Justfile does not contain submodule `foo`\n")
.status(1)
.run();
}
#[test]
fn submodule_recipe_not_found_spaced_error_message() {
Test::new()
.write("foo.just", "bar:\n @echo MODULE")
.justfile(
"
mod foo
",
)
.test_round_trip(false)
.args(["--unstable", "foo", "baz"])
.stderr("error: Justfile does not contain recipe `foo baz`.\nDid you mean `bar`?\n")
.status(1)
.run();
}
#[test]
fn submodule_recipe_not_found_colon_separated_error_message() {
Test::new()
.write("foo.just", "bar:\n @echo MODULE")
.justfile(
"
mod foo
",
)
.test_round_trip(false)
.args(["--unstable", "foo::baz"])
.stderr("error: Justfile does not contain recipe `foo::baz`.\nDid you mean `bar`?\n")
.status(1)
.run();
}
#[test]
fn colon_separated_path_does_not_run_recipes() {
Test::new()
.justfile(
"
foo:
@echo FOO
bar:
@echo BAR
",
)
.args(["--unstable", "foo::bar"])
.stderr("error: Expected submodule at `foo` but found recipe.\n")
.status(1)
.run();
}
#[test]
fn expected_submodule_but_found_recipe_in_root_error() {
Test::new()
.justfile("foo:")
.arg("foo::baz")
.stderr("error: Expected submodule at `foo` but found recipe.\n")
.status(1)
.run();
}
#[test]
fn expected_submodule_but_found_recipe_in_submodule_error() {
Test::new()
.justfile("mod foo")
.write("foo.just", "bar:")
.test_round_trip(false)
.args(["--unstable", "foo::bar::baz"])
.stderr("error: Expected submodule at `foo::bar` but found recipe.\n")
.status(1)
.run();
}
#[test]
fn colon_separated_path_components_are_not_used_as_arguments() {
Test::new()
.justfile("foo bar:")
.args(["foo::bar"])
.stderr("error: Expected submodule at `foo` but found recipe.\n")
.status(1)
.run();
}

View File

@ -24,6 +24,31 @@ test! {
"#, "#,
} }
test! {
name: linewise_with_attribute,
justfile: r#"
[positional-arguments]
foo bar baz:
echo $0
echo $1
echo $2
echo "$@"
"#,
args: ("foo", "hello", "goodbye"),
stdout: "
foo
hello
goodbye
hello goodbye
",
stderr: r#"
echo $0
echo $1
echo $2
echo "$@"
"#,
}
test! { test! {
name: variadic_linewise, name: variadic_linewise,
justfile: r#" justfile: r#"
@ -51,6 +76,18 @@ test! {
stdout: "hello\n", stdout: "hello\n",
} }
test! {
name: shebang_with_attribute,
justfile: "
[positional-arguments]
foo bar:
#!/bin/sh
echo $1
",
args: ("foo", "hello"),
stdout: "hello\n",
}
test! { test! {
name: variadic_shebang, name: variadic_shebang,
justfile: r#" justfile: r#"

View File

@ -151,3 +151,39 @@ test! {
stderr: "echo bar\necho foo\n", stderr: "echo bar\necho foo\n",
shell: false, shell: false,
} }
#[test]
fn recipe_shell_not_found_error_message() {
Test::new()
.justfile(
"
foo:
@echo bar
",
)
.shell(false)
.args(["--shell", "NOT_A_REAL_SHELL"])
.stderr_regex(
"error: Recipe `foo` could not be run because just could not find the shell: .*\n",
)
.status(1)
.run();
}
#[test]
fn backtick_recipe_shell_not_found_error_message() {
Test::new()
.justfile(
"
bar := `echo bar`
foo:
echo {{bar}}
",
)
.shell(false)
.args(["--shell", "NOT_A_REAL_SHELL"])
.stderr_regex("(?s)error: Backtick could not be run because just could not find the shell:.*")
.status(1)
.run();
}

View File

@ -94,6 +94,11 @@ impl Test {
self self
} }
pub(crate) fn create_dir(self, path: impl AsRef<Path>) -> Self {
fs::create_dir_all(self.tempdir.path().join(path.as_ref())).unwrap();
self
}
pub(crate) fn current_dir(mut self, path: impl AsRef<Path>) -> Self { pub(crate) fn current_dir(mut self, path: impl AsRef<Path>) -> Self {
path.as_ref().clone_into(&mut self.current_dir); path.as_ref().clone_into(&mut self.current_dir);
self self

View File

@ -99,6 +99,28 @@ fn unexport_doesnt_override_local_recipe_export() {
) )
.args(["recipe", "value"]) .args(["recipe", "value"])
.stdout("variable: value\n") .stdout("variable: value\n")
.status(0) .run();
}
#[test]
fn unexport_does_not_conflict_with_recipe_syntax() {
Test::new()
.justfile(
"
unexport foo:
@echo {{foo}}
",
)
.args(["unexport", "bar"])
.stdout("bar\n")
.run();
}
#[test]
fn unexport_does_not_conflict_with_assignment_syntax() {
Test::new()
.justfile("unexport := 'foo'")
.args(["--evaluate", "unexport"])
.stdout("foo")
.run(); .run();
} }

15
tests/windows.rs Normal file
View File

@ -0,0 +1,15 @@
use super::*;
#[test]
fn bare_bash_in_shebang() {
Test::new()
.justfile(
"
default:
#!bash
echo FOO
",
)
.stdout("FOO\n")
.run();
}